-
Notifications
You must be signed in to change notification settings - Fork 24
Expand file tree
/
Copy pathstruct_tags.go
More file actions
1606 lines (1434 loc) · 47.5 KB
/
Copy pathstruct_tags.go
File metadata and controls
1606 lines (1434 loc) · 47.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package jsonschema
import (
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"sync"
"github.com/kaptinlin/jsonschema/pkg/tagparser"
)
// StructTagError represents an error that occurred during struct tag processing.
// It provides detailed context about which struct, field, and tag rule caused the error.
type StructTagError struct {
StructType string // The type name of the struct being processed
FieldName string // The name of the field with the error
TagRule string // The tag rule that failed (e.g., "pattern=...")
Message string // Human-readable error message
Err error // Underlying error (renamed from Cause for consistency with UnmarshalError)
}
// Error returns a formatted error message with full context.
func (e *StructTagError) Error() string {
var sb strings.Builder
sb.WriteString("struct tag error")
var parts []string
if e.StructType != "" {
parts = append(parts, "struct="+e.StructType)
}
if e.FieldName != "" {
parts = append(parts, "field="+e.FieldName)
}
if e.TagRule != "" {
parts = append(parts, "rule="+e.TagRule)
}
if len(parts) > 0 {
sb.WriteString(" (")
sb.WriteString(strings.Join(parts, ", "))
sb.WriteByte(')')
}
if e.Message != "" {
sb.WriteString(": ")
sb.WriteString(e.Message)
}
if e.Err != nil {
sb.WriteString(": ")
sb.WriteString(e.Err.Error())
}
return sb.String()
}
// Unwrap returns the underlying error, allowing error chain inspection with errors.Is/As.
func (e *StructTagError) Unwrap() error {
return e.Err
}
// Import schemagen components for reuse
// Note: Since schemagen is in cmd/schemagen and we're in the main package,
// we'll reimplement the required components adapted for runtime use
// RequiredSort controls how required field names are ordered
type RequiredSort string
const (
// RequiredSortAlphabetical sorts required fields alphabetically for deterministic output
RequiredSortAlphabetical RequiredSort = "alphabetical"
// RequiredSortNone does not sort required fields, preserving the order from struct field iteration
// Note: May be non-deterministic due to map iteration in TagParser
RequiredSortNone RequiredSort = "none"
)
// StructTagOptions holds configuration for struct tag schema generation
type StructTagOptions struct {
TagName string // tag name to parse (default: "jsonschema")
AllowUntaggedFields bool // whether to include fields without tags (default: false)
DefaultRequired bool // whether fields are required by default (default: false)
FieldNameMapper func(string) string // function to map Go field names to JSON names
CustomValidators map[string]any // custom validators (for future extension)
CacheEnabled bool // whether to enable schema caching (default: true)
SchemaVersion string // $schema URI to include in generated schemas (empty string = omit $schema, default = Draft 2020-12)
RequiredSort RequiredSort // controls ordering of required fields (default: RequiredSortAlphabetical)
// Schema-level properties using map approach
SchemaProperties map[string]any // flexible configuration for any schema property
}
// CustomValidatorFunc represents a custom validator function
type CustomValidatorFunc func(_ reflect.Type, params []string) []Keyword
// ValidatorRegistry manages custom validators
type ValidatorRegistry struct {
validators map[string]CustomValidatorFunc
mutex sync.RWMutex
}
var globalValidatorRegistry = &ValidatorRegistry{
validators: make(map[string]CustomValidatorFunc),
}
// RegisterCustomValidator registers a custom validator globally
func RegisterCustomValidator(name string, validator CustomValidatorFunc) {
globalValidatorRegistry.mutex.Lock()
defer globalValidatorRegistry.mutex.Unlock()
globalValidatorRegistry.validators[name] = validator
}
// CustomValidator retrieves a custom validator by name
func (r *ValidatorRegistry) CustomValidator(name string) (CustomValidatorFunc, bool) {
r.mutex.RLock()
defer r.mutex.RUnlock()
validator, exists := r.validators[name]
return validator, exists
}
// DefaultStructTagOptions returns the default configuration for struct tag processing
func DefaultStructTagOptions() *StructTagOptions {
return &StructTagOptions{
TagName: "jsonschema",
AllowUntaggedFields: false,
DefaultRequired: false,
FieldNameMapper: nil, // use default field naming
CustomValidators: make(map[string]any),
CacheEnabled: true,
SchemaVersion: "https://json-schema.org/draft/2020-12/schema", // default to JSON Schema Draft 2020-12
RequiredSort: RequiredSortAlphabetical, // default to alphabetical sorting for determinism
// Schema-level properties - empty by default (not set)
SchemaProperties: nil, // nil = no schema properties set
}
}
// normalizeOptions ensures options fields have valid defaults. Returns new options if nil.
// Creates a copy to avoid mutating the input.
func normalizeOptions(options *StructTagOptions) *StructTagOptions {
if options == nil {
return DefaultStructTagOptions()
}
// Create a copy to avoid mutating the input
normalized := *options
// Set defaults for empty fields
if normalized.TagName == "" {
normalized.TagName = "jsonschema"
}
if normalized.CustomValidators == nil {
normalized.CustomValidators = make(map[string]any)
}
if normalized.RequiredSort == "" {
normalized.RequiredSort = RequiredSortAlphabetical
}
return &normalized
}
// structTagGenerator handles runtime struct tag schema generation with reused schemagen logic
type structTagGenerator struct {
options *StructTagOptions
tagParser *tagparser.TagParser // Use the real tagparser
typeMapping map[string]func(...Keyword) *Schema
validatorMap map[string]validatorFunc
// Dependency tracking (simplified from schemagen ReferenceAnalyzer)
visited map[reflect.Type]int // 0=unvisited, 1=visiting, 2=completed
definitions map[string]*Schema // $defs storage
generatedRefs map[reflect.Type]string // track generated $refs
}
// validatorFunc represents a function that converts tag parameters to Schema keywords
type validatorFunc func(_ reflect.Type, params []string) []Keyword
var (
// Global schema cache for improved performance across multiple calls
globalSchemaCache sync.Map // map[cacheKey]*Schema
)
// cacheKey represents a unique key for caching schemas
type cacheKey struct {
structType reflect.Type
tagName string
allowUntaggedFields bool
defaultRequired bool
cacheEnabled bool
schemaVersion string // include schema version in cache key to ensure different versions are cached separately
// Note: we don't include function pointers in the cache key as they can't be compared
}
// newStructTagGenerator creates a new struct tag generator with the given options
func newStructTagGenerator(options *StructTagOptions) *structTagGenerator {
options = normalizeOptions(options)
return &structTagGenerator{
options: options,
tagParser: tagparser.NewWithTagName(options.TagName), // Use real tagparser
typeMapping: createRuntimeTypeMapping(),
validatorMap: createRuntimeValidatorMapping(),
visited: make(map[reflect.Type]int),
definitions: make(map[string]*Schema),
generatedRefs: make(map[reflect.Type]string),
}
}
// FromStruct generates a JSON Schema from a struct type with jsonschema tags.
func FromStruct[T any]() (*Schema, error) {
return FromStructWithOptions[T](nil)
}
// FromStructWithOptions generates a JSON Schema from a struct type with custom options.
func FromStructWithOptions[T any](options *StructTagOptions) (*Schema, error) {
structType := reflect.TypeFor[T]()
// Normalize options with defaults
options = normalizeOptions(options)
// Check global cache first if enabled
if options.CacheEnabled {
key := cacheKey{
structType: structType,
tagName: options.TagName,
allowUntaggedFields: options.AllowUntaggedFields,
defaultRequired: options.DefaultRequired,
cacheEnabled: options.CacheEnabled,
schemaVersion: options.SchemaVersion,
}
if cached, ok := globalSchemaCache.Load(key); ok {
return cached.(*Schema), nil
}
}
// Create generator for this call (allows different options per call)
generator := newStructTagGenerator(options)
// Generate schema using reused schemagen logic
schema, err := generator.generateSchemaWithDependencyAnalysis(structType)
if err != nil {
return nil, err
}
// Set $schema if specified in options (empty string means omit $schema)
if options.SchemaVersion != "" {
schema.Schema = options.SchemaVersion
}
// Apply schema-level properties
applySchemaProperties(schema, options)
// Add $defs if there are any circular references
if len(generator.definitions) > 0 {
defsMap := make(SchemaMap)
for name, defSchema := range generator.definitions {
// Create a clean copy of the definition schema to avoid circular references
cleanDef := generator.createCleanDefinition(defSchema)
defsMap[name] = cleanDef
}
schema.Defs = defsMap
}
// Resolve all references to ensure ResolvedRef fields are populated
// This is critical for validation to work correctly with nested structs
schema.resolveReferences()
if err := schema.validateRegexSyntax(); err != nil {
return nil, err
}
// Clean up visited state
generator.visited = make(map[reflect.Type]int)
// Cache the result globally if caching is enabled
if options.CacheEnabled {
key := cacheKey{
structType: structType,
tagName: options.TagName,
allowUntaggedFields: options.AllowUntaggedFields,
defaultRequired: options.DefaultRequired,
cacheEnabled: options.CacheEnabled,
schemaVersion: options.SchemaVersion,
}
globalSchemaCache.Store(key, schema)
}
return schema, nil
}
// ClearSchemaCache clears the global schema cache - useful for testing and memory management
func ClearSchemaCache() {
globalSchemaCache.Range(func(key, _ any) bool {
globalSchemaCache.Delete(key)
return true
})
}
// CacheStats returns statistics about the global schema cache - useful for monitoring
func CacheStats() map[string]int {
stats := map[string]int{
"cached_schemas": 0,
}
globalSchemaCache.Range(func(_, _ any) bool {
stats["cached_schemas"]++
return true
})
return stats
}
// generateSchemaWithDependencyAnalysis generates schema using schemagen-style dependency analysis
func (g *structTagGenerator) generateSchemaWithDependencyAnalysis(structType reflect.Type) (*Schema, error) {
// Handle pointers
for structType.Kind() == reflect.Pointer {
structType = structType.Elem()
}
// Ensure it's a struct
if structType.Kind() != reflect.Struct {
return nil, ErrExpectedStructType
}
// Check current visiting state using schemagen-style three-state tracking
state := g.visited[structType]
switch state {
case 1: // Currently visiting - circular reference detected
return g.createRefSchema(structType), nil
case 2: // Already completed
// Return reference to existing schema if available in definitions
refName := g.getRefName(structType)
if _, exists := g.definitions[refName]; exists {
return g.createRefSchema(structType), nil
}
}
// Mark as visiting
g.visited[structType] = 1
// Generate unique reference name
refName := g.getRefName(structType)
// Always create a placeholder in definitions to ensure we can reference it
g.definitions[refName] = &Schema{Type: SchemaType{"object"}}
// Parse struct fields using reflection (adapted from schemagen analyzer logic)
var properties []Property
var required []string
// Use the real tagparser to parse struct fields
fieldInfos, err := g.tagParser.ParseStructTags(structType)
if err != nil {
g.visited[structType] = 0 // Reset on error
return nil, fmt.Errorf("%w: %w", ErrStructTagParsing, err)
}
for i := range fieldInfos {
fieldInfo := &fieldInfos[i]
// Skip fields without tags unless explicitly allowed or promoted from embedding
if !g.options.AllowUntaggedFields && fieldInfo.Tag == "" && !fieldInfo.IsPromoted {
continue
}
// Generate schema for this field using reused schemagen logic
fieldSchema, err := g.generateFieldSchemaWithValidators(structType, fieldInfo)
if err != nil {
return nil, err
}
if fieldSchema != nil {
properties = append(properties, Prop(fieldInfo.JSONName, fieldSchema))
// Add to required if field is marked as required or default required is true
if fieldInfo.Required || (g.options.DefaultRequired && !fieldInfo.Optional) {
required = append(required, fieldInfo.JSONName)
}
}
}
if len(required) > 0 {
if g.options.RequiredSort == RequiredSortAlphabetical {
slices.Sort(required)
}
// For RequiredSortNone, keep the order as-is from struct field iteration
}
items := make([]any, len(properties))
for i, prop := range properties {
items[i] = prop
}
if len(required) > 0 {
items = append(items, Required(required...))
}
schema := Object(items...)
// Store the final schema in definitions
g.definitions[refName] = schema
g.generatedRefs[structType] = refName
// Mark as completed
g.visited[structType] = 2
// Always return the actual schema (not a reference) from this function
// References are handled by the handleStructType function when needed
return schema, nil
}
// generateFieldSchemaWithValidators generates schema for a field using reused schemagen validator logic
func (g *structTagGenerator) generateFieldSchemaWithValidators(structType reflect.Type, fieldInfo *tagparser.FieldInfo) (*Schema, error) {
fieldType := fieldInfo.Type
rules := fieldInfo.Rules
if err := g.validateFieldRules(structType, fieldInfo); err != nil {
return nil, err
}
// Handle pointer types - make nullable
var isNullable bool
for fieldType.Kind() == reflect.Pointer {
isNullable = true
fieldType = fieldType.Elem()
}
// Get base schema from type using reused schemagen type mapping
baseSchema, err := g.getSchemaFromTypeWithMapping(fieldType)
if err != nil {
return nil, err
}
// Parse validation rules from tag using real tagparser rules
keywords := g.applyValidationRules(rules, fieldType)
// Handle array schemas with object-level constraints
if fieldType.Kind() == reflect.Slice || fieldType.Kind() == reflect.Array {
arrayKeywords, itemKeywords := g.separateArrayAndItemKeywords(keywords, fieldType)
// Apply item-level constraints to the items schema if it exists
if len(itemKeywords) > 0 && baseSchema.Items != nil {
enhancedItemSchema := g.cloneSchemaWithKeywords(baseSchema.Items, itemKeywords)
baseSchema = g.cloneSchemaAndUpdateItems(baseSchema, enhancedItemSchema)
}
// Apply array-level constraints to the array schema
if len(arrayKeywords) > 0 {
baseSchema = g.cloneSchemaWithKeywords(baseSchema, arrayKeywords)
}
// Handle nullable array
if isNullable {
nullSchema := Null()
return AnyOf(baseSchema, nullSchema), nil
}
return baseSchema, nil
}
// Handle nullable fields
if isNullable {
// Apply keywords to base schema first before creating anyOf
schemaWithRules := baseSchema
if len(keywords) > 0 {
schemaWithRules = g.cloneSchemaWithKeywords(baseSchema, keywords)
}
// Create anyOf with the enhanced schema and null schema
nullSchema := Null()
return AnyOf(schemaWithRules, nullSchema), nil
}
// Apply keywords to base schema
if len(keywords) > 0 {
// Clone the schema and apply keywords
newSchema := g.cloneSchemaWithKeywords(baseSchema, keywords)
return newSchema, nil
}
return baseSchema, nil
}
func (g *structTagGenerator) validateFieldRules(structType reflect.Type, fieldInfo *tagparser.FieldInfo) error {
for _, rule := range fieldInfo.Rules {
switch rule.Name {
case "pattern":
if len(rule.Params) == 0 {
continue
}
if err := compilePattern(rule.Params[0]); err != nil {
return &StructTagError{
StructType: structType.String(),
FieldName: fieldInfo.Name,
TagRule: fmt.Sprintf("pattern=%s", rule.Params[0]),
Message: "invalid regular expression pattern",
Err: fmt.Errorf("%w: %w", ErrRegexValidation, err),
}
}
case "patternProperties":
if len(rule.Params) == 0 {
continue
}
if err := compilePattern(rule.Params[0]); err != nil {
return &StructTagError{
StructType: structType.String(),
FieldName: fieldInfo.Name,
TagRule: fmt.Sprintf("patternProperties=%s", rule.Params[0]),
Message: "invalid regular expression pattern",
Err: fmt.Errorf("%w: %w", ErrRegexValidation, err),
}
}
}
}
return nil
}
// getSchemaFromTypeWithMapping converts Go types to JSON Schema using reused schemagen logic
func (g *structTagGenerator) getSchemaFromTypeWithMapping(fieldType reflect.Type) (*Schema, error) {
kind := fieldType.Kind()
// Handle basic types using reused type mapping
if constructor, exists := g.typeMapping[fieldType.String()]; exists {
return constructor(), nil
}
// Handle by kind if specific type not found
//exhaustive:ignore - we only handle types that are relevant for schema generation
switch kind {
case reflect.String:
return String(), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return Integer(), nil
case reflect.Float32, reflect.Float64:
return Number(), nil
case reflect.Bool:
return Boolean(), nil
case reflect.Slice, reflect.Array:
return g.handleArrayType(fieldType)
case reflect.Map:
return g.handleMapType(fieldType)
case reflect.Struct:
return g.handleStructType(fieldType)
case reflect.Interface:
return Any(), nil
default:
return nil, ErrUnsupportedType
}
}
// handleArrayType handles array/slice types with potential circular references
func (g *structTagGenerator) handleArrayType(fieldType reflect.Type) (*Schema, error) {
elemType := fieldType.Elem()
// Handle pointer element types
for elemType.Kind() == reflect.Pointer {
elemType = elemType.Elem()
}
// If element is a struct, handle potential circular reference
if elemType.Kind() == reflect.Struct {
// Check for circular reference before generating schema
if g.visited[elemType] == 1 {
// For circular reference in array, create a $ref
refName := g.getRefName(elemType)
// Store a placeholder in definitions if not already there
if _, exists := g.definitions[refName]; !exists {
g.definitions[refName] = &Schema{Type: SchemaType{"object"}}
}
// Create array with $ref to the circular type
refSchema := Ref(fmt.Sprintf("#/$defs/%s", refName))
return Array(Items(refSchema)), nil
}
elemSchema, err := g.generateSchemaWithDependencyAnalysis(elemType)
if err != nil {
// Fall back to basic array schema if struct schema fails
// Return the error instead of ignoring it
return nil, err
}
// Create array schema with proper items constraint
return Array(Items(elemSchema)), nil
}
// For non-struct elements, generate appropriate type schema to ensure array elements have correct type constraints
elemSchema, err := g.getSchemaFromTypeWithMapping(elemType)
if err != nil {
// If unable to generate element schema, fallback to basic array
return Array(), err
}
// Create array schema with type constraints
return Array(Items(elemSchema)), nil
}
// handleMapType handles map types with additionalProperties for value types
func (g *structTagGenerator) handleMapType(fieldType reflect.Type) (*Schema, error) {
valueType := fieldType.Elem()
// Handle pointer value types
for valueType.Kind() == reflect.Pointer {
valueType = valueType.Elem()
}
// If value is a struct, use handleStructType to get proper $ref
if valueType.Kind() == reflect.Struct {
valueSchema, err := g.handleStructType(valueType)
if err != nil {
// Fall back to basic object schema if struct schema fails
return nil, err
}
// Create object schema with additionalProperties as $ref to the struct type
return Object(AdditionalPropsSchema(valueSchema)), nil
}
// For non-struct values, generate appropriate type schema
valueSchema, err := g.getSchemaFromTypeWithMapping(valueType)
if err != nil {
// If unable to generate value schema, fallback to basic object
return Object(), err
}
// Create object schema with additionalProperties for the value type
return Object(AdditionalPropsSchema(valueSchema)), nil
}
// handleStructType handles struct types with circular reference detection and deduplication
func (g *structTagGenerator) handleStructType(fieldType reflect.Type) (*Schema, error) {
state := g.visited[fieldType]
refName := g.getRefName(fieldType)
switch state {
case 1: // Currently visiting - circular reference detected
// Store a placeholder in definitions if not already there
if _, exists := g.definitions[refName]; !exists {
g.definitions[refName] = &Schema{Type: SchemaType{"object"}}
}
// Return $ref to the circular type
return Ref(fmt.Sprintf("#/$defs/%s", refName)), nil
case 2: // Already completed - reuse existing definition
// Struct has already been processed, use a reference
if _, exists := g.definitions[refName]; exists {
return Ref(fmt.Sprintf("#/$defs/%s", refName)), nil
}
// Fallback: regenerate if definition doesn't exist (shouldn't happen)
return g.generateSchemaWithDependencyAnalysis(fieldType)
default: // 0 or unvisited
// Generate the schema first to populate definitions
_, err := g.generateSchemaWithDependencyAnalysis(fieldType)
if err != nil {
return nil, err
}
// After generation, always return a reference to avoid duplication
// This ensures all struct types are referenced from $defs
return Ref(fmt.Sprintf("#/$defs/%s", refName)), nil
}
}
// applyValidationRules converts tagparser rules to Schema keywords using reused schemagen logic
func (g *structTagGenerator) applyValidationRules(rules []tagparser.TagRule, fieldType reflect.Type) []Keyword {
var keywords []Keyword
for _, rule := range rules {
if rule.Name == "required" {
continue // Required is handled at the object level
}
// Check built-in validators first
if validator, exists := g.validatorMap[rule.Name]; exists {
ruleKeywords := validator(fieldType, rule.Params)
keywords = append(keywords, ruleKeywords...)
continue
}
// Check custom validators
if customValidator, exists := globalValidatorRegistry.CustomValidator(rule.Name); exists {
ruleKeywords := customValidator(fieldType, rule.Params)
keywords = append(keywords, ruleKeywords...)
continue
}
// Check options-specific custom validators
if g.options.CustomValidators != nil {
if customFunc, exists := g.options.CustomValidators[rule.Name]; exists {
if validatorFunc, ok := customFunc.(CustomValidatorFunc); ok {
ruleKeywords := validatorFunc(fieldType, rule.Params)
keywords = append(keywords, ruleKeywords...)
} else if validatorFuncLegacy, ok := customFunc.(func(reflect.Type, []string) []Keyword); ok {
// Support legacy validator function signature
ruleKeywords := validatorFuncLegacy(fieldType, rule.Params)
keywords = append(keywords, ruleKeywords...)
}
}
}
}
return keywords
}
// getRefName generates a reference name for a struct type
func (g *structTagGenerator) getRefName(structType reflect.Type) string {
if structType.Name() != "" {
return structType.Name()
}
return fmt.Sprintf("Type%p", structType) // fallback for anonymous structs
}
// createRefSchema creates a $ref schema for circular references
func (g *structTagGenerator) createRefSchema(structType reflect.Type) *Schema {
refName := g.getRefName(structType)
// Store a placeholder in definitions if not already there
if _, exists := g.definitions[refName]; !exists {
g.definitions[refName] = &Schema{Type: SchemaType{"object"}}
}
// Store the ref name for this type
g.generatedRefs[structType] = refName
// Return a $ref schema
return Ref(fmt.Sprintf("#/$defs/%s", refName))
}
// cloneSchemaWithKeywords creates a new schema by applying keywords to an existing schema
func (g *structTagGenerator) cloneSchemaWithKeywords(baseSchema *Schema, keywords []Keyword) *Schema {
// Start with a copy of the base schema
newSchema := &Schema{}
*newSchema = *baseSchema // Copy all fields
// Apply the additional keywords to the cloned schema
for _, keyword := range keywords {
keyword(newSchema)
}
return newSchema
}
// createCleanDefinition creates a clean copy of a schema for $defs to avoid circular references
func (g *structTagGenerator) createCleanDefinition(schema *Schema) *Schema {
cleanSchema := &Schema{}
*cleanSchema = *schema // Copy all fields
// Ensure no circular references by not copying $defs in the definition
cleanSchema.Defs = nil
return cleanSchema
}
// createSchemaFromParam creates a Schema from a parameter string, handling primitive types and custom types
// This is a standalone function that doesn't depend on generator instance for use in validator mappings
func createSchemaFromParam(param string) *Schema {
// Handle primitive types
primitiveTypes := map[string]func() *Schema{
"string": func() *Schema { return String() },
"integer": func() *Schema { return Integer() },
"number": func() *Schema { return Number() },
"boolean": func() *Schema { return Boolean() },
"null": func() *Schema { return Null() },
"object": func() *Schema { return Object() },
"array": func() *Schema { return Array() },
}
if constructor, exists := primitiveTypes[param]; exists {
return constructor()
}
// Handle boolean values
if param == "true" || param == "false" {
if param == "true" {
return &Schema{Type: SchemaType{"boolean"}, Const: &ConstValue{Value: true, IsSet: true}}
}
return &Schema{Type: SchemaType{"boolean"}, Const: &ConstValue{Value: false, IsSet: true}}
}
// Handle numeric values
if num, err := strconv.ParseFloat(param, 64); err == nil {
if num == float64(int64(num)) {
return &Schema{Type: SchemaType{"integer"}, Const: &ConstValue{Value: int64(num), IsSet: true}}
}
return &Schema{Type: SchemaType{"number"}, Const: &ConstValue{Value: num, IsSet: true}}
}
// Check if it's a custom struct type
if isCustomStructType(param) {
// Create a basic object schema for custom types.
// Full schema generation for referenced types is handled separately via the Compiler.
return &Schema{Type: SchemaType{"object"}, Description: ¶m}
}
// Default: treat as string constant
return &Schema{Type: SchemaType{"string"}, Const: &ConstValue{Value: param, IsSet: true}}
}
// isCustomStructType checks if a parameter string represents a custom struct type
func isCustomStructType(typeName string) bool {
// Check if it's not a built-in type
builtinTypes := map[string]bool{
"string": true, "int": true, "int8": true, "int16": true, "int32": true, "int64": true,
"uint": true, "uint8": true, "uint16": true, "uint32": true, "uint64": true,
"float32": true, "float64": true, "bool": true, "any": true,
"integer": true, "number": true, "boolean": true, "null": true, "object": true, "array": true,
"true": true, "false": true,
}
// Check if it starts with a package name (contains a dot)
if strings.Contains(typeName, ".") {
// External package type (like time.Time)
return !builtinTypes[typeName]
}
// Local type - assume it's a custom struct if it's capitalized and not builtin
if len(typeName) > 0 && typeName[0] >= 'A' && typeName[0] <= 'Z' {
return !builtinTypes[typeName]
}
return false
}
// createRuntimeTypeMapping creates the mapping from Go types to Schema constructors (reused from schemagen)
func createRuntimeTypeMapping() map[string]func(...Keyword) *Schema {
return map[string]func(...Keyword) *Schema{
"string": String,
"int": Integer,
"int8": Integer,
"int16": Integer,
"int32": Integer,
"int64": Integer,
"uint": Integer,
"uint8": Integer,
"uint16": Integer,
"uint32": Integer,
"uint64": Integer,
"float32": Number,
"float64": Number,
"bool": Boolean,
"time.Time": func(keywords ...Keyword) *Schema { return String(append(keywords, Format("date-time"))...) },
}
}
// createRuntimeValidatorMapping creates the mapping from validator names to runtime functions (reused from schemagen)
func createRuntimeValidatorMapping() map[string]validatorFunc {
return map[string]validatorFunc{
// String validators
"minLength": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if length, err := strconv.Atoi(params[0]); err == nil {
return []Keyword{MinLength(length)}
}
return nil
},
"maxLength": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if length, err := strconv.Atoi(params[0]); err == nil {
return []Keyword{MaxLength(length)}
}
return nil
},
"pattern": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
return []Keyword{Pattern(params[0])}
},
"format": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
return []Keyword{Format(params[0])}
},
// Numeric validators
"minimum": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if value, err := strconv.ParseFloat(params[0], 64); err == nil {
return []Keyword{Min(value)}
}
return nil
},
"maximum": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if value, err := strconv.ParseFloat(params[0], 64); err == nil {
return []Keyword{Max(value)}
}
return nil
},
"exclusiveMinimum": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if value, err := strconv.ParseFloat(params[0], 64); err == nil {
return []Keyword{ExclusiveMin(value)}
}
return nil
},
"exclusiveMaximum": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if value, err := strconv.ParseFloat(params[0], 64); err == nil {
return []Keyword{ExclusiveMax(value)}
}
return nil
},
"multipleOf": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if value, err := strconv.ParseFloat(params[0], 64); err == nil {
return []Keyword{MultipleOf(value)}
}
return nil
},
// Array validators
"minItems": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if count, err := strconv.Atoi(params[0]); err == nil {
return []Keyword{MinItems(count)}
}
return nil
},
"maxItems": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if count, err := strconv.Atoi(params[0]); err == nil {
return []Keyword{MaxItems(count)}
}
return nil
},
"uniqueItems": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 || params[0] == "true" {
return []Keyword{UniqueItems(true)}
}
if unique, err := strconv.ParseBool(params[0]); err == nil {
return []Keyword{UniqueItems(unique)}
}
return nil
},
// Object validators
"additionalProperties": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if allowed, err := strconv.ParseBool(params[0]); err == nil {
return []Keyword{AdditionalProps(allowed)}
}
return nil
},
"minProperties": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if count, err := strconv.Atoi(params[0]); err == nil {
return []Keyword{MinProps(count)}
}
return nil
},
"maxProperties": func(_ reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
if count, err := strconv.Atoi(params[0]); err == nil {
return []Keyword{MaxProps(count)}
}
return nil
},
// Enum and const validators
"enum": func(fieldType reflect.Type, params []string) []Keyword {
if len(params) == 0 {
return nil
}
values := make([]any, len(params))
for i, param := range params {
// Convert based on field type
//exhaustive:ignore - we only handle types that need conversion for enum values
switch fieldType.Kind() {
case reflect.String:
values[i] = param
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if intVal, err := strconv.Atoi(param); err == nil {
values[i] = intVal
} else {