lutaml-model 0.4.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +34 -18
  3. data/README.adoc +166 -8
  4. data/lib/lutaml/model/key_value_mapping.rb +0 -1
  5. data/lib/lutaml/model/key_value_mapping_rule.rb +3 -1
  6. data/lib/lutaml/model/mapping_rule.rb +14 -2
  7. data/lib/lutaml/model/serialize.rb +67 -52
  8. data/lib/lutaml/model/type/decimal.rb +5 -0
  9. data/lib/lutaml/model/version.rb +1 -1
  10. data/lib/lutaml/model/xml_adapter/builder/nokogiri.rb +1 -0
  11. data/lib/lutaml/model/xml_adapter/builder/oga.rb +180 -0
  12. data/lib/lutaml/model/xml_adapter/builder/ox.rb +1 -0
  13. data/lib/lutaml/model/xml_adapter/oga/document.rb +20 -0
  14. data/lib/lutaml/model/xml_adapter/oga/element.rb +117 -0
  15. data/lib/lutaml/model/xml_adapter/oga_adapter.rb +77 -44
  16. data/lib/lutaml/model/xml_adapter/xml_document.rb +11 -9
  17. data/lib/lutaml/model/xml_mapping.rb +0 -1
  18. data/lib/lutaml/model/xml_mapping_rule.rb +16 -4
  19. data/spec/address_spec.rb +1 -0
  20. data/spec/fixtures/sample_model.rb +7 -0
  21. data/spec/lutaml/model/custom_model_spec.rb +47 -1
  22. data/spec/lutaml/model/included_spec.rb +192 -0
  23. data/spec/lutaml/model/mixed_content_spec.rb +48 -32
  24. data/spec/lutaml/model/multiple_mapping_spec.rb +329 -0
  25. data/spec/lutaml/model/ordered_content_spec.rb +1 -1
  26. data/spec/lutaml/model/render_nil_spec.rb +3 -0
  27. data/spec/lutaml/model/serializable_spec.rb +1 -1
  28. data/spec/lutaml/model/type/boolean_spec.rb +62 -0
  29. data/spec/lutaml/model/xml_adapter/oga_adapter_spec.rb +11 -11
  30. data/spec/lutaml/model/xml_adapter/xml_namespace_spec.rb +1 -1
  31. data/spec/lutaml/model/xml_adapter_spec.rb +2 -2
  32. data/spec/lutaml/model/xml_mapping_spec.rb +24 -9
  33. data/spec/sample_model_spec.rb +114 -0
  34. metadata +8 -2
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module XmlAdapter
6
+ module Builder
7
+ class Oga
8
+ def self.build(options = {}, &block)
9
+ if block_given?
10
+ XmlAdapter::Builder::Oga.new(options, &block)
11
+ else
12
+ XmlAdapter::Builder::Oga.new(options)
13
+ end
14
+ end
15
+
16
+ attr_reader :document, :current_node, :options
17
+
18
+ def initialize(options = {})
19
+ @document = XmlAdapter::Oga::Document.new
20
+ @current_node = @document
21
+ @options = options
22
+ yield(self) if block_given?
23
+ end
24
+
25
+ def create_element(name, attributes = {}, &block)
26
+ if @current_namespace && !name.start_with?("#{@current_namespace}:")
27
+ name = "#{@current_namespace}:#{name}"
28
+ end
29
+
30
+ if block_given?
31
+ element(name, attributes, &block)
32
+ else
33
+ element(name, attributes)
34
+ end
35
+ end
36
+
37
+ def element(name, attributes = {})
38
+ oga_element = ::Oga::XML::Element.new(name: name)
39
+ if block_given?
40
+ element_attributes(oga_element, attributes)
41
+ @current_node.children << oga_element
42
+ # Save previous node to reset the pointer for the rest of the iteration
43
+ previous_node = @current_node
44
+ # Set current node to new element as pointer for the block
45
+ @current_node = oga_element
46
+ yield(self)
47
+ # Reset the pointer for the rest of the iterations
48
+ @current_node = previous_node
49
+ end
50
+ oga_element
51
+ end
52
+
53
+ def add_element(oga_element, child)
54
+ if child.is_a?(String)
55
+ current_element = oga_element.is_a?(XmlAdapter::Oga::Document) ? current_node : oga_element
56
+ add_xml_fragment(current_element, child)
57
+ elsif oga_element.is_a?(XmlAdapter::Oga::Document)
58
+ oga_element.children.last.children << child
59
+ else
60
+ oga_element.children << child
61
+ end
62
+ end
63
+
64
+ def add_attribute(element, name, value)
65
+ attribute = ::Oga::XML::Attribute.new(
66
+ name: name,
67
+ value: value.to_s,
68
+ )
69
+ if element.is_a?(XmlAdapter::Oga::Document)
70
+ element.children.last.attributes << attribute
71
+ else
72
+ element.attributes << attribute
73
+ end
74
+ end
75
+
76
+ def create_and_add_element(
77
+ element_name,
78
+ prefix: (prefix_unset = true
79
+ nil),
80
+ attributes: {},
81
+ &block
82
+ )
83
+ @current_namespace = nil if prefix.nil? && !prefix_unset
84
+ prefixed_name = if prefix
85
+ "#{prefix}:#{element_name}"
86
+ elsif @current_namespace && !element_name.start_with?("#{@current_namespace}:")
87
+ "#{@current_namespace}:#{element_name}"
88
+ else
89
+ element_name
90
+ end
91
+
92
+ if block_given?
93
+ element(prefixed_name, attributes, &block)
94
+ else
95
+ element(prefixed_name, attributes)
96
+ end
97
+ end
98
+
99
+ def <<(text)
100
+ @current_node.text(text)
101
+ end
102
+
103
+ def add_xml_fragment(element, content)
104
+ fragment = "<fragment>#{content}</fragment>"
105
+ parsed_fragment = ::Oga.parse_xml(fragment)
106
+ parsed_children = parsed_fragment.children.first.children
107
+ if element.is_a?(XmlAdapter::Oga::Document)
108
+ element.children.last.children += parsed_children
109
+ else
110
+ element.children += parsed_children
111
+ end
112
+ end
113
+
114
+ def add_text(element, text, cdata: false)
115
+ return add_cdata(element, text) if cdata
116
+
117
+ oga_text = ::Oga::XML::Text.new(text: text.to_s)
118
+ if element.is_a?(XmlAdapter::Oga::Document)
119
+ children = element.children
120
+ children.empty? ? children << oga_text : children.last.children << oga_text
121
+ else
122
+ element.children << oga_text
123
+ end
124
+ end
125
+
126
+ def add_cdata(element, value)
127
+ oga_cdata = ::Oga::XML::CData.new(text: value.to_s)
128
+ if element.is_a?(XmlAdapter::Oga::Document)
129
+ element.children.last.children << oga_cdata
130
+ else
131
+ element.children << oga_cdata
132
+ end
133
+ end
134
+
135
+ def add_namespace_prefix(prefix)
136
+ @current_namespace = prefix
137
+ self
138
+ end
139
+
140
+ def parent
141
+ @document
142
+ end
143
+
144
+ def text(value = nil)
145
+ return @current_node.inner_text if value.nil?
146
+
147
+ str = value.is_a?(Array) ? value.join : value
148
+ @current_node.children << ::Oga::XML::Text.new(text: str)
149
+ end
150
+
151
+ def method_missing(method_name, *args)
152
+ if block_given?
153
+ @current_node.public_send(method_name, *args) do
154
+ yield(self)
155
+ end
156
+ else
157
+ @current_node.public_send(method_name, *args)
158
+ end
159
+ end
160
+
161
+ def respond_to_missing?(method_name, include_private = false)
162
+ @current_node.respond_to?(method_name) || super
163
+ end
164
+
165
+ private
166
+
167
+ def element_attributes(oga_element, attributes)
168
+ oga_element.attributes = attributes.map do |name, value|
169
+ ::Oga::XML::Attribute.new(
170
+ name: name,
171
+ value: value,
172
+ element: oga_element,
173
+ )
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -43,6 +43,7 @@ module Lutaml
43
43
  end
