Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

API Handler

This is a Go port of the mental model behind wickedbyte/api-handler (PSR-15 CRUD handlers for PHP, itself a fork of phoneburner/api-handler). It is not a line-by-line translation: the design is adapted to Go generics, explicit error values, and the net/http request/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.

Requirements

  • Go 1.25 or later (following the "N-2" Go release policy)
  • No dependencies outside the standard library

Installation

go get github.com/wickedbyte/api-handler-go

The Pattern

Each 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 PHP Hydrator, split into single-method interfaces).
  • Transformer[T, R] — transforms a domain object into its response representation.
  • Writer — encodes the result onto an http.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.

Usage

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).

Errors

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

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.

Contributing

Contributions are welcome, please see CONTRIBUTING.md for more information, including reporting bugs and creating pull requests.

Coordinated Disclosure

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.

About

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`.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages