HTTP Handlers
Huma handler patterns, OpenAPI generation, request/response type mapping, and error envelopes in wordloop-core.
HTTP Handlers
TL;DR
Handlers in wordloop-core use Huma v2 on top of Chi. Each handler is a typed function registered with huma.Register. Huma generates the OpenAPI spec from Go struct tags; we never write OpenAPI YAML by hand.
Why Huma
Huma keeps the contract and the implementation in sync by construction. The Go types are the spec — there is no separate YAML file to drift from production. Every request type is validated before the handler runs, and every error response is automatically serialised to RFC 9457 application/problem+json.
Handler structure
Registration
Register every handler via huma.Register. Pass an huma.Operation for metadata and a typed function for the implementation:
func RegisterUser(api huma.API, svc service.UserService) {
huma.Register(api, huma.Operation{
OperationID: "createUser",
Method: http.MethodPost,
Path: "/users",
Summary: "Create a User",
Description: "Registers a new user. Requires system authentication (`X-Service-Token`).",
Tags: []string{"Users"},
DefaultStatus: http.StatusCreated,
Security: []map[string][]string{{"bearerAuth": {}}},
}, func(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) {
// ...
})
}Each RegisterXxx function takes huma.API and the service interfaces it needs. It is called once from the composition root in internal/entrypoints/api/router.go.
Handler signature
func(ctx context.Context, req *InputType) (*OutputType, error)ctxcarries the request context, auth principal, and cancellation signal.reqis fully validated by Huma before the function is called. Field constraints (min length, format, enum) are enforced before you see the request.- Return
(nil, huma.NewError(...))to abort with a specific HTTP status. - Return
(nil, err)for unexpected errors — Huma converts them to a 500.
Request types
Request structs compose path, query, header, and body fields with struct tags:
type CreateUserRequest struct {
IdempotencyKey string `header:"Idempotency-Key" required:"false" format:"uuid" doc:"Unique key to ensure idempotent user creation."`
Body createUserBody
}
type createUserBody struct {
Username string `json:"username" min:"1" max:"100" example:"alice" doc:"Unique username for the user."`
Email string `json:"email" format:"email" example:"alice@example.com"`
PersonID *string `json:"person_id,omitempty" doc:"Optional ID of the linked person record."`
}Use a nested body type (unexported) to separate the JSON body from the path/query/header fields. Keep the outer request type exported with a Request suffix.
Tag reference:
| Tag | Purpose |
|---|---|
path:"id" | Path parameter (/users/{id}) |
query:"cursor" | Query string parameter |
header:"X-Name" | HTTP header |
json:"field_name" | JSON body field |
format:"uuid" / format:"email" / format:"date" | OpenAPI format + validation |
enum:"a,b,c" | Allowed values |
min:"1" / max:"100" | String/array length constraints |
minimum:"0" / maximum:"100" | Numeric range constraints |
doc:"..." | OpenAPI description for the field |
example:"..." | OpenAPI example value |
readOnly:"true" | Response-only field (never accepted as input) |
required:"false" | Override Huma's default required inference |
Response types
Response structs have a single Body field:
type CreateUserResponse struct {
Body UserItem
}
type UserItem struct {
ID string `json:"id" format:"uuid" readOnly:"true"`
Username string `json:"username"`
Email string `json:"email" format:"email"`
CreatedAt string `json:"created_at" format:"date-time" readOnly:"true"`
}Use readOnly:"true" on fields that are never accepted on input. Keep response types separate from request body types — shared types lead to fields that are optional on create but required in responses, which Huma cannot represent accurately.
Authentication and principals
Extract the principal at the top of the handler:
func(ctx context.Context, req *GetMeetingRequest) (*GetMeetingResponse, error) {
principal, err := auth.PrincipalFromContext(ctx)
if err != nil {
return nil, huma.NewError(http.StatusUnauthorized, "unauthorized")
}
// use principal.ID(), principal.IsSystem(), etc.
}Pass the principal into service calls — never access auth state from global context or thread-locals.
Error handling
Huma converts all returned errors to RFC 9457 application/problem+json. Use the Huma helpers for expected domain errors; return unwrapped errors for unexpected failures:
// Domain validation error
if err := entity.Validate(); err != nil {
return nil, huma.NewError(http.StatusBadRequest, err.Error())
}
// Auth failure
return nil, huma.NewError(http.StatusForbidden, "insufficient permissions")
// Convenience helpers
return nil, huma.Error400BadRequest("invalid date format, expected YYYY-MM-DD", err)
// Unexpected error — Huma returns 500
saved, err := svc.Save(ctx, principal, entity)
if err != nil {
return nil, err // let it propagate; log at the service layer
}Do not log errors in the handler and also return them. Log at the layer where you have enough context; wrap elsewhere.
Operation metadata
Every operation needs:
OperationID— unique, camelCase, used as the generated client method name (createUser,getMeeting)Method+Path— HTTP verb and URI template; use{id}placeholders matchingpath:"id"struct tagsSummary— one sentence, title-cased, no periodTags— group the endpoint in the OpenAPI UI; one or two tags per endpointSecurity— always{"bearerAuth": {}}for user-facing endpoints;{"serviceAuth": {}}for internal service callsDefaultStatus— only for non-200 success codes (201 for creation, 204 for deletions)
Description is optional but use it for any endpoint where the summary leaves behaviour ambiguous.
Handler placement
- One
RegisterXxxfunction per domain resource, ininternal/entrypoints/api/routes/<resource>.go - Request/response types in the same file as the handler
- Domain conversion helpers (
toXxx) at the bottom of the file, unexported - Inline closures for simple handlers; named methods on a struct for handlers that share helpers
When a handler needs more than two service calls or substantial conditional logic, consider whether the logic belongs in the application service layer rather than the handler.
OpenAPI generation
The spec is generated automatically from the registered operations and type tags. Run:
./dev gen openapiThis writes the OpenAPI document to services/wordloop-core/openapi.yaml. Do not edit this file by hand — it is regenerated on every run. The generated spec is the contract consumed by the TypeScript SDK generator and the ML service client.
See Code Generation for the full generation workflow.