This is a Go port of the mental model behind
wickedbyte/api-handler(PSR-15 CRUD handlers for PHP, itself a fork ofphoneburner/api-handler). It is not a line-by-line translation: the design is adapted to Go generics, explicit error values, and thenet/httprequest/response model.
Small, composable building blocks for HTTP APIs in Go: resolvers that load a domain object from a request,
hydrators that create, update, or delete it, and transformers that convert it into a response representation —
wired into ready-made CRUD handlers that plug into net/http.
- Go 1.25 or later (following the "N-2" Go release policy)
- No dependencies outside the standard library
go get github.com/wickedbyte/api-handler-goEach CRUD handler is composed of small, focused interfaces:
Resolver[T]— resolves a domain object from the incoming request (e.g. fetch an entity by ID).Creator[T]/Updater[T]/Deleter[T]— create, update, or delete a domain object based on the request (the PHPHydrator, split into single-method interfaces).Transformer[T, R]— transforms a domain object into its response representation.Writer— encodes the result onto anhttp.ResponseWriter(JSON by default, RFC 9457 problem documents for errors).
Handlers are plain functions — func(*http.Request) (Result, error) — so middleware is function composition, and the
Result body stays lazily untransformed until the edge encodes it. Middleware can decorate the representation with
MapBody, or reach the untransformed domain object through Resourcer — the same property the PHP package gets from
TransformableResponse.
Every snippet below is compiled and run as a testable example in example_test.go.
Given a domain type and its JSON representation:
type User struct{ ID, Name string }
type UserPayload struct {
ID string `json:"id"`
Name string `json:"name"`
}implement the roles — a repository satisfying Resolver[*User], Creator[*User], Updater[*User], and
Deleter[*User], plus a transformer — and wire the CRUD constructors onto an http.ServeMux. No explicit type
arguments are needed anywhere:
transformer := apihandler.TransformerFunc[*User, UserPayload](
func(_ *http.Request, user *User) (UserPayload, error) {
return UserPayload{ID: user.ID, Name: user.Name}, nil
},
)
mux := http.NewServeMux()
mux.Handle("GET /users/{id}", apihandler.Writer{}.Handler(apihandler.Read(repo, transformer)))
mux.Handle("PATCH /users/{id}", apihandler.Writer{}.Handler(apihandler.Update(repo, repo, transformer)))
mux.Handle("DELETE /users/{id}", apihandler.Writer{}.Handler(apihandler.Delete(repo, repo, transformer)))
mux.Handle("POST /users", apihandler.Writer{}.Handler(apihandler.Create(repo, transformer,
apihandler.WithLocation(func(_ *http.Request, user *User) string {
return "/users/" + user.ID
}),
)))apihandler.Writer{} — the zero value — encodes bodies as JSON and renders errors as RFC 9457
application/problem+json documents. Responses follow the PHP package's status semantics: 201 for create, 200 for
read/update/delete, and 204 with no body when a hydrator reports nothing to return (ok == false).
Return an error carrying an HTTP status from any role — via apihandler.Error, a StatusError literal, or your own
error type with an HTTPStatus() int method — and the default error writer renders it as a problem document:
return nil, apihandler.Error(http.StatusNotFound, "no such user"){
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "no such user",
"instance": "/users/42"
}The instance member identifies the occurrence with the request path, and a status outside the 400-599 range problem
responses require is coerced to 500. Errors handled by the default Writer are also logged with log/slog — client
errors (4xx) at Debug level, server errors at Error level, with the full error chain so the wrapped internal cause
reaches the logs even though the client never sees it. Records go to Writer.Logger when set, or slog.Default()
otherwise, so logging can be routed or silenced per Writer. Supply your own OnError to own rendering and logging
entirely.
Middleware is function composition over Handler via Wrap. The response body stays untransformed until the edge
writes it; MapBody decorates it while keeping it lazy and preserving access to the domain object for other middleware:
envelope := func(next apihandler.Handler) apihandler.Handler {
return func(r *http.Request) (apihandler.Result, error) {
result, err := next(r)
if err != nil {
return result, err
}
return apihandler.MapBody(result, func(content any) (any, error) {
return map[string]any{"data": content}, nil
}), nil
}
}
handler := apihandler.Wrap(apihandler.Read(repo, transformer), envelope)To swap the wire format, replace Writer.Encode (see ExampleWriter); to mount an API mux inside an existing handler
chain with pass-through, see Example_dispatch.
Contributions are welcome, please see CONTRIBUTING.md for more information, including reporting bugs and creating pull requests.
Keeping user information safe and secure is a top priority, and we welcome the contribution of external security researchers. If you believe you've found a security issue, please read SECURITY.md for instructions on submitting a vulnerability report.