lutaml-model 0.3.25 → 0.3.26

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 923c122ff4459f02c1949dc88ee8df94b93db6ce23c00c9da2159f6b376da3a0
4
- data.tar.gz: aa537363d67a5383cdbd280cfa9ddee0a3c5de0cb151b79b2e195a030bc0061d
3
+ metadata.gz: 1dc709174fab4f4df2214f08b536e0b1aded39f0c9f48a18eb5a4a53da3fc4b6
4
+ data.tar.gz: 9eea83160b8210f84d4b2f9baf2ab577ca91d2a737eaf98b175d52385e6fb5d3
5
5
  SHA512:
6
- metadata.gz: a0da4f4a87c8fe4a3951d302df5797594675f83616ea52b4582f417683d4f55076c57ad3d6f3018119c0a6021497bf6832b150ebf695d1fa8d4304da2040b641
7
- data.tar.gz: 0d4d6d783728b53d453c23e2cfcfcd08d9eac83d94b4652d3bdee95c19d9d9bf68e233dea6d29d9478d4214271dd800be93b8f8e11a59cf9f9cd46d744dd2002
6
+ metadata.gz: 1e75326adeb9b8804f1bcc99104707edc3656e870d16c8af22e07e32ead9f4d78cd87eb372ebb834f2a5d942d58819e0fd3800e23a8099cf951360765223cd25
7
+ data.tar.gz: ecc145fb2d9250cf855243c7b9cf468919f2530eed03039bd6c7a521fa534327a1a67c5348237b3d5959e20fa0fc2b2cadc87e57139f451610cd2d7d695f251b
data/.rubocop_todo.yml CHANGED
@@ -73,7 +73,7 @@ Metrics/MethodLength:
73
73
  # Offense count: 7
74
74
  # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
75
75
  Metrics/ParameterLists:
76
- Max: 13
76
+ Max: 14
77
77
 
78
78
  # Offense count: 23
79
79
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
data/README.adoc CHANGED
@@ -543,8 +543,15 @@ end
543
543
  ====
544
544
 
545
545
 
546
+ === Attribute value validation
547
+
548
+ ==== General
549
+
550
+ There are several mechanisms to validate attribute values in Lutaml::Model.
551
+
552
+
546
553
  [[attribute-enumeration]]
547
- === Attribute as an enumeration
554
+ ==== Values of an enumeration
548
555
 
549
556
  An attribute can be defined as an enumeration by using the `values` directive.
550
557
 
@@ -641,6 +648,44 @@ acceptance of the newly updated component.
641
648
  ====
642
649
 
643
650
 
651
+ ==== String values restricted to patterns
652
+
653
+ An attribute that accepts a string value accepts value validation using regular
654
+ expressions.
655
+
656
+ Syntax:
657
+
658
+ [source,ruby]
659
+ ----
660
+ attribute :name_of_attribute, :string, pattern: /regex/
661
+ ----
662
+
663
+ .Using the `pattern` option to restrict the value of an attribute
664
+ [example]
665
+ ====
666
+ In this example, the `color` attribute takes hex color values such as `#ccddee`.
667
+
668
+ A regular expression can be used to validate values assigned to the attribute.
669
+ In this case, it is `/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/`.
670
+
671
+ [source,ruby]
672
+ ----
673
+ class Glaze < Lutaml::Model::Serializable
674
+ attribute :color, :string, pattern: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/
675
+ end
676
+ ----
677
+
678
+ [source,ruby]
679
+ ----
680
+ > Glaze.new(color: '#ff0000').color
681
+ > # "#ff0000"
682
+ > Glaze.new(color: '#ff000').color
683
+ > # Lutaml::Model::InvalidValueError: Invalid value for attribute 'color'
684
+ ----
685
+ ====
686
+
687
+
688
+
644
689
  === Attribute value default and rendering defaults
645
690
 
646
691
  Specify default values for attributes using the `default` option.
@@ -771,7 +816,8 @@ class Person < Lutaml::Model::Serializable
771
816
  end
772
817
  ----
773
818
 
774
- For the following xml
819
+ For the following XML snippet:
820
+
775
821
  [source,xml]
776
822
  ----
777
823
  <Person>
@@ -1144,6 +1190,80 @@ end
1144
1190
  ====
1145
1191
 
1146
1192
 
1193
+ ==== CDATA nodes
1194
+
1195
+ CDATA is an XML feature that allows the inclusion of text that may contain
1196
+ characters that are unescaped in XML.
1197
+
1198
+ While CDATA is not preferred in XML, it is sometimes necessary to handle CDATA
1199
+ nodes for both input and output.
1200
+
1201
+ NOTE: The W3C XML Recommendation explicitly encourages escaping characters over
1202
+ usage of CDATA.
1203
+
1204
+ Lutaml::Model supports the handling of CDATA nodes in XML in the following
1205
+ behavior:
1206
+
1207
+ . When an attribute contains a CDATA node with no text:
1208
+ ** On reading: The node (CDATA or text) is read as its value.
1209
+ ** On writing: The value is written as its native type.
1210
+
1211
+ . When an XML mapping sets `cdata: true` on `map_element` or `map_content`:
1212
+ ** On reading: The node (CDATA or text) is read as its value.
1213
+ ** On writing: The value is written as a CDATA node.
1214
+
1215
+ . When an XML mapping sets `cdata: false` on `map_element` or `map_content`:
1216
+ ** On reading: The node (CDATA or text) is read as its value.
1217
+ ** On writing: The value is written as a text node (string).
1218
+
1219
+
1220
+ Syntax:
1221
+
1222
+ [source,ruby]
1223
+ ----
1224
+ xml do
1225
+ map_content to: :name_of_attribute, cdata: (true | false)
1226
+ map_element :name, to: :name, cdata: (true | false)
1227
+ end
1228
+ ----
1229
+
1230
+ .Using `cdata` to map CDATA content
1231
+ [example]
1232
+ ====
1233
+ The following class will parse the XML snippet below:
1234
+
1235
+ [source,ruby]
1236
+ ----
1237
+ class Example < Lutaml::Model::Serializable
1238
+ attribute :name, :string
1239
+ attribute :description, :string
1240
+ attribute :title, :string
1241
+ attribute :note, :string
1242
+
1243
+ xml do
1244
+ root 'example'
1245
+ map_element :name, to: :name, cdata: true
1246
+ map_content to: :description, cdata: true
1247
+ map_element :title, to: :title, cdata: false
1248
+ map_element :note, to: :note, cdata: false
1249
+ end
1250
+ end
1251
+ ----
1252
+
1253
+ [source,xml]
1254
+ ----
1255
+ <example><name><![CDATA[John]]></name><![CDATA[here is the description]]><title><![CDATA[Lutaml]]></title><note>Careful</note></example>
1256
+ ----
1257
+
1258
+ [source,ruby]
1259
+ ----
1260
+ > Example.from_xml(xml)
1261
+ > #<Example:0x0000000104ac7240 @name="John" @description="here is the description" @title="Lutaml" @note="Careful">
1262
+ > Example.new(name: "John", description: "here is the description", title: "Lutaml", note: "Careful").to_xml
1263
+ > #<example><name><![CDATA[John]]></name><![CDATA[here is the description]]><title>Lutaml</title><note>Careful</note></example>
1264
+ ----
1265
+ ====
1266
+
1147
1267
 
