Handlers
ShiftAPI uses Go generics to capture request and response types at compile time. Each handler is a plain function that receives an *http.Request and a typed input, and returns a typed output.
Registering handlers
Section titled “Registering handlers”Use Handle to register handlers:
shiftapi.Handle(api, "GET /users", listUsers)shiftapi.Handle(api, "POST /users", createUser)shiftapi.Handle(api, "PUT /users/{id}", updateUser)shiftapi.Handle(api, "DELETE /users/{id}", deleteUser)Handler signature
Section titled “Handler signature”Every handler follows the same pattern:
func handler(r *http.Request, in InputType) (OutputType, error) { // ...}r *http.Request— the standard HTTP request, useful for cookies, path parameters, and other request metadatain InputType— automatically decoded from JSON body, query parameters, HTTP headers, or multipart form dataOutputType— serialized as JSON in the response (can be a pointer or value type). Fields withheadertags are sent as HTTP response headers instead.error— return an error to send an error response
Use struct{} as the input type when a handler takes no input:
shiftapi.Handle(api, "GET /health", func(r *http.Request, _ struct{}) (*Status, error) { return &Status{OK: true}, nil})Input decoding
Section titled “Input decoding”Fields are decoded based on struct tags:
| Tag | Source | Example |
|---|---|---|
path | URL path parameters | path:"id" |
json | Request body (JSON) | json:"name" |
query | Query parameters | query:"page" |
header | HTTP headers (request) or response headers (output) | header:"Authorization" |
form | Multipart form data | form:"file" |
You can combine tags in a single struct — path, query, header, and json fields can be freely mixed. The only restriction is that form and json tags cannot coexist on the same struct.
type UpdateUser struct { ID int `path:"id"` Token string `header:"Authorization"` Name string `json:"name"`}Header and query fields support string, bool, int*, uint*, float* scalars and *T pointers for optional values. Query fields also support []T slices for repeated params. Slices are not supported for headers. Parse errors return 400; validation failures return 422.
Path parameters
Section titled “Path parameters”Use path tags to declare typed path parameters. The field name in the tag must match a {param} in the route pattern:
type GetUserInput struct { ID int `path:"id" validate:"required,gt=0"`}
shiftapi.Handle(api, "GET /users/{id}", func(r *http.Request, in GetUserInput) (*User, error) { return findUser(in.ID) // in.ID is already an int, validated})Path parameters are always required and scalar — string, bool, int*, uint*, float* are supported. Use validate:"uuid" on a string field for UUID params. Parse errors return 400; validation failures return 422.
If a path-tagged field name doesn’t match any {param} in the route, registration panics immediately.
You can also access path parameters directly via r.PathValue() — this still works for routes that don’t need typed parsing:
shiftapi.Handle(api, "GET /users/{id}", func(r *http.Request, _ struct{}) (*User, error) { id := r.PathValue("id") // string return findUser(id)})Response headers
Section titled “Response headers”Use header tags on the output struct to set HTTP response headers. Header-tagged fields are written as response headers and automatically excluded from the JSON body.
type CachedResponse struct { CacheControl string `header:"Cache-Control"` ETag *string `header:"ETag"` Items []Item `json:"items"`}
shiftapi.Handle(api, "GET /items", func(r *http.Request, _ struct{}) (*CachedResponse, error) { etag := `"v1"` return &CachedResponse{ CacheControl: "max-age=3600", ETag: &etag, Items: items, }, nil})The response above sends:
Cache-Control: max-age=3600andETag: "v1"as HTTP headers{"items": [...]}as the JSON body (header fields are stripped)
Non-pointer fields are always sent — even with a zero value like "" or 0. Use a pointer field (*string, *int, etc.) for optional headers that should only be sent when set. Supported types are the same scalars as request headers: string, bool, int*, uint*, float*.
Response headers are automatically documented in the OpenAPI spec. Non-pointer fields appear as required, pointer fields as optional.
JSON struct tags like omitempty on non-header fields are fully preserved. If your response type implements json.Marshaler, headers are still extracted and set, but your custom MarshalJSON controls the body — header fields won’t be automatically stripped in that case. Use json:"-" on header fields to exclude them from your custom encoding:
type CustomResp struct { XReq string `header:"X-Request-Id" json:"-"` Message string `json:"message"`}
func (r CustomResp) MarshalJSON() ([]byte, error) { // XReq won't appear in the output thanks to json:"-" return json.Marshal(struct { Message string `json:"message"` }{Message: r.Message})}Static response headers
Section titled “Static response headers”Use WithResponseHeader to set a fixed header on every response. It works at all three levels — API, group, and route — and is documented in the OpenAPI spec.
api := shiftapi.New( shiftapi.WithResponseHeader("X-Content-Type-Options", "nosniff"), // all routes)v1 := api.Group("/api/v1", shiftapi.WithResponseHeader("X-API-Version", "1"), // group routes)shiftapi.Handle(v1, "GET /items", listItems, shiftapi.WithResponseHeader("Cache-Control", "max-age=3600"), // single route)Static headers are inherited: a route in a group receives headers from the API, the group, and any route-level options. Use header struct tags (above) for dynamic headers that depend on the response value, and WithResponseHeader for fixed values.
Header application order
Section titled “Header application order”When a response is written, headers are applied in this order:
- Static headers (
WithResponseHeader) — applied first, in API → Group → Route order. If the same header name is declared at multiple levels, the later level wins (Route overrides Group, Group overrides API). - Dynamic headers (
headerstruct tags) — applied second. If a dynamic header uses the same name as a static header, the dynamic value takes precedence. - Middleware-set headers — middleware runs before the handler, so headers set by middleware can be overridden by both static and dynamic headers.
This means the most specific source always wins: handler struct tags override WithResponseHeader, which overrides middleware.
No-body responses
Section titled “No-body responses”For status codes that forbid a response body (204 No Content, 304 Not Modified), use WithStatus with struct{} or a header-only response type. No JSON body or Content-Type header is written. Response headers (both static and dynamic) are still sent.
shiftapi.Handle(api, "DELETE /items/{id}", func(r *http.Request, in DeleteInput) (struct{}, error) { deleteItem(in.ID) return struct{}{}, nil}, shiftapi.WithStatus(http.StatusNoContent))You can also use a header-only struct to return response headers without a body:
type DeleteResponse struct { Location string `header:"Location"`}
shiftapi.Handle(api, "DELETE /items/{id}", func(r *http.Request, in DeleteInput) (DeleteResponse, error) { deleteItem(in.ID) return DeleteResponse{Location: "/items"}, nil}, shiftapi.WithStatus(http.StatusNoContent))The OpenAPI spec correctly reflects no response content for these routes. Registering a route with status 204 or 304 and a response type that has JSON body fields panics at startup.
Route options
Section titled “Route options”Customize routes with options:
shiftapi.Handle(api, "POST /users", createUser, shiftapi.WithStatus(http.StatusCreated), shiftapi.WithRouteInfo(shiftapi.RouteInfo{ Summary: "Create a user", Description: "Creates a new user account.", Tags: []string{"users"}, }), shiftapi.WithError[*ConflictError](http.StatusConflict), shiftapi.WithMiddleware(adminOnly),)| Option | Description |
|---|---|
WithStatus(code) | Set the success HTTP status code (default: 200) |
WithRouteInfo(info) | Set OpenAPI summary, description, and tags |
WithError[T](code) | Declare an error type T at status code — see Error Handling |
WithMiddleware(mw...) | Apply HTTP middleware to this route |
WithResponseHeader(name, value) | Set a static response header — see Response headers |
WithError, WithMiddleware, and WithResponseHeader are Option values — they work at all levels (API, group, and route). WithStatus and WithRouteInfo are route-only options. See Options for the full option system and composition.
See also: Middleware, Error Handling, Options.