HTTP Basics

Your First HTTP Server

LuettavaEsikatselu

Katselet ilmaista esikatseluoppituntia.

Goal

Start Part 2 of the course. Build a small web server with Go's standard net/http package, call it with curl, and test it without starting a real server.

What you will learn

  • What an HTTP request and an HTTP response contain
  • How to write a handler with http.ResponseWriter and *http.Request
  • How to define routes with methods and path values, like GET /hello/{name}
  • How to read query parameters
  • How to send status codes and errors
  • How to start a server safely with http.Server
  • How to test handlers with httptest

Why this matters

In Part 1, Tasker ran on one computer for one person. A web API lets many programs use Tasker at the same time: a website, a phone app, or the Tasker CLI itself. Almost every backend you will build in Go starts with the ideas in this lesson. Fiber, which you meet in Lesson 7.3, is built on the same ideas, so learn them here first.

Step 1: HTTP in one minute

HTTP is a text conversation between a client (for example a browser or curl) and a server (your program).

The client sends a request:

GET /hello/Ana?greeting=Hi HTTP/1.1
Host: localhost:8080
  • GET is the method. It says what the client wants to do. GET reads data, POST creates data, PATCH and PUT change data, DELETE removes data.
  • /hello/Ana is the path.
  • ?greeting=Hi is the query string. It holds optional parameters.
  • Host: localhost:8080 is a header. Headers are extra information as Name: value lines.

The server sends back a response:

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Hi, Ana!
  • 200 OK is the status code. It says how the request went.
  • After the headers and one empty line comes the body.

Status codes you will use a lot:

CodeNameMeaning
200OKEverything worked
201CreatedA new item was created
400Bad RequestThe client sent wrong data
404Not FoundThe item or path does not exist
405Method Not AllowedThe path exists, but not for this method
500Internal Server ErrorThe server has a bug or a problem

Codes from 400 to 499 mean "the client made a mistake". Codes from 500 to 599 mean "the server made a mistake".

Step 2: Create the API program

The API lives in the same Go module as the CLI. It is a second program, so it gets its own folder under cmd/:

tasker/
├── cmd/
│   ├── tasker/        <- the CLI from Part 1
│   └── tasker-api/    <- new: the web API
└── internal/

Both programs can use the code in internal/. In the next lessons the API will use internal/task too.

Create cmd/tasker-api/main.go:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           newMux(),
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("listening on %s", srv.Addr)
	if err := srv.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}

// newMux connects each route to its handler.
func newMux() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", handleHealth)
	mux.HandleFunc("GET /hello/{name}", handleHello)
	return mux
}

// handleHealth tells the caller that the server is running.
func handleHealth(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	fmt.Fprintln(w, "ok")
}

