On this page

HTTP handlers

A handler translates a validated HTTP request into a service call, then selects public response fields. Huma supplies typed inputs and outputs; business services receive ordinary context.Context and service-owned inputs.

The usual flow is short:

Bound request → trusted caller context → authorization → service → public response

Start with the Catalog route for a database-free example. The profile update below shows the same pattern with a real service.

Register and handle once#

Identity keeps its handler private in internal/modules/identity/delivery/http/user_handler.go. This is its profile-update flow:

func (h *userHandler) updateProfile(ctx context.Context, req *UpdateProfileRequest) (*response.JSON[UserResponse], error) {
    id, err := api.CurrentUserID(ctx)
    if err != nil {
        return nil, response.Error(ctx, err)
    }
    user, err := h.svc.UpdateProfile(ctx, id, req.Input())
    if err != nil {
        return nil, response.Error(ctx, err)
    }
    return response.OK(newUserResponse(user)), nil
}

Imports are context, github.com/wisalgroup/labena/internal/platform/api, and github.com/wisalgroup/labena/internal/platform/api/response. The receiver holds Identity's existing service.UserService; UpdateProfileRequest and UserResponse belong to the same HTTP package. See request mapping for Input.

Registration in the same package uses the borrowed protected API:

huma.Register(protected, huma.Operation{
    OperationID:  "identity.updateProfile",
    Method:       "PATCH",
    Path:         "/users/me",
    Summary:      "Update your profile",
    MaxBodyBytes: 4096,
}, handler.updateProfile)

This describes the existing Identity route; do not register another copy. A request with {"name":"Ada"} updates the signed-in account and returns its public fields under data. The account ID comes from the principal, never the request body.

Input and output#

A typed handler has this shape:

func(ctx context.Context, input *Input) (*Output, error)
Part Responsibility
Input HTTP sources, JSON shape, Huma validation and documentation tags
Input.Input(...) Straightforward conversion into a service input, where useful
Handler Trusted caller context, transport-specific authorization and response conversion
Service Business decisions and validation shared by HTTP, jobs, events, and CLI
Output Public response body, headers, and status

Use struct{ Body T } for a JSON-only request. Use a named input when it has several sources or a mapping method. Return response.JSON[T] for the shared success envelope. Use response.Empty for no body and native Huma output types for streams or special headers. See responses.

Keep request and response types in the transport package. Returning a Bun entity directly can expose fields unrelated to the endpoint, including future additions. An explicit conversion makes that public boundary reviewable.

Authentication and policy#

Protected routes use targets.AuthenticatedHTTP, built with api.Authenticated. It combines bearer validation with its OpenAPI security declaration. Inside the handler:

Helper Result
api.Principal(ctx) Authenticated user and session identifiers
api.CurrentUserID(ctx) Account ID, without loading the account
api.RequireCurrentUser(ctx, id) Requires the supplied ID to match the caller; mismatch returns not found

For a self-service /users/{id} route, Identity checks ownership before reading:

id := uuid.MustParse(req.ID) // Huma already validated format:"uuid".
if err := api.RequireCurrentUser(ctx, id); err != nil {
    return nil, response.Error(ctx, err)
}
user, err := h.svc.Get(ctx, id)
if err != nil {
    return nil, response.Error(ctx, err)
}
return response.OK(newUserResponse(user)), nil

This fragment uses github.com/google/uuid and Identity's existing handler types. A validated UUID is only a well-formed identifier. For shared documents, teams, roles, or other permissions, implement the actual policy in the owning service; RequireCurrentUser is specifically a self-service account check.

Pass trusted values explicitly to services. Do not accept owner_id, session IDs, or privileged flags from a request when they are determined by authentication.

Keep context and deadlines#

Pass the handler's ctx into every service, database, storage, and provider call. It carries cancellation, tracing, and the context logger. Do not replace it with context.Background() to keep work running after the request.

If a call has a shorter deadline than its caller, derive a child context:

lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
user, err := h.svc.Get(lookupCtx, id)

