Blog

Uma API REST JSON em Go só com a biblioteca padrão

Uma API REST de lista de tarefas em Go sem pacotes de terceiros. Construa o store, as rotas, a decodificação segura de JSON, a validação, os status codes, o middleware e os logs com slog, uma camada por vez.

Uma API REST JSON é um conjunto de URLs que programas chamam para ler e alterar dados, com JSON no corpo da requisição e da resposta. A biblioteca padrão do Go tem tudo o que você precisa para construir uma: net/http para as rotas, encoding/json para os corpos e log/slog para os logs.

Este post constrói uma pequena API de lista de tarefas como um módulo de verdade, uma camada por vez. O código mostrado vem direto dos arquivos do módulo, que passam em go vet e go test -race. Todo programa abaixo rodou no Go 1.26, e a saída foi colada da execução.

O que a API faz

A API guarda uma lista de tarefas, e cada tarefa tem um ID, um título e uma flag de concluída. Cinco rotas cobrem ler, criar, substituir e apagar tarefas:

Requisição O que faz Sucesso Erros do cliente
GET /tasks lista todas as tarefas 200
POST /tasks cria uma tarefa 201 com Location 400, 413, 415, 422
GET /tasks/{id} busca uma tarefa 200 404
PUT /tasks/{id} substitui uma tarefa 200 400, 404, 413, 415, 422
DELETE /tasks/{id} apaga uma tarefa 204 404

Qualquer rota também pode responder 500. Um caminho conhecido com o método errado recebe 405, e um caminho desconhecido recebe 404.

O módulo tem dois pacotes em internal/ e um comando:

19-tasks-api/
├── go.mod
├── cmd/
│   └── tasksd/
│       └── main.go
└── internal/
    ├── task/
    │   ├── task.go
    │   └── memstore.go
    └── api/
        ├── api.go
        ├── handlers.go
        ├── json.go
        ├── validate.go
        └── middleware.go

task não sabe nada de HTTP, e api não sabe nada de onde as tarefas ficam guardadas. cmd/tasksd escolhe um store e sobe um servidor. Esta parte deixa os testes de fora, porque a próxima parte, sobre testes e publicação, trata deles.

O store: uma interface na frente de um map

Um store de tarefas nesta API é qualquer coisa com cinco métodos, então os handlers dependem de uma interface, não de um map. Aqui está task.go, o primeiro dos dois arquivos do pacote:

// Package task defines a task and the storage behind it.
package task

import (
	"context"
	"errors"
)

// Task is one item on the list.
type Task struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

// ErrNotFound is returned when no task has the requested ID.
var ErrNotFound = errors.New("task not found")

// Store is everything the API needs from storage.
type Store interface {
	List(ctx context.Context) ([]Task, error)
	Get(ctx context.Context, id int64) (Task, error)
	Create(ctx context.Context, t Task) (Task, error)
	Update(ctx context.Context, t Task) (Task, error)
	Delete(ctx context.Context, id int64) error
}

As tags JSON dão aos campos nomes em minúsculas no JSON que trafega. ErrNotFound é um sentinel error, como io.EOF na parte sobre erros. Todo método recebe um context.Context primeiro. O store em memória o ignora, mas um store de banco de dados o passaria para cada consulta, então uma requisição cancelada também para a sua consulta.

MemStore é a implementação que este post usa:

// The build fails here if MemStore stops satisfying Store.
var _ Store = (*MemStore)(nil)

// MemStore keeps tasks in memory. It's safe for concurrent use.
type MemStore struct {
	mu     sync.RWMutex
	lastID int64
	tasks  map[int64]Task
}

// NewMemStore returns an empty store. The first task gets ID 1.
func NewMemStore() *MemStore {
	return &MemStore{tasks: map[int64]Task{}}
}

// List returns every task, ordered by ID. It never returns nil.
func (s *MemStore) List(_ context.Context) ([]Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	list := make([]Task, 0, len(s.tasks))
	for _, id := range slices.Sorted(maps.Keys(s.tasks)) {
		list = append(list, s.tasks[id])
	}
	return list, nil
}

// Get returns the task with the given ID, or ErrNotFound.
func (s *MemStore) Get(_ context.Context, id int64) (Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	t, ok := s.tasks[id]
	if !ok {
		return Task{}, ErrNotFound
	}
	return t, nil
}

// Create stores t under the next ID and returns it with the ID set.
// Any ID already in t is ignored.
func (s *MemStore) Create(_ context.Context, t Task) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.lastID++
	t.ID = s.lastID
	s.tasks[t.ID] = t
	return t, nil
}

Update e Delete seguem o mesmo formato, com Lock. Cada requisição roda na sua própria goroutine, então sem o mutex duas requisições POST poderiam incrementar lastID ao mesmo tempo e receber o mesmo ID. As leituras pegam o lock de leitura, então muitas requisições podem listar tarefas ao mesmo tempo. A linha var _ Store é a verificação em tempo de compilação da parte sobre interfaces.

List monta o resultado com make, não com var list []Task, e o comentário diz que ele nunca retorna nil. Isso importa quando o slice vira JSON:

package main

import (
	"encoding/json"
	"fmt"
)

type Task struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func main() {
	var none []Task
	empty := []Task{}
	one := []Task{{ID: 1, Title: "Buy milk"}}

	for _, tasks := range [][]Task{none, empty, one} {
		b, err := json.Marshal(tasks)
		fmt.Println(string(b), err)
	}
}

Ele imprime:

null <nil>
[] <nil>
[{"id":1,"title":"Buy milk","done":false}] <nil>

Um slice nil vira null. Um slice vazio vira []. Um cliente JavaScript que percorre null num laço lança um erro, então uma lista de tarefas vazia precisa ser [].