44
44
 
45
45
  def create_and_add_element(element_name, prefix: nil, attributes: {})
46
+ element_name = element_name.first if element_name.is_a?(Array)
46
47
  prefixed_name = if prefix
47
48
  "#{prefix}:#{element_name}"
48
49
  elsif @current_namespace && !element_name.start_with?("#{@current_namespace}:")
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module XmlAdapter
6
+ module Oga
7
+ class Document < ::Oga::XML::Document
8
+ def initialize(options = {})
9
+ super
10
+ end
11
+
12
+ def text(value = nil)
13
+ children << ::Oga::XML::Text.new(text: value)
14
+ self
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module XmlAdapter
6
+ module Oga
7
+ class Element < XmlElement
8
+ def initialize(node, parent: nil)
9
+ name = case node
10
+ when ::Oga::XML::Element
11
+ namespace_name = node.namespace_name
12
+ add_namespaces(node)
13
+ children = parse_children(node)
14
+ attributes = node_attributes(node)
15
+ node.name
16
+ when ::Oga::XML::Text
17
+ "text"
18
+ end
19
+ super(
20
+ name,
21
+ Hash(attributes),
22
+ Array(children),
23
+ node.text,
24
+ parent_document: parent,
25
+ namespace_prefix: namespace_name,
26
+ )
27
+ end
28
+
29
+ def text?
30
+ children.empty? && text&.length&.positive?
31
+ end
32
+
33
+ def text
34
+ @text
35
+ end
36
+
37
+ def to_xml(builder = Builder::Oga.build)
38
+ build_xml(builder).to_xml
39
+ end
40
+
41
+ def build_xml(builder = Builder::Oga.build)
42
+ if name == "text"
43
+ builder.add_text(builder.current_node, @text)
44
+ else
45
+ builder.create_element(name, build_attributes(self)) do |xml|
46
+ children.each { |child| child.build_xml(xml) }
47
+ end
48
+ end
49
+
50
+ builder
51
+ end
52
+
53
+ def inner_xml
54
+ children.map(&:to_xml).join
55
+ end
56
+
57
+ private
58
+
59
+ def node_attributes(node)
60
+ node.attributes.each_with_object({}) do |attr, hash|
61
+ next if attr_is_namespace?(attr)
62
+
63
+ name = if attr.namespace
64
+ "#{attr.namespace.name}:#{attr.name}"
65
+ else
66
+ attr.name
67
+ end
68
+ hash[name] = XmlAttribute.new(
69
+ name,
70
+ attr.value,
71
+ namespace: attr.namespace&.uri,
72
+ namespace_prefix: attr.namespace&.name,
73
+ )
74
+ end
75
+ end
76
+
77
+ def parse_children(node)
78
+ node.children.map { |child| self.class.new(child, parent: self) }
79
+ end
80
+
81
+ def add_namespaces(node)
82
+ node.namespaces.each_value do |namespace|
83
+ add_namespace(XmlNamespace.new(namespace.uri, namespace.name))
84
+ end
85
+ end
86
+
87
+ def attr_is_namespace?(attr)
88
+ attribute_is_namespace?(attr.name) ||
89
+ namespaces[attr.name]&.uri == attr.value
90
+ end
91
+
92
+ def build_attributes(node, _options = {})
93
+ attrs = node.attributes.transform_values(&:value)
94
+
95
+ attrs.merge(build_namespace_attributes(node))
96
+ end
97
+
98
+ def build_namespace_attributes(node)
99
+ namespace_attrs = {}
100
+
101
+ node.own_namespaces.each_value do |namespace|
102
+ namespace_attrs[namespace.attr_name] = namespace.uri
103
+ end
104
+
105
+ node.children.each do |child|
106
+ namespace_attrs = namespace_attrs.merge(
107
+ build_namespace_attributes(child),
108
+ )
109
+ end
110
+
111
+ namespace_attrs
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -1,69 +1,102 @@
1
1
  require "oga"
