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.
- checksums.yaml +4 -4
- data/LICENSE +6 -6
- data/README.md +841 -4
- data/lib/schematist/dsl/complex_types.rb +49 -0
- data/lib/schematist/dsl/conditional_builder.rb +108 -0
- data/lib/schematist/dsl/conditional_context.rb +27 -0
- data/lib/schematist/dsl/conditionals.rb +84 -0
- data/lib/schematist/dsl/primitive_types.rb +27 -0
- data/lib/schematist/dsl/schema_builders.rb +315 -0
- data/lib/schematist/dsl/utilities.rb +99 -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 +66 -0
- data/lib/schematist/schema.rb +181 -0
- data/lib/schematist/validator.rb +86 -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,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
|