WordloopWordloop
LearnPlatform ServicesCore (Go)

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)
  • ctx carries the request context, auth principal, and cancellation signal.
  • req is 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:

TagPurpose
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 matching path:"id" struct tags
  • Summary — one sentence, title-cased, no period
  • Tags — group the endpoint in the OpenAPI UI; one or two tags per endpoint
  • Security — always {"bearerAuth": {}} for user-facing endpoints; {"serviceAuth": {}} for internal service calls
  • DefaultStatus — 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 RegisterXxx function per domain resource, in internal/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 openapi

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

On this page