1148
1268
  ==== Example for mapping
1149
1269
 
@@ -9,6 +9,7 @@ module Lutaml
9
9
  delegate
10
10
  collection
11
11
  values
12
+ pattern
12
13
  ].freeze
13
14
 
14
15
  def initialize(name, type, options = {})
@@ -87,23 +88,12 @@ module Lutaml
87
88
  cast_value(value)
88
89
  end
89
90
 
90
- def enum_values
91
- @options.key?(:values) ? @options[:values] : []
91
+ def pattern
92
+ options[:pattern]
92
93
  end
93
94
 
94
- # Check if the value to be assigned is valid for the attribute
95
- #
96
- # Currently there are 2 validations
97
- # 1. Value should be from the values list if they are defined
98
- # e.g values: ["foo", "bar"] is set then any other value for this
99
- # attribute will raise `Lutaml::Model::InvalidValueError`
100
- #
101
- # 2. Value count should be between the collection range if defined
102
- # e.g if collection: 0..5 is set then the value greater then 5
103
- # will raise `Lutaml::Model::CollectionCountOutOfRangeError`
104
- def validate_value!(value)
105
- valid_value!(value)
106
- valid_collection!(value)
95
+ def enum_values
96
+ @options.key?(:values) ? @options[:values] : []
107
97
  end
108
98
 
109
99
  def valid_value!(value)
@@ -123,14 +113,32 @@ module Lutaml
123
113
  options[:values].include?(value)
124
114
  end
125
115
 
126
- def validate_value!(value)
127
- # return true if none of the validations are present
128
- return true if enum_values.empty? && singular?
116
+ def valid_pattern!(value)
117
+ return true unless type == Lutaml::Model::Type::String
118
+ return true unless pattern
119
+
120
+ unless pattern.match?(value)
121
+ raise Lutaml::Model::PatternNotMatchedError.new(name, pattern, value)
122
+ end
129
123
 
124
+ true
125
+ end
126
+
127
+ # Check if the value to be assigned is valid for the attribute
128
+ #
129
+ # Currently there are 2 validations
130
+ # 1. Value should be from the values list if they are defined
131
+ # e.g values: ["foo", "bar"] is set then any other value for this
132
+ # attribute will raise `Lutaml::Model::InvalidValueError`
133
+ #
134
+ # 2. Value count should be between the collection range if defined
135
+ # e.g if collection: 0..5 is set then the value greater then 5
136
+ # will raise `Lutaml::Model::CollectionCountOutOfRangeError`
137
+ def validate_value!(value)
130
138
  # Use the default value if the value is nil
131
139
  value = default if value.nil?
132
140
 
133
- valid_value!(value) && valid_collection!(value)
141
+ valid_value!(value) && valid_collection!(value) && valid_pattern!(value)
134
142
  end
135
143
 
136
144
  def validate_collection_range
@@ -229,6 +237,13 @@ module Lutaml
229
237
  raise StandardError,
230
238
  "Invalid options given for `#{name}` #{invalid_opts}"
231
239
  end
240
+
241
+ if options.key?(:pattern) && type != Lutaml::Model::Type::String
242
+ raise StandardError,
243
+ "Invalid option `pattern` given for `#{name}`, `pattern` is only allowed for :string type"
244
+ end
245
+
246
+ true
232
247
  end
233
248
 
234
249
  def validate_type!(type)
@@ -0,0 +1,17 @@
1
+ module Lutaml
2
+ module Model
3
+ class PatternNotMatchedError < Error
4
+ def initialize(attr_name, pattern, value)
5
+ @attr_name = attr_name
6
+ @pattern = pattern
7
+ @value = value
8
+
9
+ super()
10
+ end
11
+
12
+ def to_s
13
+ "#{@attr_name}: \"#{@value}\" does not match #{@pattern.inspect}"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -7,6 +7,7 @@ end
7
7
 
8
8
  require_relative "error/invalid_value_error"
9
9
  require_relative "error/incorrect_mapping_argument_error"
10
+ require_relative "error/pattern_not_matched_error"
10
11
  require_relative "error/unknown_adapter_type_error"
11
12
  require_relative "error/collection_count_out_of_range_error"
12
13
  require_relative "error/validation_error"
@@ -28,6 +28,14 @@ module Lutaml
28
28
  @item_order = order
29
29
  end
30
30
 
