Skip to content

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.

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)

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 metadata
  • in InputType — automatically decoded from JSON body, query parameters, HTTP headers, or multipart form data
  • OutputType — serialized as JSON in the response (can be a pointer or value type). Fields with header tags 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
})

Fields are decoded based on struct tags:

TagSourceExample
pathURL path parameterspath:"id"
jsonRequest body (JSON)json:"name"
queryQuery parametersquery:"page"
headerHTTP headers (request) or response headers (output)header:"Authorization"
formMultipart form dataform:"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.

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

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=3600 and ETag: "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})
}

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.

When a response is written, headers are applied in this order:

  1. 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).
  2. Dynamic headers (header struct tags) — applied second. If a dynamic header uses the same name as a static header, the dynamic value takes precedence.
  3. 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.

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.

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