This fragment imports time and belongs before the usual error/response mapping. For work intended to outlive the request, enqueue a task and return a documented acceptance result. Cancellation does not undo a committed database transaction or an accepted task. See transactions and queues.

Pagination, uploads and streaming#

These are native Huma inputs and outputs with a few shared helpers. Use the smallest shape appropriate to the operation.

Return a page#

Embed api.PaginationInput, pass input.Pagination() to the service, and convert only the returned items. The application type remains pagination.Pagination.

type ListProductsRequest struct {
    api.PaginationInput
    Search string `query:"search" maxLength:"100"`
}

A service returns *pagination.Page[Product]; the handler should expose its public ProductResponse shape while preserving page metadata. See page responses and database pagination.

Receive an upload#

Use RawBody multipart.Form and api.Multipart on the operation. Set an explicit positive MaxBodyBytes for the whole request, not just the file contents. Authentication should run before multipart parsing.

The handler must still validate permitted parts, open the selected file, and close it. The middleware bounds parsing, spools large files, and removes temporary files after return or panic. See the multipart example.

Stream a response#

Native Huma outputs can supply Body func(huma.Context). This helper is a complete pattern for an already-authorized, already-opened download reader:

// Imports: context, io, net/http, Huma, and platform/api/response.
type downloadOutput struct {
    Body func(huma.Context)
}

func streamDownload(ctx context.Context, reader io.ReadCloser) *downloadOutput {
    return &downloadOutput{Body: func(c huma.Context) {
        defer func() { response.LogError(ctx, reader.Close()) }()
        c.SetHeader("Content-Type", "application/octet-stream")
        c.SetHeader("Content-Disposition", "attachment")
        c.SetHeader("X-Content-Type-Options", "nosniff")
        c.SetHeader("Cache-Control", "no-store")
        if _, err := io.Copy(c.BodyWriter(), reader); err != nil {
            response.LogError(ctx, err)
            panic(http.ErrAbortHandler)
        }
    }}
}

Open the reader using the request context before returning this output, so open/authorization failures can still return response.Error. The output callback owns closing the reader. Declare the binary media type in the operation's Responses; Identity's registerMedia in internal/modules/identity/delivery/http/routes.go is the working example.

Once headers or bytes are committed, do not append a JSON problem to the stream. http.ErrAbortHandler tells the server to abort the transport; the shared recovery preserves that signal. Flush, hijack, and response-controller deadlines remain available through the underlying HTTP support.

Independent use#

You can construct the platform API without the complete application. This example belongs inside the repository because internal/platform/api is an internal package:

server, err := api.New(api.Options{Title: "Catalog API", Version: "1.0.0"})
if err != nil {
    return err
}
public := huma.NewGroup(server, "/api/v1")
protected := api.Authenticated(public, authenticator)
// Register your operations on public or protected before serving.

authenticator implements api.Authenticator. A nil authenticator fails closed on protected routes. server is an http.Handler: pass it to pkg/httpserver or a standard HTTP server whose listener and graceful shutdown you own. Infrastructure handlers mount on server.Mux, but serve the server object itself.

Test a handler through HTTP#

Use a freshly constructed API and httptest to exercise the route, binding, policies, metadata, and response together. The following assertions fit a test that already registered its Catalog route on server:

request := httptest.NewRequest(http.MethodGet, "/api/v1/catalog/hello/Ada", nil)
result := httptest.NewRecorder()
server.ServeHTTP(result, request)
if result.Code != http.StatusOK {
    t.Fatalf("status = %d; body = %s", result.Code, result.Body.String())
}
if result.Header().Get("X-Request-ID") == "" {
    t.Fatal("missing request ID")
}

Imports are net/http and net/http/httptest; t is the test's *testing.T. Use the Catalog tests in tests/modules as a complete infrastructure-free starting point. Keep business behavior tests in tests/<module>, including authorization, invalid input, and failure cases. Calling a handler directly bypasses Huma binding and must not be treated as validation coverage.

Continue with requests, responses, and error handling.

Documentation

Search guides, sections, and API names.