31
+ def text
32
+ self["#cdata-section"] || self["text"]
33
+ end
34
+
35
+ def text?
36
+ key?("#cdata-section") || key?("text")
37
+ end
38
+
31
39
  def ordered?
32
40
  @ordered
33
41
  end
@@ -343,7 +343,7 @@ module Lutaml
343
343
  value = if rule.raw_mapping?
344
344
  doc.node.inner_xml
345
345
  elsif rule.content_mapping?
346
- doc["text"]
346
+ doc[rule.content_key]
347
347
  elsif doc.key_exist?(rule.namespaced_name(options[:default_namespace]))
348
348
  doc.fetch(rule.namespaced_name(options[:default_namespace]))
349
349
  else
@@ -399,12 +399,12 @@ module Lutaml
399
399
 
400
400
  value = if value.is_a?(Array)
401
401
  value.map do |v|
402
- text_hash?(attr, v) ? v["text"] : v
402
+ text_hash?(attr, v) ? v.text : v
403
403
  end
404
404
  elsif attr&.raw? && value
405
405
  value.node.children.map(&:to_xml).join
406
406
  elsif text_hash?(attr, value)
407
- value["text"]
407
+ value.text
408
408
  else
409
409
  value
410
410
  end
@@ -428,7 +428,7 @@ module Lutaml
428
428
 
429
429
  def text_hash?(attr, value)
430
430
  return false unless value.is_a?(Hash)
431
- return value.keys == ["text"] unless attr
431
+ return value.one? && value.text? unless attr
432
432
 
433
433
  !(attr.type <= Serialize) && attr.type != Lutaml::Model::Type::Hash
434
434
  end
@@ -8,7 +8,8 @@ module Lutaml
8
8
  begin
9
9
  attr.validate_value!(value)
10
10
  rescue Lutaml::Model::InvalidValueError,
11
- Lutaml::Model::CollectionCountOutOfRangeError => e
11
+ Lutaml::Model::CollectionCountOutOfRangeError,
12
+ PatternNotMatchedError => e
12
13
  errors << e
13
14
  end
14
15
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.3.25"
5
+ VERSION = "0.3.26"
6
6
  end
7
7
  end
@@ -51,7 +51,9 @@ module Lutaml
51
51
  end
52
52
  end
53
53
 
54
- def add_text(element, text)
54
+ def add_text(element, text, cdata: false)
55
+ return add_cdata(element, text) if cdata
56
+
55
57
  if element.is_a?(self.class)
56
58
  element = element.xml.parent
57
59
  end
@@ -60,6 +62,10 @@ module Lutaml
60
62
  element.add_child(text_node)
61
63
  end
62
64
 
65
+ def add_cdata(element, value)
66
+ element.cdata(value)
67
+ end
68
+
63
69
  def add_namespace_prefix(prefix)
64
70
  xml[prefix] if prefix
65
71
 
@@ -66,10 +66,16 @@ module Lutaml
66
66
  xml.text(text)
67
67
  end
68
68
 
69
- def add_text(element, text)
69
+ def add_text(element, text, cdata: false)
70
+ return element.cdata(text) if cdata
71
+
70
72
  element << text
71
73
  end
72
74
 
75
+ def add_cdata(element, value)
76
+ element.cdata(value)
77
+ end
78
+
73
79
  # Add XML namespace to document
74
80
  #
75
81
  # Ox doesn't support XML namespaces so we only save the
@@ -74,10 +74,12 @@ module Lutaml
74
74
  value = attribute_value_for(element, element_rule)
75
75
 
76
76
  if element_rule == xml_mapping.content_mapping
77
+ next if element_rule.cdata && name == "text"
78
+
77
79
  text = xml_mapping.content_mapping.serialize(element)
78
80
  text = text[curr_index] if text.is_a?(Array)
79
81
 
80
- next prefixed_xml.text(text) if element.mixed?
82
+ next prefixed_xml.add_text(xml, text, cdata: element_rule.cdata) if element.mixed?
81
83
 
82
84
  content << text
83
85
  elsif !value.nil? || element_rule.render_nil?
@@ -56,15 +56,15 @@ module Lutaml
56
56
  mapper_class: mapper_class)
57
57
  value = attribute_value_for(element, element_rule)
58
58
 
59
+ next if element_rule == xml_mapping.content_mapping && element_rule.cdata && name == "text"
60
+
59
61
  if element_rule == xml_mapping.content_mapping
60
62
  text = element.send(xml_mapping.content_mapping.to)
61
63
  text = text[curr_index] if text.is_a?(Array)
62
64
 
63
- if element.mixed?
64
- el.add_text(el, text)
65
- else
66
- content << text
67
- end
65
+ next el.add_text(el, text, cdata: element_rule.cdata) if element.mixed?
66
+
67
+ content << text
68
68
  elsif !value.nil? || element_rule.render_nil?
69
69
  value = value[curr_index] if attribute_def.collection?
70
70
 
@@ -88,10 +88,13 @@ module Lutaml
88
88
 
89
89
  class OxElement < XmlElement
90
90
  def initialize(node, root_node: nil)
91
- if node.is_a?(String)
91
+ case node
92
+ when String
92
93
  super("text", {}, [], node, parent_document: root_node)
93
- elsif node.is_a?(Ox::Comment)
94
+ when Ox::Comment
94
95
  super("comment", {}, [], node.value, parent_document: root_node)
96
+ when Ox::CData
97
+ super("#cdata-section", {}, [], node.value, parent_document: root_node)
95
98
  else
96
99
  namespace_attributes(node.attributes).each do |(name, value)|
97
100
  if root_node
@@ -152,16 +152,16 @@ module Lutaml
152
152
  )
153
153
  elsif rule.prefix_set?
154
154
  xml.create_and_add_element(rule.name, prefix: prefix) do
