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,642 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Validrb
|
|
6
|
+
# OpenAPI 3.0 schema generation
|
|
7
|
+
module OpenAPI
|
|
8
|
+
class Generator
|
|
9
|
+
attr_reader :schemas, :options
|
|
10
|
+
|
|
11
|
+
def initialize(**options)
|
|
12
|
+
@schemas = {}
|
|
13
|
+
@options = options
|
|
14
|
+
@component_schemas = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Register a schema with a name for reuse
|
|
18
|
+
def register(name, schema)
|
|
19
|
+
@schemas[name.to_s] = schema
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Generate a complete OpenAPI 3.0 document
|
|
24
|
+
def generate(info:, servers: [], paths: {}, **extras)
|
|
25
|
+
doc = {
|
|
26
|
+
"openapi" => "3.0.3",
|
|
27
|
+
"info" => normalize_info(info),
|
|
28
|
+
"servers" => servers.map { |s| normalize_server(s) },
|
|
29
|
+
"paths" => paths,
|
|
30
|
+
"components" => {
|
|
31
|
+
"schemas" => generate_component_schemas
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
doc.merge!(extras.transform_keys(&:to_s))
|
|
36
|
+
doc
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Generate OpenAPI schema for a single Validrb schema
|
|
40
|
+
def schema_to_openapi(schema, name: nil)
|
|
41
|
+
result = {
|
|
42
|
+
"type" => "object",
|
|
43
|
+
"properties" => {},
|
|
44
|
+
"required" => []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
schema.fields.each do |field_name, field|
|
|
48
|
+
prop_schema = field_to_openapi(field)
|
|
49
|
+
result["properties"][field_name.to_s] = prop_schema
|
|
50
|
+
|
|
51
|
+
if field.required? && !field.conditional? && !field.has_default?
|
|
52
|
+
result["required"] << field_name.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
result.delete("required") if result["required"].empty?
|
|
57
|
+
|
|
58
|
+
# Handle additionalProperties based on schema options
|
|
59
|
+
if schema.options[:strict]
|
|
60
|
+
result["additionalProperties"] = false
|
|
61
|
+
elsif !schema.options[:passthrough]
|
|
62
|
+
# Default: strip unknown keys (but don't enforce in schema)
|
|
63
|
+
result["additionalProperties"] = false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Generate component schemas from registered schemas
|
|
70
|
+
def generate_component_schemas
|
|
71
|
+
@schemas.transform_values { |s| schema_to_openapi(s) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Export as JSON
|
|
75
|
+
def to_json(info:, **options)
|
|
76
|
+
JSON.pretty_generate(generate(info: info, **options))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Export as YAML (requires yaml to be loaded)
|
|
80
|
+
def to_yaml(info:, **options)
|
|
81
|
+
require "yaml"
|
|
82
|
+
YAML.dump(generate(info: info, **options))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def normalize_info(info)
|
|
88
|
+
info = info.transform_keys(&:to_s)
|
|
89
|
+
{
|
|
90
|
+
"title" => info["title"] || "API",
|
|
91
|
+
"version" => info["version"] || "1.0.0",
|
|
92
|
+
"description" => info["description"]
|
|
93
|
+
}.compact
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_server(server)
|
|
97
|
+
case server
|
|
98
|
+
when String
|
|
99
|
+
{ "url" => server }
|
|
100
|
+
when Hash
|
|
101
|
+
server.transform_keys(&:to_s)
|
|
102
|
+
else
|
|
103
|
+
{ "url" => server.to_s }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def field_to_openapi(field)
|
|
108
|
+
schema = type_to_openapi(field.type)
|
|
109
|
+
|
|
110
|
+
# Handle nullable
|
|
111
|
+
if field.nullable?
|
|
112
|
+
schema["nullable"] = true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Handle default
|
|
116
|
+
if field.has_default?
|
|
117
|
+
default_val = field.default_value
|
|
118
|
+
schema["default"] = serialize_default(default_val) unless default_val.is_a?(Proc)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Handle constraints
|
|
122
|
+
field.constraints.each do |constraint|
|
|
123
|
+
apply_constraint(schema, constraint, field.type)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Handle description from custom message
|
|
127
|
+
# (We don't have a description field, but could use message as hint)
|
|
128
|
+
|
|
129
|
+
schema
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def type_to_openapi(type)
|
|
133
|
+
case type
|
|
134
|
+
when Types::String
|
|
135
|
+
{ "type" => "string" }
|
|
136
|
+
when Types::Integer
|
|
137
|
+
{ "type" => "integer" }
|
|
138
|
+
when Types::Float
|
|
139
|
+
{ "type" => "number", "format" => "float" }
|
|
140
|
+
when Types::Decimal
|
|
141
|
+
{ "type" => "number", "format" => "double" }
|
|
142
|
+
when Types::Boolean
|
|
143
|
+
{ "type" => "boolean" }
|
|
144
|
+
when Types::Date
|
|
145
|
+
{ "type" => "string", "format" => "date" }
|
|
146
|
+
when Types::DateTime
|
|
147
|
+
{ "type" => "string", "format" => "date-time" }
|
|
148
|
+
when Types::Time
|
|
149
|
+
{ "type" => "string", "format" => "date-time" }
|
|
150
|
+
when Types::Array
|
|
151
|
+
schema = { "type" => "array" }
|
|
152
|
+
if type.respond_to?(:item_type) && type.item_type
|
|
153
|
+
schema["items"] = type_to_openapi(type.item_type)
|
|
154
|
+
else
|
|
155
|
+
schema["items"] = {}
|
|
156
|
+
end
|
|
157
|
+
schema
|
|
158
|
+
when Types::Object
|
|
159
|
+
if type.respond_to?(:schema) && type.schema
|
|
160
|
+
schema_to_openapi(type.schema)
|
|
161
|
+
else
|
|
162
|
+
{ "type" => "object" }
|
|
163
|
+
end
|
|
164
|
+
when Types::Union
|
|
165
|
+
{ "oneOf" => type.types.map { |t| type_to_openapi(t) } }
|
|
166
|
+
when Types::Literal
|
|
167
|
+
{ "enum" => type.values }
|
|
168
|
+
when Types::DiscriminatedUnion
|
|
169
|
+
discriminator_schema = {
|
|
170
|
+
"oneOf" => type.mapping.map do |disc_value, disc_schema|
|
|
171
|
+
ref_or_inline = schema_to_openapi(disc_schema)
|
|
172
|
+
ref_or_inline
|
|
173
|
+
end,
|
|
174
|
+
"discriminator" => {
|
|
175
|
+
"propertyName" => type.discriminator.to_s,
|
|
176
|
+
"mapping" => type.mapping.transform_values do |disc_schema|
|
|
177
|
+
# In a full implementation, this would reference component schemas
|
|
178
|
+
"#/components/schemas/inline"
|
|
179
|
+
end
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
# Simplify - just use oneOf without mapping for inline schemas
|
|
183
|
+
{ "oneOf" => type.mapping.values.map { |s| schema_to_openapi(s) } }
|
|
184
|
+
else
|
|
185
|
+
{ "type" => "string" }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def apply_constraint(schema, constraint, type)
|
|
190
|
+
case constraint
|
|
191
|
+
when Constraints::Min
|
|
192
|
+
if numeric_type?(type)
|
|
193
|
+
schema["minimum"] = constraint.value
|
|
194
|
+
else
|
|
195
|
+
schema["minLength"] = constraint.value
|
|
196
|
+
end
|
|
197
|
+
when Constraints::Max
|
|
198
|
+
if numeric_type?(type)
|
|
199
|
+
schema["maximum"] = constraint.value
|
|
200
|
+
else
|
|
201
|
+
schema["maxLength"] = constraint.value
|
|
202
|
+
end
|
|
203
|
+
when Constraints::Length
|
|
204
|
+
opts = constraint.options
|
|
205
|
+
schema["minLength"] = opts[:min] if opts[:min]
|
|
206
|
+
schema["maxLength"] = opts[:max] if opts[:max]
|
|
207
|
+
if opts[:exact]
|
|
208
|
+
schema["minLength"] = opts[:exact]
|
|
209
|
+
schema["maxLength"] = opts[:exact]
|
|
210
|
+
end
|
|
211
|
+
if opts[:range]
|
|
212
|
+
schema["minLength"] = opts[:range].min
|
|
213
|
+
schema["maxLength"] = opts[:range].max
|
|
214
|
+
end
|
|
215
|
+
when Constraints::Format
|
|
216
|
+
if constraint.format_name
|
|
217
|
+
case constraint.format_name
|
|
218
|
+
when :email
|
|
219
|
+
schema["format"] = "email"
|
|
220
|
+
when :url
|
|
221
|
+
schema["format"] = "uri"
|
|
222
|
+
when :uuid
|
|
223
|
+
schema["format"] = "uuid"
|
|
224
|
+
when :phone
|
|
225
|
+
schema["pattern"] = constraint.pattern.source
|
|
226
|
+
else
|
|
227
|
+
schema["pattern"] = constraint.pattern.source
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
schema["pattern"] = constraint.pattern.source
|
|
231
|
+
end
|
|
232
|
+
when Constraints::Enum
|
|
233
|
+
schema["enum"] = constraint.values
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def numeric_type?(type)
|
|
238
|
+
type.is_a?(Types::Integer) ||
|
|
239
|
+
type.is_a?(Types::Float) ||
|
|
240
|
+
type.is_a?(Types::Decimal)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def serialize_default(value)
|
|
244
|
+
case value
|
|
245
|
+
when Date, DateTime, Time
|
|
246
|
+
value.iso8601
|
|
247
|
+
when BigDecimal
|
|
248
|
+
value.to_f
|
|
249
|
+
when Symbol
|
|
250
|
+
value.to_s
|
|
251
|
+
else
|
|
252
|
+
value
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Create a path item for a schema
|
|
258
|
+
class PathBuilder
|
|
259
|
+
def initialize(generator)
|
|
260
|
+
@generator = generator
|
|
261
|
+
@paths = {}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Add a POST endpoint that accepts a schema
|
|
265
|
+
def post(path, schema:, summary: nil, description: nil, responses: nil, **options)
|
|
266
|
+
@paths[path] ||= {}
|
|
267
|
+
@paths[path]["post"] = build_operation(
|
|
268
|
+
schema: schema,
|
|
269
|
+
summary: summary,
|
|
270
|
+
description: description,
|
|
271
|
+
responses: responses,
|
|
272
|
+
**options
|
|
273
|
+
)
|
|
274
|
+
self
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Add a PUT endpoint
|
|
278
|
+
def put(path, schema:, summary: nil, description: nil, responses: nil, **options)
|
|
279
|
+
@paths[path] ||= {}
|
|
280
|
+
@paths[path]["put"] = build_operation(
|
|
281
|
+
schema: schema,
|
|
282
|
+
summary: summary,
|
|
283
|
+
description: description,
|
|
284
|
+
responses: responses,
|
|
285
|
+
**options
|
|
286
|
+
)
|
|
287
|
+
self
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Add a PATCH endpoint
|
|
291
|
+
def patch(path, schema:, summary: nil, description: nil, responses: nil, **options)
|
|
292
|
+
@paths[path] ||= {}
|
|
293
|
+
@paths[path]["patch"] = build_operation(
|
|
294
|
+
schema: schema,
|
|
295
|
+
summary: summary,
|
|
296
|
+
description: description,
|
|
297
|
+
responses: responses,
|
|
298
|
+
**options
|
|
299
|
+
)
|
|
300
|
+
self
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Add a GET endpoint with query parameters from schema
|
|
304
|
+
def get(path, schema: nil, summary: nil, description: nil, responses: nil, **options)
|
|
305
|
+
@paths[path] ||= {}
|
|
306
|
+
operation = {
|
|
307
|
+
"summary" => summary,
|
|
308
|
+
"description" => description,
|
|
309
|
+
"responses" => responses || default_responses
|
|
310
|
+
}.compact
|
|
311
|
+
|
|
312
|
+
if schema
|
|
313
|
+
operation["parameters"] = schema_to_parameters(schema)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
operation.merge!(options.transform_keys(&:to_s))
|
|
317
|
+
@paths[path]["get"] = operation
|
|
318
|
+
self
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def to_h
|
|
322
|
+
@paths
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private
|
|
326
|
+
|
|
327
|
+
def build_operation(schema:, summary:, description:, responses:, **options)
|
|
328
|
+
operation = {
|
|
329
|
+
"summary" => summary,
|
|
330
|
+
"description" => description,
|
|
331
|
+
"requestBody" => {
|
|
332
|
+
"required" => true,
|
|
333
|
+
"content" => {
|
|
334
|
+
"application/json" => {
|
|
335
|
+
"schema" => @generator.schema_to_openapi(schema)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
"responses" => responses || default_responses
|
|
340
|
+
}.compact
|
|
341
|
+
|
|
342
|
+
operation.merge!(options.transform_keys(&:to_s))
|
|
343
|
+
operation
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def schema_to_parameters(schema)
|
|
347
|
+
schema.fields.map do |name, field|
|
|
348
|
+
param = {
|
|
349
|
+
"name" => name.to_s,
|
|
350
|
+
"in" => "query",
|
|
351
|
+
"required" => field.required? && !field.has_default?,
|
|
352
|
+
"schema" => @generator.send(:field_to_openapi, field)
|
|
353
|
+
}
|
|
354
|
+
param
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def default_responses
|
|
359
|
+
{
|
|
360
|
+
"200" => {
|
|
361
|
+
"description" => "Successful response"
|
|
362
|
+
},
|
|
363
|
+
"400" => {
|
|
364
|
+
"description" => "Validation error"
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Convenience method to create a generator
|
|
371
|
+
def self.generator(**options)
|
|
372
|
+
Generator.new(**options)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Quick generation from a single schema
|
|
376
|
+
def self.from_schema(schema, name: "Schema")
|
|
377
|
+
generator = Generator.new
|
|
378
|
+
generator.register(name, schema)
|
|
379
|
+
generator
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Add OpenAPI generation to Schema class
|
|
384
|
+
class Schema
|
|
385
|
+
# Generate OpenAPI 3.0 schema representation
|
|
386
|
+
def to_openapi
|
|
387
|
+
OpenAPI.from_schema(self).schema_to_openapi(self)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
module OpenAPI
|
|
392
|
+
# Import OpenAPI/JSON Schema and create Validrb schemas
|
|
393
|
+
class Importer
|
|
394
|
+
attr_reader :definitions
|
|
395
|
+
|
|
396
|
+
def initialize
|
|
397
|
+
@definitions = {}
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Import from OpenAPI document
|
|
401
|
+
def import_openapi(doc)
|
|
402
|
+
doc = normalize_doc(doc)
|
|
403
|
+
|
|
404
|
+
# Import component schemas
|
|
405
|
+
if doc["components"] && doc["components"]["schemas"]
|
|
406
|
+
doc["components"]["schemas"].each do |name, schema|
|
|
407
|
+
@definitions[name] = import_schema(schema)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Also support older OpenAPI 2.0 definitions
|
|
412
|
+
if doc["definitions"]
|
|
413
|
+
doc["definitions"].each do |name, schema|
|
|
414
|
+
@definitions[name] = import_schema(schema)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
self
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Import a single JSON Schema / OpenAPI schema object
|
|
422
|
+
def import_schema(schema)
|
|
423
|
+
schema = normalize_doc(schema)
|
|
424
|
+
|
|
425
|
+
case schema["type"]
|
|
426
|
+
when "object"
|
|
427
|
+
import_object_schema(schema)
|
|
428
|
+
when "array"
|
|
429
|
+
import_array_schema(schema)
|
|
430
|
+
else
|
|
431
|
+
# For non-object schemas, wrap in a single-field schema
|
|
432
|
+
Validrb.schema do
|
|
433
|
+
field :value, import_type(schema)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Get a specific imported schema by name
|
|
439
|
+
def [](name)
|
|
440
|
+
@definitions[name.to_s]
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# List all imported schema names
|
|
444
|
+
def schema_names
|
|
445
|
+
@definitions.keys
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
private
|
|
449
|
+
|
|
450
|
+
def normalize_doc(doc)
|
|
451
|
+
case doc
|
|
452
|
+
when String
|
|
453
|
+
JSON.parse(doc)
|
|
454
|
+
when Hash
|
|
455
|
+
doc.transform_keys(&:to_s)
|
|
456
|
+
else
|
|
457
|
+
doc
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def import_object_schema(schema)
|
|
462
|
+
properties = schema["properties"] || {}
|
|
463
|
+
required_fields = Array(schema["required"])
|
|
464
|
+
imported_props = {}
|
|
465
|
+
|
|
466
|
+
properties.each do |name, prop_schema|
|
|
467
|
+
imported_props[name] = {
|
|
468
|
+
type: determine_type(prop_schema),
|
|
469
|
+
options: extract_options(prop_schema, required_fields.include?(name))
|
|
470
|
+
}
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Build the Validrb schema
|
|
474
|
+
props = imported_props
|
|
475
|
+
Validrb.schema do
|
|
476
|
+
props.each do |name, config|
|
|
477
|
+
field name.to_sym, config[:type], **config[:options]
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def import_array_schema(schema)
|
|
483
|
+
item_type = if schema["items"]
|
|
484
|
+
determine_type(schema["items"])
|
|
485
|
+
else
|
|
486
|
+
:string
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
options = {}
|
|
490
|
+
options[:min] = schema["minItems"] if schema["minItems"]
|
|
491
|
+
options[:max] = schema["maxItems"] if schema["maxItems"]
|
|
492
|
+
|
|
493
|
+
item_t = item_type
|
|
494
|
+
opts = options
|
|
495
|
+
Validrb.schema do
|
|
496
|
+
field :items, :array, of: item_t, **opts
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def determine_type(schema)
|
|
501
|
+
schema = schema.transform_keys(&:to_s) if schema.is_a?(Hash)
|
|
502
|
+
|
|
503
|
+
# Handle oneOf / anyOf (union types)
|
|
504
|
+
if schema["oneOf"] || schema["anyOf"]
|
|
505
|
+
types = (schema["oneOf"] || schema["anyOf"]).map { |s| determine_type(s) }
|
|
506
|
+
return types.first # Simplified - return first type
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Handle allOf (merge schemas) - simplified
|
|
510
|
+
if schema["allOf"]
|
|
511
|
+
return :object
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Handle $ref
|
|
515
|
+
if schema["$ref"]
|
|
516
|
+
ref_name = schema["$ref"].split("/").last
|
|
517
|
+
return :object # Would need to resolve reference
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Handle enum
|
|
521
|
+
if schema["enum"]
|
|
522
|
+
return :string # Use enum constraint instead
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
type = schema["type"]
|
|
526
|
+
format = schema["format"]
|
|
527
|
+
|
|
528
|
+
case type
|
|
529
|
+
when "string"
|
|
530
|
+
case format
|
|
531
|
+
when "date"
|
|
532
|
+
:date
|
|
533
|
+
when "date-time"
|
|
534
|
+
:datetime
|
|
535
|
+
when "time"
|
|
536
|
+
:time
|
|
537
|
+
when "uuid"
|
|
538
|
+
:string # with uuid format constraint
|
|
539
|
+
when "email"
|
|
540
|
+
:string # with email format constraint
|
|
541
|
+
when "uri", "url"
|
|
542
|
+
:string # with url format constraint
|
|
543
|
+
else
|
|
544
|
+
:string
|
|
545
|
+
end
|
|
546
|
+
when "integer"
|
|
547
|
+
:integer
|
|
548
|
+
when "number"
|
|
549
|
+
case format
|
|
550
|
+
when "float"
|
|
551
|
+
:float
|
|
552
|
+
when "double"
|
|
553
|
+
:decimal
|
|
554
|
+
else
|
|
555
|
+
:float
|
|
556
|
+
end
|
|
557
|
+
when "boolean"
|
|
558
|
+
:boolean
|
|
559
|
+
when "array"
|
|
560
|
+
:array
|
|
561
|
+
when "object"
|
|
562
|
+
:object
|
|
563
|
+
when "null"
|
|
564
|
+
:string # Nullable will be handled separately
|
|
565
|
+
else
|
|
566
|
+
:string
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def extract_options(schema, is_required)
|
|
571
|
+
schema = schema.transform_keys(&:to_s) if schema.is_a?(Hash)
|
|
572
|
+
options = {}
|
|
573
|
+
|
|
574
|
+
# Required / Optional
|
|
575
|
+
options[:optional] = true unless is_required
|
|
576
|
+
|
|
577
|
+
# Nullable
|
|
578
|
+
if schema["nullable"] == true
|
|
579
|
+
options[:nullable] = true
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Handle type array with null (JSON Schema nullable pattern)
|
|
583
|
+
if schema["type"].is_a?(Array) && schema["type"].include?("null")
|
|
584
|
+
options[:nullable] = true
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Default value
|
|
588
|
+
if schema.key?("default")
|
|
589
|
+
options[:default] = schema["default"]
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# String constraints
|
|
593
|
+
options[:min] = schema["minLength"] if schema["minLength"]
|
|
594
|
+
options[:max] = schema["maxLength"] if schema["maxLength"]
|
|
595
|
+
|
|
596
|
+
# Numeric constraints
|
|
597
|
+
options[:min] = schema["minimum"] if schema["minimum"]
|
|
598
|
+
options[:max] = schema["maximum"] if schema["maximum"]
|
|
599
|
+
|
|
600
|
+
# Pattern
|
|
601
|
+
if schema["pattern"]
|
|
602
|
+
options[:format] = Regexp.new(schema["pattern"])
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Format (named formats)
|
|
606
|
+
if schema["format"]
|
|
607
|
+
case schema["format"]
|
|
608
|
+
when "email"
|
|
609
|
+
options[:format] = :email
|
|
610
|
+
when "uri", "url"
|
|
611
|
+
options[:format] = :url
|
|
612
|
+
when "uuid"
|
|
613
|
+
options[:format] = :uuid
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Enum
|
|
618
|
+
if schema["enum"]
|
|
619
|
+
options[:enum] = schema["enum"]
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
options
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def import_type(schema)
|
|
626
|
+
determine_type(schema)
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# Convenience method to import from OpenAPI
|
|
631
|
+
def self.import(doc)
|
|
632
|
+
importer = Importer.new
|
|
633
|
+
importer.import_openapi(doc)
|
|
634
|
+
importer
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Import a single schema
|
|
638
|
+
def self.import_schema(schema)
|
|
639
|
+
Importer.new.import_schema(schema)
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|