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
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'json/schema_dsl'
6
+ require 'pry'
7
+
8
+ # You can add fixtures and/or initialization code here to make experimenting
9
+ # with your gem easier. You can also use a different console, if you like.
10
+
11
+ # (If you use this, don't forget to add pry to your Gemfile!)
12
+ # require "pry"
13
+ # Pry.start
14
+
15
+ Pry.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'json/schema_dsl/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'json-schema_dsl'
9
+ spec.version = Json::SchemaDsl::VERSION
10
+ spec.authors = ['Paul Martensen']
11
+ spec.email = ['paul.martensen@gmx.de']
12
+
13
+ spec.summary = 'A builder dsl to programatically build json-schemas'
14
+ spec.description = 'A builder dsl to programatically build
15
+ json-schemas that are composable and reusable.'
16
+ spec.homepage = 'https://github.com/lokalportal/json-schema_dsl'
17
+ spec.license = 'MIT'
18
+
19
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
20
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
21
+ if spec.respond_to?(:metadata)
22
+ spec.metadata['homepage_uri'] = spec.homepage
23
+ else
24
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
25
+ 'public gem pushes.'
26
+ end
27
+
28
+ # Specify which files should be added to the gem when it is released.
29
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
30
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
31
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
32
+ end
33
+ spec.bindir = 'exe'
34
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
35
+ spec.require_paths = ['lib']
36
+
37
+ spec.add_dependency 'activesupport', '< 6.0'
38
+ spec.add_dependency 'docile', '< 2'
39
+ spec.add_dependency 'dry-struct', '~> 1.0'
40
+ spec.add_dependency 'dry-types', '~> 1.0'
41
+ spec.add_development_dependency 'bundler', '~> 2.0'
42
+ spec.add_development_dependency 'pry'
43
+ spec.add_development_dependency 'pry-byebug'
44
+ spec.add_development_dependency 'rake', '~> 10.0'
45
+ spec.add_development_dependency 'rspec', '~> 3.0'
46
+ spec.add_development_dependency 'rubocop', '0.74.0'
47
+ spec.add_development_dependency 'rubocop-rspec', '1.36.0'
48
+ spec.add_development_dependency 'spring'
49
+ spec.add_development_dependency 'stackprof'
50
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string'
4
+ require 'active_support/core_ext/object'
5
+ require 'docile'
6
+ require 'dry-struct'
7
+ require 'dry-types'
8
+
9
+ require 'json/schema_dsl/version'
10
+ require 'json/schema_dsl/types'
11
+ require 'json/schema_dsl/configuration'
12
+ require 'json/schema_dsl/ast_node'
13
+ require 'json/schema_dsl/entity'
14
+
15
+ %w[null boolean numeric integer string object array].each do |type|
16
+ require "json/schema_dsl/#{type}"
17
+ end
18
+
19
+ require 'json/schema_dsl/builder'
20
+ require 'json/schema_dsl/renderer'
21
+ require 'json/schema_dsl/proxy'
22
+
23
+ module JSON
24
+ # This module provides the base that it includes with the methods to build new json-schemas.
25
+ module SchemaDsl
26
+ class Error < StandardError; end
27
+
28
+ class << self
29
+ delegate(:type_defaults,
30
+ :reset_type_defaults!,
31
+ :add_defaults_for,
32
+ to: ::JSON::SchemaDsl::Builder)
33
+
34
+ DEFAULT_TYPES = JSON::SchemaDsl::Entity
35
+ .descendants.dup.push(JSON::SchemaDsl::Entity).freeze
36
+ DEFAULT_RENDERERS = [Renderers::Desugar,
37
+ Renderers::Multiplexer,
38
+ Renderers::Alias,
39
+ Renderers::Filter].freeze
40
+
41
+ attr_writer :registered_renderers
42
+
43
+ # @return [Array<Class>] The renderer classes that schema_dsl will use in the renderer
44
+ def registered_renderers
45
+ @registered_renderers ||= DEFAULT_RENDERERS.dup
46
+ end
47
+
48
+ # Resets the registered_renderers to the default settings
49
+ # @return [Array<Class>] The renderer classes that schema_dsl will use in the renderer
50
+ def reset_registered_renderers!
51
+ @registered_renderers = DEFAULT_RENDERERS.dup
52
+ end
53
+
54
+ # @return [Array<Class>] The registered types. These are used to add new dsl
55
+ # and builder methods.
56
+ def registered_types
57
+ @registered_types ||= DEFAULT_TYPES.dup
58
+ end
59
+
60
+ # @param [Class] type A new type to be registered. This will define new builder and dsl
61
+ # methods for that type.
62
+ # @return [Array<Class>] The registered types.
63
+ def register_type(type)
64
+ registered_types.push(type).tap { define_type_methods(type) }
65
+ end
66
+
67
+ # Resets schema_dsl back to default. Removes all dsl methods and redefines
68
+ # them with the default types.
69
+ def reset_schema_dsl!
70
+ type_methods.each { |tm| remove_method tm }
71
+ @registered_types = DEFAULT_TYPES.dup
72
+ define_schema_dsl!
73
+ end
74
+
75
+ # Defines the dsl for all registered types.
76
+ def define_schema_dsl!
77
+ registered_types.map { |t| define_type_methods(t) }
78
+ end
79
+
80
+ # Defines builder methods for the given type.
81
+ # @param [Class] type A class that is a {JSON::SchemaDsl::AstNode}
82
+ def define_type_methods(type)
83
+ JSON::SchemaDsl::Builder.define_builder_method(type)
84
+ builder = JSON::SchemaDsl::Builder[type]
85
+ define_method(type_method_name(type)) do |name = nil, **attributes, &block|
86
+ builder.build(name, **attributes, scope: self, &block)
87
+ end
88
+ end
89
+
90
+ # Reset all settings to default.
91
+ def reset!
92
+ reset_registered_renderers!
93
+ reset_type_defaults!
94
+ reset_schema_dsl!
95
+ end
96
+
97
+ # @return [JSON::SchemaDsl::Proxy] a new proxy to build schemas.
98
+ def proxy
99
+ ::JSON::SchemaDsl::Proxy.new
100
+ end
101
+
102
+ # @return [Array<Symbol>] An array of all type methods
103
+ def type_methods
104
+ registered_types.map { |t| type_method_name(t).to_sym } & instance_methods
105
+ end
106
+
107
+ # @param [Class] type The class for which a method will be defined.
108
+ # @return [String] the name of the new method.
109
+ def type_method_name(type)
110
+ type.type_method_name || 'entity'
111
+ end
112
+ end
113
+
114
+ define_schema_dsl!
115
+ end
116
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # Type that validates a json entity to be an array.
6
+ #
7
+ # @see https://json-schema.org/understanding-json-schema/reference/array.html
8
+ class Array < Entity
9
+ attribute?(:unique_items, Types::Bool)
10
+ attribute?(:additional_items, Types::Bool)
11
+ attribute?(:min_items, Types::Bool)
12
+ attribute?(:max_items, Types::Bool)
13
+ attribute?(:items, Types::Any)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # Methods for an object to be used as an ast node by the renderer
6
+ # Include this module to define your own types that are not descendants of
7
+ # Entity. You should still implement two methods to be compatible with the
8
+ # normal builder class:
9
+ #
10
+ # #initialize: Hash -> Self
11
+ # #to_h: Self -> Hash
12
+ # .has_attribute?: Symbol -> Boolean
13
+ module AstNode
14
+ def self.included(base)
15
+ base.extend(ClassMethods)
16
+ end
17
+
18
+ # @param [Symbol] attribute_name The name of the attribute to update.
19
+ # @param [Object] value The value that will be set for the attribute.
20
+ # @return [Entity] Since entities themselves are immutable, this method returns a new
21
+ # entity with the attribute_name and value pair added.
22
+ def update(attribute_name, value = nil)
23
+ self.class.new(to_h.merge(attribute_name => value))
24
+ end
25
+
26
+ # Used to do a simple render of the entity. Since this has no sensible scope while
27
+ # rendering, use Builder#render instead.
28
+ # @see JSON::SchemaDsl::Builder#render
29
+ def render
30
+ ::JSON::SchemaDsl::Renderer.new(self).render
31
+ end
32
+
33
+ # The class methods that ast nodes should have
34
+ module ClassMethods
35
+ # @return [String] The type that will be used in I.E. `type: 'object'` attributes.
36
+ # Also used to give names to the dsl and builder methods.
37
+ def infer_type
38
+ type = name.split('::').last.underscore
39
+ type == 'entity' ? nil : type
40
+ end
41
+
42
+ # @method! type_method_name
43
+ # Override this method to set the name of the dsl method for this type.
44
+ alias type_method_name infer_type
45
+
46
+ # Override this to set a custom builder for your type.
47
+ # @return [Class] A new builder class for this type.
48
+ def builder
49
+ ::JSON::SchemaDsl::Builder.define_builder(self)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # Boolean primitive of json schema.
6
+ #
7
+ # @see https://json-schema.org/understanding-json-schema/reference/boolean.html
8
+ class Boolean < Entity
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON
4
+ module SchemaDsl
5
+ # Builders are used to build entity-structs. They handle building this raw data so
6
+ # it can then be given to the renderers which modify the structure to be valid json-schema.
7
+ #
8
+ # Each type has an associated Builder that is a dynamically generated class.
9
+ # Since entity-structs are immutable, the builder updates the struct and keeps track of the
10
+ # latest version of the struct.
11
+ #
12
+ # Entity definitions can mostly be treated as data input while most of the logic of building
13
+ # the entity tree resides in the builders.
14
+ # @todo Refactor class and remove rubocop exception
15
+ # rubocop:disable Metrics/ClassLength
16
+ class Builder
17
+ class << self
18
+ attr_accessor :inner_class
19
+
20
+ # @param [Class] klass A class that is a subclass of {JSON::SchemaDsl::Entity}.
21
+ # @return A new builder class that subclasses {JSON::SchemaDsl::Builder}
22
+ def [](klass)
23
+ raise ArgumentError, "#{klass} is not a struct." unless klass < AstNode
24
+
25
+ registered_builders[klass] ||= klass.builder
26
+ end
27
+
28
+ # @return [Array<JSON::SchemaDsl::Builder>] All builders that have been
29
+ # registered so far. Usually builders get automatically registered when
30
+ # the associated type is registered.
31
+ def registered_builders
32
+ @registered_builders ||= {}
33
+ end
34
+
35
+ # @return [Hash<Symbol, Hash>] Defaults that are applied when a new struct of a
36
+ # given type is contsructed. The type symbol is the key and the defaults the value
37
+ # of this hash.
38
+ def type_defaults
39
+ @type_defaults ||= Hash.new { {} }
40
+ end
41
+
42
+ # Clears the registered type defaults and returns an empty hash.
43
+ # @return [Hash<Symbol, Hash>]
44
+ # @see #type_defaults
45
+ def reset_type_defaults!
46
+ type_defaults.clear
47
+ end
48
+
49
+ # Adds new defaults for the given type.
50
+ # @param [Symbol] type The type symbol for the type. Usually the name underscored and
51
+ # symbolized. I.e. {JSON::SchemaDsl::Object} => `:object`.
52
+ # @param [Hash<Symbol, Object>] defaults New defaults that will be merged to
53
+ # the existing ones.
54
+ # @return [Hash<Symbol, Hash>]
55
+ # @see #type_defaults
56
+ def add_defaults_for(type, defaults)
57
+ if type_defaults[type].empty?
58
+ type_defaults[type] = type_defaults[type].merge(defaults)
59
+ else
60
+ type_defaults[type].merge!(defaults)
61
+ end
62
+ end
63
+
64
+ # Instantiates a new builder instance with a corresponding entity and
65
+ # applies the attributes and block to construct a complete entity.
66
+ # @param [#to_s] name The name of the new entity. This is important for the entity
67
+ # to be added to properties later. Usually is the name of a property or the pattern
68
+ # for a pattern property.
69
+ # @param [Object] scope The scope will be used as a fallback to evaluate the block.
70
+ # If there are any methods that the block does not understand, the scope will
71
+ # be called instead.
72
+ # @param [Hash] attributes The initial attributes that the entity will start with
73
+ # before the block is applied.
74
+ # @param [Proc] block Will be evaluated in the context of the builder. Should contain
75
+ # setter methods.
76
+ #
77
+ def build(name = nil, scope: nil, **attributes, &block)
78
+ type = (attributes[:type] || inner_class.infer_type)&.to_sym
79
+ defaults = ::JSON::SchemaDsl::Builder
80
+ .type_defaults[type].merge(name: name, type: type)
81
+ builder = new(inner_class.new(defaults), scope: scope)
82
+ Docile.dsl_eval(builder, &config_block(attributes, &block))
83
+ end
84
+
85
+ # nodoc
86
+ def inspect
87
+ "#<#{class_name} inner_class=#{inner_class}>"
88
+ end
89
+
90
+ # nodoc
91
+ def class_name
92
+ name || inner_class.name + 'Builder'
93
+ end
94
+
95
+ # Defines a new method for the builder instance that mirrors the dsl method
96
+ # for the given type.
97
+ # @param [Class] type A class that is a subclass of {JSON::SchemaDsl::Entity}.
98
+ def define_builder_method(type)
99
+ type_param = type.type_method_name || 'entity'
100
+ define_method(type_param) do |name = nil, **attributes, &block|
101
+ new_child = build_struct(type, name, **attributes, &block)
102
+ add_child(new_child)
103
+ end
104
+ end
105
+
106
+ # @param [Class] klass A class that is a subclass of {JSON::SchemaDsl::Entity}.
107
+ # @return A new builder class that subclasses {JSON::SchemaDsl::Builder}
108
+ # @see #[]
109
+ def define_builder(klass)
110
+ Class.new(self) do
111
+ self.inner_class = klass
112
+ klass.schema.keys.map(&:name).each do |name|
113
+ define_method(name) do |*args, **opts, &block|
114
+ set(name, *args, **opts, &block)
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ # Combines a set of attributes and a block into a single proc.
123
+ def config_block(attributes, &block)
124
+ proc do
125
+ attributes.each { |k, v| send(k, v) }
126
+ instance_exec(&block) if block_given?
127
+ end
128
+ end
129
+ end
130
+
131
+ attr_reader :inner, :scope
132
+ delegate :as_json, to: :render
133
+ delegate :to_h, to: :inner
134
+
135
+ # @param [JSON::SchemaDsl::Entity] inner The struct that the builder is supposed
136
+ # to update and build up.
137
+ # @param [Object] scope The scope is used for as a fallback for helper methods.
138
+ # @see JSON::SchemaDsl::Builder.build
139
+ def initialize(inner, scope: nil)
140
+ @inner = inner
141
+ @scope = scope
142
+ end
143
+
144
+ # Renders the given tree structure into a hash. Note that this hash still has symbol keys.
145
+ # The scope used for the render is the same as the builder.
146
+ # @see JSON::SchemaDsl::Renderer#render
147
+ def render
148
+ ::JSON::SchemaDsl::Renderer.new(inner, scope).render
149
+ end
150
+
151
+ private
152
+
153
+ def update(type, *args)
154
+ args = args.first if args.count == 1 && args.is_a?(::Array)
155
+ @inner = if args.is_a?(::Array)
156
+ inner.update(type, *args)
157
+ else
158
+ inner.update(type, args)
159
+ end
160
+ end
161
+
162
+ def build_struct(type, name = nil, **attributes, &block)
163
+ builder = self.class[type || attributes[:type].constantize]
164
+ builder.build(name, **attributes, scope: scope, &block)
165
+ end
166
+
167
+ def method_missing(meth, *args, &block)
168
+ return super unless scope&.respond_to?(meth, true)
169
+
170
+ maybe_child = scope.send(meth, *args, &block)
171
+ maybe_child.respond_to?(:render) &&
172
+ add_child(maybe_child)
173
+ maybe_child
174
+ end
175
+
176
+ def respond_to_missing?(meth, priv)
177
+ return super unless scope
178
+
179
+ scope.respond_to?(meth, priv)
180
+ end
181
+
182
+ def inspect
183
+ "#<#{self.class.class_name} \n scope = #{scope}\n inner = #{inner}> "
184
+ end
185
+
186
+ def set(name, *args, **opts, &block)
187
+ args = extract_args(name, args, opts, &block)
188
+ return inner.send(name) unless args
189
+
190
+ @inner = update(name, args)
191
+ end
192
+
193
+ def extract_args(name, args, opts, &block)
194
+ if block.present? || !inner.class.has_attribute?(name) || opts[:type].present?
195
+ return build_struct(Object, **opts, &block)
196
+ end
197
+
198
+ args.presence || opts.presence
199
+ end
200
+
201
+ def add_child(child)
202
+ children(children | [child])
203
+ child
204
+ end
205
+ end
206
+ # rubocop:enable Metrics/ClassLength
207
+ end
208
+ end