155
- add_value(xml, value, attribute)
155
+ add_value(xml, value, attribute, cdata: rule.cdata)
156
156
  end
157
157
  else
158
158
  xml.create_and_add_element(rule.name) do
159
- add_value(xml, value, attribute)
159
+ add_value(xml, value, attribute, cdata: rule.cdata)
160
160
  end
161
161
  end
162
162
  end
163
163
 
164
- def add_value(xml, value, attribute)
164
+ def add_value(xml, value, attribute, cdata: false)
165
165
  if !value.nil?
166
166
  serialized_value = attribute.type.serialize(value)
167
167
 
@@ -172,7 +172,7 @@ module Lutaml
172
172
  end
173
173
  end
174
174
  else
175
- xml.add_text(xml, serialized_value)
175
+ xml.add_text(xml, serialized_value, cdata: cdata)
176
176
  end
177
177
  end
178
178
  end
@@ -244,7 +244,7 @@ module Lutaml
244
244
  value = attribute_value_for(element, rule)
245
245
  return unless render_element?(rule, element, value)
246
246
 
247
- xml.add_text(xml, value)
247
+ xml.add_text(xml, value, cdata: rule.cdata)
248
248
  end
249
249
 
250
250
  def process_content_mapping(element, content_rule, xml)
@@ -261,7 +261,7 @@ module Lutaml
261
261
  text = content_rule.serialize(element)
262
262
  text = text.join if text.is_a?(Array)
263
263
 
264
- xml.add_text(xml, text)
264
+ xml.add_text(xml, text, cdata: content_rule.cdata)
265
265
  end
266
266
  end
267
267
 
@@ -47,6 +47,7 @@ module Lutaml
47
47
  render_default: false,
48
48
  with: {},
49
49
  delegate: nil,
50
+ cdata: false,
50
51
  namespace: (namespace_set = false
51
52
  nil),
52
53
  prefix: (prefix_set = false
@@ -61,6 +62,7 @@ module Lutaml
61
62
  render_default: render_default,
62
63
  with: with,
63
64
  delegate: delegate,
65
+ cdata: cdata,
64
66
  namespace: namespace,
65
67
  default_namespace: namespace_uri,
66
68
  prefix: prefix,
@@ -109,7 +111,8 @@ module Lutaml
109
111
  render_default: false,
110
112
  with: {},
111
113
  delegate: nil,
112
- mixed: false
114
+ mixed: false,
115
+ cdata: false
113
116
  )
114
117
  validate!("content", to, with)
115
118
 
@@ -121,6 +124,7 @@ module Lutaml
121
124
  with: with,
122
125
  delegate: delegate,
123
126
  mixed_content: mixed,
127
+ cdata: cdata,
124
128
  )
125
129
  end
126
130
 
@@ -211,7 +215,7 @@ module Lutaml
211
215
  end
212
216
 
213
217
  def find_by_name(name)
214
- if name.to_s == "text"
218
+ if ["text", "#cdata-section"].include?(name.to_s)
215
219
  content_mapping
216
220
  else
217
221
  mappings.detect do |rule|
@@ -3,7 +3,7 @@ require_relative "mapping_rule"
3
3
  module Lutaml
4
4
  module Model
5
5
  class XmlMappingRule < MappingRule
6
- attr_reader :namespace, :prefix, :mixed_content, :default_namespace
6
+ attr_reader :namespace, :prefix, :mixed_content, :default_namespace, :cdata
7
7
 
