shale 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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