O construtor: rotas e dependências

O pacote api expõe uma função, New, que monta a API inteira como um único http.Handler. Ela registra as rotas com os padrões de método e caminho que chegaram no Go 1.22, que a parte anterior, sobre servidores net/http, cobre em detalhe:

// server holds the dependencies every handler needs.
type server struct {
	store  task.Store
	logger *slog.Logger
}

// New returns the API as an http.Handler. It keeps no global state:
// every call builds a fresh handler, with its own request ID counter.
func New(store task.Store, logger *slog.Logger) http.Handler {
	s := &server{store: store, logger: logger}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /tasks", s.listTasks)
	mux.HandleFunc("POST /tasks", s.createTask)
	mux.HandleFunc("GET /tasks/{id}", s.getTask)
	mux.HandleFunc("PUT /tasks/{id}", s.updateTask)
	mux.HandleFunc("DELETE /tasks/{id}", s.deleteTask)

	var h http.Handler = s.jsonRouteErrors(mux)
	h = s.recoverPanics(h)
	h = s.logRequests(h)
	h = withRequestID(h)
	return h
}

New recebe o store e o logger como argumentos e os guarda numa struct server. Não há variáveis de pacote nem função init, então cada chamada monta um handler separado. Um teste pode montar um com um store novo e um logger que escreve num buffer. As quatro linhas depois das rotas envolvem o mux em middleware, que aparece mais adiante neste post.

Handlers: ler a requisição, chamar o store, escrever a resposta

Todo handler da API lê o que precisa da requisição, chama o store e transforma o resultado num status code e num corpo. createTask é o mais longo:

func (s *server) createTask(w http.ResponseWriter, r *http.Request) {
	var in taskInput
	if err := decodeJSON(w, r, &in); err != nil {
		s.clientError(w, err)
		return
	}
	if fields := in.validate(); len(fields) > 0 {
		s.writeError(w, http.StatusUnprocessableEntity, "validation failed", fields)
		return
	}
	t, err := s.store.Create(r.Context(), task.Task{Title: in.Title, Done: in.Done})
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	w.Header().Set("Location", "/tasks/"+strconv.FormatInt(t.ID, 10))
	s.writeJSON(w, http.StatusCreated, t)
}

Cada passo que pode falhar escreve a própria resposta e retorna, então o handler nunca escreve duas vezes. Depois de uma criação bem-sucedida, ele define Location com a URL da nova tarefa e responde 201 Created com a tarefa no corpo, então o cliente descobre o ID sem uma segunda requisição.

getTask mostra como os erros do store viram status codes:

func (s *server) getTask(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(r)
	if !ok {
		s.notFound(w)
		return
	}
	t, err := s.store.Get(r.Context(), id)
	if errors.Is(err, task.ErrNotFound) {
		s.notFound(w)
		return
	}
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	s.writeJSON(w, http.StatusOK, t)
}

A verificação usa errors.Is, não ==, então continua funcionando se um store de banco de dados embrulhar ErrNotFound com %w. Qualquer outro erro é culpa do servidor, então vira um 500.

O ID sai do caminho por meio de um pequeno helper:

// parseID reads the {id} wildcard. Only positive integers name a task.
func parseID(r *http.Request) (int64, bool) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	return id, err == nil && id > 0
}

/tasks/abc e /tasks/-3 respondem 404, igual a /tasks/999, porque nenhuma tarefa mora em nenhuma dessas URLs.

Por que PUT e não PATCH

A API substitui tarefas com PUT e não oferece PATCH. Com PUT, o cliente envia a tarefa inteira, e enviar duas vezes dá o mesmo resultado, então um cliente pode tentar de novo com segurança depois de um timeout.

PATCH significa “mude só os campos que eu enviar”, então o handler teria de distinguir um done ausente de "done": false. Em Go, isso exige campos ponteiro como *bool. Uma tarefa tem dois campos, então enviar os dois quase não custa nada. O preço é que deixar done de fora o define como false. Com vinte campos, PATCH compensaria o código a mais.

Ler JSON sem confiar nele

O corpo de uma requisição vem de quem quer que a tenha enviado, então a API o decodifica por um único helper que define limites antes de ler um único byte. Aqui está decodeJSON:

// decodeJSON reads exactly one JSON value from the body into dst.
// Every error it returns is a *requestError.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if mediaType != "application/json" {
		return &requestError{http.StatusUnsupportedMediaType, "Content-Type must be application/json"}
	}

	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := dec.Decode(dst); err != nil {
		var syntaxErr *json.SyntaxError
		var typeErr *json.UnmarshalTypeError
		var tooBig *http.MaxBytesError
		switch {
		case errors.As(err, &tooBig):
			msg := fmt.Sprintf("request body must not be larger than %d bytes", tooBig.Limit)
			return &requestError{http.StatusRequestEntityTooLarge, msg}
		case errors.Is(err, io.EOF):
			return &requestError{http.StatusBadRequest, "request body must not be empty"}
		case errors.Is(err, io.ErrUnexpectedEOF):
			return &requestError{http.StatusBadRequest, "request body ends in the middle of the JSON"}
		case errors.As(err, &syntaxErr):
			msg := fmt.Sprintf("malformed JSON at byte %d", syntaxErr.Offset)
			return &requestError{http.StatusBadRequest, msg}
		case errors.As(err, &typeErr) && typeErr.Field == "":
			return &requestError{http.StatusBadRequest, "request body must be a JSON object"}
		case errors.As(err, &typeErr):
			msg := fmt.Sprintf("field %q has the wrong type", typeErr.Field)
			return &requestError{http.StatusBadRequest, msg}
		case strings.HasPrefix(err.Error(), "json: unknown field "):
			field := strings.TrimPrefix(err.Error(), "json: unknown field ")
			return &requestError{http.StatusBadRequest, "unknown field " + field}
		default:
			return &requestError{http.StatusBadRequest, "malformed JSON"}
		}
	}

	if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		return &requestError{http.StatusBadRequest, "request body must hold a single JSON value"}
	}
	return nil
}

