zoobzio December 15, 2025 Edit this page

Handlers Guide

Handlers are the core building block of rocco APIs. This guide covers advanced handler configuration, body parsing, parameters, and common patterns.

Handler Anatomy

Create handlers using HTTP method shortcuts:

handler := rocco.POST[InputType, OutputType]("/path/{id}", handlerFunc)
handler := rocco.GET[InputType, OutputType]("/path/{id}", handlerFunc)
handler := rocco.PUT[InputType, OutputType]("/path/{id}", handlerFunc)
handler := rocco.PATCH[InputType, OutputType]("/path/{id}", handlerFunc)
handler := rocco.DELETE[InputType, OutputType]("/path/{id}", handlerFunc)

The generic types [InputType, OutputType] define the request body and response types. Use rocco.NoBody for handlers without request bodies.

Handler names are auto-generated from method and path (e.g., GET /users/{id}get-users-id-a3f1b2c4). Override with WithName() if needed:

handler := rocco.GET[rocco.NoBody, User]("/users/{id}", getUser).
    WithName("fetch-user-by-id") // Custom name for logs/OpenAPI operationId

Handler Configuration Methods

Documentation

handler.
    WithSummary("Create a user").           // Short summary for OpenAPI
    WithDescription("Long description..."). // Detailed description
    WithTags("users", "admin")              // OpenAPI tags for grouping

Status Codes

handler.WithSuccessStatus(201) // Override default 200 OK

// Common status codes:
// 200 OK - Default for most operations
// 201 Created - Resource creation
// 204 No Content - Successful with no body

Parameters

handler.
    WithPathParams("id", "subId").        // Required path parameters
    WithQueryParams("page", "limit")      // Optional query parameters

Error Declaration

handler.WithErrors(
    rocco.ErrNotFound,
    rocco.ErrConflict,
    rocco.ErrUnprocessableEntity,
)

Body Limits

handler.WithMaxBodySize(1 * 1024 * 1024) // 1MB limit (default: 10MB)
handler.WithMaxBodySize(0)               // Unlimited (not recommended)

Validation

handler.WithOutputValidation() // Enable output validation (disabled by default)

Response Headers

handler.WithResponseHeaders(map[string]string{
    "X-Custom-Header": "value",
    "Cache-Control":   "no-cache",
})

Redirects

Return rocco.Redirect to perform HTTP redirects instead of returning a body:

handler := rocco.GET[rocco.NoBody, rocco.Redirect]("/old-path",
    func(req *rocco.Request[rocco.NoBody]) (rocco.Redirect, error) {
        return rocco.Redirect{URL: "/new-path"}, nil
    },
)

Configure the redirect status code (default: 302 Found):

// Permanent redirect
return rocco.Redirect{URL: "/new-path", Status: http.StatusMovedPermanently} // 301

// See Other (after POST)
return rocco.Redirect{URL: "/success", Status: http.StatusSeeOther} // 303

// Temporary redirect (default)
return rocco.Redirect{URL: "/other"} // 302

Response headers from WithResponseHeaders() are still applied to redirects (useful for setting cookies):

handler := rocco.GET[rocco.NoBody, rocco.Redirect]("/login",
    func(req *rocco.Request[rocco.NoBody]) (rocco.Redirect, error) {
        return rocco.Redirect{URL: "/dashboard"}, nil
    },
).WithResponseHeaders(map[string]string{
    "Set-Cookie": "session=abc123; Path=/; HttpOnly",
})

Content Type / Codec

// Use a custom codec for serialization
handler.WithCodec(xmlCodec)

Handlers default to JSON (application/json). Custom codecs allow alternative serialization formats such as XML, YAML, or MessagePack. The codec affects both request body parsing and response serialization.

Engine-level defaults apply to all handlers:

engine.WithCodec(xmlCodec) // All handlers use XML unless overridden

Handler-level overrides take precedence:

handler.WithCodec(jsonCodec) // This handler uses JSON despite engine default

Path Parameters

Path parameters use curly brace syntax:

// Single parameter
handler := rocco.GET[rocco.NoBody, User]("/users/{id}",
    func(req *rocco.Request[rocco.NoBody]) (User, error) {
        id := req.Params.Path["id"] // "123"
        return getUser(id)
    },
).WithPathParams("id")

// Multiple parameters
handler := rocco.GET[rocco.NoBody, Comment]("/posts/{postId}/comments/{commentId}",
    func(req *rocco.Request[rocco.NoBody]) (Comment, error) {
        postID := req.Params.Path["postId"]
        commentID := req.Params.Path["commentId"]
        return getComment(postID, commentID)
    },
).WithPathParams("postId", "commentId")

Important: Always declare path parameters with WithPathParams(). Undeclared parameters won't cause errors but won't appear in OpenAPI documentation.

Query Parameters

Query parameters are optional and accessed via req.Params.Query:

handler := rocco.GET[rocco.NoBody, UserList]("/users",
    func(req *rocco.Request[rocco.NoBody]) (UserList, error) {
        // Get query params (empty string if not provided)
        page := req.Params.Query["page"]
        limit := req.Params.Query["limit"]
        filter := req.Params.Query["filter"]

        // Parse with defaults
        pageNum := 1
        if page != "" {
            pageNum, _ = strconv.Atoi(page)
        }

        return listUsers(pageNum, filter)
    },
).WithQueryParams("page", "limit", "filter")

Query parameters must be declared with WithQueryParams() to appear in OpenAPI documentation.

Request Body Handling

Typed Bodies

For POST, PUT, PATCH requests, define a typed input struct:

