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.
@@ -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