Ele toma quatro decisões, e cada falha recebe uma mensagem com a qual o cliente pode agir:

  • O Content-Type precisa ser application/json, com ou sem ; charset=utf-8. Qualquer outra coisa recebe 415 Unsupported Media Type, não 400, porque o corpo pode estar certo e o rótulo é que está errado. O status diz ao cliente o que corrigir.
  • O corpo é limitado a 1 MiB com http.MaxBytesReader. Passado o limite, as leituras falham com *http.MaxBytesError, e o helper responde 413. Sem um limite, um único cliente poderia fazer o decoder segurar gigabytes.
  • Campos desconhecidos são rejeitados com DisallowUnknownFields, então um erro de digitação como "titel" vira um erro em vez de ser ignorado em silêncio. encoding/json não tem um tipo de erro para esse caso, então o helper compara o prefixo da mensagem.
  • Só um valor JSON é permitido. Decode lê um valor e para, então um segundo Decode precisa dar em io.EOF.

Este programa coloca o mesmo decodeJSON, copiado sem mudanças, na frente de onze corpos de requisição:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"net/http"
	"net/http/httptest"
	"strings"
)

// maxBodyBytes caps a request body at 1 MiB.
const maxBodyBytes = 1 << 20

// requestError is a problem with the request that the client can fix.
type requestError struct {
	status  int
	message string
}

func (e *requestError) Error() string { return e.message }

// decodeJSON reads exactly one JSON value from the body into dst.
// Every error it returns is a *requestError.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if mediaType != "application/json" {
		return &requestError{http.StatusUnsupportedMediaType, "Content-Type must be application/json"}
	}

	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := dec.Decode(dst); err != nil {
		var syntaxErr *json.SyntaxError
		var typeErr *json.UnmarshalTypeError
		var tooBig *http.MaxBytesError
		switch {
		case errors.As(err, &tooBig):
			msg := fmt.Sprintf("request body must not be larger than %d bytes", tooBig.Limit)
			return &requestError{http.StatusRequestEntityTooLarge, msg}
		case errors.Is(err, io.EOF):
			return &requestError{http.StatusBadRequest, "request body must not be empty"}
		case errors.Is(err, io.ErrUnexpectedEOF):
			return &requestError{http.StatusBadRequest, "request body ends in the middle of the JSON"}
		case errors.As(err, &syntaxErr):
			msg := fmt.Sprintf("malformed JSON at byte %d", syntaxErr.Offset)
			return &requestError{http.StatusBadRequest, msg}
		case errors.As(err, &typeErr) && typeErr.Field == "":
			return &requestError{http.StatusBadRequest, "request body must be a JSON object"}
		case errors.As(err, &typeErr):
			msg := fmt.Sprintf("field %q has the wrong type", typeErr.Field)
			return &requestError{http.StatusBadRequest, msg}
		case strings.HasPrefix(err.Error(), "json: unknown field "):
			field := strings.TrimPrefix(err.Error(), "json: unknown field ")
			return &requestError{http.StatusBadRequest, "unknown field " + field}
		default:
			return &requestError{http.StatusBadRequest, "malformed JSON"}
		}
	}

	if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		return &requestError{http.StatusBadRequest, "request body must hold a single JSON value"}
	}
	return nil
}

