Examples
Examples
This page contains comprehensive examples showing how to use Roamer in different scenarios.
💡 Runnable Examples: All examples on this page are also available as complete, runnable applications in the examples/ directory of the repository.
Table of Contents
Basic Usage
Simple JSON API
package main
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/slipros/roamer"
"github.com/slipros/roamer/decoder"
"github.com/slipros/roamer/formatter"
"github.com/slipros/roamer/parser"
)
type CreateUserRequest struct {
Name string `json:"name" string:"trim_space"`
Email string `json:"email" string:"trim_space,lower"`
Age int `query:"age" numeric:"min=18,max=120"`
}
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
CreatedAt time.Time `json:"created_at"`
}
func main() {
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(parser.NewQuery()),
roamer.WithFormatters(
formatter.NewString(),
formatter.NewNumeric(),
),
)
http.HandleFunc("/users", func(w http.ResponseWriter, req *http.Request) {
var userReq CreateUserRequest
if err := r.Parse(req, &userReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response := UserResponse{
ID: "user-123",
Name: userReq.Name,
Email: userReq.Email,
Age: userReq.Age,
CreatedAt: time.Now(),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
})
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
With Default Values
type SearchRequest struct {
Query string `query:"q"`
Page int `query:"page" default:"1"`
PerPage int `query:"per_page" default:"20"`
Sort string `query:"sort" default:"relevance"`
}
r := roamer.NewRoamer(roamer.WithParsers(parser.NewQuery()))
// Example: GET /search?q=golang&page=2
// Results in: Query="golang", Page=2, PerPage=20, Sort="relevance"
Router Integration
Chi Router
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/slipros/roamer"
"github.com/slipros/roamer/decoder"
"github.com/slipros/roamer/parser"
rchi "github.com/slipros/roamer/pkg/chi"
)
type ProductRequest struct {
ID string `path:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Category string `query:"category"`
}
type ProductResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Category string `json:"category"`
}
func main() {
router := chi.NewRouter()
router.Use(middleware.Logger)
roamerInstance := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewQuery(),
parser.NewPath(rchi.NewPath(router)),
),
)
router.Route("/products", func(r chi.Router) {
r.With(roamer.Middleware[ProductRequest](roamerInstance)).Post("/{id}", handleCreateProduct)
r.With(roamer.Middleware[ProductRequest](roamerInstance)).Get("/{id}", handleGetProduct)
})
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", router); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func handleCreateProduct(w http.ResponseWriter, r *http.Request) {
var req ProductRequest
if err := roamer.ParsedDataFromContext(r.Context(), &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response := ProductResponse{
ID: req.ID,
Name: req.Name,
Price: req.Price,
Category: req.Category,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
func handleGetProduct(w http.ResponseWriter, r *http.Request) {
var req ProductRequest
if err := roamer.ParsedDataFromContext(r.Context(), &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Simulate fetching product
response := ProductResponse{
ID: req.ID,
Name: "Sample Product",
Price: 99.99,
Category: req.Category,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
Gorilla Mux
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/gorilla/mux"
"github.com/slipros/roamer"
"github.com/slipros/roamer/decoder"
"github.com/slipros/roamer/parser"
rgorilla "github.com/slipros/roamer/pkg/gorilla"
)
type OrderRequest struct {
ID string `path:"id"`
Status string `query:"status"`
}
type OrderResponse struct {
ID string `json:"id"`
Status string `json:"status"`
CustomerID string `json:"customer_id"`
}
func main() {
router := mux.NewRouter()
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewQuery(),
parser.NewPath(rgorilla.Path),
),
)
router.Handle("/orders/{id}",
roamer.Middleware[OrderRequest](r)(http.HandlerFunc(handleGetOrder))).Methods("GET")
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", router); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func handleGetOrder(w http.ResponseWriter, r *http.Request) {
var req OrderRequest
if err := roamer.ParsedDataFromContext(r.Context(), &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response := OrderResponse{
ID: req.ID,
Status: req.Status,
CustomerID: "customer-456",
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
HttpRouter
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/julienschmidt/httprouter"
"github.com/slipros/roamer"
"github.com/slipros/roamer/decoder"
"github.com/slipros/roamer/parser"
rhttprouter "github.com/slipros/roamer/pkg/httprouter"
)
type ItemRequest struct {
ID string `path:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
type ItemResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
func main() {
router := httprouter.New()
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewPath(rhttprouter.Path),
),
)
chain := func(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
router.Handler("POST", "/items/:id", chain(
roamer.Middleware[ItemRequest](r),
)(http.HandlerFunc(handleCreateItem)))
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", router); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func handleCreateItem(w http.ResponseWriter, r *http.Request) {
var req ItemRequest
if err := roamer.ParsedDataFromContext(r.Context(), &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response := ItemResponse{
ID: req.ID,
Name: req.Name,
Price: req.Price,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
Content Types
XML Support
type UserXMLRequest struct {
Name string `xml:"name"`
Email string `xml:"email"`
Age int `xml:"age"`
IsAdmin bool `xml:"is_admin"`
}
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewXML()),
)
// POST /users with XML body:
// <?xml version="1.0"?>
// <user>
// <name>John Doe</name>
// <email>john@example.com</email>
// <age>30</age>
// <is_admin>false</is_admin>
// </user>
Form URL-Encoded
type ContactFormRequest struct {
Name string `form:"name"`
Email string `form:"email"`
Message string `form:"message"`
Topics []string `form:"topics"`
}
r := roamer.NewRoamer(
roamer.WithDecoders(
decoder.NewFormURL(decoder.WithSplitSymbol(",")),
),
)
// POST /contact with form data:
// name=John+Doe&email=john@example.com&message=Hello&topics=support,billing
Multipart Form Data with File Upload
type FileUploadRequest struct {
Title string `multipart:"title"`
Description string `multipart:"description"`
File *decoder.MultipartFile `multipart:"file"`
AllFiles decoder.MultipartFiles `multipart:",allfiles"`
}
r := roamer.NewRoamer(
roamer.WithDecoders(
decoder.NewMultipartFormData(decoder.WithMaxMemory(64 << 20)), // 64MB
),
)
func handleFileUpload(w http.ResponseWriter, req *http.Request) {
var uploadReq FileUploadRequest
if err := r.Parse(req, &uploadReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process uploaded file
if uploadReq.File != nil {
fmt.Printf("Uploaded file: %s (%d bytes)\n",
uploadReq.File.Filename, uploadReq.File.Size)
}
// Process all files
for _, file := range uploadReq.AllFiles {
fmt.Printf("File: %s\n", file.Filename)
}
}
Formatters
String Formatting
type UserRequest struct {
Name string `json:"name" string:"trim_space,title_case"`
Username string `json:"username" string:"trim_space,lower,slug"`
Bio string `json:"bio" string:"trim_space"`
}
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithFormatters(formatter.NewString()),
)
// Input: {"name": " john DOE ", "username": " John_Doe "}
// Output: Name="John Doe", Username="john-doe"
Numeric Constraints
type ProductRequest struct {
Price float64 `json:"price" numeric:"min=0,max=1000"`
Quantity int `json:"quantity" numeric:"min=1,abs"`
Rating float64 `json:"rating" numeric:"min=0,max=5,round"`
Discount float32 `json:"discount" numeric:"ceil"`
}
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithFormatters(formatter.NewNumeric()),
)
// Automatically applies constraints and transformations
Time Formatting
type EventRequest struct {
StartTime time.Time `json:"start_time" time:"timezone=UTC,truncate=hour"`
EndTime time.Time `json:"end_time" time:"timezone=America/New_York"`
Date time.Time `query:"date" time:"start_of_day"`
Deadline time.Time `json:"deadline" time:"end_of_day"`
}
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(parser.NewQuery()),
roamer.WithFormatters(formatter.NewTime()),
)
Slice Operations
type SearchRequest struct {
Tags []string `query:"tags" slice:"unique,sort"`
Categories []string `json:"categories" slice:"compact,limit=10"`
Scores []float64 `json:"scores" slice:"sort_desc,limit=5"`
IDs []int `query:"ids" slice:"unique,compact"`
}
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(parser.NewQuery()),
roamer.WithFormatters(formatter.NewSlice()),
)
// GET /search?tags=golang,web,golang,api&ids=1,2,0,3
// Results: Tags=["api","golang","web"], IDs=[1,2,3]
Middleware
Type-Safe Middleware
type CreateUserRequest struct {
Name string `json:"name" string:"trim_space"`
Email string `json:"email" string:"trim_space,lower"`
Age int `json:"age" numeric:"min=18"`
}
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithFormatters(formatter.NewString(), formatter.NewNumeric()),
)
http.Handle("/users",
roamer.Middleware[CreateUserRequest](r)(http.HandlerFunc(handleCreateUser)))
func handleCreateUser(w http.ResponseWriter, req *http.Request) {
var userReq CreateUserRequest
// Data is already parsed and available in context
if err := roamer.ParsedDataFromContext(req.Context(), &userReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process the validated and formatted request...
}
Custom Middleware Chain
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check authentication
if r.Header.Get("Authorization") == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// Chain middlewares
http.Handle("/protected/users",
authMiddleware(
roamer.Middleware[CreateUserRequest](r)(
http.HandlerFunc(handleCreateUser))))
Custom Extensions
Custom Parser
const TagCustomHeader = "x-header"
type CustomHeaderParser struct {
prefix string
}
func NewCustomHeaderParser(prefix string) *CustomHeaderParser {
return &CustomHeaderParser{prefix: prefix}
}
func (p *CustomHeaderParser) Parse(r *http.Request, tag reflect.StructTag, _ parser.Cache) (any, bool) {
tagValue, ok := tag.Lookup(TagCustomHeader)
if !ok {
return "", false
}
headerName := p.prefix + "-" + tagValue
headerValue := r.Header.Get(headerName)
return headerValue, len(headerValue) > 0
}
func (p *CustomHeaderParser) Tag() string {
return TagCustomHeader
}
// Usage
type RequestWithCustomHeader struct {
UserID string `x-header:"user-id"` // Looks for X-App-user-id header
}
r := roamer.NewRoamer(
roamer.WithParsers(NewCustomHeaderParser("X-App")),
)
Custom Formatter
const TagPhone = "phone"
type PhoneFormatter struct {
formatters map[string]func(string) string
}
func NewPhoneFormatter() *PhoneFormatter {
return &PhoneFormatter{
formatters: map[string]func(string) string{
"e164": formatToE164,
"strip": stripNonDigits,
},
}
}
func (f *PhoneFormatter) Format(tag reflect.StructTag, ptr any) error {
tagValue, ok := tag.Lookup(TagPhone)
if !ok {
return nil
}
strPtr, ok := ptr.(*string)
if !ok {
return errors.Errorf("unsupported type: %T", ptr)
}
formatter, ok := f.formatters[tagValue]
if !ok {
return errors.Errorf("unknown phone formatter: %s", tagValue)
}
*strPtr = formatter(*strPtr)
return nil
}
func (f *PhoneFormatter) Tag() string {
return TagPhone
}
func formatToE164(phone string) string {
digits := stripNonDigits(phone)
if !strings.HasPrefix(digits, "+") {
return "+" + digits
}
return digits
}
func stripNonDigits(phone string) string {
re := regexp.MustCompile(`[^\d+]`)
return re.ReplaceAllString(phone, "")
}
// Usage
type ContactRequest struct {
PhoneNumber string `phone:"e164"` // Format as E.164
RawPhone string `phone:"strip"` // Strip non-digits
}
r := roamer.NewRoamer(
roamer.WithFormatters(NewPhoneFormatter()),
)
Complete Custom Example
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"reflect"
"strings"
"github.com/slipros/roamer"
"github.com/slipros/roamer/decoder"
"github.com/slipros/roamer/parser"
)
// Custom parser for API version from Accept header
type APIVersionParser struct{}
func (p *APIVersionParser) Parse(r *http.Request, tag reflect.StructTag, _ parser.Cache) (any, bool) {
accept := r.Header.Get("Accept")
re := regexp.MustCompile(`application/vnd\.myapi\.v(\d+)\+json`)
matches := re.FindStringSubmatch(accept)
if len(matches) > 1 {
return matches[1], true
}
return "1", true // default version
}
func (p *APIVersionParser) Tag() string {
return "api_version"
}
// Custom formatter for cleaning and validating usernames
type UsernameFormatter struct{}
func (f *UsernameFormatter) Format(tag reflect.StructTag, ptr any) error {
strPtr, ok := ptr.(*string)
if !ok {
return nil
}
// Clean username: lowercase, remove special chars, limit length
username := strings.ToLower(*strPtr)
username = regexp.MustCompile(`[^a-z0-9_]`).ReplaceAllString(username, "")
if len(username) > 20 {
username = username[:20]
}
*strPtr = username
return nil
}
func (f *UsernameFormatter) Tag() string {
return "username"
}
type UserRequest struct {
Username string `json:"username" username:"clean"`
Email string `json:"email"`
APIVersion string `api_version:""`
}
func main() {
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(&APIVersionParser{}),
roamer.WithFormatters(&UsernameFormatter{}),
)
http.HandleFunc("/users", func(w http.ResponseWriter, req *http.Request) {
var userReq UserRequest
if err := r.Parse(req, &userReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Printf("User: %+v\n", userReq)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{
"username": userReq.Username,
"email": userReq.Email,
"api_version": userReq.APIVersion,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
})
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
Test with:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-H "Accept: application/vnd.myapi.v2+json" \
-d '{"username": "John_Doe123!@#", "email": "john@example.com"}'
This will clean the username to “john_doe123” and set API version to “2”.
Testing Examples
Unit Testing with Roamer
func TestUserRequestParsing(t *testing.T) {
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(parser.NewQuery()),
roamer.WithFormatters(formatter.NewString()),
)
tests := []struct {
name string
body string
query string
expected CreateUserRequest
}{
{
name: "valid request",
body: `{"name": " John ", "email": "JOHN@EXAMPLE.COM"}`,
query: "age=30",
expected: CreateUserRequest{
Name: "John",
Email: "john@example.com",
Age: 30,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/users?"+tt.query,
strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
var userReq CreateUserRequest
err := r.Parse(req, &userReq)
require.NoError(t, err)
assert.Equal(t, tt.expected, userReq)
})
}
}
These examples cover the most common use cases for Roamer. For more advanced scenarios, check out the API Reference and Extending Roamer pages.