diff options
Diffstat (limited to 'content/posts/2024-08-26-01.md')
| -rw-r--r-- | content/posts/2024-08-26-01.md | 252 |
1 files changed, 0 insertions, 252 deletions
diff --git a/content/posts/2024-08-26-01.md b/content/posts/2024-08-26-01.md deleted file mode 100644 index dc25d77..0000000 --- a/content/posts/2024-08-26-01.md +++ /dev/null @@ -1,252 +0,0 @@ ---- -title: "Writing HTTP Handlers" -tags: posts -toc: true ---- - -I'm sharing how I write handlers in Go. - -I write them like this for reasons that are probably fairly contextual. I've written a few applications and had to swap REST libraries or even swapped REST for GRPC, so things that make that easier speak to me a great deal. - -I've used `ints` instead of the `http.StatusXXXX` and omitted `JSON` tags in an attempt to try save up screen space. - -To begin with, you might have something like this: -```go -package main - -import ( - "fmt" - "log" - "net/http" -) - -func handler(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Hello, World!") -} - -func main() { - http.HandleFunc("/", handler) - log.Fatal(http.ListenAndServe(":8080", nil)) -} - -``` - -Then you might get told off because you've just registered routes with the default mux, which isn't very testable. - -So you tweak it a little bit. -```go -package main - -import ( - "fmt" - "log" - "net/http" -) - -func handler(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Hello, World!") -} - -func newMux() *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc("/", handler) - - return mux -} - -func Run() error { - mux := newMux() - return http.ListenAndServe(":8080", mux) -} - -func main() { - if err := Run(); err != nil { - log.Fatal(err) - } -} -``` - -`newMux()` gives you a `mux` to use when testing. - -`Run` keeps `main` nice and clean, so you can just return errors as needed instead of going `log.Fatal` and just generally being messy. - -But now you need to do something real, you want to store and fetch data. - -```go -package main - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "strconv" -) - -func NewMux() *http.ServeMux { - mux := http.NewServeMux() - s := Server{ - data: make(map[int]Content), - } - - s.Register(mux) - return mux -} - -func Run() error { - mux := NewMux() - return http.ListenAndServe(":8080", mux) -} - -type Server struct { - data map[int]Content -} - -func (s *Server) Register(mux *http.ServeMux) { - mux.HandleFunc("GET /{id}", s.Get) - mux.HandleFunc("POST /", s.Post) -} - -func (s *Server) Get(w http.ResponseWriter, r *http.Request) { - idStr := r.PathValue("id") - id, err := strconv.Atoi(idStr) - if err != nil { - w.WriteHeader(400) - w.Write([]byte(fmt.Sprintf("failed to parse id: %v", err))) - return - } - data, ok := s.data[id] - if !ok { - w.WriteHeader(404) - w.Write([]byte("not found")) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - json.NewEncoder(w).Encode(data) -} - -type ContentPostReq struct { - Foo string -} - -func (s *Server) Post(w http.ResponseWriter, r *http.Request) { - req := ContentPostReq{} - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - w.WriteHeader(400) - w.Write([]byte(fmt.Sprintf("failed to parse request: %v", err))) - return - } - id := len(s.data) - content := Content{ - ID: id, - Foo: req.Foo, - } - s.data[id] = content - - w.WriteHeader(200) - json.NewEncoder(w).Encode(content) -} - -type Content struct { - ID int - Foo string -} - -func main() { - if err := Run(); err != nil { - log.Fatal(err) - } -} -``` - -```shell -❯ curl -X POST localhost:8080 --header "Content-Type: application/json" -d '{"foo":"bar"}' -{"ID":0,"Foo":"bar"} -❯ curl -X GET localhost:8080/0 -{"ID":0,"Foo":"bar"} -``` - -Erm, well, okay. Quite a bit has changed here, but I'm sure you can read it. We now save and fetch very, very safely from a map and return the response as `JSON`. I've done some things for brevity because I want to get to the main point. - -This API is inconsistent. It sometimes returns `JSON`, and the others return strings. Overall, it's just a mess. - -So let's try to standardise things. -First, let's design some form of REST spec. - -```go -type JSONResp[T any] struct { - Resources []T - Errs []ErrorResp -} - -type ErrorResp struct { - Status int - Msg string -} -``` -We want to be able to support fetching multiple resources at once, if we can only fetch some resources, let's return them under `resources` and show the errors under `errs` - -Now, add some helpful functions to handle things. -```go -func Post[In any, Out any](successCode int, fn func(context.Context, In) ([]Out, []ErrorResp)) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - var v In - - if err := json.NewDecoder(r.Body).Decode(&v); err != nil { - writeJSONResp[Out](w, http.StatusBadRequest, nil, []ErrorResp{ - { - Status: http.StatusBadRequest, - Msg: fmt.Sprintf("failed to parse request: %v", err), - }, - }) - - return - } - - res, errs := fn(r.Context(), v) - writeJSONResp(w, successCode, res, errs) - } -} - -func writeJSONResp[T any](w http.ResponseWriter, successCode int, res []T, errs []ErrorResp) { - body := JSONResp[T]{ - Resources: res, - Errs: errs, - } - - status := successCode - for _, e := range errs { - if e.Status > status { - status = e.Status - } - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(body) -} -``` -And we've standardised all `POST` requests! - -This function can be used by all `POST` requests, ensuring they adhere to the spec. It also removes the repetitive code around marshalling and unmarshalling to `JSON` and handles errors in a consistent manner. -The handler functions accept a `context` param and their expected struct input. -```go -func (s *Server) Register(mux *http.ServeMux) { -... - mux.HandleFunc("POST /", Post(201, s.Post)) -} - -func (s *Server) Post(ctx context.Context, req ContentPostReq) ([]Content, []ErrorResp) { - id := len(s.data) - content := Content{ - ID: id, - Foo: req.Foo, - } - s.data[id] = content - - return []Content{content}, nil -} -``` -As you can see, the post function is fairly cleaner now. - -You can extend this to all the other request types. If you have query or path parameters, you could either pass in the request, write a custom struct tag parser, or find someone else who has already done it: [https://github.com/gorilla/schema](https://github.com/gorilla/schema). |
