HTTP Basics
Your First HTTP Server
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.ResponseWriterand*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
GETis the method. It says what the client wants to do.GETreads data,POSTcreates data,PATCHandPUTchange data,DELETEremoves data./hello/Anais the path.?greeting=Hiis the query string. It holds optional parameters.Host: localhost:8080is a header. Headers are extra information asName: valuelines.
The server sends back a response:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Hi, Ana!
200 OKis 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:
| Code | Name | Meaning |
|---|---|---|
| 200 | OK | Everything worked |
| 201 | Created | A new item was created |
| 400 | Bad Request | The client sent wrong data |
| 404 | Not Found | The item or path does not exist |
| 405 | Method Not Allowed | The path exists, but not for this method |
| 500 | Internal Server Error | The 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)
ris the request. It has the method (r.Method), the URL (r.URL), the headers (r.Header) and the body (r.Body).wis where you write the response. You write it in this order:- Headers:
w.Header().Set(...) - Status code:
w.WriteHeader(...). This is optional. If you skip it, Go sends200 OK. - Body:
w.Write(...), or any function that takes anio.Writer, likefmt.Fprintf.
- Headers:
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)
GETmeans this route only matches GET requests. AGETroute also matchesHEADrequests.{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 anAllowheader 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.NewRequestbuilds a request. The third argument is the body. GET requests have no body, so it isnil.httptest.NewRecorderis anhttp.ResponseWriterthat 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.ServeMuxhas a methodServeHTTP(w, r). Any type with this method is anhttp.Handler. The server callsServeHTTPfor every request, and the mux passes the request to the matching handler.mux.HandleFuncturns a plain function into anhttp.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
/helloat the same time. This is why theStorefrom Lesson 6.1 needed a mutex: the API will call it from many goroutines.
Exercises
- Add a route
GET /timethat returns the current time in the format15: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.) - Add a query parameter
shoutto/hello. With?shout=true, the reply is in capital letters. Usestrings.ToUpper. /hello/without a name returns 404. Add a routeGET /hello/(with the slash at the end) that answersHello, stranger!. What happens now with/hello/Ana/extra? Try it with curl and explain the result.- Change the
greeting is too longerror to return status422 Unprocessable Entityinstead of 400. Use the constant fromnet/http, not the number. Update the test. - Make the port configurable with an environment variable
TASKER_API_ADDR, with:8080as the default. Useos.Getenv. Start the server on:9090and 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.Serverwith a timeout - Test a route with
httptest.NewRequestandhttptest.NewRecorder
