shale 0.3.1 → 0.4.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +105 -14
  4. data/exe/shaleb +79 -31
  5. data/lib/shale/attribute.rb +6 -0
  6. data/lib/shale/error.rb +6 -0
  7. data/lib/shale/mapper.rb +6 -4
  8. data/lib/shale/schema/json_compiler/boolean.rb +21 -0
  9. data/lib/shale/schema/json_compiler/date.rb +21 -0
  10. data/lib/shale/schema/json_compiler/float.rb +21 -0
  11. data/lib/shale/schema/json_compiler/integer.rb +21 -0
  12. data/lib/shale/schema/json_compiler/object.rb +85 -0
  13. data/lib/shale/schema/json_compiler/property.rb +70 -0
  14. data/lib/shale/schema/json_compiler/string.rb +21 -0
  15. data/lib/shale/schema/json_compiler/time.rb +21 -0
  16. data/lib/shale/schema/json_compiler/utils.rb +52 -0
  17. data/lib/shale/schema/json_compiler/value.rb +13 -0
  18. data/lib/shale/schema/json_compiler.rb +333 -0
  19. data/lib/shale/schema/{json → json_generator}/base.rb +2 -2
  20. data/lib/shale/schema/{json → json_generator}/boolean.rb +1 -1
  21. data/lib/shale/schema/{json → json_generator}/collection.rb +2 -2
  22. data/lib/shale/schema/{json → json_generator}/date.rb +1 -1
  23. data/lib/shale/schema/{json → json_generator}/float.rb +1 -1
  24. data/lib/shale/schema/{json → json_generator}/integer.rb +1 -1
  25. data/lib/shale/schema/{json → json_generator}/object.rb +5 -2
  26. data/lib/shale/schema/{json → json_generator}/ref.rb +1 -1
  27. data/lib/shale/schema/{json → json_generator}/schema.rb +6 -4
  28. data/lib/shale/schema/{json → json_generator}/string.rb +1 -1
  29. data/lib/shale/schema/{json → json_generator}/time.rb +1 -1
  30. data/lib/shale/schema/json_generator/value.rb +23 -0
  31. data/lib/shale/schema/{json.rb → json_generator.rb} +36 -36
  32. data/lib/shale/schema/{xml → xml_generator}/attribute.rb +1 -1
  33. data/lib/shale/schema/{xml → xml_generator}/complex_type.rb +5 -2
  34. data/lib/shale/schema/{xml → xml_generator}/element.rb +1 -1
  35. data/lib/shale/schema/{xml → xml_generator}/import.rb +1 -1
  36. data/lib/shale/schema/{xml → xml_generator}/ref_attribute.rb +1 -1
  37. data/lib/shale/schema/{xml → xml_generator}/ref_element.rb +1 -1
  38. data/lib/shale/schema/{xml → xml_generator}/schema.rb +5 -5
  39. data/lib/shale/schema/{xml → xml_generator}/typed_attribute.rb +1 -1
  40. data/lib/shale/schema/{xml → xml_generator}/typed_element.rb +1 -1
  41. data/lib/shale/schema/{xml.rb → xml_generator.rb} +22 -23
  42. data/lib/shale/schema.rb +28 -5
  43. data/lib/shale/type/composite.rb +14 -20
  44. data/lib/shale/version.rb +1 -1
  45. metadata +36 -24
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'utils'
4
+
5
+ module Shale
6
+ module Schema
7
+ class JSONCompiler
8
+ # Class representing Shale's property
9
+ #
10
+ # @api private
11
+ class Property
12
+ # Return property's name
13
+ #
14
+ # @return [String]
15
+ #
16
+ # @api private
17
+ attr_reader :property_name
18
+
19
+ # Return attribute's name
20
+ #
21
+ # @return [String]
22
+ #
23
+ # @api private
24
+ attr_reader :attribute_name
25
+
26
+ # Return types's name
27
+ #
28
+ # @return [String]
29
+ #
30
+ # @api private
31
+ attr_reader :type
32
+
33
+ # Initialize Property object
34
+ #
35
+ # @param [String] name
36
+ # @param [Shale::Schema::JsonCompiler::Type] type
37
+ # @param [true, false] collection
38
+ # @param [Object] default
39
+ #
40
+ # @api private
41
+ def initialize(name, type, collection, default)
42
+ @property_name = name
43
+ @attribute_name = Utils.snake_case(name)
44
+ @type = type
45
+ @collection = collection
46
+ @default = default
47
+ end
48
+
49
+ # Return whether property is a collection
50
+ #
51
+ # @return [true, false]
52
+ #
53
+ # @api private
54
+ def collection?
55
+ @collection
56
+ end
57
+
58
+ # Return default value
59
+ #
60
+ # @return [nil, Object]
61
+ #
62
+ # @api private
63
+ def default
64
+ return if collection?
65
+ @default.is_a?(::String) ? @default.dump : @default
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shale
4
+ module Schema
5
+ class JSONCompiler
6
+ # Class that maps Schema type to Shale String type
7
+ #
8
+ # @api private
9
+ class String
10
+ # Return name of the Shale type
11
+ #
12
+ # @return [String]
13
+ #
14
+ # @api private
15
+ def name
16
+ 'Shale::Type::String'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shale
4
+ module Schema
5
+ class JSONCompiler
6
+ # Class that maps Schema type to Shale Time type
7
+ #
8
+ # @api private
9
+ class Time
10
+ # Return name of the Shale type
11
+ #
12
+ # @return [String]
13
+ #
14
+ # @api private
15
+ def name
16
+ 'Shale::Type::Time'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shale
4
+ module Schema
5
+ class JSONCompiler
6
+ # Module with utility functions
7
+ #
8
+ # @api private
9
+ module Utils
10
+ # Convert string to Ruby's class naming convention
11
+ #
12
+ # @param [String] val
13
+ #
14
+ # @example
15
+ # Shale::Schema::JSONCompiler.classify('foobar')
16
+ # # => 'Foobar'
17
+ #
18
+ # @api private
19
+ def self.classify(str)
20
+ str = str.to_s.sub(/.*\./, '')
21
+
22
+ str = str.sub(/^[a-z\d]*/) { |match| match.capitalize || match }
23
+
24
+ str.gsub(%r{(?:_|(/))([a-z\d]*)}i) do
25
+ word = Regexp.last_match(2)
26
+ substituted = word.capitalize || word
27
+ Regexp.last_match(1) ? "::#{substituted}" : substituted
28
+ end
29
+ end
30
+
31
+ # Convert string to snake case
32
+ #
33
+ # @param [String] val
34
+ #
35
+ # @example
36
+ # Shale::Schema::JSONCompiler.snake_case('FooBar')
37
+ # # => 'foo_bar'
38
+ #
39
+ # @api private
40
+ def self.snake_case(str)
41
+ return str.to_s unless /[A-Z-]|::/.match?(str)
42
+ word = str.to_s.gsub('::', '/')
43
+ word = word.gsub(/([A-Z]+)(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) do
44
+ "#{Regexp.last_match(1) || Regexp.last_match(2)}_"
45
+ end
46
+ word = word.tr('-', '_')
47
+ word.downcase
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shale
4
+ module Schema
5
+ class JSONCompiler
6
+ class Value
7
+ def name
8
+ 'Shale::Type::Value'
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'uri'
5
+
6
+ require_relative '../../shale'
7
+ require_relative 'json_compiler/boolean'
8
+ require_relative 'json_compiler/date'
9
+ require_relative 'json_compiler/float'
10
+ require_relative 'json_compiler/integer'
11
+ require_relative 'json_compiler/object'
12
+ require_relative 'json_compiler/property'
13
+ require_relative 'json_compiler/string'
14
+ require_relative 'json_compiler/time'
15
+ require_relative 'json_compiler/value'
16
+
17
+ module Shale
18
+ module Schema
19
+ # Class for compiling JSON schema into Ruby data model
20
+ #
21
+ # @api public
22
+ class JSONCompiler
23
+ # Default root type name
24
+ # @api private
25
+ DEFAULT_ROOT_NAME = 'root'
26
+
27
+ # Shale model template
28
+ # @api private
29
+ MODEL_TEMPLATE = ERB.new(<<~TEMPLATE, trim_mode: '-')
30
+ require 'shale'
31
+ <%- unless type.references.empty? -%>
32
+
33
+ <%- type.references.each do |property| -%>
34
+ require_relative '<%= property.type.file_name %>'
35
+ <%- end -%>
36
+ <%- end -%>
37
+
38
+ class <%= type.name %> < Shale::Mapper
39
+ <%- type.properties.each do |property| -%>
40
+ attribute :<%= property.attribute_name %>, <%= property.type.name -%>
41
+ <%- if property.collection? %>, collection: true<% end -%>
42
+ <%- unless property.default.nil? %>, default: -> { <%= property.default %> }<% end %>
43
+ <%- end -%>
44
+
45
+ json do
46
+ <%- type.properties.each do |property| -%>
47
+ map '<%= property.property_name %>', to: :<%= property.attribute_name %>
48
+ <%- end -%>
49
+ end
50
+ end
51
+ TEMPLATE
52
+
53
+ # Generate Shale models from JSON Schema and return them as a Ruby Array od objects
54
+ #
55
+ # @param [Array<String>] schemas
56
+ # @param [String, nil] root_name
57
+ #
58
+ # @raise [JSONSchemaError] when JSON Schema has errors
59
+ #
60
+ # @return [Array<Shale::Schema::JSONCompiler::Object>]
61
+ #
62
+ # @example
63
+ # Shale::Schema::JSONCompiler.new.as_models([schema1, schema2])
64
+ #
65
+ # @api public
66
+ def as_models(schemas, root_name: nil)
67
+ schemas = schemas.map do |schema|
68
+ Shale.json_adapter.load(schema)
69
+ end
70
+
71
+ @root_name = root_name
72
+ @schema_repository = {}
73
+ @types = []
74
+
75
+ schemas.each do |schema|
76
+ disassemble_schema(schema)
77
+ end
78
+
79
+ compile(schemas[0], true)
80
+
81
+ total_duplicates = Hash.new(0)
82
+ duplicates = Hash.new(0)
83
+
84
+ # rubocop:disable Style/CombinableLoops
85
+ @types.each do |type|
86
+ total_duplicates[type.name] += 1
87
+ end
88
+
89
+ @types.each do |type|
90
+ duplicates[type.name] += 1
91
+
92
+ if total_duplicates[type.name] > 1
93
+ type.name = format("#{type.name}%d", duplicates[type.name])
94
+ end
95
+ end
96
+ # rubocop:enable Style/CombinableLoops
97
+
98
+ @types.reverse
99
+ end
100
+
101
+ # Generate Shale models from JSON Schema
102
+ #
103
+ # @param [Array<String>] schemas
104
+ # @param [String, nil] root_name
105
+ #
106
+ # @raise [JSONSchemaError] when JSON Schema has errors
107
+ #
108
+ # @return [Hash<String, String>]
109
+ #
110
+ # @example
111
+ # Shale::Schema::JSONCompiler.new.to_models([schema1, schema2])
112
+ #
113
+ # @api public
114
+ def to_models(schemas, root_name: nil)
115
+ types = as_models(schemas, root_name: root_name)
116
+
117
+ types.to_h do |type|
118
+ [type.file_name, MODEL_TEMPLATE.result(binding)]
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # Generate JSON Schema id
125
+ #
126
+ # @param [String, nil] base_id
127
+ # @param [String, nil] id
128
+ #
129
+ # @return [String]
130
+ #
131
+ # @api private
132
+ def build_id(base_id, id)
133
+ return base_id unless id
134
+
135
+ base_uri = URI(base_id || '')
136
+ uri = URI(id)
137
+
138
+ if base_uri.relative? && uri.relative?
139
+ uri.to_s
140
+ else
141
+ base_uri.merge(uri).to_s
142
+ end
143
+ end
144
+
145
+ # Generate JSON pointer
146
+ #
147
+ # @param [String, nil] id
148
+ # @param [Array<String>] fragment
149
+ #
150
+ # @return [String]
151
+ #
152
+ # @api private
153
+ def build_pointer(id, fragment)
154
+ ([id, ['#', *fragment].join('/')] - ['#']).join
155
+ end
156
+
157
+ # Resolve JSON Schem ref
158
+ #
159
+ # @param [String, nil] base_id
160
+ # @param [String, nil] ref
161
+ #
162
+ # @raise [JSONSchemaError] when ref can't be resolved
163
+ #
164
+ # @return [Hash, true, false]
165
+ #
166
+ # @api private
167
+ def resolve_ref(base_id, ref)
168
+ ref_id, fragment = (ref || '').split('#')
169
+ id = build_id(base_id, ref_id == '' ? nil : ref_id)
170
+ key = [id, fragment].compact.join('#')
171
+
172
+ entry = @schema_repository[key]
173
+
174
+ unless entry
175
+ raise JSONSchemaError, "can't resolve reference '#{key}'"
176
+ end
177
+
178
+ if entry[:schema].key?('$ref')
179
+ resolve_ref(id, entry[:schema]['$ref'])
180
+ else
181
+ entry
182
+ end
183
+ end
184
+
185
+ # Get Shale type from JSON Schema type
186
+ #
187
+ # @param [Hash, true, false, nil] schema
188
+ # @param [String] id
189
+ # @param [String] name
190
+ #
191
+ # @return [Shale::Schema::JSONCompiler::Type]
192
+ #
193
+ # @api private
194
+ def infer_type(schema, id, name)
195
+ return unless schema
196
+ return Value.new if schema == true
197
+
198
+ type = schema['type']
199
+ format = schema['format']
200
+
201
+ if type.is_a?(Array)
202
+ type -= ['null']
203
+
204
+ if type.length > 1
205
+ return Value.new
206
+ else
207
+ type = type[0]
208
+ end
209
+ end
210
+
211
+ if type == 'object'
212
+ Object.new(id, name)
213
+ elsif type == 'string' && format == 'date'
214
+ Date.new
215
+ elsif type == 'string' && format == 'date-time'
216
+ Time.new
217
+ elsif type == 'string'
218
+ String.new
219
+ elsif type == 'number'
220
+ Float.new
221
+ elsif type == 'integer'
222
+ Integer.new
223
+ elsif type == 'boolean'
224
+ Boolean.new
225
+ else
226
+ Value.new
227
+ end
228
+ end
229
+
230
+ # Disassemble JSON schema into separate subschemas
231
+ #
232
+ # @param [String] schema
233
+ # @param [Array<String>] fragment
234
+ # @param [String] base_id
235
+ #
236
+ # @raise [JSONSchemaError] when there are problems with JSON schema
237
+ #
238
+ # @api private
239
+ def disassemble_schema(schema, fragment = [], base_id = '')
240
+ schema_key = fragment[-1]
241
+
242
+ if schema.is_a?(Hash) && schema.key?('$id')
243
+ id = build_id(base_id, schema['$id'])
244
+ fragment = []
245
+ else
246
+ id = base_id
247
+ end
248
+
249
+ pointer = build_pointer(id, fragment)
250
+
251
+ if @schema_repository.key?(pointer)
252
+ raise JSONSchemaError, "schema with id '#{pointer}' already exists"
253
+ else
254
+ @schema_repository[pointer] = {
255
+ id: pointer,
256
+ key: schema_key,
257
+ schema: schema,
258
+ }
259
+ end
260
+
261
+ return unless schema.is_a?(Hash)
262
+
263
+ ['properties', '$defs'].each do |definitions|
264
+ (schema[definitions] || {}).each do |subschema_key, subschema|
265
+ disassemble_schema(subschema, [*fragment, definitions, subschema_key], id)
266
+ end
267
+ end
268
+ end
269
+
270
+ # Compile JSON schema into Shale types
271
+ #
272
+ # @param [String] schema
273
+ # @param [true, false] is_root
274
+ # @param [String] base_id
275
+ # @param [Array<String>] fragment
276
+ #
277
+ # @return [Shale::Schema::JSONCompiler::Property, nil]
278
+ #
279
+ # @api private
280
+ def compile(schema, is_root, base_id = '', fragment = [])
281
+ entry = {}
282
+ key = fragment[-1]
283
+ collection = false
284
+ default = nil
285
+
286
+ if schema.is_a?(Hash) && schema.key?('$id')
287
+ id = build_id(base_id, schema['$id'])
288
+ fragment = []
289
+ else
290
+ id = base_id
291
+ end
292
+
293
+ if schema.is_a?(Hash) && schema['type'] == 'array'
294
+ collection = true
295
+ schema = schema['items']
296
+ schema ||= true
297
+ end
298
+
299
+ if schema.is_a?(Hash) && schema.key?('$ref')
300
+ entry = resolve_ref(id, schema['$ref'])
301
+ schema = entry[:schema]
302
+ fragment = entry[:id].split('/') - ['#']
303
+ end
304
+
305
+ pointer = entry[:id] || build_pointer(id, fragment)
306
+
307
+ if is_root
308
+ name = @root_name || entry[:key] || key || DEFAULT_ROOT_NAME
309
+ else
310
+ name = entry[:key] || key
311
+ end
312
+
313
+ type = @types.find { |e| e.id == pointer }
314
+ type ||= infer_type(schema, pointer, name)
315
+
316
+ if schema.is_a?(Hash) && schema.key?('default')
317
+ default = schema['default']
318
+ end
319
+
320
+ if type.is_a?(Object) && !@types.include?(type)
321
+ @types << type
322
+
323
+ (schema['properties'] || {}).each do |subschema_key, subschema|
324
+ property = compile(subschema, false, id, [*fragment, 'properties', subschema_key])
325
+ type.add_property(property) if property
326
+ end
327
+ end
328
+
329
+ Property.new(key, type, collection, default) if type
330
+ end
331
+ end
332
+ end
333
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Shale
4
4
  module Schema
5
- class JSON
5
+ class JSONGenerator
6
6
  # Base class for JSON Schema types
7
7
  #
8
8
  # @api private
@@ -30,7 +30,7 @@ module Shale
30
30
  # @api private
31
31
  def as_json
32
32
  type = as_type
33
- type['type'] = [type['type'], 'null'] if type['type'] && @nullable
33
+ type['type'] = [*type['type'], 'null'] if type['type'] && @nullable
34
34
  type['default'] = @default unless @default.nil?
35
35
 
36
36
  type
@@ -4,7 +4,7 @@ require_relative 'base'
4
4
 
5
5
  module Shale
6
6
  module Schema
7
- class JSON
7
+ class JSONGenerator
8
8
  # Class representing JSON Schema boolean type
9
9
  #
10
10
  # @api private
@@ -2,14 +2,14 @@
2
2
 
3
3
  module Shale
4
4
  module Schema
5
- class JSON
5
+ class JSONGenerator
6
6
  # Class representing array type in JSON Schema
7
7
  #
8
8
  # @api private
9
9
  class Collection
10
10
  # Initialize Collection object
11
11
  #
12
- # @param [Shale::Schema::JSON::Base] type
12
+ # @param [Shale::Schema::JSONGenerator::Base] type
13
13
  #
14
14
  # @api private
15
15
  def initialize(type)
@@ -4,7 +4,7 @@ require_relative 'base'
4
4
 
5
5
  module Shale
6
6
  module Schema
7
- class JSON
7
+ class JSONGenerator
8
8
  # Class representing JSON Schema date type
9
9
  #
10
10
  # @api private
@@ -4,7 +4,7 @@ require_relative 'base'
4
4
 
5
5
  module Shale
6
6
  module Schema
7
- class JSON
7
+ class JSONGenerator
8
8
  # Class representing JSON Schema float type
9
9
  #
10
10
  # @api private
@@ -4,7 +4,7 @@ require_relative 'base'
4
4
 
5
5
  module Shale
6
6
  module Schema
7
- class JSON
7
+ class JSONGenerator
8
8
  # Class representing JSON Schema integer type
9
9
  #
10
10
  # @api private
@@ -4,7 +4,7 @@ require_relative 'base'
4
4
 
5
5
  module Shale
6
6
  module Schema
7
- class JSON
7
+ class JSONGenerator
8
8
  # Class representing JSON Schema object type
9
9
  #
10
10
  # @api private
@@ -12,7 +12,10 @@ module Shale
12
12
  # Initialize object
13
13
  #
14
14
  # @param [String] name
15
- # @param [Array<Shale::Schema::JSON::Base, Shale::Schema::JSON::Collection>] properties
15
+ # @param [
16
+ # Array<Shale::Schema::JSONGenerator::Base,
17
+ # Shale::Schema::JSONGenerator::Collection>
18
+ # ] properties
16
19
  #
17
20
  # @api private
18
21
  def initialize(name, properties)
@@ -4,7 +4,7 @@ require_relative 'base'
4
4
 
5
5
  module Shale
6
6
  module Schema
7
- class JSON
7
+ class JSONGenerator
8
8
  # Class representing JSON Schema reference
9
9
  #
10
10
  # @api private
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Shale
4
4
  module Schema
5
- class JSON
5
+ class JSONGenerator
6
6
  # Class representing JSON schema
7
7
  #
8
8
  # @api private
@@ -13,14 +13,15 @@ module Shale
13
13
 
14
14
  # Initialize Schema object
15
15
  #
16
- # @param [Array<Shale::Schema::JSON::Base>] types
16
+ # @param [Array<Shale::Schema::JSONGenerator::Base>] types
17
17
  # @param [String, nil] id
18
18
  # @param [String, nil] description
19
19
  #
20
20
  # @api private
21
- def initialize(types, id: nil, description: nil)
21
+ def initialize(types, id: nil, title: nil, description: nil)
22
22
  @types = types
23
23
  @id = id
24
+ @title = title
24
25
  @description = description
25
26
  end
26
27
 
@@ -29,13 +30,14 @@ module Shale
29
30
  # @return [Hash]
30
31
  #
31
32
  # @example
32
- # Shale::Schema::JSON::Schema.new(types).as_json
33
+ # Shale::Schema::JSONGenerator::Schema.new(types).as_json
33
34
  #
34
35
  # @api private
35
36
  def as_json
36
37
  schema = {
37
38
  '$schema' => DIALECT,
38
39
  '$id' => @id,
40
+ 'title' => @title,
39
41
  'description' => @description,
40
42
  }
41
43
 
@@ -4,7 +4,7 @@ require_relative 'base'
4
4
 
5
5
  module Shale
6
6
  module Schema
7
- class JSON
7
+ class JSONGenerator
8
8
  # Class representing JSON Schema string type
9
9
  #
10
10
  # @api private
@@ -4,7 +4,7 @@ require_relative 'base'
4
4
 
5
5
  module Shale
6
6
  module Schema
7
- class JSON
7
+ class JSONGenerator
8
8
  # Class representing JSON Schema time type
9
9
  #
10
10
  # @api private