summaryrefslogtreecommitdiff
path: root/content/posts/004.md
diff options
context:
space:
mode:
Diffstat (limited to 'content/posts/004.md')
-rw-r--r--content/posts/004.md251
1 files changed, 251 insertions, 0 deletions
diff --git a/content/posts/004.md b/content/posts/004.md
new file mode 100644
index 0000000..ebfaad2
--- /dev/null
+++ b/content/posts/004.md
@@ -0,0 +1,251 @@
+---
+title: "Writing HTTP Handlers"
+tags: posts
+---
+
+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:
+```
+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.
+```
+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.
+
+```
+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)
+ }
+}
+```
+
+```
+❯ 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.
+
+```
+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.
+```
+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.
+```
+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).