shale 0.2.2 → 0.4.0

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