json-schema_dsl 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,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