Skip to content

Cookbook

Typed CRUD API

Build the widgets API with typed handlers, where each function's signature says what it reads and what it returns.

This program is the CRUD API written with typed handlers. Each handler takes a struct and returns a struct, and Zinc does the binding, the error answers and the JSON response. Reach for it when you want handlers that read like plain Go functions.

Terminal window
mkdir zinc-typed-widgets && cd zinc-typed-widgets
go mod init example.com/zinc-typed-widgets
go get github.com/0mjs/zinc

Save the program as main.go, then run go run .. It listens on port 8080.

main.go
package main
import (
"log"
"sync"
"github.com/0mjs/zinc"
)
type Widget struct {
ID int `json:"id"`
Name string `json:"name"`
}
type WidgetID struct {
ID int `path:"id"`
}
type WidgetInput struct {
ID int `path:"id"`
Name string `json:"name"`
}
type store struct {
mu sync.Mutex
nextID int
widgets map[int]Widget
}
func (s *store) list(c *zinc.Context, _ struct{}) ([]Widget, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Widget, 0, len(s.widgets))
for _, w := range s.widgets {
out = append(out, w)
}
return out, nil
}
func (s *store) create(c *zinc.Context, in WidgetInput) (Widget, error) {
if in.Name == "" {
return Widget{}, zinc.UnprocessableEntity("name is required")
}
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
w := Widget{ID: s.nextID, Name: in.Name}
s.widgets[w.ID] = w
return w, nil
}
func (s *store) get(c *zinc.Context, in WidgetID) (Widget, error) {
s.mu.Lock()
defer s.mu.Unlock()
w, ok := s.widgets[in.ID]
if !ok {
return Widget{}, zinc.NotFound("widget not found")
}
return w, nil
}
func (s *store) update(c *zinc.Context, in WidgetInput) (Widget, error) {
if in.Name == "" {
return Widget{}, zinc.UnprocessableEntity("name is required")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.widgets[in.ID]; !ok {
return Widget{}, zinc.NotFound("widget not found")
}
w := Widget{ID: in.ID, Name: in.Name}
s.widgets[in.ID] = w
return w, nil
}
func (s *store) remove(c *zinc.Context, in WidgetID) (zinc.NoContent, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.widgets[in.ID]; !ok {
return zinc.NoContent{}, zinc.NotFound("widget not found")
}
delete(s.widgets, in.ID)
return zinc.NoContent{}, nil
}
func main() {
s := &store{widgets: map[int]Widget{}}
app := zinc.New()
widgets := app.Group("/widgets")
widgets.Get("/", zinc.Typed(s.list))
widgets.Post("/", zinc.Typed(s.create)).Status(zinc.StatusCreated)
widgets.Get("/{id}", zinc.Typed(s.get))
widgets.Put("/{id}", zinc.Typed(s.update))
widgets.Delete("/{id}", zinc.Typed(s.remove))
log.Fatal(app.Listen(":8080"))
}
Terminal window
curl -i -X POST localhost:8080/widgets -H 'Content-Type: application/json' -d '{"name":"sprocket"}'
# HTTP/1.1 201 Created
# Content-Type: application/json; charset=utf-8
#
# {"id":1,"name":"sprocket"}
curl -X PUT localhost:8080/widgets/1 -H 'Content-Type: application/json' -d '{"name":"gear"}'
# {"id":1,"name":"gear"}
curl localhost:8080/widgets
# [{"id":1,"name":"gear"}]
curl -i -X DELETE localhost:8080/widgets/1
# HTTP/1.1 204 No Content

Errors come back before or from your function:

Terminal window
curl -i localhost:8080/widgets/abc
# HTTP/1.1 400 Bad Request
#
# {"error":{"status":400,"message":"invalid path parameter","fields":{"id":"must be an integer"}}}
curl -i localhost:8080/widgets/9
# HTTP/1.1 404 Not Found
#
# {"error":{"status":404,"message":"widget not found"}}
curl -X POST localhost:8080/widgets -H 'Content-Type: application/json' -d '{}'
# {"error":{"status":422,"message":"name is required"}}
  • zinc.Typed(s.create) turns a func(*zinc.Context, In) (Out, error) into a normal handler. It binds In from the path, query, headers and body, then writes Out as JSON.
  • path:"id" fills ID from {id}. An id that isn’t a number is a 400 naming the field, before your function runs.
  • The path is bound after the body, so a body such as {"id":2} on PUT /widgets/1 still updates widget 1.
  • .Status(zinc.StatusCreated) on the POST route makes a successful create answer 201 instead of 200.
  • remove returns zinc.NoContent, so a successful delete answers 204 with no body.
  • The store is in memory, so widgets are gone when the process stops. See the SQLite CRUD API for a database-backed version.
  • To check name with rules instead of by hand, add a validate tag, such as validate:"required,max=40". Failures answer 422 before your function runs. Validate Input shows more.
  • Typed Handlers: every rule for zinc.Typed, inputs and outputs.
  • CRUD API: the same API with hand-written handlers.
  • Validate Input: rules on struct fields, and 422 answers that name them.