2
2
  require_relative "xml_document"
3
+ require_relative "oga/document"
4
+ require_relative "oga/element"
5
+ require_relative "builder/oga"
3
6
 
4
7
  module Lutaml
5
8
  module Model
6
9
  module XmlAdapter
7
10
  class OgaAdapter < XmlDocument
8
- def self.parse(xml, _options = {})
9
- parsed = Oga.parse_xml(xml)
10
- root = OgaElement.new(parsed)
11
- new(root)
12
- end
13
-
14
- def to_h
15
- { @root.name => parse_element(@root) }
11
+ def self.parse(xml, options = {})
12
+ encoding = options[:encoding] || xml.encoding.to_s
13
+ xml = xml.encode("UTF-16").encode("UTF-8") if encoding && encoding != "UTF-8"
14
+ parsed = ::Oga.parse_xml(xml)
15
+ @root = Oga::Element.new(parsed.children.first)
16
+ new(@root, encoding)
16
17
  end
17
18
 
18
19
  def to_xml(options = {})
19
- builder = Oga::XML::Builder.new
20
- build_element(builder, @root, options)
21
- xml_data = builder.to_xml
20
+ builder_options = {}
21
+
22
+ builder_options[:encoding] = if options.key?(:encoding)
23
+ options[:encoding] || "UTF-8"
24
+ elsif options.key?(:parse_encoding)
25
+ options[:parse_encoding]
26
+ else
27
+ "UTF-8"
28
+ end
29
+ builder = Builder::Oga.build(options) do |xml|
30
+ if @root.is_a?(Oga::Element)
31
+ @root.build_xml(xml)
32
+ else
33
+ build_element(xml, @root, options)
34
+ end
35
+ end
36
+ xml_data = builder.to_xml.encode!(builder_options[:encoding])
22
37
  options[:declaration] ? declaration(options) + xml_data : xml_data
