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.
- checksums.yaml +4 -4
- data/LICENSE +6 -6
- data/README.md +754 -4
- data/lib/schematist/dsl/complex_types.rb +47 -0
- data/lib/schematist/dsl/conditional_builder.rb +72 -0
- data/lib/schematist/dsl/conditional_context.rb +24 -0
- data/lib/schematist/dsl/conditionals.rb +81 -0
- data/lib/schematist/dsl/primitive_types.rb +27 -0
- data/lib/schematist/dsl/schema_builders.rb +306 -0
- data/lib/schematist/dsl/utilities.rb +88 -0
- data/lib/schematist/dsl.rb +11 -0
- data/lib/schematist/errors.rb +28 -0
- data/lib/schematist/helpers.rb +10 -0
- data/lib/schematist/json_output.rb +73 -0
- data/lib/schematist/schema.rb +152 -0
- data/lib/schematist/validator.rb +91 -0
- data/lib/schematist/version.rb +1 -1
- data/lib/schematist.rb +44 -2
- data/lib/tasks/release.rake +8 -0
- metadata +26 -23
|
@@ -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,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
|