Extending Roamer
Extending Roamer
Roamer is designed to be easily extended with custom parsers, decoders, and formatters. This guide shows you how to create each type of extension.
Table of Contents
- Architecture Overview
- Custom Value Assignment Extensions
- Creating Custom Parsers
- Creating Custom Decoders
- Creating Custom Formatters
- Integration Examples
- Best Practices
Architecture Overview
Roamer’s extensible architecture is built around three main interfaces:
- Parser - Extract data from HTTP request parts (headers, query, cookies, etc.)
- Decoder - Parse request body content based on Content-Type
- Formatter - Post-process parsed values before setting them on struct fields
Each component works independently and can be combined with built-in or other custom components.
Custom Value Assignment Extensions
Parsers and decoders can implement the AssignExtensions
interface to provide custom value assignment capabilities for complex types. This powerful feature allows for specialized handling during the assignment process beyond the standard type conversions.
AssignExtensions Interface
type AssignExtensions interface {
AssignExtensions() []assign.ExtensionFunc
}
When to Use Assignment Extensions
Assignment extensions are useful when:
- You need to handle complex custom types that require special conversion logic
- Your parser returns structured objects that need to be assigned to different field types
- You want to provide multiple assignment strategies for the same parsed value
- You’re working with third-party types that require custom handling
Basic Example
Here’s a simple example showing how to implement assignment extensions:
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
}
Advanced Example with HTTP Cookies
The built-in Cookie parser demonstrates a real-world use of assignment extensions:
// From parser/cookie.go
func (c *Cookie) AssignExtensions() []assign.ExtensionFunc {
return []assign.ExtensionFunc{
func(value any) (func(to reflect.Value) error, bool) {
cookie, ok := value.(*http.Cookie)
if !ok {
return nil, false
}
return func(to reflect.Value) error {
return assign.String(to, cookie.Value)
}, true
},
}
}
This allows the cookie parser to work with both *http.Cookie
objects and string fields seamlessly.
Complex Example with Multiple Types
Here’s a more sophisticated example that handles multiple related types:
package main
import (
"encoding/json"
"net/http"
"reflect"
"strconv"
"time"
"github.com/slipros/assign"
"github.com/slipros/roamer"
"github.com/slipros/roamer/parser"
)
// Complex data structure from external API
type APIResponse struct {
UserID int `json:"user_id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
Status string `json:"status"`
}
// Parser that fetches data from external API
type APIParser struct{}
func (p *APIParser) Parse(r *http.Request, tag reflect.StructTag, _ parser.Cache) (any, bool) {
userID, ok := tag.Lookup("api_user")
if !ok {
return nil, false
}
// Simulate API call (in real code, you'd make HTTP request)
apiResp := &APIResponse{
UserID: parseInt(userID),
Username: "user_" + userID,
CreatedAt: time.Now(),
Status: "active",
}
return apiResp, true
}
func (p *APIParser) Tag() string {
return "api_user"
}
// Implement AssignExtensions for flexible assignment
func (p *APIParser) AssignExtensions() []assign.ExtensionFunc {
return []assign.ExtensionFunc{
func(value any) (func(to reflect.Value) error, bool) {
apiResp, ok := value.(*APIResponse)
if !ok {
return nil, false
}
return func(to reflect.Value) error {
switch to.Kind() {
case reflect.String:
// Assign username to string fields
to.SetString(apiResp.Username)
return nil
case reflect.Int, reflect.Int64:
// Assign user ID to integer fields
to.SetInt(int64(apiResp.UserID))
return nil
case reflect.Struct:
if to.Type() == reflect.TypeOf(time.Time{}) {
// Assign creation time to time fields
to.Set(reflect.ValueOf(apiResp.CreatedAt))
return nil
}
if to.Type() == reflect.TypeOf(APIResponse{}) {
// Assign full response to APIResponse fields
to.Set(reflect.ValueOf(*apiResp))
return nil
}
}
// Fallback: convert to JSON string
jsonData, err := json.Marshal(apiResp)
if err != nil {
return err
}
return assign.String(to, string(jsonData))
}, true
},
}
}
func parseInt(s string) int {
i, _ := strconv.Atoi(s)
return i
}
// Request struct demonstrating flexible assignment
type UserDataRequest struct {
// Different fields can receive different parts of the API response
UserID int `api_user:"123"` // Gets UserID field
Username string `api_user:"123"` // Gets Username field
FullResponse APIResponse `api_user:"123"` // Gets full APIResponse
JSONData string `api_user:"123"` // Gets JSON representation
}
func main() {
r := roamer.NewRoamer(
roamer.WithParsers(&APIParser{}),
)
// All fields will be populated from the same API response
// but with different assignment strategies
}
Best Practices for Assignment Extensions
- Type Safety: Always check types before attempting assignment
- Fallback Strategy: Provide sensible fallbacks when direct assignment isn’t possible
- Error Handling: Return clear errors for unsupported assignment combinations
- Performance: Keep assignment logic lightweight since it’s called for every field
- Documentation: Document what types your extensions support
func (p *MyParser) AssignExtensions() []assign.ExtensionFunc {
return []assign.ExtensionFunc{
func(value any) (func(to reflect.Value) error, bool) {
myType, ok := value.(*MyCustomType)
if !ok {
return nil, false // Not our type, let other extensions handle it
}
return func(to reflect.Value) error {
// Check supported target types
switch to.Kind() {
case reflect.String:
to.SetString(myType.String())
return nil
case reflect.Int:
if myType.IntValue != nil {
to.SetInt(int64(*myType.IntValue))
return nil
}
return fmt.Errorf("no integer value available")
default:
return fmt.Errorf("unsupported assignment from %T to %s",
myType, to.Type())
}
}, true
},
}
}
Assignment extensions provide a powerful way to bridge the gap between complex parsed data and the variety of field types in your request structs, making Roamer extremely flexible for handling sophisticated data transformation scenarios.
Creating Custom Parsers
A parser extracts data from an HTTP request based on struct tags.
Parser Interface
type Parser interface {
Parse(req *http.Request, tag reflect.StructTag, cache Cache) (any, bool)
Tag() string
}
Custom Context Parser Example
Let’s create a parser that extracts member data from request context. This is a common pattern for authentication and authorization data:
package main
import (
"net/http"
"reflect"
"github.com/gofrs/uuid"
"github.com/slipros/roamer"
"github.com/slipros/roamer/parser"
)
const TagMember = "member"
type MemberParser struct{}
func NewMemberParser() *MemberParser {
return &MemberParser{}
}
// Parse extracts member data from the request context based on the struct tag
func (p *MemberParser) Parse(r *http.Request, tag reflect.StructTag, _ parser.Cache) (any, bool) {
tagValue, ok := tag.Lookup(TagMember)
if !ok {
return nil, false
}
// Extract member from context (injected by middleware)
m, ok := MemberFromContext(r.Context())
if !ok {
return nil, false
}
// Return different parts of member data based on tag value
switch tagValue {
case "organization_id":
return m.OrganizationID, true
case "id":
return m.ID, true
case "member":
return m, true
default:
return nil, false
}
}
// Tag implements the Parser interface
func (p *MemberParser) Tag() string {
return TagMember
}
// Member represents user/member data
type Member struct {
ID uuid.UUID
OrganizationID uuid.UUID
Name string
Role string
}
// Usage example showing different field types
type APIRequest struct {
MemberID string `member:"id"` // As string
MemberIDAsUUID uuid.UUID `member:"id"` // As UUID
Member *Member `member:"member"` // As pointer
MemberAsStruct Member `member:"member"` // As struct
}
func main() {
r := roamer.NewRoamer(
roamer.WithParsers(NewMemberParser()),
)
// Use with middleware that injects member into context
http.HandleFunc("/api", memberMiddleware(func(w http.ResponseWriter, req *http.Request) {
var apiReq APIRequest
if err := r.Parse(req, &apiReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// apiReq.MemberID, apiReq.Member, etc. are now populated
w.WriteHeader(http.StatusOK)
}))
}
For a complete working example, see examples/custom_parser.
Environment Variable Parser
Here’s a more complex example that parses environment variables:
package main
import (
"net/http"
"os"
"reflect"
"github.com/slipros/roamer"
"github.com/slipros/roamer/parser"
)
const TagEnv = "env"
type EnvParser struct{}
func NewEnvParser() *EnvParser {
return &EnvParser{}
}
func (p *EnvParser) Parse(req *http.Request, tag reflect.StructTag, _ parser.Cache) (any, bool) {
envVar, ok := tag.Lookup(TagEnv)
if !ok {
return "", false
}
value := os.Getenv(envVar)
return value, len(value) > 0
}
func (p *EnvParser) Tag() string {
return TagEnv
}
type ConfigRequest struct {
DatabaseURL string `env:"DATABASE_URL"`
APIKey string `env:"API_KEY"`
Debug string `env:"DEBUG"`
}
Creating Custom Decoders
A decoder parses request body content based on the Content-Type header.
Decoder Interface
type Decoder interface {
Decode(req *http.Request, ptr any) error
ContentType() string
}
MessagePack Decoder Example
Let’s create a decoder for MessagePack format:
package main
import (
"net/http"
"github.com/slipros/roamer"
"github.com/vmihailenco/msgpack/v5" // Third-party MessagePack library
)
const ContentTypeMsgPack = "application/msgpack"
type MsgPackDecoder struct {
contentType string
}
func NewMsgPackDecoder(opts ...MsgPackOption) *MsgPackDecoder {
d := &MsgPackDecoder{
contentType: ContentTypeMsgPack,
}
for _, opt := range opts {
opt(d)
}
return d
}
// 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
}
// Option pattern for configuration
type MsgPackOption func(*MsgPackDecoder)
func WithContentType(contentType string) MsgPackOption {
return func(d *MsgPackDecoder) {
d.contentType = contentType
}
}
// Usage
func main() {
r := roamer.NewRoamer(
roamer.WithDecoders(
NewMsgPackDecoder(),
// Or with custom content type
NewMsgPackDecoder(WithContentType("application/x-msgpack")),
),
)
// Now you can decode MessagePack content in your requests
}
YAML Decoder Example
package main
import (
"net/http"
"github.com/slipros/roamer"
"gopkg.in/yaml.v3"
)
const ContentTypeYAML = "application/yaml"
type YAMLDecoder struct {
contentType string
}
func NewYAMLDecoder() *YAMLDecoder {
return &YAMLDecoder{
contentType: ContentTypeYAML,
}
}
func (d *YAMLDecoder) Decode(r *http.Request, ptr any) error {
return yaml.NewDecoder(r.Body).Decode(ptr)
}
func (d *YAMLDecoder) ContentType() string {
return d.contentType
}
type YAMLRequest struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Dependencies map[string]string `yaml:"dependencies"`
}
Creating Custom Formatters
A formatter post-processes parsed values before they’re set on struct fields.
Formatter Interface
type Formatter interface {
Format(tag reflect.StructTag, ptr any) error
Tag() string
}
Phone Number Formatter Example
package main
import (
"reflect"
"regexp"
"strings"
"github.com/pkg/errors"
"github.com/slipros/roamer"
rerr "github.com/slipros/roamer/err"
)
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,
"us_format": formatUSPhone,
"international": formatInternational,
},
}
}
// 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, "phone formatter only supports *string, got %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
}
// Formatting functions
func formatToE164(phone string) string {
digits := stripNonDigits(phone)
if !strings.HasPrefix(digits, "+") {
// Assume US number if no country code
if len(digits) == 10 {
return "+1" + digits
}
return "+" + digits
}
return digits
}
func stripNonDigits(phone string) string {
re := regexp.MustCompile(`[^\d+]`)
return re.ReplaceAllString(phone, "")
}
func formatUSPhone(phone string) string {
digits := stripNonDigits(phone)
if len(digits) == 10 {
return fmt.Sprintf("(%s) %s-%s", digits[0:3], digits[3:6], digits[6:10])
}
return phone
}
func formatInternational(phone string) string {
digits := stripNonDigits(phone)
if strings.HasPrefix(digits, "+") {
return digits
}
return "+" + digits
}
// Usage example
type ContactRequest struct {
HomePhone string `json:"home_phone" phone:"us_format"`
MobilePhone string `json:"mobile_phone" phone:"e164"`
WorkPhone string `json:"work_phone" phone:"strip"`
}
func main() {
r := roamer.NewRoamer(
roamer.WithFormatters(NewPhoneFormatter()),
)
// Phone numbers will be automatically formatted
}
Address Formatter Example
package main
import (
"reflect"
"strings"
"unicode"
"github.com/slipros/roamer"
)
const TagAddress = "address"
type AddressFormatter struct{}
func NewAddressFormatter() *AddressFormatter {
return &AddressFormatter{}
}
func (f *AddressFormatter) Format(tag reflect.StructTag, ptr any) error {
operation, ok := tag.Lookup(TagAddress)
if !ok {
return nil
}
strPtr, ok := ptr.(*string)
if !ok {
return nil // Skip non-string fields
}
switch operation {
case "normalize":
*strPtr = normalizeAddress(*strPtr)
case "upper":
*strPtr = strings.ToUpper(*strPtr)
case "title":
*strPtr = strings.Title(strings.ToLower(*strPtr))
}
return nil
}
func (f *AddressFormatter) Tag() string {
return TagAddress
}
func normalizeAddress(addr string) string {
// Clean up extra whitespace
addr = strings.TrimSpace(addr)
addr = regexp.MustCompile(`\s+`).ReplaceAllString(addr, " ")
// Common abbreviations
replacements := map[string]string{
" St ": " Street ",
" Ave ": " Avenue ",
" Blvd ": " Boulevard ",
" Dr ": " Drive ",
" Rd ": " Road ",
" Ct ": " Court ",
" Ln ": " Lane ",
}
for old, new := range replacements {
addr = strings.ReplaceAll(addr, old, new)
}
return addr
}
type AddressRequest struct {
StreetAddress string `json:"street" address:"normalize"`
City string `json:"city" address:"title"`
State string `json:"state" address:"upper"`
}
Integration Examples
Complete Custom Extension
Here’s a complete example showing how to create a comprehensive extension:
package main
import (
"encoding/csv"
"fmt"
"log"
"net/http"
"reflect"
"strconv"
"strings"
"github.com/slipros/roamer"
"github.com/slipros/roamer/parser"
)
// Custom CSV Parser
const TagCSV = "csv"
type CSVParser struct{}
func NewCSVParser() *CSVParser {
return &CSVParser{}
}
func (p *CSVParser) Parse(r *http.Request, tag reflect.StructTag, _ parser.Cache) (any, bool) {
paramName, ok := tag.Lookup(TagCSV)
if !ok {
return nil, false
}
csvData := r.URL.Query().Get(paramName)
if csvData == "" {
return nil, false
}
reader := csv.NewReader(strings.NewReader(csvData))
records, err := reader.ReadAll()
if err != nil {
return nil, false
}
// Flatten all records into a single slice
var result []string
for _, record := range records {
result = append(result, record...)
}
return result, true
}
func (p *CSVParser) Tag() string {
return TagCSV
}
// Custom CSV Decoder
const ContentTypeCSV = "text/csv"
type CSVDecoder struct{}
func NewCSVDecoder() *CSVDecoder {
return &CSVDecoder{}
}
func (d *CSVDecoder) Decode(r *http.Request, ptr any) error {
// Assume ptr is a slice of structs for CSV rows
reader := csv.NewReader(r.Body)
records, err := reader.ReadAll()
if err != nil {
return err
}
// This is a simplified example - in reality you'd use reflection
// to populate the struct slice based on CSV headers
fmt.Printf("CSV records: %+v\n", records)
return nil
}
func (d *CSVDecoder) ContentType() string {
return ContentTypeCSV
}
// Custom Validation Formatter
const TagValidate = "validate"
type ValidationFormatter struct{}
func NewValidationFormatter() *ValidationFormatter {
return &ValidationFormatter{}
}
func (f *ValidationFormatter) Format(tag reflect.StructTag, ptr any) error {
validation, ok := tag.Lookup(TagValidate)
if !ok {
return nil
}
switch validation {
case "email":
return validateEmail(ptr)
case "positive":
return validatePositive(ptr)
case "non_empty":
return validateNonEmpty(ptr)
}
return nil
}
func (f *ValidationFormatter) Tag() string {
return TagValidate
}
func validateEmail(ptr any) error {
strPtr, ok := ptr.(*string)
if !ok {
return nil
}
if !strings.Contains(*strPtr, "@") {
return fmt.Errorf("invalid email format")
}
return nil
}
func validatePositive(ptr any) error {
switch v := ptr.(type) {
case *int:
if *v < 0 {
*v = 0 // or return error
}
case *float64:
if *v < 0 {
*v = 0 // or return error
}
}
return nil
}
func validateNonEmpty(ptr any) error {
strPtr, ok := ptr.(*string)
if !ok {
return nil
}
if strings.TrimSpace(*strPtr) == "" {
return fmt.Errorf("field cannot be empty")
}
return nil
}
// Usage example
type ComplexRequest struct {
// CSV data from query parameter
Tags []string `csv:"tags"`
// Validated fields
Email string `json:"email" validate:"email"`
Amount float64 `json:"amount" validate:"positive"`
Name string `json:"name" validate:"non_empty"`
}
func main() {
r := roamer.NewRoamer(
roamer.WithParsers(NewCSVParser()),
roamer.WithDecoders(NewCSVDecoder()),
roamer.WithFormatters(NewValidationFormatter()),
)
http.HandleFunc("/complex", func(w http.ResponseWriter, req *http.Request) {
var complexReq ComplexRequest
if err := r.Parse(req, &complexReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Printf("Parsed request: %+v\n", complexReq)
w.WriteHeader(http.StatusOK)
})
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
Router-Specific Extension
Create a custom path parser for a hypothetical router:
package main
import (
"net/http"
"github.com/slipros/roamer"
"github.com/slipros/roamer/parser"
"your/custom/router" // Your custom router
)
// CustomRouterPathParser adapts your router to work with roamer
func CustomRouterPathParser(r *router.YourRouter) parser.PathValueFunc {
return func(req *http.Request, paramName string) (string, bool) {
// Implement extraction logic for your router
value, exists := r.GetPathParam(req, paramName)
return value, exists
}
}
func main() {
customRouter := router.New()
r := roamer.NewRoamer(
roamer.WithParsers(
parser.NewQuery(),
parser.NewPath(CustomRouterPathParser(customRouter)),
),
)
// Use with your router...
}
Best Practices
Parser Best Practices
- Handle missing tags gracefully - Return
(nil, false)
if tag is not found - Type conversion - Let Roamer handle type conversion, return appropriate types
- Error handling - Return
(nil, false)
for parsing errors rather than panicking - Performance - Cache expensive operations when possible
func (p *MyParser) Parse(r *http.Request, tag reflect.StructTag, cache parser.Cache) (any, bool) {
tagValue, ok := tag.Lookup(p.Tag())
if !ok {
return nil, false // Tag not found
}
// Use cache for expensive operations
if cached, exists := cache.Get("key"); exists {
return cached, true
}
value := extractValue(r, tagValue)
if value == "" {
return nil, false // Value not found
}
cache.Set("key", value) // Cache result
return value, true
}
Decoder Best Practices
- Content-Type matching - Be specific about content types you handle
- Error handling - Return descriptive errors for parsing failures
- Stream handling - Don’t load entire body into memory for large requests
- Security - Validate input to prevent attacks
func (d *MyDecoder) Decode(r *http.Request, ptr any) error {
// Limit request size to prevent DoS
r.Body = http.MaxBytesReader(nil, r.Body, 1<<20) // 1MB limit
// Use streaming decoder when possible
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // Security: reject unknown fields
return decoder.Decode(ptr)
}
Formatter Best Practices
- Type safety - Check types before formatting
- Idempotency - Formatting should be safe to apply multiple times
- Error handling - Return clear errors for unsupported operations
- Performance - Avoid expensive operations in formatters
func (f *MyFormatter) Format(tag reflect.StructTag, ptr any) error {
operation, ok := tag.Lookup(f.Tag())
if !ok {
return nil // No formatting needed
}
// Type check first
strPtr, ok := ptr.(*string)
if !ok {
return fmt.Errorf("formatter %s only supports *string, got %T", f.Tag(), ptr)
}
// Apply formatting
*strPtr = f.transform(*strPtr, operation)
return nil
}
Testing Custom Components
Always test your custom components thoroughly:
func TestCustomParser(t *testing.T) {
parser := NewCustomParser()
tests := []struct {
name string
request *http.Request
tag string
expected any
found bool
}{
{
name: "valid tag",
request: createTestRequest(),
tag: `custom:"test"`,
expected: "expected_value",
found: true,
},
{
name: "missing tag",
request: createTestRequest(),
tag: `other:"test"`,
expected: nil,
found: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tag := reflect.StructTag(tt.tag)
result, found := parser.Parse(tt.request, tag, nil)
assert.Equal(t, tt.expected, result)
assert.Equal(t, tt.found, found)
})
}
}
Integration Testing
Test your extensions work with Roamer:
func TestCustomExtensionIntegration(t *testing.T) {
r := roamer.NewRoamer(
roamer.WithParsers(NewCustomParser()),
roamer.WithDecoders(NewCustomDecoder()),
roamer.WithFormatters(NewCustomFormatter()),
)
type TestRequest struct {
CustomField string `custom:"field" custom_format:"operation"`
}
req := createTestRequest()
var testReq TestRequest
err := r.Parse(req, &testReq)
require.NoError(t, err)
assert.Equal(t, "expected_formatted_value", testReq.CustomField)
}
By following these patterns and best practices, you can create powerful extensions that integrate seamlessly with Roamer’s architecture.