schematist 0.1.0 → 1.1.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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module DSL
5
+ module ComplexTypes
6
+ def object(name = nil, description: nil, required: true, requires: nil, **options, &block)
7
+ add_property_or_self(name, object_schema(description: description, **options, &block), required: required, requires: requires)
8
+ end
9
+
10
+ def array(name = nil, description: nil, required: true, requires: nil, **options, &block)
11
+ add_property_or_self(name, array_schema(description: description, **options, &block), required: required, requires: requires)
12
+ end
13
+
14
+ def tuple(name = nil, description: nil, required: true, requires: nil, **options, &block)
15
+ add_property_or_self(name, tuple_schema(description: description, **options, &block), required: required, requires: requires)
16
+ end
17
+
18
+ def any_of(name = nil, description: nil, required: true, requires: nil, **options, &block)
19
+ add_property_or_self(name, any_of_schema(description: description, **options, &block), required: required, requires: requires)
20
+ end
21
+
22
+ def one_of(name = nil, description: nil, required: true, requires: nil, **options, &block)
23
+ add_property_or_self(name, one_of_schema(description: description, **options, &block), required: required, requires: requires)
24
+ end
25
+
26
+ def all_of(name = nil, description: nil, required: true, requires: nil, **options, &block)
27
+ add_property_or_self(name, all_of_schema(description: description, **options, &block), required: required, requires: requires)
28
+ end
29
+
30
+ def none_of(name = nil, description: nil, required: true, requires: nil, **options, &block)
31
+ add_property_or_self(name, none_of_schema(description: description, **options, &block), required: required, requires: requires)
32
+ end
33
+
34
+ # Emits a schema fragment verbatim, for the corners of JSON Schema this DSL does not cover
35
+ def raw(name, schema = nil, required: true, requires: nil)
36
+ return self_schema(name) && nil if schema.nil? && name.is_a?(Hash)
37
+
38
+ add_property_or_self(name, schema, required: required, requires: requires)
39
+ end
40
+
41
+ def optional(name, description: nil, &block)
42
+ any_of(name, description: description) do
43
+ instance_eval(&block)
44
+ null
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module DSL
5
+ # A branch of a conditional. Backed by a schema class, so anything you can write in a schema
6
+ # you can write in a branch: nested objects, arrays, composition, references, raw fragments.
7
+ # `requires` and `validates` stay as shorthands for the two common cases.
8
+ class ConditionalBuilder
9
+ VALIDATES_KEY_MAP = {
10
+ type: :type,
11
+ const: :const,
12
+ enum: :enum,
13
+ not_value: :not,
14
+ min_length: :minLength,
15
+ max_length: :maxLength,
16
+ pattern: :pattern,
17
+ minimum: :minimum,
18
+ maximum: :maximum
19
+ }.freeze
20
+
21
+ def initialize
22
+ @schema_class = Class.new(Schema)
23
+ end
24
+
25
+ # Requires properties without saying anything else about them
26
+ def requires(*fields)
27
+ required.concat(fields.map(&:to_s))
28
+ end
29
+
30
+ def validates(field, **options)
31
+ constraints = {}
32
+
33
+ options.each do |key, value|
34
+ schema_key = VALIDATES_KEY_MAP[key]
35
+ raise ArgumentError, "unknown validates option: #{key.inspect}" unless schema_key
36
+
37
+ case key
38
+ when :type then constraints[:type] = value.to_s
39
+ when :not_value then constraints[:not] = {const: value}
40
+ when :pattern then constraints[:pattern] = value.is_a?(Regexp) ? value.source : value
41
+ else constraints[schema_key] = value
42
+ end
43
+ end
44
+
45
+ validations[field.to_s] = constraints
46
+ end
47
+
48
+ def to_schema
49
+ return @schema_class.self_schema.dup if @schema_class.self_schema
50
+
51
+ schema = {}
52
+ schema[:properties] = branch_properties if branch_properties.any?
53
+ schema[:required] = branch_required if branch_required.any?
54
+ schema[:additionalProperties] = @schema_class.additional_properties if additional_properties_set?
55
+
56
+ @schema_class.send(:merge_schema_keywords, schema, @schema_class)
57
+ end
58
+
59
+ def empty?
60
+ to_schema.empty?
61
+ end
62
+
63
+ def required_fields
64
+ branch_required
65
+ end
66
+
67
+ # A branch that only lists required properties becomes dependentRequired rather than
68
+ # dependentSchemas, which is the smaller thing to say when it is all you mean.
69
+ def validations_empty?
70
+ to_schema.keys == [:required]
71
+ end
72
+
73
+ # Everything else is the ordinary schema DSL, evaluated against the branch's schema class
74
+ def method_missing(name, ...)
75
+ return super unless @schema_class.respond_to?(name)
76
+
77
+ @schema_class.public_send(name, ...)
78
+ end
79
+
80
+ def respond_to_missing?(name, include_private = false)
81
+ @schema_class.respond_to?(name) || super
82
+ end
83
+
84
+ private
85
+
86
+ def branch_properties
87
+ declared = @schema_class.properties.transform_keys(&:to_s)
88
+ validations.merge(declared)
89
+ end
90
+
91
+ def branch_required
92
+ (@schema_class.required_properties.map(&:to_s) + required).uniq
93
+ end
94
+
95
+ def additional_properties_set?
96
+ @schema_class.instance_variable_defined?(:@additional_properties)
97
+ end
98
+
99
+ def required
100
+ @required ||= []
101
+ end
102
+
103
+ def validations
104
+ @validations ||= {}
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module DSL
5
+ class ConditionalContext
6
+ def initialize(then_builder, else_builder)
7
+ @then_builder = then_builder
8
+ @else_builder = else_builder
9
+ end
10
+
11
+ def otherwise(&block)
12
+ @else_builder.instance_eval(&block)
13
+ end
14
+
15
+ # Everything else describes the then branch
16
+ def method_missing(name, ...)
17
+ return super unless @then_builder.respond_to?(name)
18
+
19
+ @then_builder.public_send(name, ...)
20
+ end
21
+
22
+ def respond_to_missing?(name, include_private = false)
23
+ @then_builder.respond_to?(name) || super
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module DSL
5
+ module Conditionals
6
+ def conditions
7
+ @conditions ||= []
8
+ end
9
+
10
+ def dependencies
11
+ @dependencies ||= {}
12
+ end
13
+
14
+ def dependent(property, &block)
15
+ builder = ConditionalBuilder.new
16
+ builder.instance_eval(&block)
17
+
18
+ dependencies[property.to_s] = builder
19
+ end
20
+
21
+ # Matches on property values, or on any schema when one is passed explicitly:
22
+ # given(status: "shipped") { ... }
23
+ # given({required: %w[tax_id]}) { ... }
24
+ def given(condition = nil, **properties, &block)
25
+ raise ArgumentError, "given requires a condition" if condition.nil? && properties.empty?
26
+
27
+ if_schema = condition || {
28
+ properties: properties.transform_keys(&:to_s).transform_values { |v| coerce_condition(v) },
29
+ required: properties.keys.map(&:to_s)
30
+ }
31
+
32
+ then_builder = ConditionalBuilder.new
33
+ else_builder = ConditionalBuilder.new
34
+
35
+ context = ConditionalContext.new(then_builder, else_builder)
36
+ context.instance_eval(&block)
37
+
38
+ condition = {if: if_schema, then: then_builder.to_schema}
39
+ condition[:else] = else_builder.to_schema unless else_builder.empty?
40
+
41
+ conditions << condition
42
+ end
43
+
44
+ private
45
+
46
+ def merge_conditions(schema, schema_class)
47
+ if schema_class.respond_to?(:conditions) && schema_class.conditions.any?
48
+ if schema_class.conditions.length == 1
49
+ schema.merge!(schema_class.conditions.first)
50
+ else
51
+ schema[:allOf] = schema_class.conditions
52
+ end
53
+ end
54
+
55
+ if schema_class.respond_to?(:dependencies) && schema_class.dependencies.any?
56
+ dependent_required = {}
57
+ dependent_schemas = {}
58
+
59
+ schema_class.dependencies.each do |property, builder|
60
+ if builder.validations_empty?
61
+ dependent_required[property] = builder.required_fields
62
+ else
63
+ dependent_schemas[property] = builder.to_schema
64
+ end
65
+ end
66
+
67
+ schema[:dependentRequired] = dependent_required if dependent_required.any?
68
+ schema[:dependentSchemas] = dependent_schemas if dependent_schemas.any?
69
+ end
70
+
71
+ schema
72
+ end
73
+
74
+ def coerce_condition(value)
75
+ case value
76
+ when Array then {enum: value}
77
+ when Regexp then {pattern: value.source}
78
+ when Hash then value
79
+ else {const: value}
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module DSL
5
+ module PrimitiveTypes
6
+ def string(name = nil, description: nil, required: true, requires: nil, **options, &block)
7
+ add_property_or_self(name, string_schema(description: description, **options, &block), required: required, requires: requires)
8
+ end
9
+
10
+ def number(name = nil, description: nil, required: true, requires: nil, **options, &block)
11
+ add_property_or_self(name, number_schema(description: description, **options, &block), required: required, requires: requires)
12
+ end
13
+
14
+ def integer(name = nil, description: nil, required: true, requires: nil, **options, &block)
15
+ add_property_or_self(name, integer_schema(description: description, **options, &block), required: required, requires: requires)
16
+ end
17
+
18
+ def boolean(name = nil, description: nil, required: true, requires: nil, **options, &block)
19
+ add_property_or_self(name, boolean_schema(description: description, **options, &block), required: required, requires: requires)
20
+ end
21
+
22
+ def null(name = nil, description: nil, required: true, requires: nil, **options, &block)
23
+ add_property_or_self(name, null_schema(description: description, **options, &block), required: required, requires: requires)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module DSL
5
+ module SchemaBuilders
6
+ # What a schema block yields: the schemas declared in it, and the keywords set on the enclosing node
7
+ SchemaBlock = Struct.new(:schemas, :keywords)
8
+
9
+ def string_schema(description: nil, enum: nil, const: nil, min_length: nil, max_length: nil, pattern: nil, format: nil, content_encoding: nil, content_media_type: nil, **annotations, &block)
10
+ schema_block = collect_schema_block(&block) if block
11
+
12
+ annotate({
13
+ type: "string",
14
+ enum: enum,
15
+ const: const,
16
+ description: description,
17
+ minLength: min_length,
18
+ maxLength: max_length,
19
+ pattern: pattern,
20
+ format: format,
21
+ contentEncoding: content_encoding,
22
+ contentMediaType: content_media_type
23
+ }.compact, annotations, schema_block)
24
+ end
25
+
26
+ def number_schema(description: nil, minimum: nil, maximum: nil, greater_than: nil, less_than: nil, multiple_of: nil, enum: nil, const: nil, format: nil, **annotations, &block)
27
+ schema_block = collect_schema_block(&block) if block
28
+
29
+ annotate({
30
+ type: "number",
31
+ description: description,
32
+ format: format,
33
+ minimum: minimum,
34
+ maximum: maximum,
35
+ exclusiveMinimum: greater_than,
36
+ exclusiveMaximum: less_than,
37
+ multipleOf: multiple_of,
38
+ enum: enum,
39
+ const: const
40
+ }.compact, annotations, schema_block)
41
+ end
42
+
43
+ def integer_schema(description: nil, minimum: nil, maximum: nil, greater_than: nil, less_than: nil, multiple_of: nil, enum: nil, const: nil, format: nil, **annotations, &block)
44
+ schema_block = collect_schema_block(&block) if block
45
+
46
+ annotate({
47
+ type: "integer",
48
+ description: description,
49
+ format: format,
50
+ minimum: minimum,
51
+ maximum: maximum,
52
+ exclusiveMinimum: greater_than,
53
+ exclusiveMaximum: less_than,
54
+ multipleOf: multiple_of,
55
+ enum: enum,
56
+ const: const
57
+ }.compact, annotations, schema_block)
58
+ end
59
+
60
+ def boolean_schema(description: nil, enum: nil, const: nil, **annotations, &block)
61
+ schema_block = collect_schema_block(&block) if block
62
+
63
+ annotate({type: "boolean", description: description, enum: enum, const: const}.compact, annotations, schema_block)
64
+ end
65
+
66
+ def null_schema(description: nil, enum: nil, **annotations, &block)
67
+ schema_block = collect_schema_block(&block) if block
68
+
69
+ annotate({type: "null", description: description, enum: enum}.compact, annotations, schema_block)
70
+ end
71
+
72
+ def object_schema(description: nil, of: nil, reference: nil, min_properties: nil, max_properties: nil, unevaluated_properties: nil, **annotations, &block)
73
+ if reference
74
+ warn "[DEPRECATION] The `reference` option will be deprecated. Please use `of` instead."
75
+ of = reference
76
+ end
77
+
78
+ schema = of ? determine_object_reference(of, description) : build_object_schema(description, &block)
79
+
80
+ annotate(schema.merge({
81
+ minProperties: min_properties,
82
+ maxProperties: max_properties,
83
+ unevaluatedProperties: unevaluated_properties
84
+ }.compact), annotations)
85
+ end
86
+
87
+ def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, unique: nil, unevaluated_items: nil, **annotations, &block)
88
+ schema_block = collect_schema_block(&block) if block
89
+
90
+ annotate({
91
+ type: "array",
92
+ description: description,
93
+ items: determine_array_items(of, schema_block),
94
+ minItems: min_items,
95
+ maxItems: max_items,
96
+ uniqueItems: unique,
97
+ unevaluatedItems: unevaluated_items
98
+ }.compact, annotations, schema_block)
99
+ end
100
+
101
+ def tuple_schema(description: nil, of: nil, min_items: nil, max_items: nil, unevaluated_items: nil, **annotations, &block)
102
+ schema_block = collect_schema_block(&block)
103
+ schemas = schema_block.schemas
104
+ tail = determine_array_items(of) if of
105
+
106
+ # A tuple is exactly its prefix unless you give it somewhere for the rest to go
107
+ closed = tail.nil? && unevaluated_items.nil? && max_items.nil?
108
+
109
+ annotate({
110
+ type: "array",
111
+ description: description,
112
+ prefixItems: schemas,
113
+ items: tail,
114
+ minItems: min_items || schemas.length,
115
+ maxItems: closed ? schemas.length : max_items,
116
+ unevaluatedItems: unevaluated_items
117
+ }.compact, annotations, schema_block)
118
+ end
119
+
120
+ def any_of_schema(description: nil, unevaluated_properties: nil, unevaluated_items: nil, **annotations, &block)
121
+ schema_block = collect_schema_block(&block)
122
+
123
+ annotate({
124
+ description: description,
125
+ anyOf: schema_block.schemas,
126
+ unevaluatedProperties: unevaluated_properties,
127
+ unevaluatedItems: unevaluated_items
128
+ }.compact, annotations, schema_block)
129
+ end
130
+
131
+ def one_of_schema(description: nil, unevaluated_properties: nil, unevaluated_items: nil, **annotations, &block)
132
+ schema_block = collect_schema_block(&block)
133
+
134
+ annotate({
135
+ description: description,
136
+ oneOf: schema_block.schemas,
137
+ unevaluatedProperties: unevaluated_properties,
138
+ unevaluatedItems: unevaluated_items
139
+ }.compact, annotations, schema_block)
140
+ end
141
+
142
+ def all_of_schema(description: nil, unevaluated_properties: nil, unevaluated_items: nil, **annotations, &block)
143
+ schema_block = collect_schema_block(&block)
144
+
145
+ annotate({
146
+ description: description,
147
+ allOf: schema_block.schemas,
148
+ unevaluatedProperties: unevaluated_properties,
149
+ unevaluatedItems: unevaluated_items
150
+ }.compact, annotations, schema_block)
151
+ end
152
+
153
+ def none_of_schema(description: nil, unevaluated_properties: nil, unevaluated_items: nil, **annotations, &block)
154
+ schema_block = collect_schema_block(&block)
155
+ schemas = schema_block.schemas
156
+
157
+ annotate({
158
+ description: description,
159
+ not: schemas.size == 1 ? schemas.first : {anyOf: schemas},
160
+ unevaluatedProperties: unevaluated_properties,
161
+ unevaluatedItems: unevaluated_items
162
+ }.compact, annotations, schema_block)
163
+ end
164
+
165
+ private
166
+
167
+ # Annotations set inside the block are defaults; options and keyword annotations win over them.
168
+ def annotate(schema, annotations, schema_block = nil)
169
+ unknown = annotations.keys - ANNOTATIONS.keys
170
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" if unknown.any?
171
+
172
+ block_keywords = schema_block ? schema_block.keywords : {}
173
+ block_keywords.merge(schema).merge(annotations.transform_keys { |name| ANNOTATIONS.fetch(name) })
174
+ end
175
+
176
+ def build_object_schema(description, &block)
177
+ raise InvalidObjectTypeError, "An object needs a block or an `of:` schema to describe it." unless block
178
+
179
+ sub_schema = Class.new(Schema)
180
+ result = sub_schema.class_eval(&block)
181
+
182
+ # If the block returned a reference and no properties were added, use the reference
183
+ if result.is_a?(Hash) && result["$ref"] && sub_schema.properties.empty?
184
+ result.merge(description ? {description: description} : {})
185
+ # If the block returned a Schema class or instance, convert it to inline schema
186
+ elsif schema_class?(result) && sub_schema.properties.empty?
187
+ schema_class_to_inline_schema(result).merge(description ? {description: description} : {})
188
+ # Block didn't return reference or schema, so we build an inline object schema
189
+ else
190
+ schema = schema_for(sub_schema)
191
+ # The keyword wins over an annotation set inside the block
192
+ schema[:description] = description if description
193
+ schema
194
+ end
195
+ end
196
+
197
+ def determine_array_items(of, schema_block = nil)
198
+ return schema_block.schemas.first if schema_block
199
+ return send("#{of}_schema") if primitive_type?(of)
200
+ return reference(of) if of.is_a?(Symbol)
201
+ return schema_class_to_inline_schema(of) if schema_class?(of)
202
+
203
+ raise InvalidArrayTypeError, "Invalid array type: #{of.inspect}. Must be a primitive type (:string, :number, etc.), a symbol reference, a Schema class, or a Schema instance."
204
+ end
205
+
206
+ def determine_object_reference(of, description = nil)
207
+ result = case of
208
+ when Symbol
209
+ reference(of)
210
+ when Class
211
+ raise InvalidObjectTypeError, "Invalid object type: #{of.inspect}. Class must inherit from Schematist::Schema." unless schema_class?(of)
212
+
213
+ schema_class_to_inline_schema(of)
214
+
215
+ else
216
+ raise InvalidObjectTypeError, "Invalid object type: #{of.inspect}. Must be a symbol reference, a Schema class, or a Schema instance." unless schema_class?(of)
217
+
218
+ schema_class_to_inline_schema(of)
219
+
220
+ end
221
+
222
+ description ? result.merge(description: description) : result
223
+ end
224
+
225
+ def collect_schemas_from_block(&)
226
+ collect_schema_block(&).schemas
227
+ end
228
+
229
+ def collect_schema_block(&block)
230
+ schema_block = SchemaBlock.new([], {})
231
+ schema_builder = self
232
+
233
+ context = Object.new
234
+
235
+ # Dynamically create methods for all schema builders
236
+ schema_builder.methods.grep(/_schema$/).each do |schema_method|
237
+ type_name = schema_method.to_s.sub(/_schema$/, "")
238
+
239
+ context.define_singleton_method(type_name) do |_name = nil, **options, &blk|
240
+ schema_block.schemas << schema_builder.send(schema_method, **options, &blk)
241
+ end
242
+ end
243
+
244
+ context.define_singleton_method(:contains) do |min: nil, max: nil, &blk|
245
+ schema_block.keywords.merge!({
246
+ contains: schema_builder.send(:collect_schemas_from_block, &blk).first,
247
+ minContains: min,
248
+ maxContains: max
249
+ }.compact)
250
+ end
251
+
252
+ # The two boolean schemas: true accepts every value, false accepts none
253
+ context.define_singleton_method(:any_schema) do
254
+ schema_block.schemas << true
255
+ end
256
+
257
+ context.define_singleton_method(:no_schema) do
258
+ schema_block.schemas << false
259
+ end
260
+
261
+ context.define_singleton_method(:raw) do |schema|
262
+ schema_block.schemas << schema
263
+ end
264
+
265
+ context.define_singleton_method(:content_schema) do |&blk|
266
+ schema_block.keywords[:contentSchema] = schema_builder.send(:collect_schemas_from_block, &blk).first
267
+ end
268
+
269
+ # Annotations and core keywords set here describe the schema the block belongs to,
270
+ # not the schemas declared inside it
271
+ ANNOTATIONS.merge(CORE_KEYWORDS).each do |name, keyword|
272
+ context.define_singleton_method(name) do |value|
273
+ schema_block.keywords[keyword] = value
274
+ end
275
+ end
276
+
277
+ # Allow Schema classes to be accessed in the context
278
+ context.define_singleton_method(:const_missing) do |name|
279
+ const_get(name) if const_defined?(name)
280
+ end
281
+
282
+ context.instance_eval(&block)
283
+ schema_block
284
+ end
285
+
286
+ def schema_class_to_inline_schema(schema_class_or_instance)
287
+ # Handle both Schema classes and Schema instances
288
+ schema_class = if schema_class_or_instance.is_a?(Class)
289
+ schema_class_or_instance
290
+ else
291
+ schema_class_or_instance.class
292
+ end
293
+
294
+ # Directly convert schema class to inline object schema
295
+ {
296
+ type: "object",
297
+ properties: schema_class.properties,
298
+ required: schema_class.required_properties,
299
+ additionalProperties: schema_class.additional_properties
300
+ }.tap do |schema|
301
+ # For instances, prefer instance description over class description
302
+ description = if schema_class_or_instance.is_a?(Class)
303
+ schema_class.description
304
+ else
305
+ schema_class_or_instance.instance_variable_get(:@description) || schema_class.description
306
+ end
307
+
308
+ schema[:description] = description if description
309
+
310
+ merge_schema_keywords(schema, schema_class)
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end