Custom Routes with OpenAPI Documentation
Tonica's fluent API allows you to add custom HTTP routes that are not defined in your Protocol Buffers. These routes are automatically documented in your OpenAPI specification and appear in the Scalar UI alongside your proto-generated endpoints.
Why Custom Routes?
While proto-first is powerful, sometimes you need routes that don't fit the RPC model:
- Health checks (
/health,/ready) - Static file serving
- Legacy endpoints
- Internal utilities
- Webhooks
- Special auth endpoints
Custom routes let you add these without modifying your proto files.
Quick Start
Basic Example
tonica.NewRoute(app).
GET("/health").
Summary("Health check endpoint").
Description("Returns the health status of the service").
Tags("Monitoring").
Response(200, "Service is healthy", tonica.InlineObjectSchema(map[string]string{
"status": "string",
})).
Handle(func(c *gin.Context) {
c.JSON(200, gin.H{"status": "healthy"})
})
This creates:
- A GET endpoint at
/health - OpenAPI documentation for the endpoint
- Automatic appearance in Scalar UI at
/docs
Viewing Documentation
After starting your app:
- OpenAPI Spec:
http://localhost:8080/openapi.json - Scalar UI:
http://localhost:8080/docs
HTTP Methods
All standard HTTP methods are supported:
GET
tonica.NewRoute(app).
GET("/users/:id").
Summary("Get user by ID").
Handle(func(c *gin.Context) {
id := c.Param("id")
// Fetch user...
c.JSON(200, gin.H{"id": id})
})
POST
tonica.NewRoute(app).
POST("/users").
Summary("Create new user").
BodyParam("User data", tonica.InlineObjectSchema(map[string]string{
"name": "string",
"email": "string",
})).
Handle(func(c *gin.Context) {
var user map[string]interface{}
if err := c.BindJSON(&user); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Create user...
c.JSON(201, gin.H{"id": "123"})
})
PUT
tonica.NewRoute(app).
PUT("/users/:id").
Summary("Update user").
PathParam("id", "string", "User ID").
BodyParam("Updated user data", tonica.InlineObjectSchema(map[string]string{
"name": "string",
"email": "string",
})).
Handle(func(c *gin.Context) {
id := c.Param("id")
// Update user...
c.JSON(200, gin.H{"id": id})
})
PATCH
tonica.NewRoute(app).
PATCH("/users/:id").
Summary("Partially update user").
PathParam("id", "string", "User ID").
BodyParam("Fields to update", tonica.ObjectSchema(map[string]interface{}{
"name": map[string]string{"type": "string"},
"email": map[string]string{"type": "string"},
})).
Handle(func(c *gin.Context) {
id := c.Param("id")
// Patch user...
c.JSON(200, gin.H{"id": id})
})
DELETE
tonica.NewRoute(app).
DELETE("/users/:id").
Summary("Delete user").
PathParam("id", "string", "User ID").
Response(204, "User deleted successfully", nil).
Handle(func(c *gin.Context) {
id := c.Param("id")
// Delete user...
c.Status(204)
})
Parameters
Query Parameters
Query parameters are passed in the URL: /endpoint?name=value
tonica.NewRoute(app).
GET("/users").
Summary("List users").
QueryParam("page", "integer", "Page number", false).
QueryParam("limit", "integer", "Items per page", false).
QueryParam("status", "string", "Filter by status", false).
Handle(func(c *gin.Context) {
page := c.DefaultQuery("page", "1")
limit := c.DefaultQuery("limit", "10")
status := c.Query("status")
// Fetch users with pagination...
c.JSON(200, gin.H{
"page": page,
"limit": limit,
"users": []interface{}{},
})
})
Required vs Optional:
// Required query parameter
.QueryParam("name", "string", "User name", true)
// Optional query parameter
.QueryParam("lang", "string", "Language code", false)
Path Parameters
Path parameters are part of the URL: /users/:id
tonica.NewRoute(app).
GET("/users/:id/orders/:orderId").
Summary("Get user order").
PathParam("id", "string", "User ID").
PathParam("orderId", "string", "Order ID").
Handle(func(c *gin.Context) {
userID := c.Param("id")
orderID := c.Param("orderId")
// Fetch order...
c.JSON(200, gin.H{
"userId": userID,
"orderId": orderID,
})
})
Note: Path parameters are always required.
Header Parameters
Read values from HTTP headers:
tonica.NewRoute(app).
GET("/protected").
Summary("Protected endpoint").
HeaderParam("Authorization", "string", "Bearer token", true).
HeaderParam("X-Request-ID", "string", "Request ID for tracing", false).
Handle(func(c *gin.Context) {
auth := c.GetHeader("Authorization")
requestID := c.GetHeader("X-Request-ID")
// Validate auth...
c.JSON(200, gin.H{"authenticated": true})
})
Body Parameters
For POST, PUT, PATCH requests:
tonica.NewRoute(app).
POST("/users").
Summary("Create user").
BodyParam("User data", tonica.InlineObjectSchema(map[string]string{
"name": "string",
"email": "string",
"age": "integer",
"active": "boolean",
})).
Handle(func(c *gin.Context) {
var user struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
Active bool `json:"active"`
}
if err := c.BindJSON(&user); err != nil {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
// Create user...
c.JSON(201, user)
})
Documentation
Summary and Description
tonica.NewRoute(app).
GET("/stats").
Summary("Get statistics"). // Short one-line description
Description(`
Returns comprehensive statistics about the system including:
- Total users
- Active sessions
- Request rates
- Error rates
This endpoint is cached for 5 minutes.
`). // Long detailed description
Handle(func(c *gin.Context) {
// ...
})
Tags
Tags group endpoints in the documentation UI:
// Single tag
tonica.NewRoute(app).
GET("/health").
Tag("Monitoring").
Handle(func(c *gin.Context) { /* ... */ })
// Multiple tags
tonica.NewRoute(app).
GET("/users").
Tags("Users", "Public", "v1").
Handle(func(c *gin.Context) { /* ... */ })
// Adding tags one by one
tonica.NewRoute(app).
GET("/orders").
Tag("Orders").
Tag("Commerce").
Tag("v2").
Handle(func(c *gin.Context) { /* ... */ })
Result in Scalar UI:
└─ Monitoring
└─ GET /health
└─ Users
└─ GET /users
└─ Orders
└─ GET /orders
Responses
Simple Response
tonica.NewRoute(app).
GET("/ping").
Response(200, "Pong response", tonica.StringSchema()).
Handle(func(c *gin.Context) {
c.String(200, "pong")
})
Multiple Responses
tonica.NewRoute(app).
POST("/users").
Response(201, "User created successfully", tonica.InlineObjectSchema(map[string]string{
"id": "string",
"message": "string",
})).
Response(400, "Invalid request data", tonica.InlineObjectSchema(map[string]string{
"error": "string",
})).
Response(409, "User already exists", tonica.InlineObjectSchema(map[string]string{
"error": "string",
})).
Response(500, "Internal server error", tonica.InlineObjectSchema(map[string]string{
"error": "string",
})).
Handle(func(c *gin.Context) {
// Handler implementation...
})
Response with Complex Schema
tonica.NewRoute(app).
GET("/users/:id").
Response(200, "User information", tonica.ObjectSchema(map[string]interface{}{
"id": map[string]string{"type": "string"},
"name": map[string]string{"type": "string"},
"email": map[string]string{"type": "string", "format": "email"},
"age": map[string]string{"type": "integer", "format": "int32"},
"roles": map[string]interface{}{
"type": "array",
"items": map[string]string{"type": "string"},
},
})).
Response(404, "User not found", nil).
Handle(func(c *gin.Context) {
// ...
})
Schema Helpers
Tonica provides helper functions for creating OpenAPI schemas:
StringSchema
tonica.StringSchema() // {"type": "string"}
InlineObjectSchema
Quick way to define an object:
tonica.InlineObjectSchema(map[string]string{
"name": "string",
"age": "integer",
"email": "string",
"active": "boolean",
})
Result:
{
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"email": {"type": "string"},
"active": {"type": "boolean"}
}
}
ObjectSchema
Full control over schema:
tonica.ObjectSchema(map[string]interface{}{
"name": map[string]string{
"type": "string",
"minLength": "1",
"maxLength": "100",
},
"age": map[string]string{
"type": "integer",
"minimum": "0",
"maximum": "150",
},
"email": map[string]string{
"type": "string",
"format": "email",
},
})
ArraySchema
// Array of strings
tonica.ArraySchema(map[string]string{"type": "string"})
// Array of objects
tonica.ArraySchema(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]string{"type": "string"},
"name": map[string]string{"type": "string"},
},
})
RefSchema
Reference a definition from your proto-generated spec:
// Reference to #/definitions/User
tonica.RefSchema("User")
This is useful when you want to reuse schemas from your proto definitions:
tonica.NewRoute(app).
POST("/users").
BodyParam("User data", tonica.RefSchema("UserCreateRequest")).
Response(200, "Created user", tonica.RefSchema("User")).
Handle(func(c *gin.Context) {
// ...
})
Security
Add authentication requirements to your routes:
Bearer Authentication
tonica.NewRoute(app).
GET("/protected").
Summary("Protected endpoint").
Security("bearer"). // Requires Bearer token
Response(200, "Success", tonica.StringSchema()).
Handle(func(c *gin.Context) {
// Check authorization header
auth := c.GetHeader("Authorization")
if auth == "" {
c.JSON(401, gin.H{"error": "unauthorized"})
return
}
// Validate token...
c.JSON(200, gin.H{"message": "authorized"})
})
API Key
tonica.NewRoute(app).
GET("/api-endpoint").
Security("apiKey").
Handle(func(c *gin.Context) {
// Check API key from header or query param
apiKey := c.GetHeader("X-API-Key")
// Validate...
c.JSON(200, gin.H{"data": "..."})
})
OAuth2 with Scopes
tonica.NewRoute(app).
POST("/users/:id/delete").
Summary("Delete user (requires admin access)").
Security("oauth2", "admin", "users:delete"). // Requires specific scopes
Handle(func(c *gin.Context) {
// Check OAuth2 token and scopes
// ...
})
Note: Security definitions must be defined in your OpenAPI spec or main app configuration.
Complete Examples
RESTful CRUD API
// List users
tonica.NewRoute(app).
GET("/api/v1/users").
Summary("List all users").
Tags("Users", "v1").
QueryParam("page", "integer", "Page number", false).
QueryParam("limit", "integer", "Items per page", false).
Response(200, "List of users", tonica.InlineObjectSchema(map[string]string{
"users": "array",
"total": "integer",
"page": "integer",
})).
Handle(listUsersHandler)
// Get user
tonica.NewRoute(app).
GET("/api/v1/users/:id").
Summary("Get user by ID").
Tags("Users", "v1").
PathParam("id", "string", "User ID").
Response(200, "User information", tonica.RefSchema("User")).
Response(404, "User not found", nil).
Handle(getUserHandler)
// Create user
tonica.NewRoute(app).
POST("/api/v1/users").
Summary("Create new user").
Tags("Users", "v1").
BodyParam("User data", tonica.InlineObjectSchema(map[string]string{
"name": "string",
"email": "string",
})).
Response(201, "User created", tonica.RefSchema("User")).
Response(400, "Invalid input", nil).
Handle(createUserHandler)
// Update user
tonica.NewRoute(app).
PUT("/api/v1/users/:id").
Summary("Update user").
Tags("Users", "v1").
PathParam("id", "string", "User ID").
BodyParam("Updated data", tonica.RefSchema("UserUpdateRequest")).
Response(200, "User updated", tonica.RefSchema("User")).
Response(404, "User not found", nil).
Handle(updateUserHandler)
// Delete user
tonica.NewRoute(app).
DELETE("/api/v1/users/:id").
Summary("Delete user").
Tags("Users", "v1").
Security("bearer").
PathParam("id", "string", "User ID").
Response(204, "User deleted", nil).
Response(404, "User not found", nil).
Handle(deleteUserHandler)
Health Check Endpoints
// Basic health check
tonica.NewRoute(app).
GET("/health").
Summary("Basic health check").
Tags("Monitoring").
Response(200, "Service is healthy", tonica.InlineObjectSchema(map[string]string{
"status": "string",
})).
Handle(func(c *gin.Context) {
c.JSON(200, gin.H{"status": "healthy"})
})
// Detailed health check
tonica.NewRoute(app).
GET("/health/detailed").
Summary("Detailed health check").
Description("Returns health status of all service dependencies").
Tags("Monitoring").
Response(200, "Health status", tonica.InlineObjectSchema(map[string]string{
"status": "string",
"database": "string",
"redis": "string",
"temporal": "string",
})).
Handle(func(c *gin.Context) {
health := gin.H{
"status": "healthy",
"database": checkDatabase(),
"redis": checkRedis(),
"temporal": checkTemporal(),
}
c.JSON(200, health)
})
// Readiness check (Kubernetes)
tonica.NewRoute(app).
GET("/ready").
Summary("Readiness probe").
Tags("Monitoring").
Response(200, "Service is ready", nil).
Response(503, "Service not ready", nil).
Handle(func(c *gin.Context) {
if !isReady() {
c.Status(503)
return
}
c.Status(200)
})
// Liveness check (Kubernetes)
tonica.NewRoute(app).
GET("/alive").
Summary("Liveness probe").
Tags("Monitoring").
Response(200, "Service is alive", nil).
Handle(func(c *gin.Context) {
c.Status(200)
})
Webhook Endpoint
tonica.NewRoute(app).
POST("/webhooks/stripe").
Summary("Stripe webhook handler").
Description("Receives webhook events from Stripe payment processor").
Tags("Webhooks", "Payments").
HeaderParam("Stripe-Signature", "string", "Webhook signature", true).
BodyParam("Webhook payload", tonica.ObjectSchema(map[string]interface{}{
"id": map[string]string{"type": "string"},
"type": map[string]string{"type": "string"},
"data": map[string]string{"type": "object"},
})).
Response(200, "Webhook processed", nil).
Response(400, "Invalid signature", nil).
Handle(func(c *gin.Context) {
signature := c.GetHeader("Stripe-Signature")
// Verify signature
if !verifyStripeSignature(signature) {
c.JSON(400, gin.H{"error": "invalid signature"})
return
}
// Process webhook
var event map[string]interface{}
if err := c.BindJSON(&event); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Handle event...
c.Status(200)
})
File Upload
tonica.NewRoute(app).
POST("/upload").
Summary("Upload file").
Tags("Files").
Security("bearer").
Response(200, "File uploaded successfully", tonica.InlineObjectSchema(map[string]string{
"fileId": "string",
"url": "string",
})).
Response(400, "Invalid file", nil).
Handle(func(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": "no file provided"})
return
}
// Save file...
fileID := saveFile(file)
c.JSON(200, gin.H{
"fileId": fileID,
"url": "/files/" + fileID,
})
})
Fluent API Chaining
All methods return the RouteBuilder, allowing you to chain calls:
tonica.NewRoute(app).
GET("/users/:id/orders").
Summary("Get user orders").
Description("Returns all orders for a specific user with optional filtering").
Tags("Users", "Orders").
PathParam("id", "string", "User ID").
QueryParam("status", "string", "Filter by order status", false).
QueryParam("limit", "integer", "Max number of results", false).
Security("bearer").
Response(200, "List of orders", tonica.ArraySchema(tonica.RefSchema("Order"))).
Response(404, "User not found", nil).
Handle(func(c *gin.Context) {
// Handler implementation
})
Best Practices
1. Always Document Your Routes
// ❌ Bad: No documentation
tonica.NewRoute(app).
GET("/data").
Handle(handler)
// ✅ Good: Well-documented
tonica.NewRoute(app).
GET("/data").
Summary("Fetch data").
Description("Retrieves aggregated data for the dashboard").
Tags("Analytics").
Response(200, "Data payload", schema).
Handle(handler)
2. Use Consistent Tags
// Group related endpoints
// Users
tonica.NewRoute(app).GET("/users").Tags("Users")...
tonica.NewRoute(app).POST("/users").Tags("Users")...
// Orders
tonica.NewRoute(app).GET("/orders").Tags("Orders")...
tonica.NewRoute(app).POST("/orders").Tags("Orders")...
3. Document All Responses
// Include success and error responses
tonica.NewRoute(app).
POST("/users").
Response(201, "Created", schema).
Response(400, "Invalid input", errorSchema).
Response(409, "Already exists", errorSchema).
Response(500, "Server error", errorSchema).
Handle(handler)
4. Reuse Schemas
// Define once
errorSchema := tonica.InlineObjectSchema(map[string]string{
"error": "string",
"code": "string",
"details": "string",
})
// Reuse everywhere
tonica.NewRoute(app).POST("/users").Response(400, "Error", errorSchema)...
tonica.NewRoute(app).POST("/orders").Response(400, "Error", errorSchema)...
5. Validate Input
tonica.NewRoute(app).
POST("/users").
BodyParam("User data", schema).
Handle(func(c *gin.Context) {
var user User
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(400, gin.H{"error": "invalid input"})
return
}
// Validate business rules
if user.Email == "" {
c.JSON(400, gin.H{"error": "email is required"})
return
}
// Process...
})
Troubleshooting
Routes Not Appearing in OpenAPI Spec
Problem: Custom routes don't show up in /openapi.json
Solution:
- Make sure you call
.Handle()- this registers the route - Verify
WithSpec()is set correctly in app initialization - Check app logs for errors during spec merging
Scalar UI Not Loading
Problem: /docs returns 404
Solution:
- Ensure you've set a spec path:
WithSpec("path/to/spec.json") - Check the spec file exists at the specified path
- Verify HTTP server is running on correct port
Wrong Schema in Documentation
Problem: Parameters or responses show incorrect types
Solution:
// Use correct type strings
"string", "integer", "number", "boolean", "array", "object"
// Not: "int", "str", "bool"
Route Handler Not Called
Problem: Route registered but handler doesn't execute
Solution:
- Check the path matches exactly (case-sensitive)
- Verify HTTP method is correct
- Check if middleware is blocking the request
- Look for errors in console/logs
Next Steps
- Testing - Test your custom routes
- Best Practices - API design patterns
- Configuration - Configure OpenAPI spec serving