Huma & OpenAPI
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?
- Documentation: Visit
/docs(or similar) and you get a beautiful, interactive Scalar or Swagger UI automatically. - Safety: Meaningful error messages for users (“Field ‘name’ is too long”).
- Standard Library Compatible: It works on top of
http.ServeMux,Chi, orGin. 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.