38
+ rescue Encoding::ConverterNotFoundError
39
+ invalid_encoding!(builder_options[:encoding])
23
40
  end
24
41
 
25
42
  private
26
43
 
27
- def build_element(builder, element, options = {})
28
- attributes = build_attributes(element.attributes)
29
- builder.element(element.name, attributes) do
30
- element.children.each do |child|
31
- build_element(builder, child, options)
32
- end
33
- builder.text(element.text) if element.text
34
- end
35
- end
44
+ def build_ordered_element(builder, element, options = {})
45
+ mapper_class = options[:mapper_class] || element.class
46
+ xml_mapping = mapper_class.mappings_for(:xml)
47
+ return xml unless xml_mapping
36
48
 
37
- def build_attributes(attributes)
38
- attributes.transform_values(&:value)
39
- end
49
+ attributes = build_attributes(element, xml_mapping).compact
40
50
 
41
- def parse_element(element)
42
- result = { "_text" => element.text }
43
- element.children.each do |child|
44
- next if child.is_a?(Oga::XML::Text)
51
+ tag_name = options[:tag_name] || xml_mapping.root_element
52
+ builder.create_and_add_element(tag_name,
53
+ attributes: attributes) do |el|
54
+ index_hash = {}
55
+ content = []
45
56
 
46
- result[child.name] ||= []
47
- result[child.name] << parse_element(child)
48
- end
49
- result
50
- end
51
- end
57
+ element.element_order.each do |name|
58
+ index_hash[name] ||= -1
59
+ curr_index = index_hash[name] += 1
60
+
61
+ element_rule = xml_mapping.find_by_name(name)
62
+ next if element_rule.nil?
63
+
64
+ attribute_def = attribute_definition_for(element, element_rule,
65
+ mapper_class: mapper_class)
66
+ value = attribute_value_for(element, element_rule)
67
+
68
+ next if element_rule == xml_mapping.content_mapping && element_rule.cdata && name == "text"
69
+
70
+ if element_rule == xml_mapping.content_mapping
71
+ text = xml_mapping.content_mapping.serialize(element)
72
+ text = text[curr_index] if text.is_a?(Array)
73
+
74
+ next el.add_text(el, text, cdata: element_rule.cdata) if element.mixed?
52
75
 