type taskInput struct {
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func try(name, contentType, body string) {
	req := httptest.NewRequest("POST", "/tasks", strings.NewReader(body))
	req.Header.Set("Content-Type", contentType)

	var in taskInput
	err := decodeJSON(httptest.NewRecorder(), req, &in)

	var re *requestError
	if errors.As(err, &re) {
		fmt.Printf("%-13s %d %s\n", name, re.status, re.message)
		return
	}
	fmt.Printf("%-13s ok  %+v\n", name, in)
}

func main() {
	const ct = "application/json"
	try("valid", ct, `{"title":"Buy milk"}`)
	try("charset", ct+"; charset=utf-8", `{"title":"Buy milk","done":true}`)
	try("form", "application/x-www-form-urlencoded", "title=Buy+milk")
	try("empty", ct, "")
	try("broken", ct, `{"title" "Buy milk"}`)
	try("cut off", ct, `{"title":"Buy`)
	try("unknown", ct, `{"title":"Buy milk","id":7}`)
	try("wrong type", ct, `{"title":"Buy milk","done":"yes"}`)
	try("array", ct, `["Buy milk"]`)
	try("two values", ct, `{"title":"a"}{"title":"b"}`)
	try("too big", ct, `{"title":"`+strings.Repeat("a", 2<<20)+`"}`)
}

Ele imprime:

valid         ok  {Title:Buy milk Done:false}
charset       ok  {Title:Buy milk Done:true}
form          415 Content-Type must be application/json
empty         400 request body must not be empty
broken        400 malformed JSON at byte 10
cut off       400 request body ends in the middle of the JSON
unknown       400 unknown field "id"
wrong type    400 field "done" has the wrong type
array         400 request body must be a JSON object
two values    400 request body must hold a single JSON value
too big       413 request body must not be larger than 1048576 bytes

Olhe a linha unknown. O tipo de entrada não tem campo ID, então um cliente não consegue escolher o próprio ID enviando "id": 7. O store escolhe os IDs, e a URL os nomeia.

encoding/json/v2 existe no código-fonte do Go 1.26, mas só atrás de GOEXPERIMENT=jsonv2, então este módulo fica com encoding/json.

Validação, e por que é 422

A validação confere os valores de uma requisição que foi decodificada sem problemas, e informa os problemas por campo. Aqui está validate.go inteiro:

// maxTitleLen is the longest title allowed, counted in characters.
const maxTitleLen = 200

// taskInput is what a client sends for POST and PUT. It has no ID field:
// the ID comes from the store or the URL, never from the body.
type taskInput struct {
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

// validate trims the title in place, then returns one message per
// invalid field, or nil if the input is fine.
func (in *taskInput) validate() map[string]string {
	fields := map[string]string{}
	in.Title = strings.TrimSpace(in.Title)
	switch {
	case in.Title == "":
		fields["title"] = "must not be empty"
	case utf8.RuneCountInString(in.Title) > maxTitleLen:
		fields["title"] = "must be at most 200 characters"
	}
	if len(fields) == 0 {
		return nil
	}
	return fields
}

O tamanho é contado com utf8.RuneCountInString, não com len, que conta bytes. Um título em chinês também ganha 200 caracteres.

Quando validate retorna campos, o handler responde 422 Unprocessable Content. Um 400 vindo de decodeJSON significa “não consegui ler sua requisição”. Um 422 significa “li sem problemas, e os valores quebram uma regra”. Um cliente pode mostrar as mensagens de cada campo ao lado das caixas de entrada, e tratar um 400 como bug dele mesmo. A constante do Go é http.StatusUnprocessableEntity, e o texto do status ainda diz “Unprocessable Entity”, o nome antigo do mesmo código.

Um só formato JSON para todo erro

Todo erro que a API envia, de 400 a 500, é um objeto JSON com uma mensagem error e, na validação, um objeto fields. Um cliente precisa de um único trecho de código para ler qualquer um deles:

// errorBody is the one shape every error response uses.
type errorBody struct {
	Error  string            `json:"error"`
	Fields map[string]string `json:"fields,omitempty"`
}

// writeJSON encodes v before writing anything, so an encoding failure
// can still become a clean 500.
func (s *server) writeJSON(w http.ResponseWriter, status int, v any) {
	body, err := json.Marshal(v)
	if err != nil {
		s.logger.Error("encode response", "err", err)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusInternalServerError)
		io.WriteString(w, `{"error":"internal server error"}`+"\n")
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	w.Write(append(body, '\n'))
}

omitempty descarta fields quando não há nenhum, então um 404 é só {"error":"task not found"}.

writeJSON chama json.Marshal antes de escrever qualquer coisa. Depois que WriteHeader roda, o status não volta atrás, então um encoder que falhasse no meio deixaria o cliente com um 200 e meio corpo. Fazer o marshal primeiro significa que uma falha ainda pode virar um 500 limpo.

Um 500 nunca diz ao cliente o motivo:

// internalError logs the real error and tells the client nothing about it.
func (s *server) internalError(w http.ResponseWriter, r *http.Request, err error) {
	s.logger.ErrorContext(r.Context(), "internal error", "err", err, "request_id", requestIDFrom(r.Context()))
	s.writeError(w, http.StatusInternalServerError, "internal server error", nil)
}

O erro real, que pode citar o host de um banco de dados ou o caminho de um arquivo, vai para o log. O cliente recebe internal server error e um ID de requisição no header X-Request-Id, então um relato de bug pode apontar para a linha do log.

Status codes, de ponta a ponta

O status code é a primeira coisa que um cliente lê, então cada resposta da API escolhe um de propósito. Um programa avulso não consegue importar os pacotes internal/ do módulo, então este programa é uma cópia enxuta da API: um map no lugar do store e um passo curto de decodificação, com os mesmos status e o mesmo formato de erro. Ele roda sobre httptest.NewServer, um servidor HTTP de verdade numa porta local aleatória:

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"
)

type Task struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

// A trimmed version of the module's API: a map for a store, and a short
// decode step in place of decodeJSON.
type api struct {
	mu     sync.Mutex
	lastID int64
	tasks  map[int64]Task
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

type errorBody struct {
	Error  string            `json:"error"`
	Fields map[string]string `json:"fields,omitempty"`
}

func writeError(w http.ResponseWriter, status int, msg string, fields map[string]string) {
	writeJSON(w, status, errorBody{Error: msg, Fields: fields})
}

func (a *api) decode(w http.ResponseWriter, r *http.Request) (Task, bool) {
	var in struct {
		Title string `json:"title"`
		Done  bool   `json:"done"`
	}
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&in); err != nil {
		writeError(w, http.StatusBadRequest, "malformed JSON", nil)
		return Task{}, false
	}
	if strings.TrimSpace(in.Title) == "" {
		writeError(w, http.StatusUnprocessableEntity, "validation failed",
			map[string]string{"title": "must not be empty"})
		return Task{}, false
	}
	return Task{Title: in.Title, Done: in.Done}, true
}

func (a *api) create(w http.ResponseWriter, r *http.Request) {
	t, ok := a.decode(w, r)
	if !ok {
		return
	}
	a.mu.Lock()
	a.lastID++
	t.ID = a.lastID
	a.tasks[t.ID] = t
	a.mu.Unlock()
	w.Header().Set("Location", "/tasks/"+strconv.FormatInt(t.ID, 10))
	writeJSON(w, http.StatusCreated, t)
}

func (a *api) get(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	a.mu.Lock()
	t, ok := a.tasks[id]
	a.mu.Unlock()
	if !ok {
		writeError(w, http.StatusNotFound, "task not found", nil)
		return
	}
	writeJSON(w, http.StatusOK, t)
}

func (a *api) update(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	t, ok := a.decode(w, r)
	if !ok {
		return
	}
	t.ID = id
	a.mu.Lock()
	_, found := a.tasks[id]
	if found {
		a.tasks[id] = t
	}
	a.mu.Unlock()
	if !found {
		writeError(w, http.StatusNotFound, "task not found", nil)
		return
	}
	writeJSON(w, http.StatusOK, t)
}

func (a *api) remove(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	a.mu.Lock()
	_, found := a.tasks[id]
	delete(a.tasks, id)
	a.mu.Unlock()
	if !found {
		writeError(w, http.StatusNotFound, "task not found", nil)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	a := &api{tasks: map[int64]Task{}}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /tasks", a.create)
	mux.HandleFunc("GET /tasks/{id}", a.get)
	mux.HandleFunc("PUT /tasks/{id}", a.update)
	mux.HandleFunc("DELETE /tasks/{id}", a.remove)

	srv := httptest.NewServer(mux)
	defer srv.Close()

	send := func(method, path, body string) {
		req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
		req.Header.Set("Content-Type", "application/json")
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			fmt.Println(err)
			return
		}
		defer resp.Body.Close()
		out, _ := io.ReadAll(resp.Body)

		fmt.Println(method, path, "->", resp.Status)
		if loc := resp.Header.Get("Location"); loc != "" {
			fmt.Println("  Location:", loc)
		}
		if len(out) > 0 {
			fmt.Print("  ", string(out))
		}
	}

	send("POST", "/tasks", `{"title":"Buy milk"}`)
	send("GET", "/tasks/1", "")
	send("PUT", "/tasks/1", `{"title":"Buy milk","done":true}`)
	send("POST", "/tasks", `{"title":"  "}`)
	send("DELETE", "/tasks/1", "")
	send("GET", "/tasks/1", "")
	send("PATCH", "/tasks/1", `{"done":false}`)
}

Ele imprime:

POST /tasks -> 201 Created
  Location: /tasks/1
  {"id":1,"title":"Buy milk","done":false}
GET /tasks/1 -> 200 OK
  {"id":1,"title":"Buy milk","done":false}
PUT /tasks/1 -> 200 OK
  {"id":1,"title":"Buy milk","done":true}
POST /tasks -> 422 Unprocessable Entity
  {"error":"validation failed","fields":{"title":"must not be empty"}}
DELETE /tasks/1 -> 204 No Content
GET /tasks/1 -> 404 Not Found
  {"error":"task not found"}
PATCH /tasks/1 -> 405 Method Not Allowed
  Method Not Allowed

Essa é a vida de uma tarefa. DELETE responde 204 No Content, que não pode ter corpo, então o handler chama WriteHeader e não escreve nada. Depois disso, a tarefa é um 404.

A última resposta é texto puro, Method Not Allowed, porque a cópia enxuta deixa o 405 para o mux. O módulo real corrige isso com um middleware na próxima seção.

Middleware: um handler que envolve um handler

Middleware em Go é uma função do tipo func(http.Handler) http.Handler: ela recebe um handler e retorna um novo, que faz algum trabalho antes ou depois de chamá-lo. Este programa envolve um handler em três camadas que imprimem conforme a requisição passa:

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

// layer returns a middleware that prints when a request passes in and out.
func layer(name, indent string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Println(indent + name + ": in")
			next.ServeHTTP(w, r)
			fmt.Println(indent + name + ": out")
		})
	}
}

