validrb 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +99 -0
- data/CLAUDE.md +434 -0
- data/LICENSE +21 -0
- data/README.md +654 -0
- data/Rakefile +10 -0
- data/lib/validrb/constraints/base.rb +59 -0
- data/lib/validrb/constraints/enum.rb +33 -0
- data/lib/validrb/constraints/format.rb +63 -0
- data/lib/validrb/constraints/length.rb +72 -0
- data/lib/validrb/constraints/max.rb +43 -0
- data/lib/validrb/constraints/min.rb +43 -0
- data/lib/validrb/context.rb +41 -0
- data/lib/validrb/custom_type.rb +95 -0
- data/lib/validrb/errors.rb +122 -0
- data/lib/validrb/field.rb +346 -0
- data/lib/validrb/i18n.rb +88 -0
- data/lib/validrb/introspection.rb +206 -0
- data/lib/validrb/openapi.rb +642 -0
- data/lib/validrb/result.rb +89 -0
- data/lib/validrb/schema.rb +303 -0
- data/lib/validrb/serializer.rb +113 -0
- data/lib/validrb/types/array.rb +91 -0
- data/lib/validrb/types/base.rb +90 -0
- data/lib/validrb/types/boolean.rb +37 -0
- data/lib/validrb/types/date.rb +70 -0
- data/lib/validrb/types/datetime.rb +71 -0
- data/lib/validrb/types/decimal.rb +57 -0
- data/lib/validrb/types/discriminated_union.rb +74 -0
- data/lib/validrb/types/float.rb +46 -0
- data/lib/validrb/types/integer.rb +53 -0
- data/lib/validrb/types/literal.rb +43 -0
- data/lib/validrb/types/object.rb +52 -0
- data/lib/validrb/types/string.rb +29 -0
- data/lib/validrb/types/time.rb +69 -0
- data/lib/validrb/types/union.rb +75 -0
- data/lib/validrb/version.rb +5 -0
- data/lib/validrb.rb +55 -0
- data/validrb.gemspec +43 -0
- metadata +91 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
# Represents a field definition with type, constraints, and options
|
|
5
|
+
class Field
|
|
6
|
+
# Sentinel for missing values (distinguishes from nil)
|
|
7
|
+
MISSING = Object.new.tap do |obj|
|
|
8
|
+
def obj.inspect
|
|
9
|
+
"MISSING"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def obj.to_s
|
|
13
|
+
"MISSING"
|
|
14
|
+
end
|
|
15
|
+
end.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :name, :type, :constraints, :options, :refinements
|
|
18
|
+
|
|
19
|
+
def initialize(name, type, **options)
|
|
20
|
+
@name = name.to_sym
|
|
21
|
+
@type = resolve_type(type, options)
|
|
22
|
+
@constraints = build_constraints(options)
|
|
23
|
+
@refinements = build_refinements(options)
|
|
24
|
+
@options = extract_options(options)
|
|
25
|
+
@transform = options[:transform]
|
|
26
|
+
@preprocess = options[:preprocess]
|
|
27
|
+
@message = options[:message]
|
|
28
|
+
@coerce = options.fetch(:coerce, true)
|
|
29
|
+
@when_condition = options[:when]
|
|
30
|
+
@unless_condition = options[:unless]
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def optional?
|
|
35
|
+
@options[:optional] == true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def required?
|
|
39
|
+
!optional?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def nullable?
|
|
43
|
+
@options[:nullable] == true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def has_default?
|
|
47
|
+
@options.key?(:default)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def default_value
|
|
51
|
+
value = @options[:default]
|
|
52
|
+
value.respond_to?(:call) ? value.call : value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def conditional?
|
|
56
|
+
!@when_condition.nil? || !@unless_condition.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Validate a value for this field
|
|
60
|
+
# Returns [coerced_value, errors_array]
|
|
61
|
+
# @param value - the value to validate
|
|
62
|
+
# @param path - the path prefix for error messages
|
|
63
|
+
# @param data - the full input data (for conditional validation)
|
|
64
|
+
# @param context - optional validation context
|
|
65
|
+
def call(value, path: [], data: nil, context: nil)
|
|
66
|
+
field_path = path + [@name]
|
|
67
|
+
|
|
68
|
+
# Check conditional validation (when:/unless:)
|
|
69
|
+
if conditional? && !should_validate?(data, context)
|
|
70
|
+
# Skip validation - treat as optional
|
|
71
|
+
return [nil, []] if value.equal?(MISSING) || value.nil?
|
|
72
|
+
|
|
73
|
+
# Still process the value if present (preprocess + transform)
|
|
74
|
+
value = apply_preprocess(value, context) unless value.equal?(MISSING)
|
|
75
|
+
return [apply_transform(value, context), []]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Handle missing values
|
|
79
|
+
if value.equal?(MISSING)
|
|
80
|
+
return handle_missing_value(field_path, context)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Apply preprocessing BEFORE type coercion
|
|
84
|
+
value = apply_preprocess(value, context)
|
|
85
|
+
|
|
86
|
+
# Handle nil values - nullable fields accept nil
|
|
87
|
+
if value.nil?
|
|
88
|
+
return [nil, []] if nullable?
|
|
89
|
+
|
|
90
|
+
return handle_missing_value(field_path, context)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Type coercion and validation
|
|
94
|
+
coerced, type_errors = coerce_value(value, field_path)
|
|
95
|
+
return [nil, apply_custom_message(type_errors)] unless type_errors.empty?
|
|
96
|
+
|
|
97
|
+
# Constraint validation
|
|
98
|
+
constraint_errors = validate_constraints(coerced, field_path)
|
|
99
|
+
return [nil, apply_custom_message(constraint_errors)] unless constraint_errors.empty?
|
|
100
|
+
|
|
101
|
+
# Refinement validation
|
|
102
|
+
refinement_errors = validate_refinements(coerced, field_path, context)
|
|
103
|
+
return [nil, apply_custom_message(refinement_errors)] unless refinement_errors.empty?
|
|
104
|
+
|
|
105
|
+
# Apply transform if present
|
|
106
|
+
coerced = apply_transform(coerced, context)
|
|
107
|
+
|
|
108
|
+
[coerced, []]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def resolve_type(type, options)
|
|
114
|
+
# Handle literal types
|
|
115
|
+
if options[:literal]
|
|
116
|
+
return Types::Literal.new(values: options[:literal])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Handle union types
|
|
120
|
+
if options[:union]
|
|
121
|
+
return Types::Union.new(types: options[:union])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
case type
|
|
125
|
+
when Symbol
|
|
126
|
+
type_options = extract_type_options(type, options)
|
|
127
|
+
Types.build(type, **type_options)
|
|
128
|
+
when Types::Base
|
|
129
|
+
type
|
|
130
|
+
when Class
|
|
131
|
+
type.new
|
|
132
|
+
else
|
|
133
|
+
raise ArgumentError, "Invalid type: #{type.inspect}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def extract_type_options(type, options)
|
|
138
|
+
case type
|
|
139
|
+
when :array
|
|
140
|
+
{ of: options[:of] }.compact
|
|
141
|
+
when :object, :hash
|
|
142
|
+
{ schema: options[:schema] }.compact
|
|
143
|
+
when :discriminated_union
|
|
144
|
+
{
|
|
145
|
+
discriminator: options[:discriminator],
|
|
146
|
+
mapping: options[:mapping]
|
|
147
|
+
}.compact
|
|
148
|
+
else
|
|
149
|
+
{}
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_constraints(options)
|
|
154
|
+
constraints = []
|
|
155
|
+
|
|
156
|
+
# Min constraint
|
|
157
|
+
constraints << Constraints::Min.new(options[:min]) if options.key?(:min)
|
|
158
|
+
|
|
159
|
+
# Max constraint
|
|
160
|
+
constraints << Constraints::Max.new(options[:max]) if options.key?(:max)
|
|
161
|
+
|
|
162
|
+
# Length constraint
|
|
163
|
+
if options.key?(:length)
|
|
164
|
+
length_opts = options[:length]
|
|
165
|
+
case length_opts
|
|
166
|
+
when Integer
|
|
167
|
+
constraints << Constraints::Length.new(exact: length_opts)
|
|
168
|
+
when Range
|
|
169
|
+
constraints << Constraints::Length.new(range: length_opts)
|
|
170
|
+
when Hash
|
|
171
|
+
constraints << Constraints::Length.new(**length_opts)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Format constraint
|
|
176
|
+
constraints << Constraints::Format.new(options[:format]) if options.key?(:format)
|
|
177
|
+
|
|
178
|
+
# Enum constraint
|
|
179
|
+
constraints << Constraints::Enum.new(options[:enum]) if options.key?(:enum)
|
|
180
|
+
|
|
181
|
+
constraints.freeze
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_refinements(options)
|
|
185
|
+
refinements = []
|
|
186
|
+
|
|
187
|
+
# Handle refine: option (single or array of procs/hashes)
|
|
188
|
+
if options.key?(:refine)
|
|
189
|
+
refine_opts = options[:refine]
|
|
190
|
+
# Normalize to array - be careful with Hash (don't use Array())
|
|
191
|
+
refine_opts = [refine_opts] unless refine_opts.is_a?(::Array)
|
|
192
|
+
|
|
193
|
+
refine_opts.each do |refine_opt|
|
|
194
|
+
case refine_opt
|
|
195
|
+
when Proc
|
|
196
|
+
refinements << { check: refine_opt, message: "failed refinement" }
|
|
197
|
+
when Hash
|
|
198
|
+
refinements << {
|
|
199
|
+
check: refine_opt[:check] || refine_opt[:if],
|
|
200
|
+
message: refine_opt[:message] || "failed refinement"
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
refinements.freeze
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def extract_options(options)
|
|
210
|
+
{
|
|
211
|
+
optional: options[:optional] || false,
|
|
212
|
+
nullable: options[:nullable] || false,
|
|
213
|
+
default: options[:default]
|
|
214
|
+
}.tap { |h| h.delete(:default) unless options.key?(:default) }.freeze
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def handle_missing_value(path, context = nil)
|
|
218
|
+
# Apply default if present
|
|
219
|
+
if has_default?
|
|
220
|
+
value = default_value
|
|
221
|
+
value = apply_preprocess(value, context) if @preprocess
|
|
222
|
+
value = apply_transform(value, context) if @transform
|
|
223
|
+
return [value, []]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Optional fields can be missing
|
|
227
|
+
if optional?
|
|
228
|
+
return [nil, []]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Required field is missing
|
|
232
|
+
message = @message || I18n.t(:required)
|
|
233
|
+
error = Error.new(path: path, message: message, code: :required)
|
|
234
|
+
[nil, [error]]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def validate_constraints(value, path)
|
|
238
|
+
errors = []
|
|
239
|
+
@constraints.each do |constraint|
|
|
240
|
+
constraint_errors = constraint.call(value, path: path)
|
|
241
|
+
errors.concat(constraint_errors)
|
|
242
|
+
end
|
|
243
|
+
errors
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_refinements(value, path, context = nil)
|
|
247
|
+
errors = []
|
|
248
|
+
@refinements.each do |refinement|
|
|
249
|
+
check = refinement[:check]
|
|
250
|
+
# Support context-aware refinements (2 or 3 args)
|
|
251
|
+
result = if check.arity == 1
|
|
252
|
+
check.call(value)
|
|
253
|
+
elsif check.arity == 2
|
|
254
|
+
check.call(value, context)
|
|
255
|
+
else
|
|
256
|
+
check.call(value, context)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
unless result
|
|
260
|
+
message = refinement[:message]
|
|
261
|
+
message = message.call(value) if message.respond_to?(:call)
|
|
262
|
+
errors << Error.new(path: path, message: message, code: :refinement)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
errors
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def apply_custom_message(errors)
|
|
269
|
+
return errors unless @message
|
|
270
|
+
|
|
271
|
+
errors.map do |error|
|
|
272
|
+
Error.new(path: error.path, message: @message, code: error.code)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def apply_transform(value, context = nil)
|
|
277
|
+
return value unless @transform
|
|
278
|
+
|
|
279
|
+
# Support context-aware transforms
|
|
280
|
+
if @transform.arity == 1
|
|
281
|
+
@transform.call(value)
|
|
282
|
+
else
|
|
283
|
+
@transform.call(value, context)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def apply_preprocess(value, context = nil)
|
|
288
|
+
return value unless @preprocess
|
|
289
|
+
|
|
290
|
+
# Support context-aware preprocessing
|
|
291
|
+
if @preprocess.arity == 1
|
|
292
|
+
@preprocess.call(value)
|
|
293
|
+
else
|
|
294
|
+
@preprocess.call(value, context)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def coerce_value(value, path)
|
|
299
|
+
if @coerce
|
|
300
|
+
@type.call(value, path: path)
|
|
301
|
+
else
|
|
302
|
+
# No coercion - just validate the type
|
|
303
|
+
if @type.valid?(value)
|
|
304
|
+
[value, []]
|
|
305
|
+
else
|
|
306
|
+
[nil, [Error.new(path: path, message: @type.validation_error_message(value), code: :type_error)]]
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def should_validate?(data, context = nil)
|
|
312
|
+
return true if data.nil?
|
|
313
|
+
|
|
314
|
+
# Check when: condition
|
|
315
|
+
if @when_condition
|
|
316
|
+
result = evaluate_condition(@when_condition, data, context)
|
|
317
|
+
return false unless result
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Check unless: condition
|
|
321
|
+
if @unless_condition
|
|
322
|
+
result = evaluate_condition(@unless_condition, data, context)
|
|
323
|
+
return false if result
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
true
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def evaluate_condition(condition, data, context = nil)
|
|
330
|
+
case condition
|
|
331
|
+
when Proc
|
|
332
|
+
# Support context-aware conditions
|
|
333
|
+
if condition.arity == 1
|
|
334
|
+
condition.call(data)
|
|
335
|
+
else
|
|
336
|
+
condition.call(data, context)
|
|
337
|
+
end
|
|
338
|
+
when Symbol
|
|
339
|
+
# Symbol refers to a field value being truthy
|
|
340
|
+
!!data[condition]
|
|
341
|
+
else
|
|
342
|
+
!!condition
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
data/lib/validrb/i18n.rb
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
# Simple I18n module for error message translations
|
|
5
|
+
# Can be configured to use Rails I18n or custom translations
|
|
6
|
+
module I18n
|
|
7
|
+
class << self
|
|
8
|
+
attr_accessor :backend
|
|
9
|
+
|
|
10
|
+
# Default English translations
|
|
11
|
+
DEFAULT_TRANSLATIONS = {
|
|
12
|
+
en: {
|
|
13
|
+
required: "is required",
|
|
14
|
+
type_error: "is invalid",
|
|
15
|
+
min: "must be at least %{value}",
|
|
16
|
+
min_length: "length must be at least %{value} (got %{actual})",
|
|
17
|
+
max: "must be at most %{value}",
|
|
18
|
+
max_length: "length must be at most %{value} (got %{actual})",
|
|
19
|
+
length_exact: "length must be exactly %{value} (got %{actual})",
|
|
20
|
+
length_range: "length must be between %{min} and %{max} (got %{actual})",
|
|
21
|
+
format: "must match format %{pattern}",
|
|
22
|
+
format_named: "must be a valid %{name}",
|
|
23
|
+
enum: "must be one of: %{values}",
|
|
24
|
+
unknown_key: "is not allowed",
|
|
25
|
+
union_type_error: "must be one of the allowed types"
|
|
26
|
+
}
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Current locale
|
|
30
|
+
def locale
|
|
31
|
+
@locale ||= :en
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def locale=(loc)
|
|
35
|
+
@locale = loc.to_sym
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Custom translations storage
|
|
39
|
+
def translations
|
|
40
|
+
@translations ||= deep_dup(DEFAULT_TRANSLATIONS)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Add or override translations for a locale
|
|
44
|
+
def add_translations(locale, trans)
|
|
45
|
+
translations[locale.to_sym] ||= {}
|
|
46
|
+
translations[locale.to_sym].merge!(trans)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Translate a key with optional interpolations
|
|
50
|
+
def t(key, **options)
|
|
51
|
+
# If using Rails I18n backend
|
|
52
|
+
if backend == :rails && defined?(::I18n)
|
|
53
|
+
return ::I18n.t("validrb.errors.#{key}", **options, default: key.to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Use built-in translations
|
|
57
|
+
message = translations.dig(locale, key) || translations.dig(:en, key) || key.to_s
|
|
58
|
+
|
|
59
|
+
# Interpolate values
|
|
60
|
+
options.each do |k, v|
|
|
61
|
+
message = message.gsub("%{#{k}}", v.to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
message
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Reset to defaults
|
|
68
|
+
def reset!
|
|
69
|
+
@locale = :en
|
|
70
|
+
@translations = deep_dup(DEFAULT_TRANSLATIONS)
|
|
71
|
+
@backend = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Configure the I18n module
|
|
75
|
+
def configure
|
|
76
|
+
yield self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def deep_dup(hash)
|
|
82
|
+
hash.transform_values do |v|
|
|
83
|
+
v.is_a?(Hash) ? deep_dup(v) : v.dup
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
# Schema introspection methods
|
|
5
|
+
class Schema
|
|
6
|
+
# Get field names
|
|
7
|
+
def field_names
|
|
8
|
+
@fields.keys
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Get a field by name
|
|
12
|
+
def field(name)
|
|
13
|
+
@fields[name.to_sym]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Check if field exists
|
|
17
|
+
def field?(name)
|
|
18
|
+
@fields.key?(name.to_sym)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get required field names (excludes fields with defaults since they won't fail validation)
|
|
22
|
+
def required_fields
|
|
23
|
+
@fields.select { |_, f| f.required? && !f.conditional? && !f.has_default? }.keys
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get optional field names
|
|
27
|
+
def optional_fields
|
|
28
|
+
@fields.select { |_, f| f.optional? }.keys
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get conditional field names
|
|
32
|
+
def conditional_fields
|
|
33
|
+
@fields.select { |_, f| f.conditional? }.keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get fields with defaults
|
|
37
|
+
def fields_with_defaults
|
|
38
|
+
@fields.select { |_, f| f.has_default? }.keys
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Get schema structure as a hash (for documentation/debugging)
|
|
42
|
+
def to_schema_hash
|
|
43
|
+
{
|
|
44
|
+
fields: @fields.transform_values { |f| field_to_hash(f) },
|
|
45
|
+
options: @options,
|
|
46
|
+
validators_count: @validators.size
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Generate JSON Schema (subset of JSON Schema Draft-07)
|
|
51
|
+
def to_json_schema
|
|
52
|
+
{
|
|
53
|
+
"$schema" => "https://json-schema.org/draft-07/schema#",
|
|
54
|
+
"type" => "object",
|
|
55
|
+
"properties" => @fields.transform_values { |f| field_to_json_schema(f) }.transform_keys(&:to_s),
|
|
56
|
+
"required" => required_fields.map(&:to_s),
|
|
57
|
+
"additionalProperties" => @options[:passthrough] || !@options[:strict]
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def field_to_hash(field)
|
|
64
|
+
{
|
|
65
|
+
type: field.type.type_name,
|
|
66
|
+
optional: field.optional?,
|
|
67
|
+
nullable: field.nullable?,
|
|
68
|
+
has_default: field.has_default?,
|
|
69
|
+
conditional: field.conditional?,
|
|
70
|
+
constraints: field.constraints.map { |c| constraint_to_hash(c) }
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def constraint_to_hash(constraint)
|
|
75
|
+
case constraint
|
|
76
|
+
when Constraints::Min
|
|
77
|
+
{ type: :min, value: constraint.value }
|
|
78
|
+
when Constraints::Max
|
|
79
|
+
{ type: :max, value: constraint.value }
|
|
80
|
+
when Constraints::Length
|
|
81
|
+
{ type: :length, options: constraint.options }
|
|
82
|
+
when Constraints::Format
|
|
83
|
+
{ type: :format, pattern: constraint.pattern.to_s }
|
|
84
|
+
when Constraints::Enum
|
|
85
|
+
{ type: :enum, values: constraint.values }
|
|
86
|
+
else
|
|
87
|
+
{ type: constraint.class.name }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def field_to_json_schema(field)
|
|
92
|
+
schema = {}
|
|
93
|
+
|
|
94
|
+
# Map type to JSON Schema type
|
|
95
|
+
type_mapping = type_to_json_schema(field.type)
|
|
96
|
+
schema.merge!(type_mapping)
|
|
97
|
+
|
|
98
|
+
# Handle nullable
|
|
99
|
+
if field.nullable?
|
|
100
|
+
if schema["type"].is_a?(::Array)
|
|
101
|
+
schema["type"] << "null" unless schema["type"].include?("null")
|
|
102
|
+
elsif schema["type"]
|
|
103
|
+
schema["type"] = [schema["type"], "null"]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Handle default
|
|
108
|
+
schema["default"] = field.default_value if field.has_default?
|
|
109
|
+
|
|
110
|
+
# Handle constraints
|
|
111
|
+
field.constraints.each do |constraint|
|
|
112
|
+
case constraint
|
|
113
|
+
when Constraints::Min
|
|
114
|
+
if %w[integer number].include?(schema["type"]) || (schema["type"].is_a?(::Array) && (schema["type"] & %w[integer number]).any?)
|
|
115
|
+
schema["minimum"] = constraint.value
|
|
116
|
+
else
|
|
117
|
+
schema["minLength"] = constraint.value
|
|
118
|
+
end
|
|
119
|
+
when Constraints::Max
|
|
120
|
+
if %w[integer number].include?(schema["type"]) || (schema["type"].is_a?(::Array) && (schema["type"] & %w[integer number]).any?)
|
|
121
|
+
schema["maximum"] = constraint.value
|
|
122
|
+
else
|
|
123
|
+
schema["maxLength"] = constraint.value
|
|
124
|
+
end
|
|
125
|
+
when Constraints::Length
|
|
126
|
+
opts = constraint.options
|
|
127
|
+
schema["minLength"] = opts[:min] if opts[:min]
|
|
128
|
+
schema["maxLength"] = opts[:max] if opts[:max]
|
|
129
|
+
if opts[:exact]
|
|
130
|
+
schema["minLength"] = opts[:exact]
|
|
131
|
+
schema["maxLength"] = opts[:exact]
|
|
132
|
+
end
|
|
133
|
+
when Constraints::Format
|
|
134
|
+
# Only add pattern for regex formats
|
|
135
|
+
schema["pattern"] = constraint.pattern.source if constraint.pattern.is_a?(Regexp)
|
|
136
|
+
when Constraints::Enum
|
|
137
|
+
schema["enum"] = constraint.values
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
schema
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def type_to_json_schema(type)
|
|
145
|
+
case type
|
|
146
|
+
when Types::String
|
|
147
|
+
{ "type" => "string" }
|
|
148
|
+
when Types::Integer
|
|
149
|
+
{ "type" => "integer" }
|
|
150
|
+
when Types::Float, Types::Decimal
|
|
151
|
+
{ "type" => "number" }
|
|
152
|
+
when Types::Boolean
|
|
153
|
+
{ "type" => "boolean" }
|
|
154
|
+
when Types::Array
|
|
155
|
+
schema = { "type" => "array" }
|
|
156
|
+
schema["items"] = type_to_json_schema(type.item_type) if type.respond_to?(:item_type) && type.item_type
|
|
157
|
+
schema
|
|
158
|
+
when Types::Object
|
|
159
|
+
if type.respond_to?(:schema) && type.schema
|
|
160
|
+
type.schema.to_json_schema.tap { |s| s.delete("$schema") }
|
|
161
|
+
else
|
|
162
|
+
{ "type" => "object" }
|
|
163
|
+
end
|
|
164
|
+
when Types::Date, Types::DateTime, Types::Time
|
|
165
|
+
{ "type" => "string", "format" => "date-time" }
|
|
166
|
+
when Types::Union
|
|
167
|
+
{ "oneOf" => type.types.map { |t| type_to_json_schema(t) } }
|
|
168
|
+
when Types::Literal
|
|
169
|
+
{ "enum" => type.values }
|
|
170
|
+
else
|
|
171
|
+
{ "type" => "string" }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Field introspection
|
|
177
|
+
class Field
|
|
178
|
+
# Get constraint by type
|
|
179
|
+
def constraint(type)
|
|
180
|
+
@constraints.find { |c| c.is_a?(type) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Check if field has a specific constraint type
|
|
184
|
+
def has_constraint?(type)
|
|
185
|
+
@constraints.any? { |c| c.is_a?(type) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Get all constraint values as a hash
|
|
189
|
+
def constraint_values
|
|
190
|
+
@constraints.each_with_object({}) do |c, hash|
|
|
191
|
+
case c
|
|
192
|
+
when Constraints::Min
|
|
193
|
+
hash[:min] = c.value
|
|
194
|
+
when Constraints::Max
|
|
195
|
+
hash[:max] = c.value
|
|
196
|
+
when Constraints::Length
|
|
197
|
+
hash[:length] = c.options
|
|
198
|
+
when Constraints::Format
|
|
199
|
+
hash[:format] = c.pattern
|
|
200
|
+
when Constraints::Enum
|
|
201
|
+
hash[:enum] = c.values
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|