diff options
| author | alex emery <[email protected]> | 2024-11-03 15:33:28 +0000 |
|---|---|---|
| committer | alex emery <[email protected]> | 2024-11-03 15:33:28 +0000 |
| commit | 508527f52de524a4fd174d386808e314b4138b11 (patch) | |
| tree | 2593af258b67decbf0207e2547b7ea55f6b051d7 /content/posts/2024-08-26-01.md | |
| parent | 22bfae8f9637633d5608caad3ce56b64c6819505 (diff) | |
feat: static builds
Diffstat (limited to 'content/posts/2024-08-26-01.md')
| -rw-r--r-- | content/posts/2024-08-26-01.md | 252 |
1 files changed, 252 insertions, 0 deletions
diff --git a/content/posts/2024-08-26-01.md b/content/posts/2024-08-26-01.md new file mode 100644 index 0000000..dc25d77 --- /dev/null +++ b/content/posts/2024-08-26-01.md @@ -0,0 +1,252 @@ +--- +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). |