func main() {
	var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("      handler")
	})
	h = layer("recover", "    ")(h)
	h = layer("log", "  ")(h)
	h = layer("request id", "")(h)

	h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/tasks", nil))
}

Ele imprime:

request id: in
  log: in
    recover: in
      handler
    recover: out
  log: out
request id: out

A última camada aplicada, request id, é a mais externa, então roda primeiro. O código depois de next.ServeHTTP roda na volta, em ordem inversa.

Explicado como se você tivesse dez anos

Imagine mandar uma carta para um escritório grande. Na porta da frente, alguém carimba um número no envelope. Em seguida, um funcionário anota a hora em que ela chegou. Depois ela passa por um socorrista, que está ali caso algo dê errado. Só então ela chega à pessoa que escreve a resposta.

A resposta sai pelo mesmo caminho, ao contrário. O socorrista a deixa passar. O funcionário anota “carta 7: respondida, sim”. A porta da frente a despacha.

Cada pessoa só sabe para quem entregar a carta em seguida.

A versão precisa

Um middleware retorna um http.HandlerFunc que é uma closure sobre next. a(b(c(h))) monta uma cadeia em que o handler de a chama o de b, e assim por diante até h. O código antes de next.ServeHTTP roda de fora para dentro. O código depois dele, incluindo funções adiadas, roda de dentro para fora. Por isso um recover adiado numa camada captura um panic de qualquer camada dentro dela.

Uma camada pode responder sozinha e nunca chamar next, ou alterar a requisição na ida com r.WithContext. Ela não consegue ler a resposta na volta, a não ser que envolva o http.ResponseWriter.

Onde a analogia falha: a resposta não é uma segunda carta que volta passando por todo mundo. O handler escreve direto no http.ResponseWriter, e os bytes podem chegar ao cliente antes de o código “depois” das camadas externas rodar. É por isso que a camada de log mais abaixo envolve o writer para descobrir o status code.

withRequestID dá um ID à requisição, define X-Request-Id logRequests grava uma linha de log na volta recoverPanics converte um panic em 500 jsonRouteErrors envia os 404 e 405 do mux em JSON ServeMux, depois um handler como createTask

