lutaml-model 0.3.30 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +34 -18
  3. data/README.adoc +172 -8
  4. data/lib/lutaml/model/attribute.rb +6 -2
  5. data/lib/lutaml/model/key_value_mapping.rb +0 -1
  6. data/lib/lutaml/model/key_value_mapping_rule.rb +3 -1
  7. data/lib/lutaml/model/mapping_rule.rb +14 -2
  8. data/lib/lutaml/model/serialize.rb +174 -61
  9. data/lib/lutaml/model/type/decimal.rb +5 -0
  10. data/lib/lutaml/model/type/time.rb +4 -4
  11. data/lib/lutaml/model/utils.rb +1 -1
  12. data/lib/lutaml/model/validation.rb +6 -2
  13. data/lib/lutaml/model/version.rb +1 -1
  14. data/lib/lutaml/model/xml_adapter/builder/nokogiri.rb +1 -0
  15. data/lib/lutaml/model/xml_adapter/builder/oga.rb +180 -0
  16. data/lib/lutaml/model/xml_adapter/builder/ox.rb +1 -0
  17. data/lib/lutaml/model/xml_adapter/oga/document.rb +20 -0
  18. data/lib/lutaml/model/xml_adapter/oga/element.rb +117 -0
  19. data/lib/lutaml/model/xml_adapter/oga_adapter.rb +77 -44
  20. data/lib/lutaml/model/xml_adapter/xml_document.rb +11 -9
  21. data/lib/lutaml/model/xml_mapping.rb +0 -1
  22. data/lib/lutaml/model/xml_mapping_rule.rb +16 -4
  23. data/spec/address_spec.rb +1 -0
  24. data/spec/fixtures/sample_model.rb +7 -0
  25. data/spec/lutaml/model/custom_model_spec.rb +47 -1
  26. data/spec/lutaml/model/custom_serialization_spec.rb +16 -0
  27. data/spec/lutaml/model/enum_spec.rb +131 -0
  28. data/spec/lutaml/model/included_spec.rb +192 -0
  29. data/spec/lutaml/model/mixed_content_spec.rb +48 -32
  30. data/spec/lutaml/model/multiple_mapping_spec.rb +329 -0
  31. data/spec/lutaml/model/ordered_content_spec.rb +1 -1
  32. data/spec/lutaml/model/render_nil_spec.rb +3 -2
  33. data/spec/lutaml/model/serializable_spec.rb +3 -3
  34. data/spec/lutaml/model/type/boolean_spec.rb +62 -0
  35. data/spec/lutaml/model/xml_adapter/oga_adapter_spec.rb +11 -11
  36. data/spec/lutaml/model/xml_adapter/xml_namespace_spec.rb +1 -1
  37. data/spec/lutaml/model/xml_adapter_spec.rb +2 -2
  38. data/spec/lutaml/model/xml_mapping_spec.rb +24 -9
  39. data/spec/sample_model_spec.rb +114 -0
  40. metadata +9 -2
@@ -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
@@ -138,6 +138,22 @@ RSpec.describe CustomSerialization do
138
138
  end
139
139
  end
140
140
 
141
+ context "with partial JSON input" do
142
+ it "deserializes from JSON with missing attributes" do
143
+ json = {
144
+ name: "JSON Masterpiece: Vase",
145
+ color: "BLUE",
146
+ }.to_json
147
+
148
+ ceramic = described_class.from_json(json)
149
+
150
+ expect(ceramic.full_name).to eq("Vase")
151
+ expect(ceramic.color).to eq("blue")
152
+ expect(ceramic.size).to be_nil
153
+ expect(ceramic.description).to be_nil
154
+ end
155
+ end
156
+
141
157
  context "with XML serialization" do
142
158
  it "serializes to XML with custom methods" do
143
159
  expected_xml = <<~XML
