A comparative demonstration of building REST APIs in Go, showcasing the advantages of Huma over traditional frameworks like Gin.
This project implements the same blog post API using two different approaches:
- API v1 (
/api/v1/posts) - Traditional Gin framework - API v2 (
/api/v2/posts) - Huma with Gin adapter
Huma: Documentation is automatically generated from your code structure, always in sync with implementation.
type IdInput struct {
Id int `path:"id" minimum:"0" example:"100" doc:"ID of the post"`
}Gin: Requires manual documentation with tools like Swaggo, prone to drift from actual implementation.
See it in action: Navigate to http://localhost:8080/doc for interactive API documentation with downloadable OpenAPI specs.
Huma: Strongly-typed request/response structs with automatic validation.
func (pc *PostsController) readByIdHandler(ctx context.Context, input *IdInput) (*PostSingle, error)Gin: Generic gin.Context with manual type assertions and conversions.
func (pc *PostsController) readByIdHandler(c *gin.Context)Compare: v2/posts_controller.go:38 vs v1/posts_controller.go:88
Huma: Built-in validation via struct tags, reducing boilerplate.
type CreatePostCommand struct {
Body struct {
Author string `json:"author" maxLength:"50" doc:"Author of the post"`
Title string `json:"title" maxLength:"50" doc:"Title of the post"`
Content string `json:"content" maxLength:"5000" doc:"Content"`
}
}Gin: Manual validation code scattered throughout handlers.
Compare: v2/posts_controller.go:59-66 vs v1/posts_controller.go:20-34
Huma: Path/query parameters are automatically parsed, type-checked, and validated.
type IdInput struct {
Id int `path:"id" minimum:"0" example:"100" doc:"ID of the post"`
}Gin: Manual string-to-int conversion with error handling in every handler.
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
_ = c.Error(err)
_ = c.AbortWithError(http.StatusUnprocessableEntity, fmt.Errorf("invalid id"))
return
}Compare: v2/posts_controller.go:38 vs v1/posts_controller.go:88-93
Huma: Clean, semantic error constructors with proper RFC 9457 Problem Details.
return nil, huma.Error404NotFound("post not found", err)Gin: Manual status code management.
_ = c.AbortWithError(http.StatusNotFound, err)
returnCompare: v2/posts_controller.go:42 vs v1/posts_controller.go:96-99
Lines of code comparison for the same functionality:
- v1 (Gin): 114 lines routes/api/v1/posts_controller.go
- v2 (Huma): 130 lines routes/api/v2/posts_controller.go
While v2 has more lines, the code is more declarative and maintainable. The extra lines are mostly struct definitions that serve as both documentation and validation schema.
Huma provides a beautiful, interactive documentation interface out of the box at /doc. No configuration needed.
| Endpoint | Description |
|---|---|
GET /api/v1/posts |
List all posts (Gin) |
GET /api/v1/posts/:id |
Get post by ID (Gin) |
POST /api/v1/posts |
Create new post (Gin) |
PUT /api/v1/posts/:id |
Update post (Gin) |
DELETE /api/v1/posts/:id |
Delete post (Gin) |
GET /api/v2/posts |
List all posts (Huma) |
GET /api/v2/posts/{id} |
Get post by ID (Huma) |
POST /api/v2/posts |
Create new post (Huma) |
PUT /api/v2/posts/{id} |
Update post (Huma) |
DELETE /api/v2/posts/{id} |
Delete post (Huma) |
GET /healthcheck |
Health check endpoint |
GET /doc |
Interactive API documentation (OpenAPI 3.1) |
make depor
go mod tidymake runor
go build -o bin/api-demo cmd/main.go && ./bin/api-demoThe server will start on http://localhost:8080
make testor
go test -v ./tests/unit/....
├── cmd/
│ └── main.go # Application entry point
├── config/
│ └── config.go # Configuration management
├── models/
│ └── posts/ # Post model and data layer
├── routes/
│ ├── api/
│ │ ├── v1/ # Gin implementation
│ │ └── v2/ # Huma implementation
│ ├── middleware/ # Custom middleware
│ └── router.go # Main router setup
└── tests/
└── unit/ # Unit tests for both versions
- Start the server:
make run - Open http://localhost:8080/doc
- Compare the implementations:
- Try creating a post with invalid data to see validation in action
- Notice how v2 provides detailed error messages with proper structure
- Observe the auto-generated documentation showing validation rules
- Download the OpenAPI schema and use it with code generators
The server comes pre-loaded with sample blog posts from cmd/blog_data.json. Below are examples of how to interact with both API versions.
curl http://localhost:8080/healthcheck
# Response: pongv1 (Gin):
curl http://localhost:8080/api/v1/postsv2 (Huma):
curl http://localhost:8080/api/v2/postsResponse Example:
{
"result": [
{
"id": 1,
"title": "Title 1",
"content": "Quaerat sit dolorem velit...",
"author": "Author 1"
},
{
"id": 2,
"title": "Title 2",
"content": "Amet quiquia sed ut velit...",
"author": "Author 2"
}
]
}v1 (Gin):
curl http://localhost:8080/api/v1/posts/1v2 (Huma):
curl http://localhost:8080/api/v2/posts/1Response Example:
{
"result": {
"id": 1,
"title": "Title 1",
"content": "Quaerat sit dolorem velit...",
"author": "Author 1"
}
}v1 (Gin):
curl -X POST http://localhost:8080/api/v1/posts \
-H "Content-Type: application/json" \
-d '{
"title": "My New Post",
"content": "This is the content of my post",
"author": "John Doe"
}'v2 (Huma):
curl -X POST http://localhost:8080/api/v2/posts \
-H "Content-Type: application/json" \
-d '{
"title": "My New Post",
"content": "This is the content of my post",
"author": "John Doe"
}'Response Example:
{
"result": {
"id": 101,
"title": "My New Post",
"content": "This is the content of my post",
"author": "John Doe"
}
}v1 (Gin):
curl -X PUT http://localhost:8080/api/v1/posts/1 \
-H "Content-Type: application/json" \
-d '{
"title": "Updated Title",
"content": "Updated content",
"author": "Updated Author"
}'v2 (Huma):
curl -X PUT http://localhost:8080/api/v2/posts/1 \
-H "Content-Type: application/json" \
-d '{
"title": "Updated Title",
"content": "Updated content",
"author": "Updated Author"
}'v1 (Gin):
curl -X DELETE http://localhost:8080/api/v1/posts/1v2 (Huma):
curl -X DELETE http://localhost:8080/api/v2/posts/1Invalid Request (v1 - Gin):
curl -X POST http://localhost:8080/api/v1/posts \
-H "Content-Type: application/json" \
-d '{
"title": "",
"content": "Some content",
"author": "John"
}'Response:
{
"error": "title can`t be empty"
}Invalid Request (v2 - Huma):
curl -X POST http://localhost:8080/api/v2/posts \
-H "Content-Type: application/json" \
-d '{
"title": "",
"content": "Some content",
"author": "John"
}'Response (RFC 9457 Problem Details):
{
"$schema": "https://huma.rocks/schemas/problem",
"title": "Bad Request",
"status": 400,
"detail": "title can`t be empty",
"errors": [
{
"message": "title can`t be empty",
"location": "body",
"value": ""
}
]
}Notice how Huma's v2 API provides more structured error responses following RFC 9457 standard, making it easier for clients to handle errors programmatically.
Field Length Validation:
# Title too long (>50 characters)
curl -X POST http://localhost:8080/api/v2/posts \
-H "Content-Type: application/json" \
-d '{
"title": "This is a very long title that exceeds the maximum allowed length of fifty characters",
"content": "Some content",
"author": "John"
}'Special Characters Validation:
# Title contains prohibited characters
curl -X POST http://localhost:8080/api/v2/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Title with @ special chars!",
"content": "Some content",
"author": "John"
}'Invalid ID Type (v2 only - automatic validation):
# v2 automatically validates that ID is a number
curl http://localhost:8080/api/v2/posts/invalid
# Returns 422 Unprocessable Entity with proper error message
# v1 requires manual validation in code
curl http://localhost:8080/api/v1/posts/invalid
# Returns error but less structuredThe application uses Viper for flexible configuration management. Configuration can be provided via:
- config.yaml file (recommended)
- Environment variables (override config.yaml values)
- Default values (used if no config file or env vars are set)
Copy the example configuration and customize it:
cp config.yaml.example config.yamlEdit config.yaml:
# Server configuration
server:
addr: "localhost:8080"
url_origin: "http://localhost:8080"
# Data configuration
data:
initial_data_file: "cmd/blog_data.json"| YAML Path | Default | Description |
|---|---|---|
server.addr |
localhost:8080 |
Server address and port |
server.url_origin |
http://localhost:8080 |
CORS allowed origin |
data.initial_data_file |
cmd/blog_data.json |
Path to initial blog data |
Configuration is loaded in the following order (later sources override earlier ones):
- Default values (hardcoded in application)
- config.yaml file (if present)
- Environment variables (highest priority)
The application searches for config.yaml in the following locations:
- Current working directory (
.) ./configdirectory$HOME/.api-demodirectory
Environment variables automatically override config.yaml values. Convert YAML paths to uppercase and replace dots with underscores:
YAML path → Environment variable:
server.addr→SERVER_ADDRserver.url_origin→SERVER_URL_ORIGINdata.initial_data_file→DATA_INITIAL_DATA_FILE
Examples:
# Override server address
export SERVER_ADDR="localhost:3000"
# Override data file location
export DATA_INITIAL_DATA_FILE="/path/to/custom/data.json"
# Override CORS origin
export SERVER_URL_ORIGIN="http://localhost:3000"
# Run the application
make runNote: Viper's AutomaticEnv() automatically maps environment variables to config keys. No explicit binding needed!
The application will work without a config.yaml file, using default values:
# Delete or rename config.yaml
mv config.yaml config.yaml.bak
# Run with defaults
make run
# Server will start on localhost:8080MIT License - see LICENSE file for details.
Copyright (c) 2024 Serhii Nesterenko