// handleHello greets the name from the path, for example /hello/Ana.
// The optional query parameter "greeting" changes the first word.
func handleHello(w http.ResponseWriter, r *http.Request) {
	name := r.PathValue("name")
	greeting := r.URL.Query().Get("greeting")
	if greeting == "" {
		greeting = "Hello"
	}
	if len(greeting) > 20 {
		http.Error(w, "greeting is too long", http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	fmt.Fprintf(w, "%s, %s!\n", greeting, name)
}

We use the standard log package here on purpose, to keep this first server small. In Module 8 the API gets zap, like the CLI.

Step 3: Understand handlers

A handler is a function with this signature:

func(w http.ResponseWriter, r *http.Request)
  • r is the request. It has the method (r.Method), the URL (r.URL), the headers (r.Header) and the body (r.Body).
  • w is where you write the response. You write it in this order:
    1. Headers: w.Header().Set(...)
    2. Status code: w.WriteHeader(...). This is optional. If you skip it, Go sends 200 OK.
    3. Body: w.Write(...), or any function that takes an io.Writer, like fmt.Fprintf.

http.ResponseWriter is an interface with a Write method, so it is an io.Writer. That is why fmt.Fprintln(w, "ok") works. You saw the same idea in Lesson 4.2 with io.Writer in the CLI.

The order matters. After the first call to Write or WriteHeader, the headers are already sent. Setting a header after that has no effect.

http.Error(w, message, code) is a helper for errors. It sets a plain text content type, writes the status code and writes the message. After http.Error, always return. If you do not, the handler keeps running and writes more text into the same response.

Step 4: Understand routes

http.ServeMux is a router. It looks at each request and picks a handler. Since Go 1.22, a route pattern can contain a method and path values:

mux.HandleFunc("GET /hello/{name}", handleHello)
  • GET means this route only matches GET requests. A GET route also matches HEAD requests.
  • {name} is a wildcard. It matches one path segment. Inside the handler, r.PathValue("name") returns it.

Query parameters are not part of the pattern. Read them with r.URL.Query().Get("greeting"). Get returns "" if the parameter is missing, so you can use a default value.

The mux also handles mistakes for you:

  • An unknown path returns 404 Not Found.
  • A known path with the wrong method returns 405 Method Not Allowed, with an Allow header that lists the correct methods.

Step 5: Run it and call it with curl

In one terminal:

go run ./cmd/tasker-api

Output:

2026/10/16 23:29:33 listening on :8080

The program does not end. It waits for requests. In a second terminal, call it with curl. The -i flag shows the status line and the headers:

curl -i localhost:8080/health

Output:

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Fri, 16 Oct 2026 23:29:34 GMT
Content-Length: 3

ok

Try the path value and the query string:

curl localhost:8080/hello/Ana
curl "localhost:8080/hello/Ana?greeting=Hi"
curl localhost:8080/hello/Ana%20Maria

Output:

Hello, Ana!
Hi, Ana!
Hello, Ana Maria!

Put the URL in quotes when it contains ? or &. Your shell gives these characters a special meaning. %20 is a space in a URL. Go decodes it before PathValue returns it.

Now make some mistakes on purpose:

curl -i -X POST localhost:8080/health

Output (some headers removed):

HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8

Method Not Allowed
curl -i localhost:8080/nope

Output (some headers removed):

HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8

404 page not found
curl -i "localhost:8080/hello/Ana?greeting=aaaaaaaaaaaaaaaaaaaaaaaaa"

Output (some headers removed):

HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8

greeting is too long

You can also open http://localhost:8080/hello/Ana in your browser.

Stop the server with Ctrl+C. If you start it twice, the second one fails:

listen tcp :8080: bind: address already in use

Only one program can listen on a port. Stop the old server first.

Step 6: Why http.Server and not http.ListenAndServe

Many examples on the internet use this short form:

http.ListenAndServe(":8080", mux)

It works, but it has no timeouts. A slow or bad client can open a connection and send headers very slowly, forever. Each such connection uses memory. With many of them, your server can run out of resources.

http.Server lets you set timeouts. ReadHeaderTimeout: 5 * time.Second closes a connection if the client does not send its headers within 5 seconds. Always create an http.Server in real programs. In Module 8 you add more timeouts and a graceful shutdown.

Step 7: Test the routes without a server

You do not need a running server or a real port to test handlers. The httptest package gives you a fake request and a recorder that stores the response.

The test sends requests through newMux(), the same router as main uses. So it tests the routes, the path values and the handlers together. That is why we put the routes in their own function.

Create cmd/tasker-api/main_test.go:

package main

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestRoutes(t *testing.T) {
	tests := []struct {
		name       string
		method     string
		target     string
		wantStatus int
		wantBody   string
	}{
		{"health", http.MethodGet, "/health", http.StatusOK, "ok\n"},
		{"default greeting", http.MethodGet, "/hello/Ana", http.StatusOK, "Hello, Ana!\n"},
		{"custom greeting", http.MethodGet, "/hello/Ana?greeting=Hi", http.StatusOK, "Hi, Ana!\n"},
		{"greeting too long", http.MethodGet, "/hello/Ana?greeting=aaaaaaaaaaaaaaaaaaaaa", http.StatusBadRequest, "greeting is too long\n"},
		{"wrong method", http.MethodPost, "/health", http.StatusMethodNotAllowed, "Method Not Allowed\n"},
		{"unknown path", http.MethodGet, "/nope", http.StatusNotFound, "404 page not found\n"},
	}

	mux := newMux()
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req := httptest.NewRequest(tt.method, tt.target, nil)
			rec := httptest.NewRecorder()

			mux.ServeHTTP(rec, req)

			assert.Equal(t, tt.wantStatus, rec.Code)
			assert.Equal(t, tt.wantBody, rec.Body.String())
		})
	}
}
  • httptest.NewRequest builds a request. The third argument is the body. GET requests have no body, so it is nil.
  • httptest.NewRecorder is an http.ResponseWriter that keeps everything in memory.
  • mux.ServeHTTP(rec, req) is exactly what the real server calls for each request.

Run it:

go test ./cmd/tasker-api/ -v

Output:

--- PASS: TestRoutes (0.00s)
    --- PASS: TestRoutes/health (0.00s)
    --- PASS: TestRoutes/default_greeting (0.00s)
    --- PASS: TestRoutes/custom_greeting (0.00s)
    --- PASS: TestRoutes/greeting_too_long (0.00s)
    --- PASS: TestRoutes/wrong_method (0.00s)
    --- PASS: TestRoutes/unknown_path (0.00s)
PASS
ok  	github.com/yourname/tasker/cmd/tasker-api	0.005s

In Lesson 6.3 you used httptest.NewServer, which starts a real server on a free port. That is good for testing a client. For testing handlers, NewRecorder is simpler and faster.

Why it works

  • http.ServeMux has a method ServeHTTP(w, r). Any type with this method is an http.Handler. The server calls ServeHTTP for every request, and the mux passes the request to the matching handler.
  • mux.HandleFunc turns a plain function into an http.Handler. This is the interface idea from Lesson 4.2 again: the server does not care what your handler is, only that it has the right method.
  • The server runs each request in its own goroutine. Two clients can call /hello at the same time. This is why the Store from Lesson 6.1 needed a mutex: the API will call it from many goroutines.

Exercises

  1. Add a route GET /time that returns the current time in the format 15:04:05. Add a test case. (Hint: the test cannot know the exact time, so only check the status and that the body has 9 characters, including the newline.)
  2. Add a query parameter shout to /hello. With ?shout=true, the reply is in capital letters. Use strings.ToUpper.
  3. /hello/ without a name returns 404. Add a route GET /hello/ (with the slash at the end) that answers Hello, stranger!. What happens now with /hello/Ana/extra? Try it with curl and explain the result.
  4. Change the greeting is too long error to return status 422 Unprocessable Entity instead of 400. Use the constant from net/http, not the number. Update the test.
  5. Make the port configurable with an environment variable TASKER_API_ADDR, with :8080 as the default. Use os.Getenv. Start the server on :9090 and call it with curl.

Checkpoint

You are ready for the next lesson when you can:

  • Name the parts of an HTTP request and an HTTP response
  • Write a handler that reads a path value and a query parameter
  • Explain the difference between 400, 404 and 405
  • Explain why you use http.Server with a timeout
  • Test a route with httptest.NewRequest and httptest.NewRecorder