@@ -0,0 +1,131 @@
1
+ require "spec_helper"
2
+ require "lutaml/model"
3
+
4
+ module EnumSpec
5
+ class WithEnum < Lutaml::Model::Serializable
6
+ attribute :without_enum, :string
7
+ attribute :single_value, :string, values: %w[user admin super_admin]
8
+ attribute :multi_value, :string, values: %w[singular dual plural], collection: true
9
+ end
10
+ end
11
+
12
+ RSpec.describe "Enum" do
13
+ let(:single_value_attr) do
14
+ EnumSpec::WithEnum.attributes[:single_value]
15
+ end
16
+
17
+ let(:multi_value_attr) do
18
+ EnumSpec::WithEnum.attributes[:multi_value]
19
+ end
20
+
21
+ let(:without_enum_attr) do
22
+ EnumSpec::WithEnum.attributes[:without_enum]
23
+ end
24
+
25
+ let(:object) do
26
+ EnumSpec::WithEnum.new
27
+ end
28
+
29
+ context "when values are provided for an attribute" do
30
+ it "is marked as enum for single_value" do
31
+ expect(single_value_attr.enum?).to be(true)
32
+ end
33
+
34
+ it "is marked as enum for multi_value" do
35
+ expect(multi_value_attr.enum?).to be(true)
36
+ end
37
+
38
+ context "with enum convinience methods" do
39
+ describe "#single_value" do
40
+ it "returns single value" do
41
+ expect { object.single_value = "user" }
42
+ .to change(object, :single_value)
43
+ .from(nil)
44
+ .to("user")
45
+ end
46
+ end
47
+
48
+ describe "#multi_value" do
49
+ it "returns single value in array" do
50
+ expect { object.multi_value = "dual" }
51
+ .to change(object, :multi_value)
52
+ .from([])
53
+ .to(["dual"])
54
+ end
55
+
56
+ it "returns multiple value in array" do
57
+ expect { object.multi_value = %w[dual plural] }
58
+ .to change(object, :multi_value)
59
+ .from([])
60
+ .to(%w[dual plural])
61
+ end
62
+ end
63
+
64
+ EnumSpec::WithEnum.enums.each_value do |enum_attr|
65
+ enum_attr.enum_values.each do |value|
66
+ describe "##{value}=" do
67
+ it "sets the #{value} if true" do
68
+ expect { object.public_send(:"#{value}=", true) }
69
+ .to change { object.public_send(:"#{value}?") }
70
+ .from(false)
71
+ .to(true)
72
+ end
73
+
74
+ it "unsets the #{value} if false" do
75
+ object.public_send(:"#{value}=", true)
76
+
77
+ expect { object.public_send(:"#{value}=", false) }
78
+ .to change(object, "#{value}?")
79
+ .from(true)
80
+ .to(false)
81
+ end
82
+ end
83
+
84
+ describe "##{value}!" do
85
+ it "method #{value}? should be present" do
86
+ expect(object.respond_to?(:"#{value}!")).to be(true)
87
+ end
88
+
89
+ it "sets #{value} to true for enum" do
90
+ expect { object.public_send(:"#{value}!") }
91
+ .to change(object, "#{value}?")
92
+ .from(false)
93
+ .to(true)
94
+ end
95
+ end
96
+
97
+ describe "##{value}?" do
98
+ it "method #{value}? should be present" do
99
+ expect(object.respond_to?(:"#{value}?")).to be(true)
100
+ end
101
+
102
+ it "is false if role is not #{value}" do
103
+ expect(object.public_send(:"#{value}?")).to be(false)
104
+ end
105
+
106
+ it "is true if role is set to #{value}" do
107
+ expect { object.public_send(:"#{value}=", value) }
108
+ .to change(object, "#{value}?")
109
+ .from(false)
110
+ .to(true)
111
+ end
112
+ end
113
+
114
+ it "adds a method named #{value}=" do
115
+ expect(object.respond_to?(:"#{value}?")).to be(true)
116
+ end
117
+
118
+ it "adds a method named #{value}!" do
119
+ expect(object.respond_to?(:"#{value}!")).to be(true)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ context "when values are not provided for an attribute" do
127
+ it "is not marked as enum" do
128
+ expect(without_enum_attr.enum?).to be(false)
129
+ end
130
+ end
131
+ end