lutaml-model 0.8.126 → 0.8.128
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 +4 -4
- data/README.adoc +60 -0
- data/docs/_guides/keyvalue-serialization.adoc +75 -0
- data/docs/_pages/value_types.adoc +102 -0
- data/lib/compat/opal/lutaml_model_boot.rb +15 -0
- data/lib/lutaml/key_value/transform.rb +238 -30
- data/lib/lutaml/model/error/unknown_mapping_error.rb +18 -0
- data/lib/lutaml/model/schema/generator/property.rb +60 -9
- data/lib/lutaml/model/serialize/format_conversion.rb +115 -2
- data/lib/lutaml/model/serialize/transformation_builder.rb +4 -3
- data/lib/lutaml/model/transform.rb +29 -4
- data/lib/lutaml/model/transformation_registry.rb +7 -5
- data/lib/lutaml/model/type/date_time_with_timezone.rb +28 -0
- data/lib/lutaml/model/type/date_with_timezone.rb +28 -0
- data/lib/lutaml/model/type/day_time_duration.rb +35 -0
- data/lib/lutaml/model/type/email_address.rb +28 -0
- data/lib/lutaml/model/type/hostname.rb +30 -0
- data/lib/lutaml/model/type/ip_v4_address.rb +27 -0
- data/lib/lutaml/model/type/ip_v6_address.rb +47 -0
- data/lib/lutaml/model/type/metaschema_pattern.rb +34 -0
- data/lib/lutaml/model/type/non_negative_integer.rb +25 -0
- data/lib/lutaml/model/type/positive_integer.rb +25 -0
- data/lib/lutaml/model/type/token.rb +25 -0
- data/lib/lutaml/model/type/uri_reference.rb +29 -0
- data/lib/lutaml/model/type/uuid.rb +25 -0
- data/lib/lutaml/model/type/value.rb +9 -0
- data/lib/lutaml/model/type/year_month_duration.rb +29 -0
- data/lib/lutaml/model/type.rb +27 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +2 -0
- data/lib/lutaml/xml/adapter/xml_serializer.rb +8 -6
- data/lib/lutaml/xml/model_transform.rb +8 -4
- data/lib/lutaml/xml/schema/xsd_schema.rb +30 -9
- data/lib/lutaml/xml/transformation.rb +3 -2
- data/spec/lutaml/key_value/child_map_plan_spec.rb +169 -0
- data/spec/lutaml/model/metaschema_types_spec.rb +367 -0
- data/spec/lutaml/model/serialization_profiles_spec.rb +276 -0
- metadata +20 -2
|
@@ -249,7 +249,9 @@ module Lutaml
|
|
|
249
249
|
# Check if model class has a pre-compiled transformation
|
|
250
250
|
model_class = model.class
|
|
251
251
|
if model_class.is_a?(Class) && model_class.include?(::Lutaml::Model::Serialize)
|
|
252
|
-
transformation = model_class.transformation_for(
|
|
252
|
+
transformation = model_class.transformation_for(
|
|
253
|
+
:xml, lutaml_register, mapping: @lutaml_mapping_override
|
|
254
|
+
)
|
|
253
255
|
|
|
254
256
|
# If transformation exists and is an Xml::Transformation, use it
|
|
255
257
|
if transformation.is_a?(::Lutaml::Xml::Transformation)
|
|
@@ -271,11 +273,13 @@ module Lutaml
|
|
|
271
273
|
instance_is_serialize = session.instance_is_serialize
|
|
272
274
|
|
|
273
275
|
# Performance: Resolve xml_mapping once — used for both rules and metadata
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
# An explicit mapping (#176 profiles) governs the rules and the
|
|
277
|
+
# metadata alike; its own catch-all rule applies.
|
|
278
|
+
xml_mapping = options[:mappings] || mappings_for(:xml, effective_register)
|
|
279
|
+
mappings = xml_mapping.mappings(effective_register)
|
|
276
280
|
# Catch-all rule (map_any_element): iterated like any other,
|
|
277
281
|
# claiming every child no explicit rule matches.
|
|
278
|
-
mappings += [xml_mapping.any_element_rule].compact
|
|
282
|
+
mappings += [xml_mapping.any_element_rule].compact
|
|
279
283
|
default_namespace = options[:default_namespace]
|
|
280
284
|
ordered_option = options[:ordered]
|
|
281
285
|
mixed_content_option = options[:mixed_content]
|
|
@@ -87,7 +87,14 @@ module Lutaml
|
|
|
87
87
|
|
|
88
88
|
def self.generate(klass, options = {})
|
|
89
89
|
register = extract_register_from(klass)
|
|
90
|
-
|
|
90
|
+
# A mapping profile (#176) generates the schema of its own
|
|
91
|
+
# projection; the base mapping remains the default.
|
|
92
|
+
xml_mapping = if options[:mapping]
|
|
93
|
+
klass.serialization_mapping_for(:xml,
|
|
94
|
+
options[:mapping])
|
|
95
|
+
else
|
|
96
|
+
klass.mappings_for(:xml)
|
|
97
|
+
end
|
|
91
98
|
|
|
92
99
|
# Validate XSD types unless explicitly skipped
|
|
93
100
|
validate_xsd_types!(klass, register) unless options[:skip_validation]
|
|
@@ -237,21 +244,29 @@ module Lutaml
|
|
|
237
244
|
# Pattern 2: type_name only -> named complexType (no element)
|
|
238
245
|
# Pattern 3: both element and type_name -> element + named complexType
|
|
239
246
|
|
|
247
|
+
# A mapping profile (#176) is a complete projection:
|
|
248
|
+
# attributes its mapping does not claim stay outside the
|
|
249
|
+
# generated schema.
|
|
250
|
+
skip_unmapped = !options[:mapping].nil?
|
|
251
|
+
|
|
240
252
|
if element_name && type_name
|
|
241
253
|
xs(xml, "element",
|
|
242
254
|
{ name: element_name,
|
|
243
255
|
type: qualify_type_ref(type_name, target_prefix) })
|
|
244
256
|
generate_complex_type(xml, klass, type_name, register,
|
|
245
|
-
xml_mapping, ctx: ctx
|
|
257
|
+
xml_mapping, ctx: ctx,
|
|
258
|
+
skip_unmapped: skip_unmapped)
|
|
246
259
|
elsif type_name && !element_name
|
|
247
260
|
generate_complex_type(xml, klass, type_name, register,
|
|
248
|
-
xml_mapping, ctx: ctx
|
|
261
|
+
xml_mapping, ctx: ctx,
|
|
262
|
+
skip_unmapped: skip_unmapped)
|
|
249
263
|
else
|
|
250
264
|
# Use class name as fallback element name if not specified
|
|
251
265
|
elem_name = element_name || klass.name
|
|
252
266
|
xs(xml, "element", { name: elem_name }) do
|
|
253
267
|
generate_complex_type_content(xml, klass, register, xml_mapping,
|
|
254
|
-
ctx: ctx
|
|
268
|
+
ctx: ctx,
|
|
269
|
+
skip_unmapped: skip_unmapped)
|
|
255
270
|
end
|
|
256
271
|
end
|
|
257
272
|
|
|
@@ -491,11 +506,13 @@ module Lutaml
|
|
|
491
506
|
attr.collection? && mapping&.type_name_value.nil?
|
|
492
507
|
end
|
|
493
508
|
|
|
494
|
-
def self.generate_complex_type_content(xml, klass, register,
|
|
509
|
+
def self.generate_complex_type_content(xml, klass, register,
|
|
510
|
+
xml_mapping, ctx:, skip_unmapped: false)
|
|
495
511
|
xs(xml, "complexType") do
|
|
496
512
|
if klass.attributes.any?
|
|
497
513
|
xs(xml, "sequence") do
|
|
498
|
-
generate_elements(xml, klass, register, xml_mapping, ctx: ctx
|
|
514
|
+
generate_elements(xml, klass, register, xml_mapping, ctx: ctx,
|
|
515
|
+
skip_unmapped: skip_unmapped)
|
|
499
516
|
end
|
|
500
517
|
end
|
|
501
518
|
if xml_mapping
|
|
@@ -505,23 +522,27 @@ module Lutaml
|
|
|
505
522
|
end
|
|
506
523
|
end
|
|
507
524
|
|
|
508
|
-
def self.generate_complex_type(xml, klass, type_name, register,
|
|
525
|
+
def self.generate_complex_type(xml, klass, type_name, register,
|
|
526
|
+
xml_mapping = nil, ctx:, skip_unmapped: false)
|
|
509
527
|
xs(xml, "complexType", { name: type_name }) do
|
|
510
528
|
if klass.attributes.any?
|
|
511
529
|
xs(xml, "sequence") do
|
|
512
|
-
generate_elements(xml, klass, register, xml_mapping, ctx: ctx
|
|
530
|
+
generate_elements(xml, klass, register, xml_mapping, ctx: ctx,
|
|
531
|
+
skip_unmapped: skip_unmapped)
|
|
513
532
|
end
|
|
514
533
|
end
|
|
515
534
|
generate_attributes(xml, klass, register, xml_mapping)
|
|
516
535
|
end
|
|
517
536
|
end
|
|
518
537
|
|
|
519
|
-
def self.generate_elements(xml, klass, register, xml_mapping, ctx
|
|
538
|
+
def self.generate_elements(xml, klass, register, xml_mapping, ctx:,
|
|
539
|
+
skip_unmapped: false)
|
|
520
540
|
klass.attributes.each do |name, attr|
|
|
521
541
|
next if xml_mapping && attr_is_xml_attribute?(xml_mapping, name)
|
|
522
542
|
|
|
523
543
|
# Find the mapping rule for this attribute
|
|
524
544
|
mapping_rule = xml_mapping&.find_element(name)
|
|
545
|
+
next if skip_unmapped && xml_mapping && mapping_rule.nil?
|
|
525
546
|
|
|
526
547
|
attr_type = attr.type(register)
|
|
527
548
|
|
|
@@ -28,8 +28,9 @@ module Lutaml
|
|
|
28
28
|
# @param options [Hash] Transformation options
|
|
29
29
|
# @return [::Lutaml::Xml::DataModel::XmlElement] The root XML element
|
|
30
30
|
def transform(model_instance, options = {})
|
|
31
|
-
# Get root element name from mapping
|
|
32
|
-
|
|
31
|
+
# Get root element name from mapping — an explicit mapping
|
|
32
|
+
# (#176 profiles) supplies its own root and root namespace.
|
|
33
|
+
mapping = options[:mappings] || model_class.mappings_for(:xml, register_id)
|
|
33
34
|
root_name = options[:tag_name] ||
|
|
34
35
|
mapping.root_element ||
|
|
35
36
|
(model_class.name&.split("::")&.last || "anonymous")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Compiled child-map walk plans (#986b): child_mappings rules ride the
|
|
6
|
+
# compiled dispatch (per-rule wire names, paths, and child type resolved
|
|
7
|
+
# once) instead of the per-node interpretive translate_mappings. Parity
|
|
8
|
+
# with the interpretive output is pinned here, including the
|
|
9
|
+
# keys-only merge rule (the merge tests the whole document hash, not
|
|
10
|
+
# each child) and the absent-key contract (the attribute stays
|
|
11
|
+
# uninitialized).
|
|
12
|
+
module ChildMapPlanSpec
|
|
13
|
+
class Schema < Lutaml::Model::Serializable
|
|
14
|
+
attribute :id, :string
|
|
15
|
+
attribute :path, :string
|
|
16
|
+
attribute :name, :string
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Schemas < Lutaml::Model::Serializable
|
|
20
|
+
attribute :type, :string
|
|
21
|
+
attribute :schemas, Schema, collection: true
|
|
22
|
+
|
|
23
|
+
key_value do
|
|
24
|
+
map "type", to: :type
|
|
25
|
+
map "schemas", to: :schemas,
|
|
26
|
+
child_mappings: {
|
|
27
|
+
id: :key,
|
|
28
|
+
path: %i[path link],
|
|
29
|
+
name: %i[path name],
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Entry < Lutaml::Model::Serializable
|
|
35
|
+
attribute :key, :string
|
|
36
|
+
attribute :value, :string
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class EntryList < Lutaml::Model::Serializable
|
|
40
|
+
attribute :title, :string
|
|
41
|
+
attribute :entries, Entry, collection: true
|
|
42
|
+
|
|
43
|
+
key_value do
|
|
44
|
+
map "title", to: :title
|
|
45
|
+
map "entries", to: :entries,
|
|
46
|
+
child_mappings: {
|
|
47
|
+
key: :key,
|
|
48
|
+
value: %i[detail text],
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class Tag < Lutaml::Model::Serializable
|
|
54
|
+
attribute :name, :string
|
|
55
|
+
attribute :count, :integer
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Tags < Lutaml::Model::Serializable
|
|
59
|
+
attribute :tags, Tag, collection: true
|
|
60
|
+
|
|
61
|
+
key_value do
|
|
62
|
+
map "tags", to: :tags,
|
|
63
|
+
child_mappings: {
|
|
64
|
+
name: :key,
|
|
65
|
+
count: :value,
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class KeyedChild < Lutaml::Model::Serializable
|
|
71
|
+
attribute :id, :string
|
|
72
|
+
attribute :label, :string
|
|
73
|
+
attribute :extra, :hash
|
|
74
|
+
|
|
75
|
+
key_value do
|
|
76
|
+
map "id", to: :id
|
|
77
|
+
map "label", to: :label
|
|
78
|
+
map_unmapped to: :extra
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class KeyOnly < Lutaml::Model::Serializable
|
|
83
|
+
attribute :items, KeyedChild, collection: true
|
|
84
|
+
|
|
85
|
+
key_value do
|
|
86
|
+
map "items", to: :items,
|
|
87
|
+
child_mappings: { id: :key }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
RSpec.describe "compiled child_mappings walk plans" do
|
|
92
|
+
it "translates explicit-path child mappings into child models" do
|
|
93
|
+
model = Schemas.from_json(
|
|
94
|
+
'{"type":"object","schemas":{' \
|
|
95
|
+
'"sch1":{"path":{"link":"https://one","name":"one"}},' \
|
|
96
|
+
'"sch2":{"path":{"link":"https://two","name":"two"}}}}',
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
expect(model.schemas.map(&:id)).to eq(%w[sch1 sch2])
|
|
100
|
+
expect(model.schemas.first.path).to eq("https://one")
|
|
101
|
+
expect(model.schemas.last.name).to eq("two")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "round-trips the translated wire form" do
|
|
105
|
+
json = '{"type":"object","schemas":{' \
|
|
106
|
+
'"sch1":{"path":{"link":"https://one","name":"one"}}}}'
|
|
107
|
+
model = Schemas.from_json(json)
|
|
108
|
+
|
|
109
|
+
expect(model.to_json).to eq(json)
|
|
110
|
+
expect(model.to_json).to eq(json) # warm-plan second pass
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "translates value paths" do
|
|
114
|
+
model = EntryList.from_json(
|
|
115
|
+
'{"title":"list","entries":{' \
|
|
116
|
+
'"e1":{"detail":{"text":"first"}},' \
|
|
117
|
+
'"e2":{"detail":{"text":"second"}}}}',
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(model.entries.map(&:key)).to eq(%w[e1 e2])
|
|
121
|
+
expect(model.entries.first.value).to eq("first")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "casts values through the child attribute types" do
|
|
125
|
+
model = Tags.from_json('{"tags":{"red":3,"blue":5}}')
|
|
126
|
+
|
|
127
|
+
expect(model.tags.map(&:name)).to eq(%w[red blue])
|
|
128
|
+
expect(model.tags.map(&:count)).to eq([3, 5])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "keeps the attribute uninitialized when the key is absent" do
|
|
132
|
+
model = Schemas.from_json('{"type":"object"}')
|
|
133
|
+
|
|
134
|
+
expect(model.type).to eq("object")
|
|
135
|
+
expect(model.schemas).to be_nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "handles an empty mapping value" do
|
|
139
|
+
model = Schemas.from_json('{"type":"object","schemas":{}}')
|
|
140
|
+
|
|
141
|
+
expect(model.schemas).to eq([])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "merges unmapped keys only when every value in the hash is a hash" do
|
|
145
|
+
model = KeyOnly.from_json(
|
|
146
|
+
'{"items":{"a":{"label":"A","extra":1},"b":{"label":"B"}}}',
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(model.items.map(&:id)).to eq(%w[a b])
|
|
150
|
+
expect(model.items.first.label).to eq("A")
|
|
151
|
+
expect(model.items.first.extra).to eq("extra" => 1)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "skips the merge when any value in the hash is not a hash" do
|
|
155
|
+
model = KeyOnly.from_json('{"items":{"a":"plain","b":{"label":"B"}}}')
|
|
156
|
+
|
|
157
|
+
expect(model.items.first.id).to eq("a")
|
|
158
|
+
expect(model.items.first.extra).to be_nil
|
|
159
|
+
expect(model.items.last.id).to eq("b")
|
|
160
|
+
expect(model.items.last.label).to be_nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "serializes a keyed collection through the compiled plan" do
|
|
164
|
+
model = Tags.from_json('{"tags":{"red":3}}')
|
|
165
|
+
|
|
166
|
+
expect(model.to_json).to eq('{"tags":{"red":3}}')
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# The NIST Metaschema simple data types (#107), per
|
|
6
|
+
# metaschema-datatypes.xsd and the sample values on
|
|
7
|
+
# pages.nist.gov/metaschema/specification/datatypes/.
|
|
8
|
+
RSpec.describe "NIST Metaschema simple data types" do
|
|
9
|
+
describe Lutaml::Model::Type::EmailAddress do
|
|
10
|
+
it "accepts addresses with an at-sign" do
|
|
11
|
+
expect(described_class.valid?("mailbox@example.com")).to be(true)
|
|
12
|
+
expect(described_class.cast("mailbox@example.com")).to eq("mailbox@example.com")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "rejects strings without an at-sign" do
|
|
16
|
+
expect(described_class.valid?("mailbox.example.com")).to be(false)
|
|
17
|
+
expect(described_class.valid?("")).to be(false)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe Lutaml::Model::Type::Hostname do
|
|
22
|
+
it "accepts non-empty trimmed strings" do
|
|
23
|
+
expect(described_class.valid?("example.com")).to be(true)
|
|
24
|
+
expect(described_class.valid?("localhost")).to be(true)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "rejects empty and outer-whitespace values" do
|
|
28
|
+
expect(described_class.valid?("")).to be(false)
|
|
29
|
+
expect(described_class.valid?(" example.com ")).to be(false)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe Lutaml::Model::Type::IpV4Address do
|
|
34
|
+
it "accepts dotted-quad addresses" do
|
|
35
|
+
expect(described_class.valid?("192.168.0.1")).to be(true)
|
|
36
|
+
expect(described_class.valid?("0.0.0.0")).to be(true)
|
|
37
|
+
expect(described_class.valid?("255.255.255.255")).to be(true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "rejects out-of-range octets and malformed forms" do
|
|
41
|
+
expect(described_class.valid?("256.0.0.1")).to be(false)
|
|
42
|
+
expect(described_class.valid?("192.168.0")).to be(false)
|
|
43
|
+
expect(described_class.valid?("192.168.0.1.5")).to be(false)
|
|
44
|
+
expect(described_class.valid?("example.com")).to be(false)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe Lutaml::Model::Type::IpV6Address do
|
|
49
|
+
it "accepts full and compressed forms" do
|
|
50
|
+
expect(described_class.valid?("2001:db8:0:0:0:0:2:1")).to be(true)
|
|
51
|
+
expect(described_class.valid?("2001:db8::2:1")).to be(true)
|
|
52
|
+
expect(described_class.valid?("::1")).to be(true)
|
|
53
|
+
expect(described_class.valid?("::")).to be(true)
|
|
54
|
+
expect(described_class.valid?("fe80::1%eth0")).to be(true)
|
|
55
|
+
expect(described_class.valid?("::ffff:192.168.0.1")).to be(true)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "rejects malformed forms" do
|
|
59
|
+
expect(described_class.valid?("2001:db8:::1")).to be(false)
|
|
60
|
+
expect(described_class.valid?("192.168.0.1")).to be(false)
|
|
61
|
+
expect(described_class.valid?("2001:db8:0:0:0:0:2")).to be(false)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe Lutaml::Model::Type::Token do
|
|
66
|
+
it "accepts letters, digits, underscore and .-_ runs" do
|
|
67
|
+
expect(described_class.valid?("simple")).to be(true)
|
|
68
|
+
expect(described_class.valid?("_private.v2-final")).to be(true)
|
|
69
|
+
expect(described_class.valid?("label3")).to be(true)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "rejects leading digits, spaces, and empty values" do
|
|
73
|
+
expect(described_class.valid?("3rd")).to be(false)
|
|
74
|
+
expect(described_class.valid?("with space")).to be(false)
|
|
75
|
+
expect(described_class.valid?("")).to be(false)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe Lutaml::Model::Type::UriReference do
|
|
80
|
+
it "accepts relative and absolute references" do
|
|
81
|
+
expect(described_class.valid?("#example")).to be(true)
|
|
82
|
+
expect(described_class.valid?("relative/path")).to be(true)
|
|
83
|
+
expect(described_class.valid?("https://example.com/x")).to be(true)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "rejects empty and whitespace-padded values" do
|
|
87
|
+
expect(described_class.valid?("")).to be(false)
|
|
88
|
+
expect(described_class.valid?(" no ")).to be(false)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe Lutaml::Model::Type::Uuid do
|
|
93
|
+
it "accepts version 4 and 5 UUIDs" do
|
|
94
|
+
expect(described_class.valid?("a3bb189e-8bf9-4888-9912-ace4e6543002")).to be(true)
|
|
95
|
+
expect(described_class.valid?("5c0da4c0-6d4d-5e21-b0cd-3a5f9b1c9d01")).to be(true)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "rejects other versions and malformed values" do
|
|
99
|
+
expect(described_class.valid?("a3bb189e-8bf9-1888-9912-ace4e6543002")).to be(false)
|
|
100
|
+
expect(described_class.valid?("a3bb189e-8bf9-4888-9912ace4e6543002")).to be(false)
|
|
101
|
+
expect(described_class.valid?("not-a-uuid")).to be(false)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe Lutaml::Model::Type::NonNegativeInteger do
|
|
106
|
+
it "accepts zero and positive integers" do
|
|
107
|
+
expect(described_class.valid?(0)).to be(true)
|
|
108
|
+
expect(described_class.valid?("42")).to be(true)
|
|
109
|
+
expect(described_class.cast("42")).to eq(42)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "rejects negative values and non-integers" do
|
|
113
|
+
expect(described_class.valid?(-1)).to be(false)
|
|
114
|
+
expect(described_class.valid?("abc")).to be(false)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe Lutaml::Model::Type::PositiveInteger do
|
|
119
|
+
it "accepts integers of one and above" do
|
|
120
|
+
expect(described_class.valid?(1)).to be(true)
|
|
121
|
+
expect(described_class.valid?("7")).to be(true)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "rejects zero, negatives, and non-integers" do
|
|
125
|
+
expect(described_class.valid?(0)).to be(false)
|
|
126
|
+
expect(described_class.valid?(-3)).to be(false)
|
|
127
|
+
expect(described_class.valid?("abc")).to be(false)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe Lutaml::Model::Type::DateWithTimezone do
|
|
132
|
+
it "accepts dates with Z or an offset, per the page samples" do
|
|
133
|
+
expect(described_class.valid?("2019-09-28Z")).to be(true)
|
|
134
|
+
expect(described_class.valid?("2019-12-02-08:00")).to be(true)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "rejects dates without a timezone" do
|
|
138
|
+
expect(described_class.valid?("2019-09-28")).to be(false)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe Lutaml::Model::Type::DateTimeWithTimezone do
|
|
143
|
+
it "accepts date-times with Z or an offset, per the page samples" do
|
|
144
|
+
expect(described_class.valid?("2019-09-28T23:20:50.52Z")).to be(true)
|
|
145
|
+
expect(described_class.valid?("2019-12-02T16:39:57-08:00")).to be(true)
|
|
146
|
+
expect(described_class.valid?("2019-12-31T23:59:59Z")).to be(true)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "rejects date-times without a timezone" do
|
|
150
|
+
expect(described_class.valid?("2019-09-28T23:20:50.52")).to be(false)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
describe Lutaml::Model::Type::DayTimeDuration do
|
|
155
|
+
it "accepts day and time durations, per the page samples" do
|
|
156
|
+
expect(described_class.valid?("P1DT12H45M")).to be(true)
|
|
157
|
+
expect(described_class.valid?("-PT3H")).to be(true)
|
|
158
|
+
expect(described_class.valid?("PT0S")).to be(true)
|
|
159
|
+
expect(described_class.valid?("P3D")).to be(true)
|
|
160
|
+
expect(described_class.valid?("PT2M30.5S")).to be(true)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "rejects year and month components" do
|
|
164
|
+
expect(described_class.valid?("P1Y6M")).to be(false)
|
|
165
|
+
expect(described_class.valid?("P1DT")).to be(false)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
describe Lutaml::Model::Type::YearMonthDuration do
|
|
170
|
+
it "accepts year and month durations, per the page samples" do
|
|
171
|
+
expect(described_class.valid?("P1Y6M")).to be(true)
|
|
172
|
+
expect(described_class.valid?("-P9M")).to be(true)
|
|
173
|
+
expect(described_class.valid?("P10M")).to be(true)
|
|
174
|
+
expect(described_class.valid?("P2Y")).to be(true)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "rejects time components and bare days" do
|
|
178
|
+
expect(described_class.valid?("P1DT12H")).to be(false)
|
|
179
|
+
expect(described_class.valid?("P3D")).to be(false)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "round-trips a metaschema-typed model through JSON" do
|
|
184
|
+
stub_const("MetaNode", Class.new(Lutaml::Model::Serializable) do
|
|
185
|
+
attribute :id, :uuid
|
|
186
|
+
attribute :contact, :email_address
|
|
187
|
+
attribute :address, :ip_v4_address
|
|
188
|
+
attribute :retries, :non_negative_integer
|
|
189
|
+
|
|
190
|
+
json do
|
|
191
|
+
map "id", to: :id
|
|
192
|
+
map "contact", to: :contact
|
|
193
|
+
map "address", to: :address
|
|
194
|
+
map "retries", to: :retries
|
|
195
|
+
end
|
|
196
|
+
end)
|
|
197
|
+
|
|
198
|
+
model = MetaNode.from_json(
|
|
199
|
+
'{"id":"a3bb189e-8bf9-4888-9912-ace4e6543002",' \
|
|
200
|
+
'"contact":"ops@example.com","address":"10.0.0.7","retries":"3"}',
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
expect(model.id).to eq("a3bb189e-8bf9-4888-9912-ace4e6543002")
|
|
204
|
+
expect(model.contact).to eq("ops@example.com")
|
|
205
|
+
expect(model.address).to eq("10.0.0.7")
|
|
206
|
+
expect(model.retries).to eq(3)
|
|
207
|
+
expect(model.to_json).to eq(
|
|
208
|
+
'{"id":"a3bb189e-8bf9-4888-9912-ace4e6543002",' \
|
|
209
|
+
'"contact":"ops@example.com","address":"10.0.0.7","retries":3}',
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it "declares the metaschema XSD bases for schema generation" do
|
|
214
|
+
expect(Lutaml::Model::Type::NonNegativeInteger.default_xsd_type)
|
|
215
|
+
.to eq("xs:nonNegativeInteger")
|
|
216
|
+
expect(Lutaml::Model::Type::PositiveInteger.default_xsd_type)
|
|
217
|
+
.to eq("xs:positiveInteger")
|
|
218
|
+
expect(Lutaml::Model::Type::DayTimeDuration.default_xsd_type)
|
|
219
|
+
.to eq("xs:dayTimeDuration")
|
|
220
|
+
expect(Lutaml::Model::Type::YearMonthDuration.default_xsd_type)
|
|
221
|
+
.to eq("xs:yearMonthDuration")
|
|
222
|
+
expect(Lutaml::Model::Type::Uuid.default_xsd_type).to eq("xs:string")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
describe "wiring into validation and schema emission" do
|
|
226
|
+
it "declares the patterns and bounds as Layer-2 facets" do
|
|
227
|
+
expect(Lutaml::Model::Type::Uuid.facets[:pattern])
|
|
228
|
+
.to include(Lutaml::Model::Type::Uuid::PATTERN)
|
|
229
|
+
expect(Lutaml::Model::Type::NonNegativeInteger.facets[:min_inclusive])
|
|
230
|
+
.to eq(0)
|
|
231
|
+
expect(Lutaml::Model::Type::PositiveInteger.facets[:min_inclusive])
|
|
232
|
+
.to eq(1)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it "validates metaschema-typed attributes through model validation" do
|
|
236
|
+
stub_const("WiredNode", Class.new(Lutaml::Model::Serializable) do
|
|
237
|
+
attribute :id, :uuid
|
|
238
|
+
attribute :contact, :email_address
|
|
239
|
+
attribute :retries, :positive_integer
|
|
240
|
+
|
|
241
|
+
json do
|
|
242
|
+
map "id", to: :id
|
|
243
|
+
map "contact", to: :contact
|
|
244
|
+
map "retries", to: :retries
|
|
245
|
+
end
|
|
246
|
+
end)
|
|
247
|
+
|
|
248
|
+
good = WiredNode.new(id: "a3bb189e-8bf9-4888-9912-ace4e6543002",
|
|
249
|
+
contact: "ops@example.com", retries: 2)
|
|
250
|
+
expect(good.validate).to be_empty
|
|
251
|
+
|
|
252
|
+
bad = WiredNode.new(id: "not-a-uuid", contact: "no-at-sign", retries: 0)
|
|
253
|
+
messages = bad.validate.map(&:message)
|
|
254
|
+
|
|
255
|
+
expect(messages).to all(be_a(String))
|
|
256
|
+
expect(messages.join("\n")).to include("id")
|
|
257
|
+
expect(messages.join("\n")).to include("contact")
|
|
258
|
+
expect(messages.join("\n")).to include("retries")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it "exports the metaschema restrictions in generated XSD" do
|
|
262
|
+
stub_const("XsdNode", Class.new(Lutaml::Model::Serializable) do
|
|
263
|
+
attribute :id, :uuid
|
|
264
|
+
attribute :contact, :email_address
|
|
265
|
+
attribute :retries, :positive_integer
|
|
266
|
+
|
|
267
|
+
xml do
|
|
268
|
+
element "node"
|
|
269
|
+
map_attribute "id", to: :id
|
|
270
|
+
map_element "contact", to: :contact
|
|
271
|
+
map_element "retries", to: :retries
|
|
272
|
+
end
|
|
273
|
+
end)
|
|
274
|
+
|
|
275
|
+
xsd = Lutaml::Model::Schema.to_xsd(XsdNode)
|
|
276
|
+
|
|
277
|
+
expect(xsd).to include("[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[45]")
|
|
278
|
+
expect(xsd).to include(".+@.+")
|
|
279
|
+
expect(xsd).to include('minInclusive value="1"')
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "exports the NIST JSON representations in generated JSON and YAML schemas" do
|
|
283
|
+
stub_const("JsonWiredNode", Class.new(Lutaml::Model::Serializable) do
|
|
284
|
+
attribute :id, :uuid
|
|
285
|
+
attribute :contact, :email_address
|
|
286
|
+
attribute :address, :ip_v4_address
|
|
287
|
+
attribute :retries, :positive_integer
|
|
288
|
+
attribute :term, :year_month_duration
|
|
289
|
+
|
|
290
|
+
json do
|
|
291
|
+
map "id", to: :id
|
|
292
|
+
map "contact", to: :contact
|
|
293
|
+
map "address", to: :address
|
|
294
|
+
map "retries", to: :retries
|
|
295
|
+
map "term", to: :term
|
|
296
|
+
end
|
|
297
|
+
end)
|
|
298
|
+
|
|
299
|
+
properties = JSON.parse(Lutaml::Model::Schema.to_json(JsonWiredNode))
|
|
300
|
+
.fetch("$defs").fetch("JsonWiredNode").fetch("properties")
|
|
301
|
+
|
|
302
|
+
expect(properties.fetch("id").fetch("pattern"))
|
|
303
|
+
.to eq("^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[45][0-9A-Fa-f]{3}-" \
|
|
304
|
+
"[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$")
|
|
305
|
+
expect(properties.fetch("contact")).to include("format" => "email")
|
|
306
|
+
expect(properties.fetch("address")).to include("format" => "ipv4")
|
|
307
|
+
expect(Array(properties.fetch("retries").fetch("type")))
|
|
308
|
+
.to include("integer")
|
|
309
|
+
expect(properties.fetch("retries")).to include("minimum" => 1)
|
|
310
|
+
expect(properties.fetch("term")).to include("format" => "duration")
|
|
311
|
+
|
|
312
|
+
yaml_properties = YAML.safe_load(
|
|
313
|
+
Lutaml::Model::Schema.to_yaml(JsonWiredNode),
|
|
314
|
+
).fetch("$defs").fetch("JsonWiredNode").fetch("properties")
|
|
315
|
+
|
|
316
|
+
expect(yaml_properties.fetch("id")["pattern"]).to include("[45]")
|
|
317
|
+
expect(yaml_properties.fetch("retries")["minimum"]).to eq(1)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
it "round-trips metaschema-typed models through every format" do
|
|
321
|
+
stub_const("FormatNode", Class.new(Lutaml::Model::Serializable) do
|
|
322
|
+
attribute :id, :uuid
|
|
323
|
+
attribute :host, :hostname
|
|
324
|
+
attribute :address, :ip_v4_address
|
|
325
|
+
attribute :renewal, :year_month_duration
|
|
326
|
+
|
|
327
|
+
xml do
|
|
328
|
+
element "node"
|
|
329
|
+
map_attribute "id", to: :id
|
|
330
|
+
map_element "host", to: :host
|
|
331
|
+
map_element "address", to: :address
|
|
332
|
+
map_element "renewal", to: :renewal
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
json do
|
|
336
|
+
map "id", to: :id
|
|
337
|
+
map "host", to: :host
|
|
338
|
+
map "address", to: :address
|
|
339
|
+
map "renewal", to: :renewal
|
|
340
|
+
end
|
|
341
|
+
end)
|
|
342
|
+
|
|
343
|
+
node = FormatNode.new(id: "a3bb189e-8bf9-4888-9912-ace4e6543002",
|
|
344
|
+
host: "example.com", address: "10.0.0.7",
|
|
345
|
+
renewal: "P1Y6M")
|
|
346
|
+
|
|
347
|
+
xml_round_trip = FormatNode.from_xml(node.to_xml)
|
|
348
|
+
expect(xml_round_trip).to eq(node)
|
|
349
|
+
|
|
350
|
+
json_round_trip = FormatNode.from_json(node.to_json)
|
|
351
|
+
expect(json_round_trip).to eq(node)
|
|
352
|
+
|
|
353
|
+
yaml_round_trip = FormatNode.from_yaml(node.to_yaml)
|
|
354
|
+
expect(yaml_round_trip).to eq(node)
|
|
355
|
+
|
|
356
|
+
toml_round_trip = FormatNode.from_toml(node.to_toml)
|
|
357
|
+
expect(toml_round_trip).to eq(node)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it "keeps the metaschema pattern sources export-clean" do
|
|
361
|
+
[Lutaml::Model::Type::IpV6Address,
|
|
362
|
+
Lutaml::Model::Type::DayTimeDuration].each do |type|
|
|
363
|
+
expect(type::PATTERN.source).not_to match(/\s/)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|