8
8
  def initialize(
9
9
  name,
@@ -15,6 +15,7 @@ module Lutaml
15
15
  namespace: nil,
16
16
  prefix: nil,
17
17
  mixed_content: false,
18
+ cdata: false,
18
19
  namespace_set: false,
19
20
  prefix_set: false,
20
21
  attribute: false,
@@ -38,6 +39,7 @@ module Lutaml
38
39
  end
39
40
  @prefix = prefix
40
41
  @mixed_content = mixed_content
42
+ @cdata = cdata
41
43
 
42
44
  @default_namespace = default_namespace
43
45
 
@@ -61,6 +63,10 @@ module Lutaml
61
63
  name == "__raw_mapping"
62
64
  end
63
65
 
66
+ def content_key
67
+ cdata ? "#cdata-section" : "text"
68
+ end
69
+
64
70
  def mixed_content?
65
71
  !!@mixed_content
66
72
  end
@@ -33,6 +33,33 @@ RSpec.describe Lutaml::Model::Attribute do
33
33
  .to("avatar.png")
34
34
  end
35
35
 
36
+ describe "#validate_options!" do
37
+ let(:validate_options) { name_attr.method(:validate_options!) }
38
+
39
+ Lutaml::Model::Attribute::ALLOWED_OPTIONS.each do |option|
40
+ it "return true if option is `#{option}`" do
41
+ expect(validate_options.call({ option => "value" })).to be(true)
42
+ end
43
+ end
44
+
45
+ it "raise exception if option is not allowed" do
46
+ expect do
47
+ validate_options.call({ foo: "bar" })
48
+ end.to raise_error(StandardError, "Invalid options given for `name` [:foo]")
49
+ end
50
+
51
+ it "raise exception if pattern is given with non string type" do
52
+ age_attr = described_class.new("age", :integer)
53
+
54
+ expect do
55
+ age_attr.send(:validate_options!, { pattern: /[A-Za-z ]/ })
56
+ end.to raise_error(
57
+ StandardError,
58
+ "Invalid option `pattern` given for `age`, `pattern` is only allowed for :string type",
59
+ )
60
+ end
61
+ end
62
+
36
63
  describe "#validate_type!" do
37
64
  let(:validate_type) { name_attr.method(:validate_type!) }
38
65
 
@@ -0,0 +1,520 @@
1
+ require "spec_helper"
2
+ require "lutaml/model"
3
+ require "lutaml/model/xml_adapter/nokogiri_adapter"
4
+ require "lutaml/model/xml_adapter/ox_adapter"
5
+
6
+ module CDATA
7
+ class Beta < Lutaml::Model::Serializable
8
+ attribute :element1, :string
9
+
10
+ xml do
11
+ root "beta"
12
+ map_content to: :element1, cdata: true
13
+ end
14
+ end
15
+
16
+ class Alpha < Lutaml::Model::Serializable
17
+ attribute :element1, :string
18
+ attribute :element2, :string
19
+ attribute :element3, :string
20
+ attribute :beta, Beta
21
+
22
+ xml do
23
+ root "alpha"
24
+
25
+ map_element "element1", to: :element1, cdata: false
26
+ map_element "element2", to: :element2, cdata: true
27
+ map_element "element3", to: :element3, cdata: false
28
+ map_element "beta", to: :beta
29
+ end
30
+ end
31
+
32
+ class Address < Lutaml::Model::Serializable
33
+ attribute :street, :string
34
+ attribute :city, :string
35
+ attribute :house, :string
36
+ attribute :address, Address
37
+
38
+ xml do
39
+ root "address"
40
+ map_element "street", to: :street
41
+ map_element "city", with: { from: :city_from_xml, to: :city_to_xml }, cdata: true
42
+ map_element "house", with: { from: :house_from_xml, to: :house_to_xml }, cdata: false
43
+ map_element "address", to: :address
44
+ end
45
+
46
+ def house_from_xml(model, node)
47
+ model.house = node
48
+ end
49
+
50
+ def house_to_xml(model, _parent, doc)
51
+ doc.create_and_add_element("house") do |element|
52
+ element.add_text(element, model.house, cdata: false)
53
+ end
54
+ end
55
+
56
+ def city_from_xml(model, node)
57
+ model.city = node
58
+ end
59
+
60
+ def city_to_xml(model, _parent, doc)
61
+ doc.create_and_add_element("city") do |element|
62
+ element.add_text(element, model.city, cdata: true)
63
+ end
64
+ end
65
+ end
66
+
67
+ class CustomModelChild
68
+ attr_accessor :street, :city
69
+ end
70
+
71
+ class CustomModelParent
72
+ attr_accessor :first_name, :middle_name, :last_name, :child_mapper
73
+
74
+ def name
75
+ "#{first_name} #{last_name}"
76
+ end
77
+ end
78
+
79
+ class CustomModelChildMapper < Lutaml::Model::Serializable
80
+ model CustomModelChild
81
+
82
+ attribute :street, Lutaml::Model::Type::String
83
+ attribute :city, Lutaml::Model::Type::String
84
+
85
+ xml do
86
+ map_element :street, to: :street, cdata: true
87
+ map_element :city, to: :city, cdata: true
88
+ end
89
+ end
90
+
91
+ class CustomModelParentMapper < Lutaml::Model::Serializable
92
+ model CustomModelParent
93
+
94
+ attribute :first_name, Lutaml::Model::Type::String
95
+ attribute :middle_name, Lutaml::Model::Type::String
96
+ attribute :last_name, Lutaml::Model::Type::String
97
+ attribute :child_mapper, CustomModelChildMapper
98
+
99
+ xml do
100
+ root "CustomModelParent"
101
+ map_element :first_name, to: :first_name, cdata: true
102
+ map_element :middle_name, to: :middle_name, cdata: true
103
+ map_element :last_name, to: :last_name, cdata: false
104
+ map_element :CustomModelChild, with: { to: :child_to_xml, from: :child_from_xml }, cdata: true
105
+ end
106
+
107
+ def child_to_xml(model, _parent, doc)
108
+ doc.create_and_add_element("CustomModelChild") do |child_el|
109
+ child_el.create_and_add_element("street") do |street_el|
110
+ street_el.add_text(street_el, model.child_mapper.street, cdata: true)
111
+ end
112
+ child_el.create_and_add_element("city") do |city_el|
113
+ city_el.add_text(city_el, model.child_mapper.city, cdata: true)
114
+ end
115
+ end
116
+ end
117
+
118
+ def child_from_xml(model, value)
119
+ model.child_mapper ||= CustomModelChild.new
120
+
121
+ model.child_mapper.street = value["street"].text
122
+ model.child_mapper.city = value["city"].text
123
+ end
124
+ end
125
+
126
+ class RootMixedContent < Lutaml::Model::Serializable
127
+ attribute :id, :string
128
+ attribute :bold, :string, collection: true
129
+ attribute :italic, :string, collection: true
130
+ attribute :underline, :string
131
+ attribute :content, :string
132
+
133
+ xml do
134
+ root "RootMixedContent", mixed: true
135
+ map_attribute :id, to: :id
136
+ map_element :bold, to: :bold, cdata: true
137
+ map_element :italic, to: :italic, cdata: true
138
+ map_element :underline, to: :underline, cdata: true
139
+ map_content to: :content, cdata: true
140
+ end
141
+ end
142
+
143
+ class RootMixedContentNested < Lutaml::Model::Serializable
144
+ attribute :id, :string
145
+ attribute :data, :string
146
+ attribute :content, RootMixedContent
147
+ attribute :sup, :string, collection: true
148
+ attribute :sub, :string, collection: true
149
+
150
+ xml do
151
+ root "RootMixedContentNested", mixed: true
152
+ map_content to: :data, cdata: true
153
+ map_attribute :id, to: :id
154
+ map_element :sup, to: :sup, cdata: true
155
+ map_element :sub, to: :sub, cdata: false
156
+ map_element "MixedContent", to: :content
157
+ end
158
+ end
159
+
160
+ class DefaultValue < Lutaml::Model::Serializable
161
+ attribute :name, :string, default: -> { "Default Value" }
162
+ attribute :temperature, :integer, default: -> { 1050 }
163
+ attribute :opacity, :string, default: -> { "Opaque" }
164
+ attribute :content, :string, default: -> { " " }
165
+
166
+ xml do
167
+ root "DefaultValue"
168
+ map_element "name", to: :name, render_default: true, cdata: true
169
+ map_element "temperature", to: :temperature, render_default: true, cdata: true
170
+ map_element "opacity", to: :opacity, cdata: false, render_default: true
171
+ map_content to: :content, cdata: true, render_default: true
172
+ end
173
+ end
174
+ end
175
+
176
+ RSpec.describe "CDATA" do
177
+ let(:parent_mapper) { CDATA::CustomModelParentMapper }
178
+ let(:child_mapper) { CDATA::CustomModelChildMapper }
179
+ let(:parent_model) { CDATA::CustomModelParent }
180
+ let(:child_model) { CDATA::CustomModelChild }
181
+
182
+ shared_examples "cdata behavior" do |adapter_class|
183
+ around do |example|
184
+ old_adapter = Lutaml::Model::Config.xml_adapter
185
+ Lutaml::Model::Config.xml_adapter = adapter_class
186
+ example.run
187
+ ensure
188
+ Lutaml::Model::Config.xml_adapter = old_adapter
189
+ end
190
+
191
+ context "with CDATA option" do
192
+ let(:xml) do
193
+ <<~XML.strip
194
+ <alpha>
195
+ <element1><![CDATA[foo]]></element1>
196
+ <element2><![CDATA[one]]></element2>
197
+ <element2><![CDATA[two]]></element2>
198
+ <element2><![CDATA[three]]></element2>
199
+ <element3>bar</element3>
200
+ <beta><![CDATA[child]]></beta>
201
+ </alpha>
202
+ XML
203
+ end
204
+
205
+ let(:expected_xml) do
206
+ <<~XML.strip
207
+ <alpha>
208
+ <element1>foo</element1>
209
+ <element2>
210
+ <![CDATA[one]]>
211
+ </element2>
212
+ <element2>
213
+ <![CDATA[two]]>
214
+ </element2>
215
+ <element2>
216
+ <![CDATA[three]]>
217
+ </element2>
218
+ <element3>bar</element3>
219
+ <beta>
220
+ <![CDATA[child]]>
221
+ </beta>
222
+ </alpha>
223
+ XML
224
+ end
225
+
226
+ it "maps xml to object" do
227
+ instance = CDATA::Alpha.from_xml(xml)
228
+
229
+ expect(instance.element1).to eq("foo")
230
+ expect(instance.element2).to eq(%w[one two three])
231
+ expect(instance.element3).to eq("bar")
232
+ expect(instance.beta.element1).to eq("child")
233
+ end
234
+
235
+ it "converts objects to xml" do
236
+ instance = CDATA::Alpha.new(
237
+ element1: "foo",
238
+ element2: %w[one two three],
239
+ element3: "bar",
240
+ beta: CDATA::Beta.new(element1: "child"),
241
+ )
242
+
243
+ expect(instance.to_xml).to be_equivalent_to(expected_xml)
244
+ end
245
+ end
246
+
247
+ context "with custom methods" do
248
+ let(:xml) do
249
+ <<~XML
250
+ <address>
251
+ <street>A</street>
252
+ <city><![CDATA[B]]></city>
253
+ <house><![CDATA[H]]></house>
254
+ <address>
255
+ <street>C</street>
256
+ <city><![CDATA[D]]></city>
257
+ <house><![CDATA[G]]></house>
258
+ </address>
259
+ </address>
260
+ XML
261
+ end
262
+
263
+ let(:expected_xml) do
264
+ <<~XML
265
+ <address>
266
+ <street>A</street>
267
+ <city>
268
+ <![CDATA[B]]>
269
+ </city>
270
+ <house>H</house>
271
+ <address>
272
+ <street>C</street>
273
+ <city>
274
+ <![CDATA[D]]>
275
+ </city>
276
+ <house>G</house>
277
+ </address>
278
+ </address>
279
+ XML
280
+ end
281
+
282
+ it "round-trips XML" do
283
+ model = CDATA::Address.from_xml(xml)
284
+ expect(model.to_xml).to be_equivalent_to(expected_xml)
285
+ end
286
+ end
287
+
288
+ context "with custom models" do
289
+ let(:input_xml) do
290
+ <<~XML
291
+ <CustomModelParent>
292
+ <first_name><![CDATA[John]]></first_name>
293
+ <last_name><![CDATA[Doe]]></last_name>
294
+ <CustomModelChild>
295
+ <street><![CDATA[Oxford Street]]></street>
296
+ <city><![CDATA[London]]></city>
297
+ </CustomModelChild>
298
+ </CustomModelParent>
299
+ XML
300
+ end
301
+
302
+ let(:expected_nokogiri_xml) do
303
+ <<~XML
304
+ <CustomModelParent>
305
+ <first_name><![CDATA[John]]></first_name>
306
+ <last_name>Doe</last_name>
307
+ <CustomModelChild>
308
+ <street><![CDATA[Oxford Street]]></street>
309
+ <city><![CDATA[London]]></city>
310
+ </CustomModelChild>
311
+ </CustomModelParent>
312
+ XML
313
+ end
314
+
315
+ let(:expected_ox_xml) do
316
+ <<~XML
317
+ <CustomModelParent>
318
+ <first_name>
319
+ <![CDATA[John]]>
320
+ </first_name>
321
+ <last_name>Doe</last_name>
322
+ <CustomModelChild>
323
+ <street>
324
+ <![CDATA[Oxford Street]]>
325
+ </street>
326
+ <city>
327
+ <![CDATA[London]]>
328
+ </city>
329
+ </CustomModelChild>
330
+ </CustomModelParent>
331
+ XML
332
+ end
333
+
334
+ describe ".from_xml" do
335
+ it "maps XML content to custom model using custom methods" do
336
+ instance = parent_mapper.from_xml(input_xml)
337
+
338
+ expect(instance.class).to eq(parent_model)
339
+ expect(instance.first_name).to eq("John")
340
+ expect(instance.last_name).to eq("Doe")
341
+ expect(instance.name).to eq("John Doe")
342
+
343
+ expect(instance.child_mapper.class).to eq(child_model)
344
+ expect(instance.child_mapper.street).to eq("Oxford Street")
345
+ expect(instance.child_mapper.city).to eq("London")
346
+ end
347
+ end
348
+
349
+ describe ".to_xml" do
350
+ it "with correct model converts objects to xml using custom methods" do
351
+ instance = parent_mapper.from_xml(input_xml)
352
+ result_xml = parent_mapper.to_xml(instance)
353
+
354
+ expected_output = adapter_class == Lutaml::Model::XmlAdapter::OxAdapter ? expected_ox_xml : expected_nokogiri_xml
355
+
356
+ expect(result_xml.strip).to eq(expected_output.strip)
357
+ end
358
+ end
359
+ end
360
+
361
+ context "when mixed: true is set for nested content" do
362
+ let(:xml) do
363
+ <<~XML
364
+ <RootMixedContentNested id="outer123">
365
+ <![CDATA[The following text is about the Moon.]]>
366
+ <MixedContent id="inner456">
367
+ <![CDATA[The Earth's Moon rings like a ]]>
368
+ <bold><![CDATA[bell]]></bold>
369
+ <![CDATA[ when struck by meteroids. Distanced from the Earth by ]]>
370
+ <italic><![CDATA[384,400 km]]></italic>,
371
+ <![CDATA[ ,its surface is covered in ]]>
372
+ <underline><![CDATA[craters]]></underline>.
373
+ <![CDATA[ .Ain't that ]]>
374
+ <bold><![CDATA[cool]]></bold>
375
+ <![CDATA[ ? ]]>
376
+ </MixedContent>
377
+ <sup><![CDATA[1]]></sup>: <![CDATA[The Moon is not a planet.]]>
378
+ <sup><![CDATA[2]]></sup>: <![CDATA[The Moon's atmosphere is mainly composed of helium in the form of He]]><sub><![CDATA[2]]></sub>.
379
+ </RootMixedContentNested>
380
+ XML
381
+ end
382
+
383
+ expected_xml = "<RootMixedContentNested id=\"outer123\"><![CDATA[The following text is about the Moon.]]><MixedContent id=\"inner456\"><![CDATA[The Earth's Moon rings like a ]]><bold><![CDATA[bell]]></bold><![CDATA[ when struck by meteroids. Distanced from the Earth by ]]><italic><![CDATA[384,400 km]]></italic><![CDATA[ ,its surface is covered in ]]><underline><![CDATA[craters]]></underline><![CDATA[ .Ain't that ]]><bold><![CDATA[cool]]></bold><![CDATA[ ? ]]></MixedContent><sup><![CDATA[1]]></sup><![CDATA[The Moon is not a planet.]]><sup><![CDATA[2]]></sup><![CDATA[The Moon's atmosphere is mainly composed of helium in the form of He]]><sub>2</sub></RootMixedContentNested>"
384
+
385
+ expected_ox_xml = <<~XML
386
+ <RootMixedContentNested id="outer123">
387
+ <![CDATA[The following text is about the Moon.]]>
388
+ <MixedContent id="inner456">
389
+ <![CDATA[The Earth's Moon rings like a ]]>
390
+ <bold>
391
+ <![CDATA[bell]]>
392
+ </bold>
393
+ <![CDATA[ when struck by meteroids. Distanced from the Earth by ]]>
394
+ <italic>
395
+ <![CDATA[384,400 km]]>
396
+ </italic>
397
+ <![CDATA[ ,its surface is covered in ]]>
398
+ <underline>
399
+ <![CDATA[craters]]>
400
+ </underline>
401
+ <![CDATA[ .Ain't that ]]>
402
+ <bold>
403
+ <![CDATA[cool]]>
404
+ </bold>
405
+ <![CDATA[ ? ]]>
406
+ </MixedContent>
407
+ <sup>
408
+ <![CDATA[1]]>
409
+ </sup>
410
+ <![CDATA[The Moon is not a planet.]]>
411
+ <sup>
412
+ <![CDATA[2]]>
413
+ </sup>
414
+ <![CDATA[The Moon's atmosphere is mainly composed of helium in the form of He]]>
415
+ <sub>2</sub>
416
+ </RootMixedContentNested>
417
+ XML
418
+
419
+ it "deserializes and serializes mixed content correctly" do
420
+ parsed = CDATA::RootMixedContentNested.from_xml(xml)
421
+
422
+ expected_content = [
423
+ "The Earth's Moon rings like a ",
424
+ " when struck by meteroids. Distanced from the Earth by ",
425
+ " ,its surface is covered in ",
426
+ " .Ain't that ",
427
+ " ? ",
428
+ ]
429
+
430
+ expect(parsed.id).to eq("outer123")
431
+ expect(parsed.sup).to eq(["1", "2"])
432
+ expect(parsed.sub).to eq(["2"])
433
+ expect(parsed.content.id).to eq("inner456")
434
+ expect(parsed.content.bold).to eq(["bell", "cool"])
435
+ expect(parsed.content.italic).to eq(["384,400 km"])
436
+ expect(parsed.content.underline).to eq("craters")
437
+
438
+ parsed.content.content.each_with_index do |content, index|
439
+ expected_output = expected_content[index]
440
+
441
+ # due to the difference in capturing
442
+ # newlines in ox and nokogiri adapters
443
+ if adapter_class == Lutaml::Model::XmlAdapter::OxAdapter
444
+ expected_xml = expected_ox_xml
445
+ end
446
+
447
+ expect(content).to eq(expected_output)
448
+ end
449
+
450
+ serialized = parsed.to_xml
451
+ expect(serialized).to eq(expected_xml)
452
+ end
453
+ end
454
+
455
+ context "when defualt: true is set for attributes default values" do
456
+ let(:xml) do
457
+ <<~XML
458
+ <DefaultValue>
459
+ <![CDATA[The following text is about the Moon]]>
460
+ <temperature>
461
+ <![CDATA[500]]>
462
+ </temperature>
463
+ <![CDATA[The Moon's atmosphere is mainly composed of helium in the form]]>
464
+ </DefaultValue>
465
+ XML
466
+ end
467
+
468
+ expected_xml = "<DefaultValue><name><![CDATA[Default Value]]></name><temperature><![CDATA[500]]></temperature><opacity>Opaque</opacity><![CDATA[The following text is about the MoonThe Moon's atmosphere is mainly composed of helium in the form]]></DefaultValue>"
469
+
470
+ expected_ox_xml = <<~XML
471
+ <DefaultValue>
472
+ <name>
473
+ <![CDATA[Default Value]]>
474
+ </name>
475
+ <temperature>
476
+ <![CDATA[500]]>
477
+ </temperature>
478
+ <opacity>Opaque</opacity>
479
+ <![CDATA[The following text is about the MoonThe Moon's atmosphere is mainly composed of helium in the form]]>
480
+ </DefaultValue>
481
+ XML
482
+
483
+ it "deserializes and serializes mixed content correctly" do
484
+ parsed = CDATA::DefaultValue.from_xml(xml)
485
+
486
+ expected_content = [
487
+ "The following text is about the Moon",
488
+ "The Moon's atmosphere is mainly composed of helium in the form",
489
+ ]
490
+
491
+ expect(parsed.name).to eq("Default Value")
492
+ expect(parsed.opacity).to eq("Opaque")
493
+ expect(parsed.temperature).to eq(500)
494
+
495
+ parsed.content.each_with_index do |content, index|
496
+ expected_output = expected_content[index]
497
+
498
+ # due to the difference in capturing
499
+ # newlines in ox and nokogiri adapters
500
+ if adapter_class == Lutaml::Model::XmlAdapter::OxAdapter
501
+ expected_xml = expected_ox_xml
502
+ end
503
+
504
+ expect(content).to eq(expected_output)
505
+ end
506
+
507
+ serialized = parsed.to_xml
508
+ expect(serialized).to eq(expected_xml)
509
+ end
510
+ end
511
+ end
512
+
513
+ describe Lutaml::Model::XmlAdapter::NokogiriAdapter do
514
+ it_behaves_like "cdata behavior", described_class
515
+ end
516
+
517
+ describe Lutaml::Model::XmlAdapter::OxAdapter do
518
+ it_behaves_like "cdata behavior", described_class
519
+ end
520
+ end
@@ -2,6 +2,7 @@ require "spec_helper"
2
2
 
3
3
  class TestSerializable < Lutaml::Model::Serializable
4
4
  attribute :name, :string, values: ["Alice", "Bob", "Charlie"]
5
+ attribute :email, :string, pattern: /.*?\S+@.+\.\S+/
5
6
  attribute :age, :integer, collection: 1..3
6
7
 
7
8
  xml do
@@ -27,9 +28,12 @@ class TestSerializable < Lutaml::Model::Serializable
27
28
  end
28
29
 
29
30
  RSpec.describe Lutaml::Model::Serializable do
30
- let(:valid_instance) { TestSerializable.new(name: "Alice", age: [30]) }
31
+ let(:valid_instance) do
32
+ TestSerializable.new(name: "Alice", age: [30], email: "alice@gmail.com")
33
+ end
34
+
31
35
  let(:invalid_instance) do
32
- TestSerializable.new(name: "David", age: [25, 30, 35, 40])
36
+ TestSerializable.new(name: "David", age: [25, 30, 35, 40], email: "david@gmail")
33
37
  end
34
38
 
35
39
  describe "serialization methods" do
@@ -66,8 +70,9 @@ RSpec.describe Lutaml::Model::Serializable do
66
70
  it "returns errors for invalid attributes" do
67
71
  errors = invalid_instance.validate
68
72
  expect(errors).not_to be_empty
69
- expect(errors.first).to be_a(Lutaml::Model::InvalidValueError)
70
- expect(errors.last).to be_a(Lutaml::Model::CollectionCountOutOfRangeError)
73
+ expect(errors[0]).to be_a(Lutaml::Model::InvalidValueError)
74
+ expect(errors[1]).to be_a(Lutaml::Model::PatternNotMatchedError)
75
+ expect(errors[2]).to be_a(Lutaml::Model::CollectionCountOutOfRangeError)
71
76
  end
72
77
  end
73
78
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.25
4
+ version: 0.3.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -59,6 +59,7 @@ files:
59
59
  - lib/lutaml/model/error/collection_count_out_of_range_error.rb
60
60
  - lib/lutaml/model/error/incorrect_mapping_argument_error.rb
61
61
  - lib/lutaml/model/error/invalid_value_error.rb
62
+ - lib/lutaml/model/error/pattern_not_matched_error.rb
62
63
  - lib/lutaml/model/error/type_error.rb
63
64
  - lib/lutaml/model/error/type_not_enabled_error.rb
64
65
  - lib/lutaml/model/error/unknown_adapter_type_error.rb
@@ -127,6 +128,7 @@ files:
127
128
  - spec/fixtures/vase.rb
128
129
  - spec/fixtures/xml/special_char.xml
129
130
  - spec/lutaml/model/attribute_spec.rb
131
+ - spec/lutaml/model/cdata_spec.rb
130
132
  - spec/lutaml/model/collection_spec.rb
131
133
  - spec/lutaml/model/comparable_model_spec.rb
132
134
  - spec/lutaml/model/custom_model_spec.rb