shale 0.2.2 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +366 -8
  4. data/exe/shaleb +123 -0
  5. data/lib/shale/adapter/json.rb +7 -2
  6. data/lib/shale/adapter/nokogiri.rb +48 -12
  7. data/lib/shale/adapter/ox.rb +28 -4
  8. data/lib/shale/adapter/rexml.rb +56 -13
  9. data/lib/shale/attribute.rb +7 -1
  10. data/lib/shale/error.rb +12 -0
  11. data/lib/shale/mapper.rb +17 -15
  12. data/lib/shale/mapping/descriptor/dict.rb +57 -0
  13. data/lib/shale/mapping/descriptor/xml.rb +43 -0
  14. data/lib/shale/mapping/descriptor/xml_namespace.rb +37 -0
  15. data/lib/shale/mapping/{key_value.rb → dict.rb} +8 -6
  16. data/lib/shale/mapping/validator.rb +51 -0
  17. data/lib/shale/mapping/xml.rb +86 -15
  18. data/lib/shale/schema/json_compiler/boolean.rb +21 -0
  19. data/lib/shale/schema/json_compiler/date.rb +21 -0
  20. data/lib/shale/schema/json_compiler/float.rb +21 -0
  21. data/lib/shale/schema/json_compiler/integer.rb +21 -0
  22. data/lib/shale/schema/json_compiler/object.rb +85 -0
  23. data/lib/shale/schema/json_compiler/property.rb +70 -0
  24. data/lib/shale/schema/json_compiler/string.rb +21 -0
  25. data/lib/shale/schema/json_compiler/time.rb +21 -0
  26. data/lib/shale/schema/json_compiler/utils.rb +52 -0
  27. data/lib/shale/schema/json_compiler/value.rb +13 -0
  28. data/lib/shale/schema/json_compiler.rb +333 -0
  29. data/lib/shale/schema/json_generator/base.rb +41 -0
  30. data/lib/shale/schema/json_generator/boolean.rb +23 -0
  31. data/lib/shale/schema/json_generator/collection.rb +39 -0
  32. data/lib/shale/schema/json_generator/date.rb +23 -0
  33. data/lib/shale/schema/json_generator/float.rb +23 -0
  34. data/lib/shale/schema/json_generator/integer.rb +23 -0
  35. data/lib/shale/schema/json_generator/object.rb +40 -0
  36. data/lib/shale/schema/json_generator/ref.rb +28 -0
  37. data/lib/shale/schema/json_generator/schema.rb +59 -0
  38. data/lib/shale/schema/json_generator/string.rb +23 -0
  39. data/lib/shale/schema/json_generator/time.rb +23 -0
  40. data/lib/shale/schema/json_generator/value.rb +23 -0
  41. data/lib/shale/schema/json_generator.rb +165 -0
  42. data/lib/shale/schema/xml_generator/attribute.rb +41 -0
  43. data/lib/shale/schema/xml_generator/complex_type.rb +70 -0
  44. data/lib/shale/schema/xml_generator/element.rb +55 -0
  45. data/lib/shale/schema/xml_generator/import.rb +46 -0
  46. data/lib/shale/schema/xml_generator/ref_attribute.rb +37 -0
  47. data/lib/shale/schema/xml_generator/ref_element.rb +39 -0
  48. data/lib/shale/schema/xml_generator/schema.rb +121 -0
  49. data/lib/shale/schema/xml_generator/typed_attribute.rb +46 -0
  50. data/lib/shale/schema/xml_generator/typed_element.rb +46 -0
  51. data/lib/shale/schema/xml_generator.rb +315 -0
  52. data/lib/shale/schema.rb +70 -0
  53. data/lib/shale/type/boolean.rb +2 -2
  54. data/lib/shale/type/composite.rb +78 -72
  55. data/lib/shale/type/date.rb +35 -2
  56. data/lib/shale/type/float.rb +2 -2
  57. data/lib/shale/type/integer.rb +2 -2
  58. data/lib/shale/type/string.rb +2 -2
  59. data/lib/shale/type/time.rb +35 -2
  60. data/lib/shale/type/{base.rb → value.rb} +18 -7
  61. data/lib/shale/utils.rb +18 -2
  62. data/lib/shale/version.rb +1 -1
  63. data/lib/shale.rb +10 -10
  64. data/shale.gemspec +6 -2
  65. metadata +53 -13
  66. data/lib/shale/mapping/base.rb +0 -32
