Roamer is a flexible, extensible HTTP request parser for Go that makes handling and extracting data from HTTP requests effortless. It provides a declarative way to map HTTP request data to Go structs using struct tags, with support for multiple data sources and content types.
graph TD
subgraph "Input"
A[HTTP Request]
end
subgraph "Data Sources"
B1[Headers]
B2[Cookies]
B3[Query Params]
B4[Path Variables]
B5[Request Body]
B6[Custom]
end
subgraph "Roamer Core Engine"
direction LR
P[Parsers]
D[Decoders]
F[Formatters]
end
subgraph "Output"
E[Populated Go Struct]
end
A --> B1 & B2 & B3 & B4 & B5 & B6
B1 & B2 & B3 & B4 & B6 -- values for --> P
B5 -- content for --> D
P -- parsed data --> F
D -- decoded data --> F
F -- formatted values --> E
classDef source stroke:#d4ac0d,stroke-width:4px
classDef core stroke:#0097c0,stroke-width:4px
classDef io stroke:#333,stroke-width:4px
class A,E io
class B1,B2,B3,B4,B5,B6 source
class P,D,F core
- Multiple data sources: Parse data from HTTP headers, cookies, query parameters, path variables, and custom sources (including request context)
- Content-type based decoding: Automatically decode request bodies based on Content-Type header
- Default Values: Set default values for fields using the
default
tag if no value is found in the request - Formatters: Format parsed data (e.g., trim spaces from strings, apply numeric constraints, handle time zones, manipulate slices)
- Router integration: Built-in support for popular routers (Chi, Gorilla Mux, HttpRouter)
- Type conversion: Automatic conversion of string values to appropriate Go types
- Extensibility: Easily create custom parsers, decoders, and formatters
- Middleware support: Convenient middleware for integrating with HTTP handlers
- Performance optimizations: Efficient reflection techniques and caching for improved performance
- Body preservation: Read request body multiple times when needed with
WithPreserveBody()
option
go get -u github.com/slipros/roamer@latest
For router integrations:
# Chi router
go get -u github.com/slipros/roamer/pkg/chi@latest
# Gorilla Mux router
go get -u github.com/slipros/roamer/pkg/gorilla@latest
# HttpRouter
go get -u github.com/slipros/roamer/pkg/httprouter@latest
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"
)
// Define a request struct with appropriate tags
type CreateUserRequest struct {
// From JSON body
Name string `json:"name" string:"trim_space"`
Email string `json:"email" string:"trim_space"`
// From query parameters
Age int `query:"age"`
CreatedAt time.Time `query:"created_at"`
// From headers
UserAgent string `header:"User-Agent"`
Referer string `header:"Referer,X-Referer"` // Tries Referer first, then X-Referer as fallback
}
// Response struct is separate from request parsing
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() {
// Initialize roamer with needed components
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewHeader(),
parser.NewQuery(),
),
roamer.WithFormatters(
formatter.NewString(),
formatter.NewNumeric(),
formatter.NewTime(),
formatter.NewSlice(),
),
)
// Create an HTTP handler
http.HandleFunc("/users", func(w http.ResponseWriter, req *http.Request) {
var userReq CreateUserRequest
// Parse the request into the user struct
if err := r.Parse(req, &userReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process the request data (in a real app, save to database etc.)
// Create a response
response := UserResponse{
ID: "user-123",
Name: userReq.Name,
Email: userReq.Email,
Age: userReq.Age,
CreatedAt: time.Now(),
}
// Return the response
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)
}
}
package main
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/slipros/roamer"
"github.com/slipros/roamer/decoder"
"github.com/slipros/roamer/parser"
)
// Request-specific struct
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `query:"age"`
CreatedAt time.Time `query:"created_at"`
}
// Response struct (not used with roamer)
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()),
)
// Create an HTTP handler with middleware
http.Handle("/users", roamer.Middleware[CreateUserRequest](r)(http.HandlerFunc(handleCreateUser)))
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func handleCreateUser(w http.ResponseWriter, req *http.Request) {
var userReq CreateUserRequest
// Get parsed data from context
if err := roamer.ParsedDataFromContext(req.Context(), &userReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process the request (in a real app, save to database etc.)
// Create and return a response
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
}
}
You can provide default values for fields using the default
tag. The default value is applied only if no value is found by any parser (e.g., from a query parameter or header) and the field has its zero value.
// Define a request struct with default values
type ListRequest struct {
// Page will be 1 if the "page" query param is not provided.
Page int `query:"page" default:"1"`
// PerPage will be 20 if the "per_page" query param is not provided.
PerPage int `query:"per_page" default:"20"`
// Sort will be "asc" if the "sort" query param is not provided.
Sort string `query:"sort" default:"asc"`
}
// Example usage:
// r := roamer.NewRoamer(roamer.WithParsers(parser.NewQuery()))
// req, _ := http.NewRequest("GET", "/items", nil)
// var listReq ListRequest
// r.Parse(req, &listReq)
// listReq.Page is now 1, PerPage is 20, Sort is "asc"
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"
)
// Request-specific struct for product creation
type CreateProductRequest struct {
ID string `path:"id"`
Name string `json:"name"`
Description string `json:"description"`
Category string `query:"category"`
}
// Response struct (not used with roamer)
type ProductResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
}
func main() {
// Initialize Chi router
router := chi.NewRouter()
router.Use(middleware.Logger)
// Initialize roamer with Chi path parser
roamerInstance := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewHeader(),
parser.NewQuery(),
parser.NewPath(rchi.NewPath(router)),
),
)
// Apply middleware and define routes
router.Route("/products", func(r chi.Router) {
r.With(roamer.Middleware[CreateProductRequest](roamerInstance)).Post("/{id}", handleCreateProduct)
})
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, req *http.Request) {
var productReq CreateProductRequest
if err := roamer.ParsedDataFromContext(req.Context(), &productReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process the request (in a real app, save to database etc.)
// Create and return a response
response := ProductResponse{
ID: productReq.ID,
Name: productReq.Name,
Description: productReq.Description,
Category: productReq.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
}
}
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"
)
// Request-specific struct for order retrieval
type GetOrderRequest struct {
ID string `path:"id"`
Status string `query:"status"`
}
// Response struct (not used with roamer)
type OrderResponse struct {
ID string `json:"id"`
Status string `json:"status"`
CustomerID string `json:"customer_id"`
}
func main() {
// Initialize Gorilla Mux router
router := mux.NewRouter()
// Initialize roamer with Gorilla path parser
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewQuery(),
parser.NewPath(rgorilla.Path),
),
)
// Apply middleware and define routes
router.Handle("/orders/{id}", roamer.Middleware[GetOrderRequest](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, req *http.Request) {
var orderReq GetOrderRequest
if err := roamer.ParsedDataFromContext(req.Context(), &orderReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process the request (in a real app, fetch from database etc.)
// Create and return a response
response := OrderResponse{
ID: orderReq.ID,
Status: orderReq.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
}
}
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"
)
// Request-specific struct for item creation
type CreateItemRequest struct {
ID string `path:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
// Response struct (not used with roamer)
type ItemResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
func main() {
// Initialize HttpRouter
router := httprouter.New()
// Initialize roamer with HttpRouter path parser
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewPath(rhttprouter.Path),
),
)
// Helper middleware chain
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
}
}
// Define routes with middleware
router.Handler("POST", "/items/:id", chain(
roamer.Middleware[CreateItemRequest](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, req *http.Request) {
var itemReq CreateItemRequest
if err := roamer.ParsedDataFromContext(req.Context(), &itemReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process the request (in a real app, save to database etc.)
// Create and return a response
response := ItemResponse{
ID: itemReq.ID,
Name: itemReq.Name,
Price: itemReq.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
}
}
Roamer is designed to work with any router by implementing a simple path parser adapter. Here's how to create an integration for any custom router or framework:
package main
import (
"log"
"net/http"
"github.com/slipros/roamer"
"github.com/slipros/roamer/parser"
"your/custom/router" // Your custom router package
)
// CustomRouterPathParser adapts your custom router to work with roamer
func CustomRouterPathParser(r *router.YourRouter) parser.PathValueFunc {
return func(req *http.Request, paramName string) (string, bool) {
// Implement the logic to extract path parameters from your router
// For example:
value, ok := r.GetPathParam(req, paramName)
return value, ok
}
}
func main() {
// Initialize your custom router
customRouter := router.New()
// Initialize roamer with your custom path parser
r := roamer.NewRoamer(
roamer.WithParsers(
parser.NewHeader(),
parser.NewQuery(),
parser.NewPath(CustomRouterPathParser(customRouter)),
),
)
// Use with your router...
}
This approach allows roamer to work with any router that can extract path parameters from requests, regardless of its internal implementation.
// Request-specific struct for JSON data
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
IsAdmin bool `json:"is_admin"`
}
// Initialize roamer with JSON decoder
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
)
// With custom content type
r := roamer.NewRoamer(
roamer.WithDecoders(
decoder.NewJSON(decoder.WithContentType[*decoder.JSON]("application/vnd.company.user+json")),
),
)
// Request-specific struct for XML data
type CreateUserXMLRequest struct {
Name string `xml:"name"`
Email string `xml:"email"`
Age int `xml:"age"`
IsAdmin bool `xml:"is_admin"`
}
// Initialize roamer with XML decoder
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewXML()),
)
// Request-specific struct for form data
type ContactFormRequest struct {
Name string `form:"name"`
Email string `form:"email"`
Age int `form:"age"`
Comments string `form:"comments"`
}
// Initialize roamer with FormURL decoder
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewFormURL()),
)
// With custom split symbol for array values
r := roamer.NewRoamer(
roamer.WithDecoders(
decoder.NewFormURL(decoder.WithSplitSymbol(";")),
),
)
// Request-specific struct for file upload
type FileUploadRequest struct {
Title string `multipart:"title"`
Description string `multipart:"description"`
File *decoder.MultipartFile `multipart:"file"`
AllFiles decoder.MultipartFiles `multipart:",allfiles"`
}
// Initialize roamer with MultipartFormData decoder
r := roamer.NewRoamer(
roamer.WithDecoders(
decoder.NewMultipartFormData(),
),
)
// With custom max memory limit (default is 32MB)
r := roamer.NewRoamer(
roamer.WithDecoders(
decoder.NewMultipartFormData(decoder.WithMaxMemory(64 << 20)), // 64MB
),
)
Roamer provides several built-in formatters to post-process parsed values. Formatters are applied after values are extracted from the HTTP request and converted to the appropriate type.
The String formatter provides text processing operations:
type UserRequest struct {
Name string `json:"name" string:"trim_space,title"`
Username string `query:"username" string:"trim_space,snake"`
Bio string `json:"bio" string:"trim_space"`
}
// Initialize with string formatter
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(parser.NewQuery()),
roamer.WithFormatters(formatter.NewString()),
)
Available string operations:
trim_space
- Remove leading and trailing whitespacelower
- Convert to lowercaseupper
- Convert to uppercasetitle
- Convert to title case (capitalize first letter of each word)snake
- Convert to snake_case formatcamel
- Convert to camelCase formatkebab
- Convert to kebab-case formatslug
- Convert to URL-friendly slug (lowercase with hyphens)base64_encode
- Encode string to base64base64_decode
- Decode base64 stringurl_encode
- URL encode stringurl_decode
- URL decode stringsanitize_html
- Basic HTML sanitization (escape < and >)reverse
- Reverse string characterstrim_prefix=PREFIX
- Remove specified prefix from stringtrim_suffix=SUFFIX
- Remove specified suffix from stringtruncate=N
- Truncate string to N charactersreplace=OLD:NEW
orreplace=OLD:NEW:COUNT
- Replace occurrences of OLD with NEW (optionally limit to COUNT replacements)substring=START
orsubstring=START:END
- Extract substring from START to END (or to end of string)pad_left=LENGTH
orpad_left=LENGTH:CHAR
- Pad string to LENGTH on the left (with CHAR or space)pad_right=LENGTH
orpad_right=LENGTH:CHAR
- Pad string to LENGTH on the right (with CHAR or space)
The Numeric formatter provides mathematical operations and constraints:
type ProductRequest struct {
Price float64 `json:"price" numeric:"min=0,max=1000"`
Quantity int `json:"quantity" numeric:"min=1,abs"`
Rating float64 `query:"rating" numeric:"min=0,max=5,round"`
Discount float32 `json:"discount" numeric:"ceil"`
}
// Initialize with numeric formatter
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(parser.NewQuery()),
roamer.WithFormatters(formatter.NewNumeric()),
)
Available numeric operations:
min=N
- Enforce minimum value (clamps to N if value is less)max=N
- Enforce maximum value (clamps to N if value is greater)abs
- Convert to absolute valueround
- Round to nearest integer (float types only)ceil
- Round up to next integer (float types only)floor
- Round down to previous integer (float types only)
The Time formatter provides time manipulation operations:
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"`
}
// Initialize with time formatter
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(parser.NewQuery()),
roamer.WithFormatters(formatter.NewTime()),
)
Available time operations:
timezone=TZ
- Convert to specified timezone (e.g.,UTC
,America/New_York
)truncate=UNIT
- Truncate to time unit (hour
,minute
,second
, or duration like1h30m
)start_of_day
- Set time to beginning of day (00:00:00)end_of_day
- Set time to end of day (23:59:59.999999999)
The Slice formatter provides operations for slice manipulation:
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"`
}
// Initialize with slice formatter
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(parser.NewQuery()),
roamer.WithFormatters(formatter.NewSlice()),
)
Available slice operations:
unique
- Remove duplicate valuessort
- Sort in ascending ordersort_desc
- Sort in descending ordercompact
- Remove zero values (empty strings, 0, nil, etc.)limit=N
- Limit slice to first N elements
You can use multiple formatters together in a single roamer instance:
type ComprehensiveRequest struct {
// String formatting
Name string `json:"name" string:"trim_space,title"`
// Numeric constraints
Age int `json:"age" numeric:"min=18,max=120"`
Salary float64 `query:"salary" numeric:"min=0,round"`
// Time manipulation
BirthDate time.Time `json:"birth_date" time:"timezone=UTC,start_of_day"`
// Slice operations
Skills []string `json:"skills" slice:"unique,sort" string:"trim_space,lower"`
Scores []int `query:"scores" slice:"compact,sort_desc,limit=10" numeric:"min=0,max=100"`
}
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewHeader(),
parser.NewQuery(),
),
roamer.WithFormatters(
formatter.NewString(),
formatter.NewNumeric(),
formatter.NewTime(),
formatter.NewSlice(),
),
)
Note: When multiple formatters are applied to the same field, they are processed in the order the formatters are registered with roamer.
The architecture is robust, modular, and designed for performance and extensibility.
- Clear Separation of Concerns: The core concepts of
Decoder
(for request bodies),Parser
(for other request parts like headers, query, etc.), andFormatter
(for post-processing values) create a clean and understandable system. - High Extensibility: The interface-based design allows users to easily add support for new data formats (e.g., MessagePack), data sources (e.g., context), or custom formatters without modifying the core library.
- Concurrency Safety: The
Roamer
instance is thread-safe due to the use ofsync.Map
andsync.Pool
. A single instance can be safely shared across multiple goroutines, which is essential for web server environments. - Router Independence: The library is decoupled from any specific HTTP router. The
parser.Path
component relies on a user-provided function to extract path parameters, making it universally compatible. - Flexible Configuration: The functional options pattern (
NewRoamer(opts ...OptionsFunc)
) provides a clean, readable, and extensible API for configuration.
Roamer is designed to be easily extended with custom parsers, decoders, and formatters. Here are examples of how to create each type of extension.
A parser extracts data from an HTTP request based on a struct tag. Here's an example of a custom parser that extracts data from a custom HTTP header:
package main
import (
"net/http"
"reflect"
"strings"
"github.com/slipros/roamer"
"github.com/slipros/roamer/parser"
)
const (
TagCustomHeader = "x-header"
)
// CustomHeaderParser parses headers with a specific prefix
type CustomHeaderParser struct {
prefix string
}
func NewCustomHeaderParser(prefix string) *CustomHeaderParser {
return &CustomHeaderParser{prefix: prefix}
}
// Parse implements the Parser interface
func (p *CustomHeaderParser) Parse(r *http.Request, tag reflect.StructTag, _ parser.Cache) (any, bool) {
tagValue, ok := tag.Lookup(TagCustomHeader)
if !ok {
return "", false
}
// Look for header with the specified prefix
headerName := p.prefix + "-" + tagValue
headerValue := r.Header.Get(headerName)
if len(headerValue) == 0 {
return "", false
}
return headerValue, true
}
// Tag implements the Parser interface
func (p *CustomHeaderParser) Tag() string {
return TagCustomHeader
}
// Usage
func main() {
r := roamer.NewRoamer(
roamer.WithParsers(NewCustomHeaderParser("X-App")),
)
// Now you can use the x-header tag in your structs:
// type MyRequestStruct struct {
// UserID string `x-header:"user-id"` // Will look for X-App-user-id header
// }
}
A decoder transforms the body of an HTTP request based on its Content-Type header. Here's an example of a custom decoder for MessagePack:
package main
import (
"net/http"
"github.com/slipros/roamer"
"github.com/vmihailenco/msgpack/v5" // Third-party MessagePack library
)
const (
ContentTypeMsgPack = "application/msgpack"
)
// MsgPackDecoder decodes MessagePack format
type MsgPackDecoder struct {
contentType string
}
func NewMsgPackDecoder() *MsgPackDecoder {
return &MsgPackDecoder{
contentType: ContentTypeMsgPack,
}
}
// Decode implements the Decoder interface
func (d *MsgPackDecoder) Decode(r *http.Request, ptr any) error {
return msgpack.NewDecoder(r.Body).Decode(ptr)
}
// ContentType implements the Decoder interface
func (d *MsgPackDecoder) ContentType() string {
return d.contentType
}
// SetContentType allows changing the content type
func (d *MsgPackDecoder) setContentType(contentType string) {
d.contentType = contentType
}
// WithContentType is an options function
func WithContentType(contentType string) func(*MsgPackDecoder) {
return func(d *MsgPackDecoder) {
d.setContentType(contentType)
}
}
// Usage
func main() {
r := roamer.NewRoamer(
roamer.WithDecoders(NewMsgPackDecoder()),
)
// Now you can decode MessagePack content in your request structs
}
A formatter processes parsed data before setting it on the struct field. Here's an example of a custom formatter for phone numbers:
package main
import (
"reflect"
"regexp"
"strings"
"github.com/pkg/errors"
"github.com/slipros/roamer"
rerr "github.com/slipros/roamer/err"
)
const (
TagPhone = "phone"
)
// PhoneFormatter formats phone numbers
type PhoneFormatter struct {
formatters map[string]func(string) string
}
func NewPhoneFormatter() *PhoneFormatter {
return &PhoneFormatter{
formatters: map[string]func(string) string{
"e164": formatToE164,
"strip": stripNonDigits,
},
}
}
// Format implements the Formatter interface
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.Wrapf(rerr.NotSupported, "%T", ptr)
}
formatter, ok := f.formatters[tagValue]
if !ok {
return errors.WithStack(rerr.FormatterNotFound{Tag: TagPhone, Formatter: tagValue})
}
*strPtr = formatter(*strPtr)
return nil
}
// Tag implements the Formatter interface
func (f *PhoneFormatter) Tag() string {
return TagPhone
}
// Format functions
func formatToE164(phone string) string {
// Strip all non-digit characters
digits := stripNonDigits(phone)
// Add + prefix if not present
if !strings.HasPrefix(digits, "+") {
return "+" + digits
}
return digits
}
func stripNonDigits(phone string) string {
re := regexp.MustCompile(`[^\d+]`)
return re.ReplaceAllString(phone, "")
}
// Usage
func main() {
r := roamer.NewRoamer(
roamer.WithFormatters(NewPhoneFormatter()),
)
// Now you can use the phone tag in your structs:
// type ContactRequest struct {
// PhoneNumber string `phone:"e164"` // Format as E.164 international format
// RawPhone string `phone:"strip"` // Strip all non-digit characters
// }
}
Parsers and decoders can implement the AssignExtensions
interface to provide custom value assignment capabilities for complex types. This allows for specialized handling during the assignment process beyond the standard type conversions.
package main
import (
"net/http"
"reflect"
"github.com/slipros/assign"
"github.com/slipros/roamer"
"github.com/slipros/roamer/parser"
)
// Custom API token parser that returns structured token data
type APITokenParser struct{}
func (p *APITokenParser) Parse(r *http.Request, tag reflect.StructTag, _ parser.Cache) (any, bool) {
tokenType, ok := tag.Lookup("api_token")
if !ok {
return nil, false
}
// Get the raw token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil, false
}
// Return a structured Token object instead of just a string
// This allows us to provide metadata along with the token value
return &APIToken{
Value: authHeader,
Type: tokenType, // Use the tag value to specify token type
ExpiresIn: 3600, // Example metadata
Scope: "read:user write:user",
}, true
}
func (p *APITokenParser) Tag() string {
return "api_token"
}
// Custom token type that contains more than just the token value
type APIToken struct {
Value string
Type string
ExpiresIn int
Scope string
}
// This is where AssignExtensions becomes necessary - the assign library
// doesn't know how to extract the string value from our APIToken struct
func (p *APITokenParser) AssignExtensions() []assign.ExtensionFunc {
return []assign.ExtensionFunc{
func(value any) (func(to reflect.Value) error, bool) {
token, ok := value.(*APIToken)
if !ok {
return nil, false
}
return func(to reflect.Value) error {
// Extract just the token value for string fields
return assign.String(to, token.Value)
}, true
},
}
}
// Usage example - demonstrates why this extension is needed
type AuthRequest struct {
// This field will receive just the token string value
// WITHOUT the extension, this assignment would fail because
// assign library doesn't know how to convert *APIToken to string
TokenValue string `api_token:"access"`
}
func main() {
r := roamer.NewRoamer(
roamer.WithParsers(&APITokenParser{}),
)
// The extension enables automatic conversion from *APIToken to string
// for fields that need just the token value
}
When a parser or decoder implements AssignExtensions
, the extension functions are automatically registered during Roamer initialization. This enables:
- Custom type conversions beyond standard string-to-type assignments
- Complex object handling where you need to work with structured data
- Flexible field assignment where the same source can populate different target field types
- Extended compatibility with third-party types and custom business objects
The extension functions are called during the assignment phase, allowing you to handle specific types that require special processing or conversion logic.
Roamer is designed with performance in mind, using efficient reflection techniques and caching where possible. For optimal performance:
- Use request structs that only include fields needed for specific endpoints
- Consider the performance implications of heavy reflection usage
- Benchmark your specific use case to identify bottlenecks
When you need to read the request body multiple times (e.g., for logging or validation before parsing), use the WithPreserveBody()
option:
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithPreserveBody(), // Enable body preservation
)
Important: Body preservation reads the entire body into memory, which may impact performance for large request bodies. Use it only when necessary.
For high-throughput applications, you can use custom sync.Pool
for parsing:
import "github.com/slipros/roamer"
// Create a custom pool for your request type
pool := &sync.Pool{
New: func() any {
return &MyRequest{}
},
}
// Use the pool-aware parsing function
req := pool.Get().(*MyRequest)
defer pool.Put(req)
if err := roamer.NewParseWithPool(r, httpReq, req, pool); err != nil {
// Handle error
}
This approach reduces allocations by reusing request struct instances across requests.
Always create dedicated structs for parsing requests, separate from your response structures:
// Request struct - used with roamer
type ProductRequest struct {
Name string `json:"name" string:"trim_space"`
Price float64 `json:"price"`
Category string `query:"category"`
}
// Response struct - not used with roamer
type ProductResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Category string `json:"category"`
CreatedAt time.Time `json:"created_at"`
}
Create specific request structs for each endpoint to minimize parsing overhead:
// Get request only needs ID and optional filters
type GetProductRequest struct {
ID string `path:"id"`
Category string `query:"category"`
}
// Create request needs more fields
type CreateProductRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
Category string `query:"category"`
}
// Update request may need ID from path and body fields
type UpdateProductRequest struct {
ID string `path:"id"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
}
Here's an example of how to test an HTTP handler that uses roamer:
func TestHandleCreateProduct(t *testing.T) {
// Create a test router and roamer instance
router := chi.NewRouter()
r := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewHeader(),
parser.NewQuery(),
parser.NewPath(rchi.NewPath(router)),
),
)
// Create a test handler with roamer middleware
router.With(roamer.Middleware[CreateProductRequest](r)).Post("/{id}", handleCreateProduct)
// Create a test server
ts := httptest.NewServer(router)
defer ts.Close()
// Create test request payload
payload := `{"name":"Test Product","description":"A test product","price":29.99}`
// Send test request
resp, err := http.Post(
ts.URL+"/products/123?category=test",
"application/json",
strings.NewReader(payload),
)
require.NoError(t, err)
defer resp.Body.Close()
// Check response
require.Equal(t, http.StatusCreated, resp.StatusCode)
// Decode response
var product ProductResponse
err = json.NewDecoder(resp.Body).Decode(&product)
require.NoError(t, err)
// Assert expected values
require.Equal(t, "123", product.ID)
require.Equal(t, "Test Product", product.Name)
require.Equal(t, "A test product", product.Description)
require.Equal(t, 29.99, product.Price)
require.Equal(t, "test", product.Category)
}
Here's a complete example that combines multiple roamer features:
package main
import (
"encoding/json"
"log"
"net/http"
"time"
"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/formatter"
"github.com/slipros/roamer/parser"
rchi "github.com/slipros/roamer/pkg/chi"
)
type Custom string
const (
CustomValue Custom = "value"
)
// Request-specific structs for different endpoints
type CreateProductRequest struct {
// Body data
Name string `json:"name" string:"trim_space"`
Description string `json:"description" string:"trim_space"`
Price float64 `json:"price" numeric:"min=0"`
// Path parameter
ID string `path:"id"`
// Query parameters
Category string `query:"category" string:"trim_space,lower"`
Tags []string `query:"tags" slice:"unique,compact,sort"`
CustomType *Custom `query:"custom_type"`
}
type GetProductRequest struct {
// We only need the ID for GET requests
ID string `path:"id"`
Category string `query:"category"` // Optional filter
}
// Response structure (not used with roamer)
type ProductResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
Category string `json:"category"`
CreatedAt time.Time `json:"created_at"`
}
func main() {
// Initialize Chi router
router := chi.NewRouter()
router.Use(middleware.Logger, middleware.Recoverer)
// Initialize roamer
roamerInstance := roamer.NewRoamer(
roamer.WithDecoders(decoder.NewJSON()),
roamer.WithParsers(
parser.NewHeader(),
parser.NewQuery(),
parser.NewPath(rchi.NewPath(router)),
),
roamer.WithFormatters(
formatter.NewString(),
formatter.NewNumeric(),
formatter.NewTime(),
formatter.NewSlice(),
),
)
// Define routes with appropriate request structs for each endpoint
router.Route("/api/products", func(r chi.Router) {
// Use CreateProductRequest for POST
r.With(roamer.Middleware[CreateProductRequest](roamerInstance)).Post("/{id}", handleProductCreate)
// Use GetProductRequest for GET - only parses what's needed
r.With(roamer.Middleware[GetProductRequest](roamerInstance)).Get("/{id}", handleProductGet)
})
// Start server
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", router); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func handleProductCreate(w http.ResponseWriter, r *http.Request) {
var req CreateProductRequest
// Parse the incoming request data only
if err := roamer.ParsedDataFromContext(r.Context(), &req); err != nil {
http.Error(w, "Invalid product data: "+err.Error(), http.StatusBadRequest)
return
}
// Process the request (in a real app, save to database etc.)
log.Printf("Creating product: %s in category %s", req.Name, req.Category)
// Create a response (separate from request parsing)
response := ProductResponse{
ID: req.ID,
Name: req.Name,
Description: req.Description,
Price: req.Price,
Category: req.Category,
CreatedAt: time.Now(),
}
// Return the response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
func handleProductGet(w http.ResponseWriter, r *http.Request) {
var req GetProductRequest
// Parse only the parameters needed for retrieval
if err := roamer.ParsedDataFromContext(r.Context(), &req); err != nil {
http.Error(w, "Invalid parameters: "+err.Error(), http.StatusBadRequest)
return
}
// In a real app, fetch from database using req.ID
response := ProductResponse{
ID: req.ID,
Name: "Sample Product",
Description: "This is a sample product description",
Price: 99.99,
Category: req.Category,
CreatedAt: time.Now().Add(-24 * time.Hour), // Sample creation time
}
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
}
}
Roamer offers several advantages:
- Declarative syntax - Define your request structure once with tags, rather than writing repetitive parsing code
- Type safety - Automatic conversion from strings to appropriate Go types
- Reduced boilerplate - No need to manually extract values from different request sources
- Separation of concerns - Clean separation between request parsing and business logic
- Extensibility - Easy to add custom parsers, decoders, and formatters
Roamer is primarily designed for HTTP requests, but its architecture is extensible. You could create custom parsers and decoders for other protocols, though you would need to adapt the interface to work with non-HTTP requests.
Roamer focuses on parsing, not validation. For validation, consider combining roamer with a validation library such as:
Example:
func handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
// Parse the request
if err := roamer.ParsedDataFromContext(r.Context(), &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Validate the parsed data
if err := validate.Struct(req); err != nil {
http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest)
return
}
// Process the validated request...
}
Roamer is designed with performance in mind, using efficient reflection techniques and caching where possible. For high-load applications, consider:
- Using request structs that only include fields needed for specific endpoints
- Benchmarking your specific use case to identify bottlenecks
- Profiling memory usage and allocations in your specific context
Yes. A performance analysis was conducted, and the codebase is written to a very high standard with performance in mind. Key optimizations are already in place, demonstrating a strong understanding of potential bottlenecks. The overall design is efficient and minimizes unnecessary allocations. The analysis concluded that the project is in an excellent state regarding performance and memory allocations, and no changes were recommended.
Yes, roamer works well with code generated from OpenAPI specifications. You can add roamer tags to your generated models or create dedicated request structs that map to your API specification.
Contributions are welcome! Feel free to submit issues or pull requests.
Roamer is licensed under the MIT License. See the LICENSE file for details.
- Go Documentation
- GitHub Repository
- Issue Tracker
- Changelog - See what's new in each version
Note: This documentation is based on the latest version of roamer. Make sure to check the project's official documentation for the most up-to-date information.