Huma & OpenAPI

Updated

July 30, 2026

Modern APIs: Huma & OpenAPI

Writing REST APIs involves two pains: 1. Validation (Checking JSON inputs). 2. Documentation (Keeping Swagger/OpenAPI YAML in sync with code).

In the past, we wrote code, then manually wrote YAML. The YAML effectively lied because it drifted from the code.

Enter Huma

Huma (huma.rocks) is the modern (2025/2026) framework of choice for building APIs. * Code-First OpenAPI: It generates the OpenAPI 3.1 Spec from your Go structs. * Automatic Validation: It validates inputs based on struct tags.

Huma Example

package main

import (
    "context"
    "net/http"
    "github.com/danielgtaylor/huma/v2"
    "github.com/danielgtaylor/huma/v2/adapters/humachi"
    "github.com/go-chi/chi/v5"
)

// 1. Define Request/Response
type GreetingInput struct {
    Name string `query:"name" maxLength:"30" doc:"Name to greet"`
}

type GreetingOutput struct {
    Body struct {
        Message string `json:"message" example:"Hello, World!"`
    }
}

func main() {
    router := chi.NewMux()
    api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))

    // 2. Register Operation
    huma.Register(api, huma.Operation{
        OperationID: "get-greeting",
        Method:      http.MethodGet,
        Path:        "/greeting",
        Summary:     "Get a welcome message",
    }, func(ctx context.Context, input *GreetingInput) (*GreetingOutput, error) {
        // Only executes if validation passes!
        resp := &GreetingOutput{}
        resp.Body.Message = "Hello, " + input.Name
        return resp, nil
    })

    http.ListenAndServe(":8888", router)
}

Why Huma?

  1. Documentation: Visit /docs (or similar) and you get a beautiful, interactive Scalar or Swagger UI automatically.
  2. Safety: Meaningful error messages for users (“Field ‘name’ is too long”).
  3. Standard Library Compatible: It works on top of http.ServeMux, Chi, or Gin. It doesn’t lock you into a monolithic ecosystem.

This replaces the older swag comment-based generation which was error-prone and brittle.

Worked example

Struct-driven validation + JSON error/success responses (Huma-shaped, stdlib).

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "strings"
    "unicode/utf8"
)

type CreateUser struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func validate(u CreateUser) []string {
    var errs []string
    if u.Name == "" {
        errs = append(errs, "name is required")
    }
    if utf8.RuneCountInString(u.Name) > 30 {
        errs = append(errs, "name maxLength 30")
    }
    if u.Age < 0 || u.Age > 150 {
        errs = append(errs, "age out of range")
    }
    return errs
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        var u CreateUser
        if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid json"})
            return
        }
        if errs := validate(u); len(errs) > 0 {
            w.WriteHeader(http.StatusUnprocessableEntity)
            _ = json.NewEncoder(w).Encode(map[string]any{"errors": errs})
            return
        }
        _ = json.NewEncoder(w).Encode(map[string]string{"status": "created", "name": u.Name})
    })

    srv := httptest.NewServer(mux)
    defer srv.Close()

    res, _ := http.Post(srv.URL+"/users", "application/json", strings.NewReader(`{"name":"","age":-1}`))
    fmt.Println("invalid status:", res.StatusCode)
    res.Body.Close()

    res, _ = http.Post(srv.URL+"/users", "application/json", strings.NewReader(`{"name":"Ada","age":36}`))
    var ok map[string]string
    _ = json.NewDecoder(res.Body).Decode(&ok)
    res.Body.Close()
    fmt.Println("valid:", res.StatusCode, ok["status"], ok["name"])
}

Expected output:

invalid status: 422
valid: 200 created Ada

More examples

Serve a tiny OpenAPI document next to the handler (docs stay near code).

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    spec := map[string]any{
        "openapi": "3.1.0",
        "info":    map[string]string{"title": "Users", "version": "1.0.0"},
        "paths": map[string]any{
            "/users": map[string]any{"post": map[string]string{"operationId": "createUser"}},
        },
    }
    mux := http.NewServeMux()
    mux.HandleFunc("GET /openapi.json", func(w http.ResponseWriter, r *http.Request) {
        _ = json.NewEncoder(w).Encode(spec)
    })
    srv := httptest.NewServer(mux)
    defer srv.Close()
    res, _ := http.Get(srv.URL + "/openapi.json")
    var doc map[string]any
    _ = json.NewDecoder(res.Body).Decode(&doc)
    res.Body.Close()
    fmt.Println("openapi:", doc["openapi"])
}

Expected output:

openapi: 3.1.0

Runnable example

Note: Huma generates OpenAPI 3.1 and validates from structs automatically. This stdlib stand-in shows the same product idea: validate input, return JSON, and expose a tiny machine-readable “spec” document.

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "unicode/utf8"
)

type GreetingOutput struct {
    Message string `json:"message"`
}

type APIError struct {
    Error string `json:"error"`
}

// Mini OpenAPI-ish document (hand-written for the demo).
var openAPI = map[string]any{
    "openapi": "3.1.0",
    "info":    map[string]string{"title": "Greeting API", "version": "1.0.0"},
    "paths": map[string]any{
        "/greeting": map[string]any{
            "get": map[string]any{
                "operationId": "get-greeting",
                "summary":     "Get a welcome message",
                "parameters": []map[string]any{
                    {
                        "name": "name", "in": "query", "required": true,
                        "schema": map[string]any{"type": "string", "maxLength": 30},
                    },
                },
            },
        },
    },
}

func greetingHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    w.Header().Set("Content-Type", "application/json")

    if name == "" {
        w.WriteHeader(http.StatusBadRequest)
        _ = json.NewEncoder(w).Encode(APIError{Error: "query param 'name' is required"})
        return
    }
    if utf8.RuneCountInString(name) > 30 {
        w.WriteHeader(http.StatusUnprocessableEntity)
        _ = json.NewEncoder(w).Encode(APIError{Error: "field 'name' is too long (max 30)"})
        return
    }

    _ = json.NewEncoder(w).Encode(GreetingOutput{Message: "Hello, " + name})
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /greeting", greetingHandler)
    mux.HandleFunc("GET /openapi.json", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(openAPI)
    })

    srv := httptest.NewServer(mux)
    defer srv.Close()

    // Missing name → validation error
    r1, _ := http.Get(srv.URL + "/greeting")
    var e1 APIError
    _ = json.NewDecoder(r1.Body).Decode(&e1)
    r1.Body.Close()
    fmt.Println("missing:", r1.StatusCode, e1.Error)

    // Valid
    r2, _ := http.Get(srv.URL + "/greeting?name=World")
    var ok GreetingOutput
    _ = json.NewDecoder(r2.Body).Decode(&ok)
    r2.Body.Close()
    fmt.Println("ok:", r2.StatusCode, ok.Message)

    // Spec exists
    r3, _ := http.Get(srv.URL + "/openapi.json")
    var spec map[string]any
    _ = json.NewDecoder(r3.Body).Decode(&spec)
    r3.Body.Close()
    info := spec["info"].(map[string]any)
    fmt.Println("spec title:", info["title"])
}

Expected output:

missing: 400 query param 'name' is required
ok: 200 Hello, World
spec title: Greeting API

What to notice: Validation lives next to the handler (Huma derives it from tags). Shipping /openapi.json keeps docs honest—Huma generates that document from the same structs that validate.

Try next: Reject names containing digits. Add POST /greeting with a JSON body and validate a required name field.