53
- class OgaElement < XmlElement
54
- def initialize(node)
55
- attributes = node.attributes.each_with_object({}) do |attr, hash|
56
- hash[attr.name] = XmlAttribute.new(attr.name, attr.value)
76
+ content << text
77
+ elsif !value.nil? || element_rule.render_nil?
78
+ value = value[curr_index] if attribute_def.collection?
79
+
80
+ add_to_xml(
81
+ el,
82
+ element,
83
+ nil,
84
+ value,
85
+ options.merge(
86
+ attribute: attribute_def,
87
+ rule: element_rule,
88
+ mapper_class: mapper_class,
89
+ ),
90
+ )
91
+ end
92
+ end
93
+
94
+ el.add_text(el, content.join)
57
95
  end
58
- super(node.name, attributes, parse_children(node), node.text)
59
96
  end
60
97
 
61
- private
62
-
63
- def parse_children(node)
64
- node.children.select do |child|
65
- child.is_a?(Oga::XML::Element)
66
- end.map { |child| OgaElement.new(child) }
98
+ def invalid_encoding!(encoding)
99
+ raise Error, "unknown encoding name - #{encoding}"
67
100
  end
68
101
  end
69
102
  end
@@ -233,20 +233,16 @@ module Lutaml
233
233
  end
234
234
 
235
235
  process_content_mapping(element, xml_mapping.content_mapping,
236
- prefixed_xml)
236
+ prefixed_xml, mapper_class)
237
237
  end
238
238
  end
239
239
 
240
- def process_content_mapping(element, content_rule, xml)
240
+ def process_content_mapping(element, content_rule, xml, mapper_class)
241
241
  return unless content_rule
242
242
 
243
243
  if content_rule.custom_methods[:to]
244
- @root.send(
245
- content_rule.custom_methods[:to],
246
- element,
247
- xml.parent,
248
- xml,
249
- )
244
+ mapper_class.new.send(content_rule.custom_methods[:to], element,
245
+ xml.parent, xml)
250
246
  else
251
247
  text = content_rule.serialize(element)
252
248
  text = text.join if text.is_a?(Array)
@@ -340,7 +336,9 @@ module Lutaml
340
336
  next if options[:except]&.include?(mapping_rule.to)
341
337
  next if mapping_rule.custom_methods[:to]
342
338
 
343
- if mapping_rule.namespace && mapping_rule.prefix && mapping_rule.name != "lang"
339
+ mapping_rule_name = mapping_rule.multiple_mappings? ? mapping_rule.name.first : mapping_rule.name
340
+
341
+ if mapping_rule.namespace && mapping_rule.prefix && mapping_rule_name != "lang"
344
342
  hash["xmlns:#{mapping_rule.prefix}"] = mapping_rule.namespace
345
343
  end
346
344
 
@@ -378,6 +376,10 @@ module Lutaml
378
376
  key = ["xmlns", xml_mapping.namespace_prefix].compact.join(":")
379
377
  { key => xml_mapping.namespace_uri }
380
378
  end
379
+
380
+ def self.type
381
+ Utils.snake_case(self).split("/").last.split("_").first
382
+ end
381
383
  end
382
384
  end
383
385
  end
@@ -92,7 +92,6 @@ module Lutaml
92
92
  nil)
93
93
  )
94
94
  validate!(name, to, with, type: TYPES[:attribute])
95
-
96
95
  rule = XmlMappingRule.new(
97
96
  name,
98
97
  to: to,
@@ -19,7 +19,8 @@ module Lutaml
19
19
  namespace_set: false,
20
20
  prefix_set: false,
21
21
  attribute: false,
22
- default_namespace: nil
22
+ default_namespace: nil,
23
+ id: nil
23
24
  )
24
25
  super(
25
26
  name,
@@ -29,6 +30,7 @@ module Lutaml
29
30
  with: with,
30
31
  delegate: delegate,
31
32
  attribute: attribute,
33
+ id: id
32
34
  )
33
35
 
34
36
  @namespace = if namespace.to_s == "inherit"
@@ -45,6 +47,7 @@ module Lutaml
45
47
 
46
48
  @namespace_set = namespace_set
47
49
  @prefix_set = prefix_set
50
+ @id = id
48
51
  end
49
52
 
50
53
  def namespace_set?
@@ -72,14 +75,23 @@ module Lutaml
72
75
  end
73
76
 
