json-schema_dsl 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ class Configuration
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # The basic entity type for json schemas.
6
+ #
7
+ # This is mostly used in cases where you don't exactly know what type a property will have,
8
+ # for example if the property is an `anyOf` of different types.
9
+ #
10
+ # Internally it is used as the superclass of all other types.
11
+ class Entity < Dry::Struct
12
+ include AstNode
13
+
14
+ class << self
15
+ # nodoc
16
+ def required_type
17
+ (Types::Bool | Types::Coercible::Array.of(Types::Coercible::String).default { [] })
18
+ end
19
+ end
20
+
21
+ attribute(:enum, Types::Coercible::Array.default { [] })
22
+ attribute(:all_of, Types::Coercible::Array.default { [] })
23
+ attribute(:any_of, Types::Coercible::Array.default { [] })
24
+ attribute(:one_of, Types::Coercible::Array.default { [] })
25
+ attribute(:children, Types::Coercible::Array.default { [] })
26
+ attribute?(:nullable, Types::Bool.default(false))
27
+ attribute?(:name, (Types.Instance(Regexp) | Types::Coercible::String))
28
+ attribute?(:type, Types::Coercible::String)
29
+ attribute?(:title, Types::Coercible::String)
30
+ attribute?(:description, Types::Coercible::String)
31
+ attribute?(:default, Types::Coercible::String)
32
+ attribute?(:required, required_type)
33
+ attribute?(:not_a, Types::String)
34
+ attribute?(:ref, Types::String)
35
+ attribute?(:definitions, Types::String)
36
+
37
+ # @return [Hash<Symbol, Object>] Returns this entity as a hash and all children and
38
+ # properties as simple values. This structure is used to render the eventual
39
+ # schema by the renderer.
40
+ # @see JSON::SchemaDsl::Rederer#initialize
41
+ def to_h
42
+ super.transform_values do |v|
43
+ is_array = v.is_a?(::Array)
44
+ if (is_array ? v.first : v).respond_to?(:to_h)
45
+ is_array ? v.map(&:to_h) : v.to_h
46
+ else
47
+ v
48
+ end
49
+ end
50
+ end
51
+ delegate :as_json, to: :to_h
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # The primitive integer type for json schema.
6
+ #
7
+ # @see https://json-schema.org/understanding-json-schema/reference/numeric.html
8
+ class Integer < Entity
9
+ attribute?(:multiple_of, Types::Integer)
10
+ attribute?(:minimum, Types::Integer)
11
+ attribute?(:maximum, Types::Integer)
12
+ attribute?(:exclusive_minimum, Types::Integer)
13
+ attribute?(:exclusive_maximum, Types::Integer)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # Null primitive of JSON-Schema
6
+ #
7
+ # @see https://json-schema.org/understanding-json-schema/reference/null.html
8
+ class Null < Entity
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # Number primitive of Json-Schema
6
+ #
7
+ # @see https://json-schema.org/understanding-json-schema/reference/numeric.html#number
8
+ class Numeric < Entity
9
+ attribute?(:multiple_of, Types::Integer)
10
+ attribute?(:minimum, Types::Integer)
11
+ attribute?(:maximum, Types::Integer)
12
+ attribute?(:exclusive_minimum, Types::Integer)
13
+ attribute?(:exclusive_maximum, Types::Integer)
14
+ end
15
+
16
+ # Type alias of numeric.
17
+ #
18
+ # @see https://json-schema.org/understanding-json-schema/reference/numeric.html#number
19
+ class Number < Numeric
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # Object type of JSON-Schema
6
+ #
7
+ # @see https://json-schema.org/understanding-json-schema/reference/object.html
8
+ class Object < Entity
9
+ attribute(:pattern_properties, Types::Array.of(Types.Instance(Regexp)).default { [] })
10
+ attribute?(:min_properties, Types::Integer)
11
+ attribute?(:max_properties, Types::Integer)
12
+ attribute?(:additional_properties, Types::Bool)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # A small proxy for the dsl that enables a nicer api for building schemas on the fly
6
+ class Proxy
7
+ include ::JSON::SchemaDsl
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ %w[base desugar multiplexer alias filter].each do |file|
4
+ require "json/schema_dsl/renderers/#{file}"
5
+ end
6
+
7
+ module JSON
8
+ module SchemaDsl
9
+ # Main entry point for the rendering chain of JSON::SchemaDsl.
10
+ #
11
+ # This will in turn apply the registered renderers one by one, updating the
12
+ # entity-tree to result in json-schema.
13
+ class Renderer
14
+ attr_reader :entity, :scope
15
+
16
+ # @param [Entity] entity The root entity-tree of this render run.
17
+ # @param [Object] scope Used as a fallback for renderers that need access
18
+ # to helper methods.
19
+ def initialize(entity, scope = nil)
20
+ @entity = entity.to_h
21
+ @scope = scope
22
+ end
23
+
24
+ # @see #render
25
+ def self.render(entity)
26
+ new(entity).render
27
+ end
28
+
29
+ # Applies the renderer chain in turn to produce valid json-schema.
30
+ # Each renderer traverses the whole tree before passing the resulting structure to
31
+ # the next renderer
32
+ # @return [Hash] The resulting json schema structure after each render is applied.
33
+ def render
34
+ render_chain.inject(entity) do |structure, renderer|
35
+ renderer.new(scope).visit(structure)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # @return [Array<Class>] Each rendering class that will be applied to the given entity.
42
+ # @see ::JSON::SchemaDsl.registered_renderers
43
+ def render_chain
44
+ ::JSON::SchemaDsl.registered_renderers
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ module Renderers
6
+ # Aliases certain attributes and camel-cases all others.
7
+ # The only exception are property names which are set by the user and
8
+ # will not be camel-cased.
9
+ class Alias < Base
10
+ ALIASES = {
11
+ 'ref' => '$ref'
12
+ }.freeze
13
+
14
+ # Camel-case and/or alias the attribute names of the given structure.
15
+ def visit(entity)
16
+ traverse(entity
17
+ .transform_keys { |key| ALIASES[key.to_s]&.to_sym || key }
18
+ .transform_keys { |key| camelize_snake_cased(key) })
19
+ end
20
+
21
+ private
22
+
23
+ def camelize_snake_cased(key)
24
+ key = key.to_s
25
+ (key.capitalize == key ? key : key.camelize(:lower)).to_sym
26
+ end
27
+
28
+ def traverse(entity)
29
+ entity.map do |key, value|
30
+ if key.to_s.match?(/properties$/i) && value.is_a?(Hash)
31
+ [key, value.transform_values { |v| visit(v) }]
32
+ else
33
+ [key, step(value)]
34
+ end
35
+ end.to_h
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ module Renderers
6
+ # The abstract base renderer that provides common behaviour
7
+ # to all renderers like the depth-first-traversal and
8
+ # access to the scope.
9
+ # @abstract
10
+ class Base
11
+ attr_reader :scope
12
+ # @param [Object] scope The scope used as a fallback for helper methods.
13
+ def initialize(scope)
14
+ @scope = scope
15
+ end
16
+
17
+ # @param [Hash] entity The entity-structure given as a tree.
18
+ # This method will recursively visit each value in the structure until
19
+ # all have been visited.
20
+ # @return [Hash] The hash-tree with all values visited.
21
+ def traverse(entity)
22
+ entity.transform_values { |v| step(v) }
23
+ end
24
+
25
+ protected
26
+
27
+ # @param [Object] value The value that should be visited. Behaves differently
28
+ # for each renderer, since #visit holds the core logic of each.
29
+ # @return [Object] The visited object.
30
+ def step(value)
31
+ case value
32
+ when ::Array
33
+ value.first.is_a?(Hash) ? value.map { |v| visit(v) } : value
34
+ when Hash then visit(value)
35
+ else value
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ module Renderers
6
+ # By default the first renderer that visits the tree.
7
+ # This renderer will translate all kinds of `syntax sugar` to
8
+ # a uniform format that fits json-schema.
9
+ class Desugar < Base
10
+ # Desugars all syntax sugar in that entity. This resolves concepts
11
+ # like `children` and `nullable` that are not present in the json
12
+ # schema specification itself but are used by the builder to ease
13
+ # the writing of schemas. In turn it
14
+ #
15
+ # * Transforms children to properties
16
+ # * Translates the `:required` attribute to the parent if true.
17
+ # * Collapses the item attribute of arrays into a single entity.
18
+ def visit(entity)
19
+ traverse(expand_children(nullable(entity)))
20
+ end
21
+
22
+ private
23
+
24
+ def expand_children(entity)
25
+ return entity unless entity[:children]
26
+ return collapse_items(entity) if entity[:items]
27
+
28
+ entity
29
+ .merge(required_properties(entity))
30
+ .merge(properties_properties(entity))
31
+ end
32
+
33
+ # Collapses the items into the first child for arrays.
34
+ def collapse_items(entity)
35
+ items = entity[:items]
36
+ items = items[:children].first if items[:children].to_a.count == 1
37
+ entity.merge(items: items)
38
+ end
39
+
40
+ # @param [Hash] entity An object entity-hash that has children.
41
+ # @return [Hash] The hash with children translated into properties and
42
+ # @todo Enable and fix rubocop warning about AbcSize
43
+ # rubocop:disable Metrics/AbcSize
44
+ def properties_properties(entity)
45
+ entity[:children]
46
+ .filter { |ch| ch[:name].present? }
47
+ .map { |ch| ch[:name] }
48
+ .zip(entity[:children].map { |c| visit(c) })
49
+ .map(&unrequire_property)
50
+ .group_by { |(name, _obj)| name.class }
51
+ .transform_keys { |k| k == Regexp ? :pattern_properties : :properties }
52
+ .transform_values(&:to_h)
53
+ .merge(children: nil)
54
+ end
55
+ # rubocop:enable Metrics/AbcSize
56
+
57
+ def unrequire_property
58
+ lambda do |(name, obj)|
59
+ obj = obj[:required] == true ? obj.merge(required: nil) : obj
60
+ [name, obj]
61
+ end
62
+ end
63
+
64
+ # Translates the required-properties of children into the required array
65
+ # of the parent structure.
66
+ def required_properties(entity)
67
+ requireds = entity[:children]
68
+ .select { |ch| ch[:required] == true }
69
+ .map { |ch| ch[:name].to_s }
70
+ pre_req = entity[:required].is_a?(Array) ? entity[:required] : []
71
+ { required: requireds | pre_req }
72
+ end
73
+
74
+ # Translates nullable property into a any_of: [null...]
75
+ def nullable(entity)
76
+ return entity unless entity[:nullable]
77
+
78
+ entity.merge(nullable: nil, any_of: [{ type: 'null' }]) do |k, old, new|
79
+ next unless k == :any_of
80
+
81
+ old + new
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ module Renderers
6
+ # Filters out properties that are either used internally only or
7
+ # which are redundant (I.e. set to nil).
8
+ class Filter < Base
9
+ INVISIBLES = %w[Children Nullable Name].freeze
10
+
11
+ # Filters out properties that are either used internally only or
12
+ # which are redundant (I.e. set to nil).
13
+ def visit(entity)
14
+ traverse(filter(entity))
15
+ end
16
+
17
+ private
18
+
19
+ def filter(entity)
20
+ entity
21
+ .except(*(INVISIBLES + INVISIBLES.map(&:underscore).map(&:to_sym)))
22
+ .transform_values { |v| presence_of(v, preserve: [false]) }
23
+ .compact
24
+ end
25
+
26
+ def presence_of(obj, preserve: [])
27
+ return obj if preserve.include? obj
28
+
29
+ obj.presence
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ module Renderers
6
+ # Renderer that translates multiplexer properties (any_of, one_of or all_of)
7
+ # so that a new wrapping structure is returned that holds the original in
8
+ # one of these properties.
9
+ #
10
+ # @example Nullable object
11
+ # obj = object :james, any_of: [null]
12
+ # Multiplexer.new(nil).visit(obj)
13
+ # #=> { type: 'entity', any_of: [{type: 'null'}, {type: 'object', ...}] }
14
+ #
15
+ class Multiplexer < Base
16
+ KEYS = %i[any_of one_of all_of].freeze
17
+
18
+ # Boxes the entity into a new one with the multiplexer attribute set
19
+ # to the entity, if required.
20
+ def visit(entity)
21
+ traverse(box(entity))
22
+ end
23
+
24
+ private
25
+
26
+ # Boxes the given entity unless it is of type 'entity' or has no
27
+ # multiplexer attributes. The entity itself will be added to the "box"
28
+ # if it has any other attributes set by the user.
29
+ # @param [Hash] entity The entity that may have a multiplexer attribute.
30
+ def box(entity)
31
+ present_key = entity[:type] != 'entity' && KEYS.find { |k| entity[k].present? }
32
+ return entity unless present_key
33
+
34
+ new_value = entity[present_key].map { |ch| visit(ch) }
35
+ unless container?(entity.except(present_key))
36
+ new_value.push(entity.except(present_key))
37
+ end
38
+
39
+ { type: 'entity', present_key => new_value.reject(&:blank?) }
40
+ end
41
+
42
+ # @return [Boolean] `true` if the object is only there to have the
43
+ # boxing attribute, false otherwise.
44
+ def container?(entity)
45
+ cleaned_up = clean_up(entity)
46
+
47
+ cleaned_up[:type].to_s == 'object' && cleaned_up.keys.count <= 1
48
+ end
49
+
50
+ def clean_up(entity)
51
+ defaults = ::JSON::SchemaDsl.type_defaults[entity[:type].to_sym]
52
+ (entity.to_a - defaults.to_a).to_h.yield_self do |without_defaults|
53
+ Renderers::Filter.new(scope).visit(without_defaults)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end