@@ -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
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shale
4
+ module Schema
5
+ class JSONGenerator
6
+ # Base class for JSON Schema types
7
+ #
8
+ # @api private
9
+ class Base
10
+ # Return name
11
+ #
12
+ # @api private
13
+ attr_reader :name
14
+
15
+ # Return nullable
16
+ #
17
+ # @api private
18
+ attr_writer :nullable
19
+
20
+ def initialize(name, default: nil)
21
+ @name = name.gsub('::', '_')
22
+ @default = default
23
+ @nullable = true
24
+ end
25
+
26
+ # Return JSON Schema fragment as Ruby Hash
27
+ #
28
+ # @return [Hash]
29
+ #
30
+ # @api private
31
+ def as_json
32
+ type = as_type
33
+ type['type'] = [*type['type'], 'null'] if type['type'] && @nullable
34
+ type['default'] = @default unless @default.nil?
35
+
36
+ type
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Shale
6
+ module Schema
7
+ class JSONGenerator
8
+ # Class representing JSON Schema boolean type
9
+ #
10
+ # @api private
11
+ class Boolean < Base
12
+ # Return JSON Schema fragment as Ruby Hash
13
+ #
14
+ # @return [Hash]
15
+ #
16
+ # @api private
17
+ def as_type
18
+ { 'type' => 'boolean' }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shale
4
+ module Schema
5
+ class JSONGenerator
6
+ # Class representing array type in JSON Schema
7
+ #
8
+ # @api private
9
+ class Collection
10
+ # Initialize Collection object
11
+ #
12
+ # @param [Shale::Schema::JSONGenerator::Base] type
13
+ #
14
+ # @api private
15
+ def initialize(type)
16
+ @type = type
17
+ end
18
+
19
+ # Delegate name to wrapped type object
20
+ #
21
+ # @return [String]
22
+ #
23
+ # @api private
24
+ def name
25
+ @type.name
26
+ end
27
+
28
+ # Return JSON Schema fragment as Ruby Hash
29
+ #
30
+ # @return [Hash]
31
+ #
32
+ # @api private
33
+ def as_json
34
+ { 'type' => 'array', 'items' => @type.as_type }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Shale
6
+ module Schema
7
+ class JSONGenerator
8
+ # Class representing JSON Schema date type
9
+ #
10
+ # @api private
11
+ class Date < Base
12
+ # Return JSON Schema fragment as Ruby Hash
13
+ #
14
+ # @return [Hash]
15
+ #
16
+ # @api private
17
+ def as_type
18
+ { 'type' => 'string', 'format' => 'date' }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Shale
6
+ module Schema
7
+ class JSONGenerator
8
+ # Class representing JSON Schema float type
9
+ #
10
+ # @api private
11
+ class Float < Base
12
+ # Return JSON Schema fragment as Ruby Hash
13
+ #
14
+ # @return [Hash]
15
+ #
16
+ # @api private
17
+ def as_type
18
+ { 'type' => 'number' }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Shale
6
+ module Schema
7
+ class JSONGenerator
8
+ # Class representing JSON Schema integer type
9
+ #
10
+ # @api private
11
+ class Integer < Base
12
+ # Return JSON Schema fragment as Ruby Hash
13
+ #
14
+ # @return [Hash]
15
+ #
16
+ # @api private
17
+ def as_type
18
+ { 'type' => 'integer' }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Shale
6
+ module Schema
7
+ class JSONGenerator
8
+ # Class representing JSON Schema object type
9
+ #
10
+ # @api private
11
+ class Object < Base
12
+ # Initialize object
13
+ #
14
+ # @param [String] name
15
+ # @param [
16
+ # Array<Shale::Schema::JSONGenerator::Base,
17
+ # Shale::Schema::JSONGenerator::Collection>
18
+ # ] properties
19
+ #
20
+ # @api private
21
+ def initialize(name, properties)
22
+ super(name)
23
+ @properties = properties
24
+ end
25
+
26
+ # Return JSON Schema fragment as Ruby Hash
27
+ #
28
+ # @return [Hash]
29
+ #
30
+ # @api private
31
+ def as_type
32
+ {
33
+ 'type' => 'object',
34
+ 'properties' => @properties.to_h { |el| [el.name, el.as_json] },
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Shale
6
+ module Schema
7
+ class JSONGenerator
8
+ # Class representing JSON Schema reference
9
+ #
10
+ # @api private
11
+ class Ref < Base
12
+ def initialize(name, type)
13
+ super(name)
14
+ @type = type.gsub('::', '_')
15
+ end
16
+
17
+ # Return JSON Schema fragment as Ruby Hash
18
+ #
19
+ # @return [Hash]
20
+ #
21
+ # @api private
22
+ def as_type
23
+ { '$ref' => "#/$defs/#{@type}" }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shale
4
+ module Schema
5
+ class JSONGenerator
6
+ # Class representing JSON schema
7
+ #
8
+ # @api private
9
+ class Schema
10
+ # JSON Schema dialect (aka version)
11
+ # @api private
12
+ DIALECT = 'https://json-schema.org/draft/2020-12/schema'
13
+
14
+ # Initialize Schema object
15
+ #
16
+ # @param [Array<Shale::Schema::JSONGenerator::Base>] types
17
+ # @param [String, nil] id
18
+ # @param [String, nil] description
19
+ #
20
+ # @api private
21
+ def initialize(types, id: nil, title: nil, description: nil)
22
+ @types = types
23
+ @id = id
24
+ @title = title
25
+ @description = description
26
+ end
27
+
28
+ # Return JSON schema as Ruby Hash
29
+ #
30
+ # @return [Hash]
31
+ #
32
+ # @example
33
+ # Shale::Schema::JSONGenerator::Schema.new(types).as_json
34
+ #
35
+ # @api private
36
+ def as_json
37
+ schema = {
38
+ '$schema' => DIALECT,
39
+ '$id' => @id,
40
+ 'title' => @title,
41
+ 'description' => @description,
42
+ }
43
+
44
+ unless @types.empty?
45
+ root = @types.first
46
+ root.nullable = false
47
+
48
+ schema['$ref'] = "#/$defs/#{root.name}"
49
+ schema['$defs'] = @types
50
+ .sort { |a, b| a.name <=> b.name }
51
+ .to_h { |e| [e.name, e.as_json] }
52
+ end
53
+
54
+ schema.compact
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Shale
6
+ module Schema
7
+ class JSONGenerator
8
+ # Class representing JSON Schema string type
9
+ #
10
+ # @api private
11
+ class String < Base
12
+ # Return JSON Schema fragment as Ruby Hash
13
+ #
14
+ # @return [Hash]
15
+ #
16
+ # @api private
17
+ def as_type
18
+ { 'type' => 'string' }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Shale
6
+ module Schema
7
+ class JSONGenerator
8
+ # Class representing JSON Schema time type
9
+ #
10
+ # @api private
11
+ class Time < Base
12
+ # Return JSON Schema fragment as Ruby Hash
13
+ #
14
+ # @return [Hash]
15
+ #
16
+ # @api private
17
+ def as_type
18
+ { 'type' => 'string', 'format' => 'date-time' }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end