74
77
  def prefixed_name
78
+ rule_name = multiple_mappings? ? name.first : name
75
79
  if prefix
76
- "#{prefix}:#{name}"
80
+ "#{prefix}:#{rule_name}"
81
+ else
82
+ rule_name
83
+ end
84
+ end
85
+
86
+ def namespaced_names(parent_namespace = nil)
87
+ if multiple_mappings?
88
+ name.map { |rule_name| namespaced_name(parent_namespace, rule_name) }
77
89
  else
78
- name
90
+ [namespaced_name(parent_namespace)]
79
91
  end
80
92
  end
81
93
 
82
- def namespaced_name(parent_namespace = nil)
94
+ def namespaced_name(parent_namespace = nil, name = self.name)
83
95
  if name == "lang"
84
96
  "#{prefix}:#{name}"
85
97
  elsif namespace_set? || @attribute
data/spec/address_spec.rb CHANGED
@@ -100,6 +100,7 @@ RSpec.describe Address do
100
100
  expect(address_from_json.post_code).to eq("01001")
101
101
  expect(address_from_json.person.first.first_name).to eq("Tom")
102
102
  expect(address_from_json.person.last.first_name).to eq("Jack")
103
+ expect(address_from_json.person.last.active).to be(false)
103
104
  end
104
105
 
105
106
  it "serializes to XML with a collection of persons" do
@@ -36,5 +36,12 @@ class SampleModel < Lutaml::Model::Serializable
36
36
  yaml do
37
37
  map "name", to: :name
38
38
  map "age", to: :age
39
+ map "balance", to: :balance
40
+ map "tags", to: :tags
41
+ map "preferences", to: :preferences
42
+ map "status", to: :status
43
+ map "large_number", to: :large_number
44
+ map "email", to: :email
45
+ map "role", to: :role
39
46
  end
40
47
  end
@@ -78,7 +78,7 @@ module CustomModelSpecs
78
78
  end
79
79
 
80
80
  class Id
81
- attr_accessor :id
81
+ attr_accessor :id, :prefix
82
82
  end
83
83
 
84
84
  class Docid < Lutaml::Model::Serializable
@@ -147,6 +147,28 @@ module CustomModelSpecs
147
147
  end
148
148
  end
149
149
  end
150
+
151
+ class CustomId < Lutaml::Model::Serializable
152
+ model Id
153
+ attribute :id, :string
154
+ attribute :prefix, :string
155
+
156
+ xml do
157
+ root "custom-id"
158
+ map_attribute "prefix", to: :prefix
159
+ map_content with: { to: :id_to_xml, from: :id_from_xml }
160
+ end
161
+
162
+ def id_to_xml(model, _parent, doc)
163
+ content = "ABC-#{model.id}"
164
+ doc.add_text(doc, content)
165
+ end
166
+
167
+ def id_from_xml(model, value)
168
+ id = value.split("-").last
169
+ model.id = id.to_i
170
+ end
171
+ end
150
172
  end
151
173
 
152
174
  RSpec.describe "CustomModel" do
@@ -407,4 +429,28 @@ RSpec.describe "CustomModel" do
407
429
  end
408
430
  end
409
431
  end
432
+
433
+ context "with custom methods" do
434
+ describe ".xml serialization" do
435
+ it "handles custom content mapping methods" do
436
+ xml = '<custom-id prefix="ABC">ABC-123</custom-id>'
437
+
438
+ instance = CustomModelSpecs::Id.new
439
+ instance.id = 123
440
+ instance.prefix = "ABC"
441
+ result_xml = CustomModelSpecs::CustomId.to_xml(instance)
442
+ expect(result_xml).to eq(xml)
443
+ end
444
+ end
445
+
446
+ describe ".xml deserialization" do
447
+ it "handles custom content mapping methods" do
448
+ xml = '<custom-id prefix="ABC">ABC-123</custom-id>'
449
+ instance = CustomModelSpecs::CustomId.from_xml(xml)
450
+
451
+ expect(instance.id).to eq(123)
452
+ expect(instance.prefix).to eq("ABC")
453
+ end
454
+ end
455
+ end
410
456
  end