Custom Tag Parsers Guide¶
This guide covers how to create custom struct tag parsers to extend Schema's metadata system.
Understanding Tag Parsers¶
Tag parsers convert struct tags into typed metadata that can be used during request decoding.
Tag Parser Function¶
Function Signature¶
type TagParserFunc func(field reflect.StructField, index int, tagValue string) (any, error)
Parameters:
- field: The struct field being parsed
- index: Field index in the struct
- tagValue: The tag value (e.g., "name,location=query")
Returns:
- any: Parsed metadata (typically a pointer to a struct)
- error: Parse error if tag is invalid
Example: Simple Tag Parser¶
package main
import (
"fmt"
"reflect"
"strings"
"github.com/talav/schema"
"github.com/talav/mapstructure"
)
// Define metadata structure
type CacheMetadata struct {
TTL int // Time to live in seconds
Enabled bool
}
// Parse cache tag
func ParseCacheTag(field reflect.StructField, index int, tagValue string) (any, error) {
meta := &CacheMetadata{
TTL: 300, // Default 5 minutes
Enabled: true,
}
// Parse tag options
parts := strings.Split(tagValue, ",")
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
key, value := kv[0], kv[1]
switch key {
case "ttl":
fmt.Sscanf(value, "%d", &meta.TTL)
case "enabled":
meta.Enabled = value == "true"
}
}
return meta, nil
}
// Register the parser
func main() {
registry := schema.NewTagParserRegistry(
schema.WithTagParser("schema", schema.ParseSchemaTag, schema.DefaultSchemaMetadata),
schema.WithTagParser("body", schema.ParseBodyTag),
schema.WithTagParser("cache", ParseCacheTag), // Custom tag
)
metadata := schema.NewMetadata(registry)
decoder := schema.NewDefaultDecoder()
unmarshaler := mapstructure.NewDefaultUnmarshaler()
codec := schema.NewCodec(metadata, unmarshaler, decoder)
// Use codec normally
_ = codec
}
// Use the custom tag
type UserRequest struct {
Username string `schema:"username" cache:"ttl=600,enabled=true"`
Email string `schema:"email" cache:"ttl=300"`
}
Default Metadata Function¶
Provide default metadata for fields without the tag:
type DefaultMetadataFunc func(field reflect.StructField, index int) any
Example with Default¶
// Default cache metadata
func DefaultCacheMetadata(field reflect.StructField, index int) any {
return &CacheMetadata{
TTL: 300,
Enabled: true,
}
}
// Register with default
registry := schema.NewTagParserRegistry(
schema.WithTagParser("cache", ParseCacheTag, DefaultCacheMetadata),
)
// Now fields without cache tag get default metadata
type Request struct {
Username string `schema:"username"` // Gets default cache metadata
Email string `schema:"email" cache:"ttl=600"` // Uses custom
}
Accessing Custom Metadata¶
Use GetTagMetadata to access parsed metadata:
// Type-safe access to custom metadata
func getCacheMetadata(field *schema.FieldMetadata) (*CacheMetadata, bool) {
return schema.GetTagMetadata[*CacheMetadata](field, "cache")
}
// Usage in custom decoder
func (d *CustomDecoder) Decode(...) (map[string]any, error) {
for _, field := range metadata.Fields {
if cacheMeta, ok := getCacheMetadata(&field); ok {
log.Printf("Field %s: TTL=%d, Enabled=%v",
field.StructFieldName, cacheMeta.TTL, cacheMeta.Enabled)
}
}
// Continue decoding...
}
Complete Examples¶
Example 1: Validation Tag¶
package main
import (
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"github.com/talav/schema"
"github.com/talav/mapstructure"
)
// Validation metadata
type ValidateMetadata struct {
Required bool
MinLength int
MaxLength int
Pattern *regexp.Regexp
Min *float64
Max *float64
}
// Parse validate tag
// Format: validate:"required,min:2,max:50,pattern:^[a-z]+$"
func ParseValidateTag(field reflect.StructField, index int, tagValue string) (any, error) {
meta := &ValidateMetadata{}
rules := strings.Split(tagValue, ",")
for _, rule := range rules {
rule = strings.TrimSpace(rule)
if rule == "required" {
meta.Required = true
continue
}
parts := strings.SplitN(rule, ":", 2)
if len(parts) != 2 {
continue
}
key, value := parts[0], parts[1]
switch key {
case "min":
if field.Type.Kind() == reflect.String {
v, _ := strconv.Atoi(value)
meta.MinLength = v
} else {
v, _ := strconv.ParseFloat(value, 64)
meta.Min = &v
}
case "max":
if field.Type.Kind() == reflect.String {
v, _ := strconv.Atoi(value)
meta.MaxLength = v
} else {
v, _ := strconv.ParseFloat(value, 64)
meta.Max = &v
}
case "pattern":
pattern, err := regexp.Compile(value)
if err != nil {
return nil, fmt.Errorf("invalid pattern: %w", err)
}
meta.Pattern = pattern
}
}
return meta, nil
}
// Validator using custom metadata
func validateField(fieldValue any, meta *ValidateMetadata) error {
// Check required
if meta.Required && isZeroValue(fieldValue) {
return fmt.Errorf("field is required")
}
// String validations
if str, ok := fieldValue.(string); ok {
if meta.MinLength > 0 && len(str) < meta.MinLength {
return fmt.Errorf("minimum length is %d", meta.MinLength)
}
if meta.MaxLength > 0 && len(str) > meta.MaxLength {
return fmt.Errorf("maximum length is %d", meta.MaxLength)
}
if meta.Pattern != nil && !meta.Pattern.MatchString(str) {
return fmt.Errorf("does not match pattern")
}
}
// Numeric validations
if num, ok := convertToFloat64(fieldValue); ok {
if meta.Min != nil && num < *meta.Min {
return fmt.Errorf("minimum value is %f", *meta.Min)
}
if meta.Max != nil && num > *meta.Max {
return fmt.Errorf("maximum value is %f", *meta.Max)
}
}
return nil
}
// Helper: Check if value is zero
func isZeroValue(v any) bool {
if v == nil {
return true
}
val := reflect.ValueOf(v)
return val.IsZero()
}
// Helper: Convert numeric types to float64
func convertToFloat64(v any) (float64, bool) {
val := reflect.ValueOf(v)
switch val.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return float64(val.Int()), true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return float64(val.Uint()), true
case reflect.Float32, reflect.Float64:
return val.Float(), true
default:
return 0, false
}
}
// Usage
type CreateUserRequest struct {
Name string `schema:"name" validate:"required,min:2,max:50"`
Email string `schema:"email" validate:"required,pattern:^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$"`
Age int `schema:"age" validate:"min:18,max:120"`
Username string `schema:"username" validate:"required,min:3,max:20,pattern:^[a-zA-Z0-9_]+$"`
}
Example 2: Documentation Tag¶
Generate API documentation from struct tags:
package main
import (
"fmt"
"reflect"
"strings"
"github.com/talav/schema"
"github.com/talav/mapstructure"
)
// Documentation metadata
type DocMetadata struct {
Description string
Example string
Deprecated bool
Since string
}
// Parse doc tag
// Format: doc:"description|example|deprecated|since:v1.0"
func ParseDocTag(field reflect.StructField, index int, tagValue string) (any, error) {
parts := strings.Split(tagValue, "|")
meta := &DocMetadata{}
if len(parts) > 0 {
meta.Description = parts[0]
}
if len(parts) > 1 {
meta.Example = parts[1]
}
if len(parts) > 2 {
meta.Deprecated = parts[2] == "deprecated"
}
if len(parts) > 3 {
meta.Since = strings.TrimPrefix(parts[3], "since:")
}
return meta, nil
}
// Generate OpenAPI-style documentation
func generateDocumentation(typ reflect.Type, metadata *schema.Metadata) {
structMeta, _ := metadata.GetStructMetadata(typ)
fmt.Printf("## %s\n\n", typ.Name())
for _, field := range structMeta.Fields {
// Get schema metadata
schemaMeta, hasSchema := schema.GetTagMetadata[*schema.SchemaMetadata](&field, "schema")
if !hasSchema {
continue
}
// Get doc metadata
docMeta, hasDoc := schema.GetTagMetadata[*DocMetadata](&field, "doc")
fmt.Printf("### %s\n", schemaMeta.ParamName)
fmt.Printf("- **Location**: %s\n", schemaMeta.Location)
fmt.Printf("- **Type**: %s\n", field.Type.String())
if hasDoc {
fmt.Printf("- **Description**: %s\n", docMeta.Description)
if docMeta.Example != "" {
fmt.Printf("- **Example**: `%s`\n", docMeta.Example)
}
if docMeta.Deprecated {
fmt.Printf("- **⚠️ Deprecated** (since %s)\n", docMeta.Since)
}
}
fmt.Println()
}
}
// Usage
type SearchRequest struct {
Query string `schema:"q" doc:"Search query text|golang web framework"`
Page int `schema:"page" doc:"Page number (1-indexed)|1"`
Limit int `schema:"limit" doc:"Results per page|20"`
OldField string `schema:"old_field" doc:"Legacy field|deprecated|since:v2.0"`
}
func main() {
// Register parsers
registry := schema.NewTagParserRegistry(
schema.WithTagParser("schema", schema.ParseSchemaTag, schema.DefaultSchemaMetadata),
schema.WithTagParser("doc", ParseDocTag),
)
metadata := schema.NewMetadata(registry)
// Generate docs
generateDocumentation(reflect.TypeOf(SearchRequest{}), metadata)
}
Example 3: Authorization Tag¶
Add authorization metadata:
package main
import (
"fmt"
"reflect"
"strings"
"github.com/talav/schema"
"github.com/talav/mapstructure"
)
// Authorization metadata
type AuthMetadata struct {
Roles []string
Permissions []string
Public bool
}
// Parse auth tag
// Format: auth:"roles:admin,editor;permissions:read,write" or auth:"public"
func ParseAuthTag(field reflect.StructField, index int, tagValue string) (any, error) {
meta := &AuthMetadata{}
if tagValue == "public" {
meta.Public = true
return meta, nil
}
parts := strings.Split(tagValue, ";")
for _, part := range parts {
kv := strings.SplitN(part, ":", 2)
if len(kv) != 2 {
continue
}
key, value := kv[0], kv[1]
switch key {
case "roles":
meta.Roles = strings.Split(value, ",")
case "permissions":
meta.Permissions = strings.Split(value, ",")
}
}
return meta, nil
}
// Authorization middleware using metadata
func withAuthorization(codec *schema.Codec, metadata *schema.Metadata, reqType reflect.Type, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
structMeta, _ := metadata.GetStructMetadata(reqType)
// Check authorization metadata
for _, field := range structMeta.Fields {
if authMeta, ok := schema.GetTagMetadata[*AuthMetadata](&field, "auth"); ok {
if authMeta.Public {
continue
}
// Check user roles/permissions
userRoles := extractUserRoles(r)
if !hasAnyRequiredRole(userRoles, authMeta.Roles) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
}
handler(w, r)
}
}
// Helper: Extract user roles from request (example - implement based on your auth system)
func extractUserRoles(r *http.Request) []string {
// Example: extract from JWT, session, etc.
token := r.Header.Get("Authorization")
if token == "" {
return []string{}
}
// Parse token and return roles
return []string{"user"} // Simplified example
}
// Helper: Check if user has any required role
func hasAnyRequiredRole(userRoles, requiredRoles []string) bool {
roleMap := make(map[string]bool)
for _, role := range userRoles {
roleMap[role] = true
}
for _, required := range requiredRoles {
if roleMap[required] {
return true
}
}
return len(requiredRoles) == 0 // Allow if no roles required
}
// Usage
type AdminRequest struct {
Action string `schema:"action" auth:"roles:admin;permissions:write"`
UserID string `schema:"user_id" auth:"public"`
}
Tag Parser Best Practices¶
1. Handle Errors Gracefully¶
// ✅ Good: Return descriptive errors
func ParseCustomTag(field reflect.StructField, index int, tagValue string) (any, error) {
if tagValue == "" {
return nil, fmt.Errorf("empty tag value for field %s", field.Name)
}
// Parse...
if invalid {
return nil, fmt.Errorf("invalid tag format: expected key=value, got %s", tagValue)
}
return meta, nil
}
2. Provide Sensible Defaults¶
// ✅ Good: Default values
func ParseCacheTag(field reflect.StructField, index int, tagValue string) (any, error) {
meta := &CacheMetadata{
TTL: 300, // Default 5 minutes
Enabled: true, // Default enabled
}
// Override with tag values
// ...
return meta, nil
}
3. Validate Tag Values¶
// ✅ Good: Validate inputs
func ParseValidateTag(field reflect.StructField, index int, tagValue string) (any, error) {
// Parse pattern
pattern := extractPattern(tagValue)
// Validate pattern is valid regex
if _, err := regexp.Compile(pattern); err != nil {
return nil, fmt.Errorf("invalid regex pattern: %w", err)
}
return meta, nil
}
4. Use Type-Safe Accessors¶
// ✅ Good: Type-safe helper function
func getValidationMetadata(field *schema.FieldMetadata) (*ValidateMetadata, bool) {
return schema.GetTagMetadata[*ValidateMetadata](field, "validate")
}
// Usage
if validateMeta, ok := getValidationMetadata(&field); ok {
// Type-safe access
if validateMeta.Required {
// ...
}
}
5. Document Tag Format¶
// ✅ Good: Clear documentation
// ParseMyTag parses the "mytag" struct tag.
//
// Format: mytag:"option1=value1,option2=value2"
//
// Options:
// - enabled: true/false (default: true)
// - ttl: duration in seconds (default: 300)
// - priority: high/medium/low (default: medium)
//
// Examples:
// `mytag:"enabled=true,ttl=600"`
// `mytag:"priority=high"`
func ParseMyTag(field reflect.StructField, index int, tagValue string) (any, error) {
// Implementation...
}
Registering Tag Parsers¶
Basic Registration¶
registry := schema.NewTagParserRegistry(
schema.WithTagParser("schema", schema.ParseSchemaTag, schema.DefaultSchemaMetadata),
schema.WithTagParser("body", schema.ParseBodyTag),
schema.WithTagParser("validate", ParseValidateTag),
schema.WithTagParser("cache", ParseCacheTag),
)
metadata := schema.NewMetadata(registry)
With Defaults¶
registry := schema.NewTagParserRegistry(
schema.WithTagParser("validate", ParseValidateTag, DefaultValidateMetadata),
schema.WithTagParser("cache", ParseCacheTag, DefaultCacheMetadata),
)
Dynamic Registration¶
// Start with default registry
registry := schema.NewDefaultTagParserRegistry()
// Add custom parsers dynamically
customRegistry := schema.NewTagParserRegistry(
append(
registry.All(),
schema.WithTagParser("custom", ParseCustomTag),
)...
)
Next Steps¶
- Extensibility Guide - Custom decoders and unmarshalers
- API Documentation - Full API reference