Type-safe HTTP handlers for Go with multi-source request data extraction and automatic OpenAPI generation
TypedHTTP is an experimental Go library exploring type safety and declarative request handling for HTTP APIs. Extract data from multiple HTTP sources (path, query, headers, cookies, forms, JSON) with configurable precedence rules, transformations, validation, and automatic OpenAPI 3.0+ specification generation.
- π Type Safety: Leverage Go generics for compile-time type checking
- π― Multi-Source Extraction: Get data from path, query, headers, cookies, forms, and JSON body
- β‘ Precedence Rules: Define fallback order when data exists in multiple sources
- π Transformations: Built-in data transformations (IP extraction, case conversion, etc.)
- β
Validation: Seamless integration with
go-playground/validator - π File Uploads: First-class support for multipart form uploads
- π OpenAPI Generation: Automatic OpenAPI 3.0+ specification generation with comment-based documentation
- π§ͺ Testing Utilities: Test utilities with context support and explicit error handling
- π¨ Clean APIs: Declarative struct tags for ergonomic request definition
- π§ Extensible: Custom decoders, encoders, and middleware support
go get github.com/pavelpascari/typedhttppackage main
import (
"context"
"net/http"
"github.com/pavelpascari/typedhttp/pkg/typedhttp"
)
// Define your request structure with typed fields
type GetUserRequest struct {
ID string `path:"id" validate:"required,uuid"`
Page int `query:"page" default:"1" validate:"min=1"`
}
type GetUserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Page int `json:"page"`
}
// Implement your business logic
type UserHandler struct{}
func (h *UserHandler) Handle(ctx context.Context, req GetUserRequest) (GetUserResponse, error) {
return GetUserResponse{
ID: req.ID,
Name: "John Doe",
Page: req.Page,
}, nil
}
func main() {
router := typedhttp.NewRouter()
// Register type-safe handlers
typedhttp.GET(router, "/users/{id}", &UserHandler{})
http.ListenAndServe(":8080", router)
}The real power of TypedHTTP lies in its ability to extract data from multiple HTTP sources with intelligent precedence rules:
type APIRequest struct {
// Multi-source authentication - header takes precedence
UserID string `header:"X-User-ID" cookie:"user_id" precedence:"header,cookie"`
AuthToken string `header:"Authorization" cookie:"auth_token" precedence:"header,cookie"`
// Client information with transformations
ClientIP net.IP `header:"X-Forwarded-For" transform:"first_ip"`
UserAgent string `header:"User-Agent"`
// Language preference - cookie overrides header
Language string `cookie:"lang" header:"Accept-Language" default:"en" precedence:"cookie,header"`
}type ComplexAPIRequest struct {
// Path parameters
ResourceID string `path:"id" validate:"required,uuid"`
Action string `path:"action" validate:"required,oneof=view edit delete"`
// Query parameters with defaults and validation
Page int `query:"page" default:"1" validate:"min=1"`
Limit int `query:"limit" default:"20" validate:"min=1,max=100"`
Fields []string `query:"fields" transform:"comma_split"`
// Headers with transformations
TraceID string `header:"X-Trace-ID" query:"trace_id" precedence:"header,query"`
RequestID string `header:"X-Request-ID" default:"generate_uuid"`
Timestamp time.Time `header:"X-Timestamp" format:"rfc3339" default:"now"`
// Form data (for POST/PUT requests)
Name string `form:"name" json:"name" precedence:"form,json"`
Email string `form:"email" json:"email" validate:"email" precedence:"form,json"`
Avatar *multipart.FileHeader `form:"avatar"`
// JSON body for complex data
Metadata map[string]interface{} `json:"metadata"`
Settings UserSettings `json:"settings"`
// Cookies for session management
SessionID string `cookie:"session_id" validate:"required"`
Theme string `cookie:"theme" default:"light"`
}
type UserSettings struct {
Notifications bool `json:"notifications"`
Privacy string `json:"privacy"`
}TypedHTTP automatically generates comprehensive OpenAPI 3.0+ specifications from your typed handlers and request/response types, with zero manual maintenance required.
package main
import (
"context"
"fmt"
"log"
"mime/multipart"
"net/http"
"github.com/pavelpascari/typedhttp/pkg/openapi"
"github.com/pavelpascari/typedhttp/pkg/typedhttp"
)
// Request types with OpenAPI comment documentation
type GetUserRequest struct {
//openapi:description=User unique identifier,example=123e4567-e89b-12d3-a456-426614174000
ID string `path:"id" validate:"required,uuid"`
//openapi:description=Comma-separated list of fields to return,example=id,name,email
Fields string `query:"fields" default:"id,name,email"`
//openapi:description=Authorization bearer token
Auth string `header:"Authorization" validate:"required"`
}
type GetUserResponse struct {
//openapi:description=User unique identifier
ID string `json:"id" validate:"required,uuid"`
//openapi:description=User full name
Name string `json:"name" validate:"required"`
//openapi:description=User email address
Email string `json:"email,omitempty" validate:"omitempty,email"`
}
type CreateUserRequest struct {
//openapi:description=User full name,example=John Doe
Name string `json:"name" validate:"required,min=2,max=50"`
//openapi:description=User email address,[email protected]
Email string `json:"email" validate:"required,email"`
//openapi:description=User profile picture,type=file,format=binary
Avatar *multipart.FileHeader `form:"avatar"`
}
type CreateUserResponse struct {
//openapi:description=Created user unique identifier
ID string `json:"id" validate:"required,uuid"`
//openapi:description=User full name
Name string `json:"name"`
//openapi:description=User email address
Email string `json:"email"`
//openapi:description=Creation timestamp
CreatedAt string `json:"created_at"`
}
// Handlers
type GetUserHandler struct{}
func (h *GetUserHandler) Handle(ctx context.Context, req GetUserRequest) (GetUserResponse, error) {
return GetUserResponse{
ID: req.ID,
Name: "John Doe",
Email: "[email protected]",
}, nil
}
type CreateUserHandler struct{}
func (h *CreateUserHandler) Handle(ctx context.Context, req CreateUserRequest) (CreateUserResponse, error) {
return CreateUserResponse{
ID: "123e4567-e89b-12d3-a456-426614174000",
Name: req.Name,
Email: req.Email,
CreatedAt: "2025-01-30T12:00:00Z",
}, nil
}
func main() {
// Create router and register handlers
router := typedhttp.NewRouter()
typedhttp.GET(router, "/users/{id}", &GetUserHandler{})
typedhttp.POST(router, "/users", &CreateUserHandler{})
// Create OpenAPI generator
generator := openapi.NewGenerator(openapi.Config{
Info: openapi.Info{
Title: "User Management API",
Version: "1.0.0",
Description: "A simple API for managing users with automatic OpenAPI generation",
},
Servers: []openapi.Server{
{URL: "http://localhost:8080", Description: "Development server"},
},
})
// Generate OpenAPI specification
spec, err := generator.Generate(router)
if err != nil {
log.Fatalf("Failed to generate OpenAPI spec: %v", err)
}
// Generate JSON and YAML output
jsonData, _ := generator.GenerateJSON(spec)
yamlData, _ := generator.GenerateYAML(spec)
fmt.Printf("Generated OpenAPI spec with %d paths\n", len(spec.Paths.Map()))
// Serve OpenAPI documentation endpoints
http.Handle("/openapi.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(jsonData)
}))
http.Handle("/openapi.yaml", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-yaml")
w.Write(yamlData)
}))
// Serve the API
http.Handle("/", router)
log.Println("Server starting on :8080")
log.Println("OpenAPI JSON: http://localhost:8080/openapi.json")
log.Println("OpenAPI YAML: http://localhost:8080/openapi.yaml")
http.ListenAndServe(":8080", nil)
}TypedHTTP uses clean comment-based documentation instead of verbose struct tags:
type APIRequest struct {
// β
Clean separation of concerns
//openapi:description=User unique identifier,example=123e4567-e89b-12d3-a456-426614174000
UserID string `path:"id" validate:"required,uuid"`
//openapi:description=Search query,example=john doe
Query string `query:"q" validate:"required,min=1"`
//openapi:description=Results per page,example=20
Limit int `query:"limit" default:"10" validate:"min=1,max=100"`
//openapi:description=File to upload,type=file,format=binary
Document *multipart.FileHeader `form:"document"`
//openapi:description=Complex metadata object
Metadata map[string]interface{} `json:"metadata"`
}The OpenAPI generator automatically detects and documents:
- Parameters: Extracts from
path:,query:,header:,cookie:tags - Request Bodies: Detects JSON (
json:tags) and multipart forms (form:tags) - File Uploads: Automatically handles
*multipart.FileHeaderfields - Validation Rules: Converts validation tags to OpenAPI schema constraints
- Default Values: Uses
default:tag values as OpenAPI defaults - Multi-Source Fields: Documents precedence rules for fields with multiple sources
// Configure comprehensive API documentation
generator := openapi.NewGenerator(openapi.Config{
Info: openapi.Info{
Title: "Advanced API",
Version: "2.1.0",
Description: "Comprehensive API with full documentation",
TermsOfService: "https://example.com/terms",
Contact: &openapi.Contact{
Name: "API Support",
URL: "https://example.com/support",
Email: "[email protected]",
},
License: &openapi.License{
Name: "MIT",
URL: "https://opensource.org/licenses/MIT",
},
},
Servers: []openapi.Server{
{URL: "https://api.example.com/v2", Description: "Production"},
{URL: "https://staging.example.com/v2", Description: "Staging"},
},
})
spec, err := generator.Generate(router)
if err != nil {
log.Fatal(err)
}
// Multiple output formats
http.Handle("/openapi.json", openapi.JSONHandler(spec))
http.Handle("/openapi.yaml", openapi.YAMLHandler(spec))The generated specifications include:
- Complete Parameter Documentation: All path, query, header, and cookie parameters
- Request/Response Schemas: Full JSON schemas with validation rules
- File Upload Support: Proper
multipart/form-datadocumentation - Multi-Source Documentation: Precedence rules documented in parameter descriptions
- Validation Constraints: Min/max values, string formats, required fields
- Example Values: From
example=in comments anddefault=in tags - Nested Objects: Complex request/response structures
- Array Support: Both simple arrays and arrays of objects
The generated OpenAPI specifications work seamlessly with popular documentation tools:
# View with Swagger UI
curl http://localhost:8080/openapi.json | swagger-ui-serve
# Generate client code
openapi-generator generate -i http://localhost:8080/openapi.json -g go -o ./client
# API testing with Postman
# Import http://localhost:8080/openapi.json into Postman| Source | Tag | Example | Description |
|---|---|---|---|
| Path | path:"name" |
UserID string path:"id"`` |
URL path parameters |
| Query | query:"name" |
Page int query:"page"`` |
URL query parameters |
| Headers | header:"name" |
Auth string header:"Authorization"`` |
HTTP headers |
| Cookies | cookie:"name" |
Session string cookie:"session_id"`` |
HTTP cookies |
| Form | form:"name" |
Name string form:"name"`` |
Form data (URL-encoded/multipart) |
| JSON | json:"name" |
Data map[string]interface{} json:"data"`` |
JSON request body |
Control the order in which sources are checked:
type Request struct {
// Check header first, fallback to cookie, then query
UserID string `header:"X-User-ID" cookie:"user_id" query:"user_id" precedence:"header,cookie,query"`
// Cookie takes precedence over header
Language string `cookie:"lang" header:"Accept-Language" precedence:"cookie,header"`
}Built-in transformations for common use cases:
type Request struct {
ClientIP net.IP `header:"X-Forwarded-For" transform:"first_ip"` // Extract first IP from list
Username string `header:"X-Username" transform:"to_lower"` // Convert to lowercase
IsAdmin bool `header:"X-User-Role" transform:"is_admin"` // Check if role is "admin"
Trimmed string `query:"text" transform:"trim_space"` // Remove leading/trailing spaces
}Parse data with custom formats:
type Request struct {
CreatedAt time.Time `header:"X-Created-At" format:"rfc3339"`
Birthday time.Time `query:"birthday" format:"2006-01-02"`
UnixTime time.Time `header:"X-Timestamp" format:"unix"`
CustomDate time.Time `query:"date" format:"02/01/2006"`
}Provide sensible defaults:
type Request struct {
Page int `query:"page" default:"1"`
Limit int `query:"limit" default:"20"`
Sort string `query:"sort" default:"created_at"`
Language string `header:"Accept-Language" default:"en"`
Theme string `cookie:"theme" default:"light"`
// Special defaults
RequestID string `header:"X-Request-ID" default:"generate_uuid"`
Timestamp time.Time `header:"X-Timestamp" default:"now"`
}Handle file uploads seamlessly:
type UploadRequest struct {
Name string `form:"name" validate:"required"`
Description string `form:"description"`
Avatar *multipart.FileHeader `form:"avatar"` // Single file
Documents []*multipart.FileHeader `form:"documents"` // Multiple files
}
func (h *UploadHandler) Handle(ctx context.Context, req UploadRequest) (UploadResponse, error) {
if req.Avatar != nil {
fmt.Printf("Uploaded file: %s (%d bytes)\n", req.Avatar.Filename, req.Avatar.Size)
// Process the file
file, err := req.Avatar.Open()
if err != nil {
return UploadResponse{}, err
}
defer file.Close()
// Save or process the file content...
}
return UploadResponse{Message: "Upload successful"}, nil
}Leverage go-playground/validator for robust validation:
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2,max=50"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,min=18,max=120"`
Website string `json:"website" validate:"omitempty,url"`
UserID string `path:"id" validate:"required,uuid"`
APIKey string `header:"X-API-Key" validate:"required,len=32"`
}TypedHTTP provides structured error handling:
func (h *UserHandler) Handle(ctx context.Context, req GetUserRequest) (GetUserResponse, error) {
// Validation errors are automatically handled and return 400 Bad Request
// Business logic errors can return custom error types
if req.ID == "invalid" {
return GetUserResponse{}, typedhttp.NewNotFoundError("User not found")
}
if !hasPermission(req.UserID) {
return GetUserResponse{}, typedhttp.NewForbiddenError("Access denied")
}
return GetUserResponse{ID: req.ID}, nil
}Here's a comprehensive example showing multiple features:
type OrderRequest struct {
// Path parameters
OrderID string `path:"id" validate:"required,uuid"`
// Authentication (header preferred, cookie fallback)
UserID string `header:"X-User-ID" cookie:"user_id" validate:"required" precedence:"header,cookie"`
// Pagination with defaults
Page int `query:"page" default:"1" validate:"min=1"`
Limit int `query:"limit" default:"20" validate:"min=1,max=100"`
// Client info with transformations
ClientIP net.IP `header:"X-Forwarded-For" transform:"first_ip"`
UserAgent string `header:"User-Agent"`
// Preferences (cookie preferred over header)
Language string `cookie:"lang" header:"Accept-Language" default:"en" precedence:"cookie,header"`
Currency string `query:"currency" cookie:"currency" default:"USD" precedence:"query,cookie"`
// Form data for updates
Status string `form:"status" json:"status" validate:"oneof=pending confirmed cancelled" precedence:"form,json"`
Notes string `form:"notes" json:"notes"`
Attachments []*multipart.FileHeader `form:"attachments"`
// Metadata from JSON body
CustomFields map[string]interface{} `json:"custom_fields"`
// Tracing
TraceID string `header:"X-Trace-ID" query:"trace_id" precedence:"header,query"`
RequestID string `header:"X-Request-ID" default:"generate_uuid"`
}
type OrderHandler struct {
orderService OrderService
}
func (h *OrderHandler) Handle(ctx context.Context, req OrderRequest) (OrderResponse, error) {
log.Printf("Processing order %s for user %s from IP %s",
req.OrderID, req.UserID, req.ClientIP)
order, err := h.orderService.GetOrder(ctx, req.OrderID, req.UserID)
if err != nil {
return OrderResponse{}, typedhttp.NewNotFoundError("Order not found")
}
// Handle file attachments if present
if len(req.Attachments) > 0 {
for _, attachment := range req.Attachments {
log.Printf("Processing attachment: %s (%d bytes)",
attachment.Filename, attachment.Size)
}
}
return OrderResponse{
ID: order.ID,
Status: order.Status,
Language: req.Language,
Currency: req.Currency,
}, nil
}
// Register the handler
func main() {
router := typedhttp.NewRouter()
typedhttp.PUT(router, "/orders/{id}", &OrderHandler{})
log.Println("Server starting on :8080")
http.ListenAndServe(":8080", router)
}TypedHTTP includes test utilities that aim to reduce boilerplate in HTTP testing.
- π― Go Idioms: Context-aware, explicit error handling, struct-based configuration
- π Reduced Boilerplate: Simplified JSON marshaling, header setting, and response parsing
- π Type-Safe: Leverages Go generics for compile-time type checking
- π Response Assertions: HTTP response validation with error reporting
- π¨ Request Building: Functional request modifiers for test setup
import (
"github.com/pavelpascari/typedhttp/pkg/testutil"
"github.com/pavelpascari/typedhttp/pkg/testutil/assert"
"github.com/pavelpascari/typedhttp/pkg/testutil/client"
)
func TestUserAPI(t *testing.T) {
// Setup
router := typedhttp.NewRouter()
typedhttp.POST(router, "/users", &CreateUserHandler{})
testClient := client.NewClient(router,
client.WithTimeout(10*time.Second),
)
t.Run("create user", func(t *testing.T) {
// π― Perfect Go-idiomatic request building
req := testutil.WithAuth(
testutil.WithJSON(
testutil.POST("/users", CreateUserRequest{
Name: "Jane Doe",
Email: "[email protected]",
Age: 25,
}),
),
"auth-token",
)
// Context-aware execution with explicit error handling
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := testClient.Execute(ctx, req)
if err != nil {
if testutil.IsRequestError(err) {
// Handle request-specific errors
}
t.Fatalf("Request failed: %v", err)
}
// Comprehensive assertions with detailed error reporting
assert.AssertStatusCreated(t, resp)
assert.AssertJSONContentType(t, resp)
assert.AssertJSONField(t, resp, "name", "Jane Doe")
assert.AssertJSONFieldExists(t, resp, "id")
})
}// Type-safe response handling
resp, err := client.ExecuteTyped[UserResponse](testClient, ctx, req)
user := resp.Data // Fully typed UserResponse
// File upload testing
req := testutil.WithFile(
testutil.POST("/upload", formData),
"file", fileContent,
)
// Multi-source data testing
req := testutil.WithCookie(
testutil.WithHeaders(
testutil.WithPathParams(
testutil.GET("/api/{version}/users/{id}"),
map[string]string{"version": "v1", "id": "123"},
),
map[string]string{"Authorization": "Bearer token"},
),
"session", "session-id",
)
// Validation error testing
resp, err := testutil.ExecuteExpectingError(t, client, invalidReq)
assert.AssertStatusBadRequest(t, resp)
assert.AssertValidationError(t, resp, "email", "required")
// Context and timeout testing
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err = client.Execute(ctx, slowReq)
if errors.Is(err, context.DeadlineExceeded) {
t.Log("Request timed out as expected")
}- π Complete Testing Guide - Comprehensive guide with examples
- π§ Test Utilities API - Detailed API reference
- π― ADR-004: Test Utility Design - Design decisions
For testing business logic in isolation:
func TestOrderHandler(t *testing.T) {
handler := &OrderHandler{orderService: mockOrderService}
req := OrderRequest{
OrderID: "123e4567-e89b-12d3-a456-426614174000",
UserID: "user123",
Page: 1,
Limit: 20,
Language: "en",
Currency: "USD",
Status: "confirmed",
ClientIP: net.ParseIP("192.168.1.1"),
RequestID: "req-123",
}
resp, err := handler.Handle(context.Background(), req)
assert.NoError(t, err)
assert.Equal(t, "123e4567-e89b-12d3-a456-426614174000", resp.ID)
}TypedHTTP follows hexagonal architecture principles:
- Handlers: Pure business logic, no HTTP concerns
- Decoders: Extract and validate request data
- Encoders: Format response data
- Middleware: Cross-cutting concerns (logging, auth, etc.)
- Error Mappers: Convert business errors to HTTP responses
- Architecture Decision Records (ADRs) - Design decisions and implementation details
- Testing Guide - Testing utilities documentation
- Test Utilities API Reference - Test utilities documentation
- OpenAPI Generator Guide - OpenAPI generation documentation
- API Reference - API documentation
- Examples - Working examples
Contributions are always welcome! Please see our Contributing Guide for details.
This project is licensed under the MIT License - see the LICENSE file for details.
- Inspired by modern web frameworks and Go's type system
- Built with β€οΈ for the Go community
Ready to build type-safe HTTP APIs? Get started with TypedHTTP today! π