A ordem que New monta. Uma requisição passa por cada camada na ida, e o código de cada camada depois de next.ServeHTTP roda na volta, da mais interna para fora.

O primeiro middleware do módulo dá um ID a cada requisição:

// requestIDKey is the context key for the request ID. It's unexported,
// so no other package can read or overwrite the value by accident.
type requestIDKey struct{}

// withRequestID gives every request an ID from a counter, stores it in the
// request's context and sends it back in the X-Request-Id header.
func withRequestID(next http.Handler) http.Handler {
	var counter atomic.Uint64
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := "req-" + strconv.FormatUint(counter.Add(1), 10)
		w.Header().Set("X-Request-Id", id)
		ctx := context.WithValue(r.Context(), requestIDKey{}, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// requestIDFrom returns the request ID stored by withRequestID, or "".
func requestIDFrom(ctx context.Context) string {
	id, _ := ctx.Value(requestIDKey{}).(string)
	return id
}

O contador é local a withRequestID, então cada chamada a New recomeça em req-1, o que deixa os IDs previsíveis nos testes. Ele é um atomic.Uint64 porque as requisições rodam em paralelo. O ID viaja no context sob um tipo de chave não exportado, o padrão da parte sobre context.

O middleware mais interno faz os erros do próprio mux seguirem o formato de erro da API:

// jsonRouteErrors lets the mux decide 404 and 405 for requests that match no
// pattern, including the Allow header, but sends the body as JSON.
func (s *server) jsonRouteErrors(mux *http.ServeMux) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h, pattern := mux.Handler(r)
		if pattern != "" {
			mux.ServeHTTP(w, r)
			return
		}
		// No pattern matched. h is the mux's own 404 or 405 handler.
		// Run it against a writer that keeps the status and drops the text.
		probe := &statusOnly{header: w.Header()}
		h.ServeHTTP(probe, r)
		s.writeError(w, probe.status, strings.ToLower(http.StatusText(probe.status)), nil)
	})
}

// statusOnly is a ResponseWriter that shares the real headers but keeps
// only the status code, discarding the body.
type statusOnly struct {
	header http.Header
	status int
}

func (p *statusOnly) Header() http.Header         { return p.header }
func (p *statusOnly) WriteHeader(status int)      { p.status = status }
func (p *statusOnly) Write(b []byte) (int, error) { return len(b), nil }

mux.Handler(r) pergunta ao mux qual padrão trataria a requisição, sem executá-lo. Um padrão vazio significa que nenhuma rota casou, e o handler retornado é o próprio responder de 404 ou 405 do mux. Executá-lo contra statusOnly guarda o status e o header Allow e joga fora o texto. O cliente recebe {"error":"method not allowed"}.

Logs com log/slog

O pacote log/slog escreve logs estruturados: uma mensagem mais pares chave-valor que uma máquina consegue pesquisar. Os handlers JSON e de texto dele acrescentam a hora atual a cada linha. Isso mudaria a saída aqui a cada execução, então os programas deste post usam ReplaceAttr para descartar a chave time:

package main

import (
	"errors"
	"log/slog"
	"os"
	"time"
)

func main() {
	opts := &slog.HandlerOptions{
		// Drop the time so the output is the same on every run.
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey && len(groups) == 0 {
				return slog.Attr{}
			}
			return a
		},
	}

	logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
	logger.Info("request", "method", "GET", "status", 200, "duration", 1500*time.Microsecond)

	reqLogger := logger.With("request_id", "req-7")
	reqLogger.Error("internal error", "err", errors.New("disk full"))

	text := slog.New(slog.NewTextHandler(os.Stdout, opts))
	text.Info("request", "method", "GET", "status", 200, "duration", 1500*time.Microsecond)
}

Ele imprime:

{"level":"INFO","msg":"request","method":"GET","status":200,"duration":1500000}
{"level":"ERROR","msg":"internal error","request_id":"req-7","err":"disk full"}
level=INFO msg=request method=GET status=200 duration=1.5ms

ReplaceAttr vê cada atributo antes de ele ser escrito, e retornar um slog.Attr vazio o remove. A verificação len(groups) == 0 preserva um campo time dentro de um grupo. logger.With retorna um logger que acrescenta request_id a cada linha.

Confira os valores de duration. O handler JSON escreve um time.Duration como um número inteiro de nanossegundos, 1500000, enquanto o handler de texto escreve 1.5ms. Qualquer coisa que pesquise seus logs precisa saber qual dos dois está lendo.

O middleware de log precisa do status code, que o handler escreve no ResponseWriter. Então ele entrega ao handler um wrapper que se lembra dele:

// statusRecorder remembers the status code a handler wrote.
type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (rec *statusRecorder) WriteHeader(status int) {
	if rec.status == 0 {
		rec.status = status
	}
	rec.ResponseWriter.WriteHeader(status)
}

func (rec *statusRecorder) Write(b []byte) (int, error) {
	if rec.status == 0 {
		rec.status = http.StatusOK
	}
	return rec.ResponseWriter.Write(b)
}

// Unwrap lets http.ResponseController reach the real ResponseWriter.
func (rec *statusRecorder) Unwrap() http.ResponseWriter {
	return rec.ResponseWriter
}

// logRequests writes one log line per request, after the handler returns.
func (s *server) logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w}
		next.ServeHTTP(rec, r)
		s.logger.LogAttrs(r.Context(), slog.LevelInfo, "request",
			slog.String("method", r.Method),
			slog.String("path", r.URL.Path),
			slog.Int("status", rec.status),
			slog.Duration("duration", time.Since(start)),
			slog.String("request_id", requestIDFrom(r.Context())),
		)
	})
}

