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