-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathunion.go
More file actions
304 lines (260 loc) · 8.42 KB
/
union.go
File metadata and controls
304 lines (260 loc) · 8.42 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
package schema
import (
"encoding/json"
"fmt"
"github.com/nyxstack/i18n"
)
// Default error messages for union validation
var (
unionRequiredError = i18n.S("value is required")
unionNoMatchError = i18n.S("value does not match any of the allowed schemas")
unionMultipleMatchError = i18n.S("value matches multiple schemas, only one is allowed")
)
// UnionSchema represents a JSON Schema oneOf for union types
type UnionSchema struct {
Schema
schemas []Parseable // The schemas to validate against
nullable bool // Allow null values
allowNone bool // Allow values that match none of the schemas
// Error messages for validation failures (support i18n)
requiredError ErrorMessage
noMatchError ErrorMessage
multipleMatchError ErrorMessage
typeMismatchError ErrorMessage
}
// Union creates a new union schema with the provided schemas
func Union(schemas ...Parseable) *UnionSchema {
schema := &UnionSchema{
Schema: Schema{
schemaType: "oneOf",
required: true, // Default to required
},
schemas: schemas,
}
return schema
}
// OneOf is an alias for Union for JSON Schema compatibility
func OneOf(schemas ...Parseable) *UnionSchema {
return Union(schemas...)
}
// Core fluent API methods
// Title sets the title of the schema
func (s *UnionSchema) Title(title string) *UnionSchema {
s.Schema.title = title
return s
}
// Description sets the description of the schema
func (s *UnionSchema) Description(description string) *UnionSchema {
s.Schema.description = description
return s
}
// Default sets the default value
func (s *UnionSchema) Default(value interface{}) *UnionSchema {
s.Schema.defaultValue = value
return s
}
// Example adds an example value
func (s *UnionSchema) Example(example interface{}) *UnionSchema {
s.Schema.examples = append(s.Schema.examples, example)
return s
}
// Schema manipulation
// Add appends additional schemas to the union
func (s *UnionSchema) Add(schemas ...Parseable) *UnionSchema {
s.schemas = append(s.schemas, schemas...)
return s
}
// Schemas returns all schemas in the union
func (s *UnionSchema) Schemas() []Parseable {
return s.schemas
}
// Required/Optional/Nullable control
// Optional marks the schema as optional
func (s *UnionSchema) Optional() *UnionSchema {
s.Schema.required = false
return s
}
// Required marks the schema as required (default behavior) with optional custom error message
func (s *UnionSchema) Required(errorMessage ...interface{}) *UnionSchema {
s.Schema.required = true
if len(errorMessage) > 0 {
s.requiredError = toErrorMessage(errorMessage[0])
}
return s
}
// Nullable marks the schema as nullable (allows nil values)
func (s *UnionSchema) Nullable() *UnionSchema {
s.nullable = true
return s
}
// AllowNone allows values that don't match any schema (makes union more permissive)
func (s *UnionSchema) AllowNone() *UnionSchema {
s.allowNone = true
return s
}
// Error customization
// NoMatchError sets a custom error message when no schemas match
func (s *UnionSchema) NoMatchError(message string) *UnionSchema {
s.noMatchError = toErrorMessage(message)
return s
}
// MultipleMatchError sets a custom error message when multiple schemas match
func (s *UnionSchema) MultipleMatchError(message string) *UnionSchema {
s.multipleMatchError = toErrorMessage(message)
return s
}
// TypeError sets a custom error message for type mismatch validation
func (s *UnionSchema) TypeError(message string) *UnionSchema {
s.typeMismatchError = toErrorMessage(message)
return s
}
// Getters for accessing private fields
// IsRequired returns whether the schema is marked as required
func (s *UnionSchema) IsRequired() bool {
return s.Schema.required
}
// IsOptional returns whether the schema is marked as optional
func (s *UnionSchema) IsOptional() bool {
return !s.Schema.required
}
// IsNullable returns whether the schema allows nil values
func (s *UnionSchema) IsNullable() bool {
return s.nullable
}
// GetSchemaCount returns the number of schemas in the union
func (s *UnionSchema) GetSchemaCount() int {
return len(s.schemas)
}
// Validation
// Parse validates and parses a union value, returning the final parsed value
func (s *UnionSchema) Parse(value interface{}, ctx *ValidationContext) ParseResult {
var errors []ValidationError
// Handle nil values
if value == nil {
if s.nullable {
// For nullable schemas, nil is a valid value
return ParseResult{Valid: true, Value: nil, Errors: nil}
}
if s.Schema.required {
// Check if we have a default value to use instead
if defaultVal := s.GetDefault(); defaultVal != nil {
// Use default value and re-parse it
return s.Parse(defaultVal, ctx)
}
// No default, required field is missing
message := unionRequiredError(ctx.Locale)
if !isEmptyErrorMessage(s.requiredError) {
message = resolveErrorMessage(s.requiredError, ctx)
}
return ParseResult{
Valid: false,
Value: nil,
Errors: []ValidationError{NewPrimitiveError(value, message, "required")},
}
}
// Optional field, use default if available
if defaultVal := s.GetDefault(); defaultVal != nil {
return s.Parse(defaultVal, ctx)
}
// Optional field with no default
return ParseResult{Valid: true, Value: nil, Errors: nil}
}
// Validate against each schema in the union
var validResults []ParseResult
var allErrors []ValidationError
for i, schema := range s.schemas {
result := schema.Parse(value, ctx)
if result.Valid {
validResults = append(validResults, result)
} else {
// Collect errors from failed schemas for debugging
for _, err := range result.Errors {
// Add context about which schema failed
contextualErr := ValidationError{
Path: append([]string{fmt.Sprintf("schema_%d", i)}, err.Path...),
Value: err.Value,
Message: err.Message,
Code: err.Code,
}
allErrors = append(allErrors, contextualErr)
}
}
}
// Check validation results
if len(validResults) == 0 {
// No schemas matched
if s.allowNone {
// Allow values that don't match any schema
return ParseResult{Valid: true, Value: value, Errors: nil}
}
message := unionNoMatchError(ctx.Locale)
if !isEmptyErrorMessage(s.noMatchError) {
message = resolveErrorMessage(s.noMatchError, ctx)
}
// Return the original value with no match error, plus all schema errors for context
errors = append(errors, NewPrimitiveError(value, message, "no_match"))
// Also include all the individual schema errors for debugging
errors = append(errors, allErrors...)
return ParseResult{
Valid: false,
Value: nil,
Errors: errors,
}
}
if len(validResults) > 1 {
// Multiple schemas matched - this violates oneOf semantics
message := unionMultipleMatchError(ctx.Locale)
if !isEmptyErrorMessage(s.multipleMatchError) {
message = resolveErrorMessage(s.multipleMatchError, ctx)
}
return ParseResult{
Valid: false,
Value: nil,
Errors: []ValidationError{NewPrimitiveError(value, message, "multiple_match")},
}
}
// Exactly one schema matched - this is what we want
return validResults[0]
}
// JSON generates JSON Schema representation
func (s *UnionSchema) JSON() map[string]interface{} {
schema := make(map[string]interface{})
// Generate oneOf array with all schemas
oneOfSchemas := make([]interface{}, len(s.schemas))
for i, subSchema := range s.schemas {
if jsonSchema, ok := subSchema.(interface{ JSON() map[string]interface{} }); ok {
oneOfSchemas[i] = jsonSchema.JSON()
} else {
// Fallback for schemas that don't implement JSON method
oneOfSchemas[i] = map[string]interface{}{"type": "unknown"}
}
}
schema["oneOf"] = oneOfSchemas
// Add base schema fields
addTitle(schema, s.GetTitle())
addDescription(schema, s.GetDescription())
addOptionalField(schema, "default", s.GetDefault())
addOptionalArray(schema, "examples", s.GetExamples())
// Add nullable if true
if s.nullable {
// Add null to the oneOf array
oneOfSchemas = append(oneOfSchemas, map[string]interface{}{"type": "null"})
schema["oneOf"] = oneOfSchemas
}
return schema
}
// MarshalJSON implements json.Marshaler to properly serialize UnionSchema for JSON schema generation
func (s *UnionSchema) MarshalJSON() ([]byte, error) {
type jsonUnionSchema struct {
Schema
Schemas []Parseable `json:"schemas"`
Nullable bool `json:"nullable,omitempty"`
AllowNone bool `json:"allowNone,omitempty"`
}
return json.Marshal(jsonUnionSchema{
Schema: s.Schema,
Schemas: s.schemas,
Nullable: s.nullable,
AllowNone: s.allowNone,
})
}