statusRecorder embute http.ResponseWriter e sobrescreve dois métodos. Um Write sem WriteHeader envia um 200, então Write também registra isso. Unwrap permite que http.NewResponseController alcance o writer original, para recursos como flush que o wrapper não tem.

Recuperar-se de um panic

Um panic num handler não derruba um servidor HTTP em Go, mas o cliente fica sem resposta. net/http o recupera, registra um stack trace e fecha a conexão. Quando testei, o http.Get do cliente retornou um erro EOF e nenhum status code. O middleware do módulo transforma isso num 500 de verdade:

// recoverPanics turns a panic in a handler into a 500 response. The panic
// value and stack go to the log, never to the client.
func (s *server) recoverPanics(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			v := recover()
			if v == nil {
				return
			}
			if v == http.ErrAbortHandler {
				panic(v)
			}
			s.logger.LogAttrs(r.Context(), slog.LevelError, "panic",
				slog.Any("value", v),
				slog.String("stack", string(debug.Stack())),
				slog.String("request_id", requestIDFrom(r.Context())),
			)
			s.writeError(w, http.StatusInternalServerError, "internal server error", nil)
		}()
		next.ServeHTTP(w, r)
	})
}

O valor do panic e o stack trace vão para o log, e o cliente recebe o mesmo corpo genérico de 500 de qualquer outro erro interno. http.ErrAbortHandler volta a entrar em panic de propósito: um handler faz panic com ele para abortar uma resposta deliberadamente, e net/http o trata em silêncio.

Este programa copia os três middlewares sem mudanças e os coloca em volta de um handler que indexa um slice sem checar os limites. O handler de log também descarta duration e stack, que mudam a cada execução:

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"runtime/debug"
	"strconv"
	"sync/atomic"
	"time"
)

type server struct {
	logger *slog.Logger
}

func (s *server) writeError(w http.ResponseWriter, status int, msg string, fields map[string]string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	fmt.Fprintf(w, "{\"error\":%q}\n", msg)
}

// The middleware below is copied unchanged from internal/api/middleware.go.

// requestIDKey is the context key for the request ID. It's unexported,
// so no other package can read or overwrite the value by accident.
type requestIDKey struct{}

// withRequestID gives every request an ID from a counter, stores it in the
// request's context and sends it back in the X-Request-Id header.
func withRequestID(next http.Handler) http.Handler {
	var counter atomic.Uint64
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := "req-" + strconv.FormatUint(counter.Add(1), 10)
		w.Header().Set("X-Request-Id", id)
		ctx := context.WithValue(r.Context(), requestIDKey{}, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// requestIDFrom returns the request ID stored by withRequestID, or "".
func requestIDFrom(ctx context.Context) string {
	id, _ := ctx.Value(requestIDKey{}).(string)
	return id
}

// statusRecorder remembers the status code a handler wrote.
type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (rec *statusRecorder) WriteHeader(status int) {
	if rec.status == 0 {
		rec.status = status
	}
	rec.ResponseWriter.WriteHeader(status)
}

func (rec *statusRecorder) Write(b []byte) (int, error) {
	if rec.status == 0 {
		rec.status = http.StatusOK
	}
	return rec.ResponseWriter.Write(b)
}

// Unwrap lets http.ResponseController reach the real ResponseWriter.
func (rec *statusRecorder) Unwrap() http.ResponseWriter {
	return rec.ResponseWriter
}

// logRequests writes one log line per request, after the handler returns.
func (s *server) logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w}
		next.ServeHTTP(rec, r)
		s.logger.LogAttrs(r.Context(), slog.LevelInfo, "request",
			slog.String("method", r.Method),
			slog.String("path", r.URL.Path),
			slog.Int("status", rec.status),
			slog.Duration("duration", time.Since(start)),
			slog.String("request_id", requestIDFrom(r.Context())),
		)
	})
}

// recoverPanics turns a panic in a handler into a 500 response. The panic
// value and stack go to the log, never to the client.
func (s *server) recoverPanics(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			v := recover()
			if v == nil {
				return
			}
			if v == http.ErrAbortHandler {
				panic(v)
			}
			s.logger.LogAttrs(r.Context(), slog.LevelError, "panic",
				slog.Any("value", v),
				slog.String("stack", string(debug.Stack())),
				slog.String("request_id", requestIDFrom(r.Context())),
			)
			s.writeError(w, http.StatusInternalServerError, "internal server error", nil)
		}()
		next.ServeHTTP(w, r)
	})
}

func main() {
	// Drop the attributes that change on every run: time, duration, stack.
	opts := &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			switch a.Key {
			case slog.TimeKey, "duration", "stack":
				return slog.Attr{}
			}
			return a
		},
	}
	s := &server{logger: slog.New(slog.NewJSONHandler(os.Stdout, opts))}

	titles := []string{"Buy milk", "Walk the dog"}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
		id, _ := strconv.Atoi(r.PathValue("id"))
		fmt.Fprintln(w, titles[id-1]) // a bug: no bounds check
	})

	var h http.Handler = s.recoverPanics(mux)
	h = s.logRequests(h)
	h = withRequestID(h)

	for _, path := range []string{"/tasks/2", "/tasks/5"} {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		fmt.Printf("client got %d %s: %s", rec.Code, rec.Header().Get("X-Request-Id"), rec.Body)
	}
}

Ele imprime:

{"level":"INFO","msg":"request","method":"GET","path":"/tasks/2","status":200,"request_id":"req-1"}
client got 200 req-1: Walk the dog
{"level":"ERROR","msg":"panic","value":"runtime error: index out of range [4] with length 2","request_id":"req-2"}
{"level":"INFO","msg":"request","method":"GET","path":"/tasks/5","status":500,"request_id":"req-2"}
client got 500 req-2: {"error":"internal server error"}