type CreateOrderInput struct {
    CustomerID string       `json:"customer_id" validate:"required,uuid"`
    Items      []OrderItem  `json:"items" validate:"required,min=1,dive"`
    Notes      string       `json:"notes" validate:"max=500"`
}

handler := rocco.POST[CreateOrderInput, Order]("/orders",
    func(req *rocco.Request[CreateOrderInput]) (Order, error) {
        // req.Body is CreateOrderInput (validated)
        return createOrder(req.Body.CustomerID, req.Body.Items)
    },
)

Empty Bodies

For GET, DELETE, or other bodyless requests:

handler := rocco.DELETE[rocco.NoBody, DeleteResult]("/users/{id}",
    func(req *rocco.Request[rocco.NoBody]) (DeleteResult, error) {
        return deleteUser(req.Params.Path["id"])
    },
)

Large Bodies

For file uploads or large payloads, increase the body limit:

handler := rocco.POST[UploadInput, UploadResult]("/upload", handleUpload).
    WithMaxBodySize(100 * 1024 * 1024) // 100MB

Handler Middleware

Add middleware to specific handlers:

handler := rocco.POST[Input, Output]("/action", handleAction).
    WithMiddleware(rateLimiter, auditLogger)

Middleware executes in order: first added runs first.

Hooks

Hooks provide typed data transformation at handler entry and exit. Unlike middleware, which operates on http.Request/http.ResponseWriter, hooks work directly with the typed In and Out structs. Input types implement Entryable and output types implement Sendable — following the same opt-in interface pattern as Validatable.

Entryable

Implement the Entryable interface on an input type to transform it after body parsing and validation, before the handler function:

type CreateUserInput struct {
    Email string `json:"email"`
}

func (in *CreateUserInput) OnEntry(ctx context.Context) error {
    in.Email = strings.ToLower(in.Email)
    return nil
}

The handler detects the interface automatically:

handler := rocco.POST[CreateUserInput, User]("/users", createUser)

Sendable

Implement the Sendable interface on an output type to transform it after the handler function, before marshalling:

type UserList struct {
    Users []User `json:"users"`
}

func (out *UserList) OnSend(ctx context.Context) error {
    for i := range out.Users {
        out.Users[i].Email = "" // Strip emails from response
    }
    return nil
}

Error Handling

Returning an error from a hook short-circuits processing and returns 500 Internal Server Error:

func (in *OrderInput) OnEntry(ctx context.Context) error {
    if in.TenantID == "" {
        return errors.New("tenant ID required")
    }
    return nil
}

Hooks vs Middleware

HooksMiddleware
Operates onTyped In/Out structshttp.Request/http.ResponseWriter
Can modifyInput data, output dataHeaders, context, request flow
RunsInside Process(), after parsingOutside Process(), before parsing
Access tocontext.Contexthttp.Request/http.ResponseWriter
Use forData transformation, normalisationLogging, CORS, rate limiting

Handler Patterns

Resource Handler Pattern

Group related handlers by resource:

func UserHandlers() []rocco.Endpoint {
    return []rocco.Endpoint{
        rocco.GET[rocco.NoBody, UserList]("/users", listUsers).
            WithQueryParams("page", "limit"),

        rocco.POST[CreateUserInput, User]("/users", createUser).
            WithSuccessStatus(201),

        rocco.GET[rocco.NoBody, User]("/users/{id}", getUser).
            WithPathParams("id").WithErrors(rocco.ErrNotFound),

        rocco.PUT[UpdateUserInput, User]("/users/{id}", updateUser).
            WithPathParams("id").WithErrors(rocco.ErrNotFound),

        rocco.DELETE[rocco.NoBody, DeleteResult]("/users/{id}", deleteUser).
            WithPathParams("id").WithErrors(rocco.ErrNotFound),
    }
}

// Register all at once
engine.WithHandlers(UserHandlers()...)

Versioned APIs

// v1 handlers
v1Create := rocco.POST[V1CreateInput, V1Output]("/v1/resources", handleV1Create).
    WithTags("v1")

// v2 handlers with different schema
v2Create := rocco.POST[V2CreateInput, V2Output]("/v2/resources", handleV2Create).
    WithTags("v2")

Conditional Logic

handler := rocco.GET[Input, Output]("/resource/{id}",
    func(req *rocco.Request[Input]) (Output, error) {
        format := req.Params.Query["format"]

        switch format {
        case "detailed":
            return getDetailedResource(req.Params.Path["id"])
        case "summary":
            return getSummaryResource(req.Params.Path["id"])
        default:
            return getResource(req.Params.Path["id"])
        }
    },
).WithQueryParams("format")

Common Mistakes

Forgetting Parameter Declaration

// WRONG - param won't appear in OpenAPI
handler := rocco.GET[rocco.NoBody, User]("/users/{id}", getUser)

// CORRECT
handler := rocco.GET[rocco.NoBody, User]("/users/{id}", getUser).
    WithPathParams("id")

Undeclared Errors

// WRONG - returns 500 instead of 404
handler := rocco.GET[rocco.NoBody, User]("/users/{id}",
    func(req *rocco.Request[rocco.NoBody]) (User, error) {
        return User{}, rocco.ErrNotFound // Undeclared!
    },
)

// CORRECT
handler := rocco.GET[rocco.NoBody, User]("/users/{id}",
    func(req *rocco.Request[rocco.NoBody]) (User, error) {
        return User{}, rocco.ErrNotFound
    },
).WithErrors(rocco.ErrNotFound)

Wrong NoBody Usage

// WRONG - NoBody for POST with body
handler := rocco.POST[rocco.NoBody, User]("/users", createUser)

// CORRECT - use typed input
handler := rocco.POST[CreateUserInput, User]("/users", createUser)

See Also