Package jsonhandler

import "funnyhaha.technology/httpkit/jsonhandler"
View the source code

Overview

Package jsonhandler builds an HTTP handler for a JSON endpoint from one operation: a func(context.Context, Params) (Result, error).

The handler maps between the wire and the operation, and nothing else:

  1. It negotiates the response's media type from Accept, or responds 406.
  2. It decodes the body as JSON into the wire request, checking its media type and content coding (415) and size (413). A gzip body is decoded, and its decoded size is what counts.
  3. It converts the wire request to the operation's params with its Params method (400).
  4. It calls the operation, and maps its declared errors to statuses (else 500).
  5. It converts the result to the wire response and encodes it.

Every error is written as an RFC 9457 problem. Wire an endpoint by naming its wire request type; the rest is inferred from the operation and the response constructor:

mux.Handle("POST /users", jsonhandler.New[createUserRequest](registerUser, newCreateUserResponse).
	WithStatus(http.StatusCreated).
	WithErrors(problem.ErrorStatus{Err: ErrUserExists, Status: rfc9110.Conflict()}).
	Handler())

Index

Constants

const DefaultMaxBytes = 1 << 20
source

DefaultMaxBytes is the largest request body a handler reads by default.

Variables

var (
	// ErrUnsupportedMediaType is the 415 problem's error for a body that
	// isn't JSON.
	ErrUnsupportedMediaType = errors.New("request body must be JSON")
	// ErrNotAcceptable is the 406 problem's error for an Accept field that
	// accepts none of the handler's media types.
	ErrNotAcceptable = errors.New("no acceptable media type")
	// ErrTrailingData is the 400 problem's error for a body with more than
	// one JSON value.
	ErrTrailingData = errors.New("unexpected data after the JSON value")
)
source

Types

Builder source

type Builder[Request WireRequest[Params], Params, Result, Response any] struct {
	// contains filtered or unexported fields
}

Builder configures a JSON handler. Its With methods return a modified copy.

New source

func New[Request WireRequest[Params], Params, Result, Response any](
	op func(context.Context, Params) (Result, error),
	response func(Result) Response,
) Builder[Request, Params, Result, Response]

New returns a Builder for a handler that calls op, and converts its result with response. It panics if either is nil.

Example

ExampleNew shows an endpoint wired from an operation, with a declared error mapped to a 409 problem.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"

	"funnyhaha.technology/httpkit/jsonhandler"
	"funnyhaha.technology/httpkit/problem"
	"funnyhaha.technology/rfc/rfc9110"
)

var (
	errMissingName = errors.New("missing name")
	errNameTaken   = errors.New("name taken")
	errDatabase    = errors.New("connection refused")
)

type greetParams struct{ Name string }

type greetResult struct{ Greeting string }

type greetRequest struct {
	Name string `json:"name"`
}

type greetResponse struct {
	Greeting string `json:"greeting"`
}

func (req greetRequest) Params(r *http.Request) (greetParams, error) {
	name := req.Name
	if name == "" {
		name = r.URL.Query().Get("name")
	}
	if name == "" {
		return greetParams{}, errMissingName
	}
	return greetParams{Name: name}, nil
}

func newGreetResponse(res greetResult) greetResponse {
	return greetResponse(res)
}

func greet(_ context.Context, p greetParams) (greetResult, error) {
	switch p.Name {
	case "taken":
		return greetResult{}, fmt.Errorf("greet %s: %w", p.Name, errNameTaken)
	case "db":
		return greetResult{}, fmt.Errorf("greet: %w", errDatabase)
	}
	return greetResult{Greeting: "hello, " + p.Name}, nil
}

func main() {
	h := jsonhandler.New[greetRequest](greet, newGreetResponse).
		WithErrors(problem.ErrorStatus{Err: errNameTaken, Status: rfc9110.Conflict()}).
		Handler()

	rec := httptest.NewRecorder()
	h(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/?name=taken", http.NoBody))

	var p map[string]any
	_ = json.Unmarshal(rec.Body.Bytes(), &p)
	fmt.Println(rec.Code, p["detail"])
}

Output:

409 greet taken: name taken

(Builder[Request, Params, Result, Response]) Handler source

func (b Builder[Request, Params, Result, Response]) Handler() http.HandlerFunc

Handler returns the handler.

(Builder[Request, Params, Result, Response]) WithCacheControl source

func (b Builder[Request, Params, Result, Response]) WithCacheControl(dirs ...rfc9111.Directive) Builder[Request, Params, Result, Response]

WithCacheControl sets the Cache-Control field of a successful response.

(Builder[Request, Params, Result, Response]) WithErrors source

func (b Builder[Request, Params, Result, Response]) WithErrors(errs ...problem.ErrorStatus) Builder[Request, Params, Result, Response]

WithErrors maps the operation's declared errors, and errors from the wire request's Params method, to problems. They're matched in order.

(Builder[Request, Params, Result, Response]) WithMaxBytes source

func (b Builder[Request, Params, Result, Response]) WithMaxBytes(n int64) Builder[Request, Params, Result, Response]

WithMaxBytes sets the largest request body the handler reads, in bytes, as sent and as decoded. A larger body gets a 413.

(Builder[Request, Params, Result, Response]) WithStatus source

func (b Builder[Request, Params, Result, Response]) WithStatus(code int) Builder[Request, Params, Result, Response]

WithStatus sets the status of a successful response, such as 201. It panics if code isn't a 2xx code.

(Builder[Request, Params, Result, Response]) WithText source

func (b Builder[Request, Params, Result, Response]) WithText(format func(Response) string) Builder[Request, Params, Result, Response]

WithText adds a text/plain representation of the response, for clients that prefer it. JSON stays the default, for a client that accepts both equally, such as one that sends "Accept: */*".

(Builder[Request, Params, Result, Response]) WithTextFirst source

func (b Builder[Request, Params, Result, Response]) WithTextFirst(format func(Response) string) Builder[Request, Params, Result, Response]

WithTextFirst is like WithText, but makes text/plain the default, so a client such as curl, which sends "Accept: */*", gets text.

WireRequest source

type WireRequest[Params any] interface {
	Params(r *http.Request) (Params, error)
}

WireRequest is a decoded wire request that converts itself to an operation's params. It receives the request, so it can read path and query values as well as the decoded body. It checks the input's shape and parses each value, returning a declared error for anything invalid.