A segunda requisição entra em panic. recoverPanics registra o panic com req-2, escreve o 500 e retorna normalmente, então logRequests ainda registra status: 500 com o mesmo ID. O corpo que o cliente recebe não diz nada sobre slices. Isso depende da ordem em New: com recoverPanics do lado de fora de logRequests, o panic pularia a linha de log.

Um limite: se um handler já enviou o status quando entra em panic, é tarde demais para um 500.

Rodar o servidor

O comando em cmd/tasksd monta as peças e sobe um http.Server. main lê uma flag -addr, cria um logger JSON na saída de erro padrão e chama run, que abre o listener e o entrega a serve:

func run(addr string, logger *slog.Logger) error {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	return serve(ctx, ln, api.New(task.NewMemStore(), logger), logger)
}

// serve answers requests on ln until ctx is cancelled, then shuts down
// gracefully: no new connections, and requests in flight get to finish.
func serve(ctx context.Context, ln net.Listener, handler http.Handler, logger *slog.Logger) error {
	srv := &http.Server{
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
		ErrorLog:          slog.NewLogLogger(logger.Handler(), slog.LevelError),
	}

	errc := make(chan error, 1)
	go func() {
		logger.Info("listening", "addr", ln.Addr().String())
		errc <- srv.Serve(ln)
	}()

	select {
	case err := <-errc:
		return err
	case <-ctx.Done():
	}

	logger.Info("shutting down")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		return err
	}
	if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
		return err
	}
	return nil
}

Este é o único lugar que escolhe MemStore. serve recebe um listener e um handler em vez de um endereço, então um teste pode rodá-lo numa porta livre com um handler próprio. Os timeouts são os da parte anterior, e ReadHeaderTimeout é o que você nunca deve pular. signal.NotifyContext cancela ctx no Ctrl+C, e srv.Shutdown então espera as requisições em andamento terminarem. A próxima parte explica os timeouts e o shutdown em detalhe.

Com go run ./cmd/tasksd rodando num terminal, enviei requisições de outro:

$ curl -s -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title":"Buy milk"}'
{"id":1,"title":"Buy milk","done":false}
$ curl -s -X PUT localhost:8080/tasks/1 -H 'Content-Type: application/json' -d '{"title":"Buy milk","done":true}'
{"id":1,"title":"Buy milk","done":true}
$ curl -s localhost:8080/tasks
[{"id":1,"title":"Buy milk","done":true}]
$ curl -s -X PATCH localhost:8080/tasks/1 -w '%{http_code}\n'
{"error":"method not allowed"}
405
$ curl -s -X POST localhost:8080/tasks -d 'title=Walk the dog' -w '%{http_code}\n'
{"error":"Content-Type must be application/json"}
415

Desta vez o 405 vem em JSON, graças a jsonRouteErrors. A última requisição envia um corpo de formulário, que é o que curl -d envia por padrão, e recebe 415. O terminal do servidor mostrou uma linha de log JSON por requisição. Não colei essas linhas, porque a hora e a duração mudam a cada execução.

Onde entraria um banco de dados

Uma lista de tarefas de verdade precisa sobreviver a um reinício, e a interface Store é onde um banco de dados se encaixa sem que os handlers mudem. database/sql está na biblioteca padrão, mas todo driver de banco de dados é um módulo de terceiros, então esta série para no formato. O Get de um store SQL ficaria assim:

// SQLStore keeps tasks in a SQL database. With the other four methods
// written the same way, it satisfies Store just as MemStore does.
type SQLStore struct {
	db *sql.DB
}

func (s *SQLStore) Get(ctx context.Context, id int64) (Task, error) {
	var t Task
	err := s.db.QueryRowContext(ctx,
		"SELECT id, title, done FROM tasks WHERE id = $1", id,
	).Scan(&t.ID, &t.Title, &t.Done)
	if errors.Is(err, sql.ErrNoRows) {
		return Task{}, ErrNotFound
	}
	if err != nil {
		return Task{}, fmt.Errorf("get task %d: %w", id, err)
	}
	return t, nil
}

QueryRowContext recebe o context da requisição, então um cliente que se desconecta cancela a sua consulta. sql.ErrNoRows vira ErrNotFound, então getTask ainda responde 404, e os outros erros são embrulhados e viram um 500. O placeholder $1 é o estilo do PostgreSQL; os drivers de MySQL e SQLite usam ?. Em run, o store SQL substituiria task.NewMemStore(), e nada em internal/api mudaria.

O que lembrar

  • Coloque o armazenamento atrás de uma interface pequena, e retorne sentinel errors que os handlers verificam com errors.Is.
  • Decodifique os corpos por um único helper: confira o Content-Type (415), limite o tamanho com http.MaxBytesReader (413), chame DisallowUnknownFields e permita um único valor JSON (400).
  • Responda 422 com mensagens por campo para uma entrada bem formada que quebra uma regra, 201 com Location para uma criação e 204 sem corpo para uma exclusão.
  • Envie todo erro num único formato JSON, faça o marshal antes de escrever o status e registre os erros internos no log em vez de devolvê-los.
  • Middleware é func(http.Handler) http.Handler. O último aplicado roda primeiro, então coloque os IDs de requisição do lado de fora do log, e o log do lado de fora da recuperação de panic. Faça o log com slog por meio de um ResponseWriter envolvido.
  • Monte o handler com um construtor que recebe as dependências e não usa variáveis globais.

Escolha cada status code de propósito, e envie todo erro no mesmo formato.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.