lutaml-xsd 1.1.13 → 1.2.0
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/.gitignore +0 -1
- data/CLAUDE.md +18 -1
- data/Gemfile +2 -1
- data/Gemfile.lock +327 -0
- data/Rakefile +78 -1
- data/TODO.VISION.md +123 -0
- data/TODO.cleanup.md +43 -0
- data/TODO.conformance.md +99 -0
- data/TODO.docs-spa.md +46 -0
- data/TODO.interop.md +52 -0
- data/TODO.performance.md +80 -0
- data/TODO.som.md +47 -0
- data/benchmark/engines.rb +77 -0
- data/config/validation.yml +4 -0
- data/docs/CONFORMANCE.adoc +43 -0
- data/docs/JSON_SCHEMA.adoc +42 -0
- data/docs/superpowers/plans/2026-10-20-moxml-leptris-migration.md +735 -0
- data/docs/superpowers/specs/2026-10-20-moxml-leptris-migration-design.md +169 -0
- data/docs/superpowers/specs/2026-10-21-rule-engine-wiring-design.md +84 -0
- data/lib/lutaml/xsd/conformance/runner.rb +169 -0
- data/lib/lutaml/xsd/json_schema_exporter.rb +240 -0
- data/lib/lutaml/xsd/package_builder.rb +2 -2
- data/lib/lutaml/xsd/spa/schema_serializer.rb +12 -7
- data/lib/lutaml/xsd/validation/engine_context.rb +190 -0
- data/lib/lutaml/xsd/validation/rule_engine.rb +36 -18
- data/lib/lutaml/xsd/validation/rule_registry.rb +34 -0
- data/lib/lutaml/xsd/validation/rules/attribute_validation_rule.rb +126 -17
- data/lib/lutaml/xsd/validation/rules/content_model_validation_rule.rb +304 -194
- data/lib/lutaml/xsd/validation/rules/element_structure_rule.rb +25 -15
- data/lib/lutaml/xsd/validation/rules/identity_constraints_rule.rb +134 -0
- data/lib/lutaml/xsd/validation/rules/occurrence_validation_rule.rb +76 -25
- data/lib/lutaml/xsd/validation/rules/type_validation_rule.rb +134 -17
- data/lib/lutaml/xsd/validation/schema_definition_resolver.rb +254 -0
- data/lib/lutaml/xsd/validation/validation_configuration.rb +1 -0
- data/lib/lutaml/xsd/validation/validation_job.rb +70 -3
- data/lib/lutaml/xsd/validation/validation_rule.rb +44 -5
- data/lib/lutaml/xsd/validation/validator.rb +2 -1
- data/lib/lutaml/xsd/validation/xml_element.rb +2 -2
- data/lib/lutaml/xsd/version.rb +1 -1
- data/lib/lutaml/xsd.rb +14 -0
- data/lutaml-xsd.gemspec +2 -2
- metadata +27 -8
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Design: complete Moxml migration, run on leptris, integrate `Moxml::XSD` engine validation
|
|
2
|
+
|
|
3
|
+
- Date: 2026-10-20
|
|
4
|
+
- Status: approved (design discussion 2026-10-18..20)
|
|
5
|
+
- Scope: lutaml-xsd 1.1.x
|
|
6
|
+
|
|
7
|
+
## 1. Context
|
|
8
|
+
|
|
9
|
+
lutaml-xsd is ~95% on Moxml already: `validation/*`, `definition_extractor.rb`,
|
|
10
|
+
and most of `spa/schema_serializer.rb` use `Moxml::Context`. Remaining gaps:
|
|
11
|
+
|
|
12
|
+
- Direct Nokogiri usage survives in exactly one place:
|
|
13
|
+
`create_type_wrapper_xsd` and `resolve_type_prefix`
|
|
14
|
+
(`lib/lutaml/xsd/spa/schema_serializer.rb`), including
|
|
15
|
+
`require "nokogiri"`.
|
|
16
|
+
- The bundle does not include the leptris adapter, so Moxml's default
|
|
17
|
+
adapter selection falls back to Nokogiri everywhere — including inside
|
|
18
|
+
lutaml-model 0.8.x, which is itself built on Moxml.
|
|
19
|
+
- moxml 0.5.147 (pinned via lutaml-xsd#104) ships `Moxml::XSD`: an
|
|
20
|
+
adapter-agnostic XSD validation facade over engines that implement one
|
|
21
|
+
(leptris, nokogiri — "tier-1"). lutaml-xsd maintains a substantial
|
|
22
|
+
hand-rolled validation stack (5 rules, 14 facet validators, 10
|
|
23
|
+
base-type validators) that does not use it.
|
|
24
|
+
|
|
25
|
+
## 2. Goals
|
|
26
|
+
|
|
27
|
+
1. No direct Nokogiri usage in `lib/` — everything through Moxml.
|
|
28
|
+
2. Dev/CI runs the stack on the Leptris engine via the `leptris` gem
|
|
29
|
+
(Gemfile-only; public dependency contract unchanged).
|
|
30
|
+
3. Use `Moxml::XSD` where it helps: a full-document engine fast-path in
|
|
31
|
+
`Validation`, plus engine delegation for lexical and content-model
|
|
32
|
+
checks where supported, with the Ruby rule engine remaining
|
|
33
|
+
authoritative and the fallback everywhere else.
|
|
34
|
+
4. CLAUDE.md documents the Moxml-only XML stack.
|
|
35
|
+
|
|
36
|
+
## 3. Non-goals
|
|
37
|
+
|
|
38
|
+
- No replacement of the Ruby rule engine by engine validation (tier-1
|
|
39
|
+
scope validates laxly outside named+typed top-level elements /
|
|
40
|
+
one-level content-model descent / lexical checks — silent
|
|
41
|
+
false-negatives are unacceptable).
|
|
42
|
+
- No runtime adapter dependency in the gemspec (moxml itself declares
|
|
43
|
+
zero runtime deps; consumers bundle the adapters they want).
|
|
44
|
+
- `XsdSpecValidator` (schema-level spec compliance) keeps its own rules;
|
|
45
|
+
`Moxml::XSD` validates instances, not schemas.
|
|
46
|
+
- Removing the `xsdvi` dependency (it requires `nokogiri ~> 1.16`, so
|
|
47
|
+
nokogiri remains in the lockfile transitively; lutaml-xsd code simply
|
|
48
|
+
stops touching it).
|
|
49
|
+
|
|
50
|
+
## 4. Dependency changes
|
|
51
|
+
|
|
52
|
+
| Change | Where | Status |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `moxml ~> 0.5.147` pin | gemspec | PR #104 (patch release 1.1.13 follows) |
|
|
55
|
+
| `gem "leptris"` | Gemfile (dev) | this work |
|
|
56
|
+
| drop `gem "nokogiri"` | Gemfile | this work (stays in lock via xsdvi) |
|
|
57
|
+
|
|
58
|
+
Gemspec contract otherwise unchanged. moxml auto-selects leptris when
|
|
59
|
+
the gem is present (`Leptris::XML::Document.create` probe); no adapter
|
|
60
|
+
is hardcoded in lutaml-xsd.
|
|
61
|
+
|
|
62
|
+
## 5. Workstream A — finish the Moxml port
|
|
63
|
+
|
|
64
|
+
`spa/schema_serializer.rb`:
|
|
65
|
+
|
|
66
|
+
- Delete `require "nokogiri"`.
|
|
67
|
+
- `create_type_wrapper_xsd`: `Moxml::Context.new.parse(File.read(path))`;
|
|
68
|
+
`doc.at_xpath("//xs:schema", ns)` (Moxml `xpath`/`at_xpath` accept
|
|
69
|
+
namespace hashes exactly like Nokogiri);
|
|
70
|
+
`schema.namespace_definitions.find { |nd| nd.href == XSD_NS }`;
|
|
71
|
+
`doc.create_element("element")`; `element_node["name"] = ...`;
|
|
72
|
+
`element_node["type"] = type_ref`;
|
|
73
|
+
`element_node.namespace = xsd_ns_decl`; `schema.add_child(element_node)`;
|
|
74
|
+
`doc.to_xml` into the Tempfile.
|
|
75
|
+
- `resolve_type_prefix`: same xpath + `namespace_definitions` mapping.
|
|
76
|
+
- Spec: the generated wrapper XSD must match current Nokogiri output
|
|
77
|
+
(root `xs:schema`, wrapper `xs:element` in the XSD namespace, correct
|
|
78
|
+
prefix resolution). Regression-gate with a byte-equivalence test
|
|
79
|
+
captured before the port.
|
|
80
|
+
|
|
81
|
+
## 6. Workstream B — leptris in the bundle
|
|
82
|
+
|
|
83
|
+
- Add `gem "leptris"` to the Gemfile (dev/CI). Latest published:
|
|
84
|
+
1.9.372.0.
|
|
85
|
+
- Verify `Moxml.new.config.adapter_name == :leptris` under the bundle;
|
|
86
|
+
CI stays adapter-neutral (moxml picks leptris when present, nokogiri
|
|
87
|
+
otherwise) so the suite runs both engines over time.
|
|
88
|
+
|
|
89
|
+
## 7. Workstream C — `Moxml::XSD` engine integration
|
|
90
|
+
|
|
91
|
+
### 7.1 New class `Lutaml::Xsd::Validation::EngineXsd`
|
|
92
|
+
|
|
93
|
+
One instance per `ValidationJob`, built from the job's `Moxml::Context`:
|
|
94
|
+
|
|
95
|
+
- `Moxml::XSD.supported?(context)` gates everything. Unsupported
|
|
96
|
+
adapters (Oga/Ox/REXML) yield an inert engine (`nil`-equivalent); job
|
|
97
|
+
behavior is unchanged.
|
|
98
|
+
- Compiles each schema source in `repository.all_schemas` via
|
|
99
|
+
`context.xsd(source)`; schemas with non-nil `compile_error` are
|
|
100
|
+
skipped and surface as warnings — the Ruby rules still judge them.
|
|
101
|
+
- `#validate(document)`: runs each compiled `Moxml::XSD::Schema#validate`,
|
|
102
|
+
maps findings, returns them merged.
|
|
103
|
+
- Provenance: every mapped finding gets `code: "engine_validation"` so
|
|
104
|
+
consumers can tell engine findings from rule findings.
|
|
105
|
+
|
|
106
|
+
### 7.2 Error mapping
|
|
107
|
+
|
|
108
|
+
`Moxml::XSD::ValidationError(message:, line:, column:, path:)` →
|
|
109
|
+
`Lutaml::Xsd::Validation::ValidationError` with
|
|
110
|
+
`severity: :error` and `location:` derived from line/column/path.
|
|
111
|
+
`to_s` keeps the `"<line>: <message>"` shape.
|
|
112
|
+
|
|
113
|
+
### 7.3 Job wiring
|
|
114
|
+
|
|
115
|
+
`ValidationJob#execute` gains a phase after `parse_xml`, gated by a new
|
|
116
|
+
config feature `:engine_validation` (default: enabled when supported).
|
|
117
|
+
Findings merge into the existing `ResultCollector`. A document is valid
|
|
118
|
+
only when both engines are silent — engine findings are additive, the
|
|
119
|
+
Ruby rule engine remains authoritative (laxness mitigation).
|
|
120
|
+
|
|
121
|
+
### 7.4 Delegated checks
|
|
122
|
+
|
|
123
|
+
An `EngineContext` (supported? flag + compiled schema handles) hangs off
|
|
124
|
+
the job and reaches validators through the existing registries:
|
|
125
|
+
|
|
126
|
+
- `validation/base_types/*`: when the engine is available,
|
|
127
|
+
`Moxml::XSD.builtin_valid?` / `schema.simple_valid?` run first; Ruby
|
|
128
|
+
logic stays the fallback (other adapters, engine gaps).
|
|
129
|
+
- `validation/rules/content_model_validation_rule.rb` and
|
|
130
|
+
`occurrence_validation_rule.rb`: consult
|
|
131
|
+
`schema.content_valid?(element_name, children)` the same way.
|
|
132
|
+
- No validator public interface changes; delegation is internal and
|
|
133
|
+
capability-gated (OCP: engine support is a registry concern, not a
|
|
134
|
+
per-validator branch sprawl).
|
|
135
|
+
|
|
136
|
+
## 8. Testing
|
|
137
|
+
|
|
138
|
+
Real objects throughout (no doubles, per standing rules):
|
|
139
|
+
|
|
140
|
+
- Supported-engine shared context: engine tests run where leptris is
|
|
141
|
+
installed, skip cleanly elsewhere (`Moxml::XSD.supported?` probe).
|
|
142
|
+
- Fallback specs: adapter forced to REXML; behavior byte-identical to
|
|
143
|
+
today (engine inert, no new findings, registries use Ruby paths).
|
|
144
|
+
- Port specs: wrapper-XSD byte-equivalence vs. the captured Nokogiri
|
|
145
|
+
baseline.
|
|
146
|
+
- Mapping specs: each `Moxml::XSD::ValidationError` field lands in the
|
|
147
|
+
right `Validation::ValidationError` field; merge semantics with Ruby
|
|
148
|
+
findings; `compile_error` → warning path.
|
|
149
|
+
- Delegation specs: engine path and Ruby path agree on valid/invalid
|
|
150
|
+
fixtures from `spec/fixtures`; disagreement is a test failure.
|
|
151
|
+
|
|
152
|
+
## 9. Risks
|
|
153
|
+
|
|
154
|
+
- Tier-1 laxness → engine findings additive only; Ruby engine
|
|
155
|
+
authoritative; documented in CLAUDE.md and YARD.
|
|
156
|
+
- Multi-schema repositories (imports, RNG→XSD-converted files) may
|
|
157
|
+
under-report in the engine → acceptable because findings are additive;
|
|
158
|
+
cross-schema resolution stays the Ruby engine's job.
|
|
159
|
+
- Moxml serialization nuances in the wrapper-XSD port → byte-equivalence
|
|
160
|
+
spec catches drift.
|
|
161
|
+
- leptris platform coverage → Gemfile-only today; runtime adoption is a
|
|
162
|
+
separate, owner-decided decision.
|
|
163
|
+
|
|
164
|
+
## 10. Rollout order
|
|
165
|
+
|
|
166
|
+
1. PR #104 merged + 1.1.13 patch release (moxml pin only).
|
|
167
|
+
2. Workstreams A + B (port + leptris in Gemfile) — one PR.
|
|
168
|
+
3. Workstream C (engine integration) — one PR.
|
|
169
|
+
4. CLAUDE.md update lands with (2)/(3).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Design: wire the Ruby rule engine into production validation
|
|
2
|
+
|
|
3
|
+
- Date: 2026-10-21
|
|
4
|
+
- Status: implemented with this PR
|
|
5
|
+
- Scope: lutaml-xsd 1.1.x
|
|
6
|
+
|
|
7
|
+
## 1. Problem
|
|
8
|
+
|
|
9
|
+
`Validator#build_rule_registry` returns an **empty** registry, and
|
|
10
|
+
`RuleEngine#execute_rule` calls `rule.validate(document, repository,
|
|
11
|
+
collector)` while every concrete rule implements
|
|
12
|
+
`validate(instance_element, schema_definition, collector)`. Consequence:
|
|
13
|
+
the production validation stack runs zero rules. The conformance harness
|
|
14
|
+
(2026-10-20 baseline) measured pipeline mode == engine mode for exactly
|
|
15
|
+
this reason; every deferred rule-depth gap is blocked on this wiring.
|
|
16
|
+
|
|
17
|
+
## 2. Contract
|
|
18
|
+
|
|
19
|
+
Rules stay element-scoped. The engine walks the instance document
|
|
20
|
+
(depth-first over `XmlDocument#root_element` / `XmlElement#children`),
|
|
21
|
+
resolves each element's schema declaration, and dispatches:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
rule.validate(instance_element, rule.schema_argument(schema_def, repository:),
|
|
25
|
+
collector, repository: repository)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `ValidationRule#schema_argument(schema_def, repository:)` defaults to the
|
|
29
|
+
resolved schema element; rules override it when they validate against
|
|
30
|
+
something else (OccurrenceValidationRule returns the content-model
|
|
31
|
+
particle of the schema element's type).
|
|
32
|
+
- `ValidationRule#runs_without_schema?` defaults false; the structure rule
|
|
33
|
+
returns true so undeclared elements still produce findings (its
|
|
34
|
+
element-existence check takes a nil declaration).
|
|
35
|
+
- `repository:` is the resolver (not the raw repository), giving rules
|
|
36
|
+
named-type lookup (`resolve_type`) for `type="tns:…"` references.
|
|
37
|
+
- Dispatch order follows the existing categories:
|
|
38
|
+
structure → type → constraint → facet → identity. Each rule invocation
|
|
39
|
+
keeps `RuleEngine`'s existing per-rule rescue.
|
|
40
|
+
|
|
41
|
+
## 3. Schema definition resolution
|
|
42
|
+
|
|
43
|
+
`Validation::SchemaDefinitionResolver` indexes, per repository instance:
|
|
44
|
+
|
|
45
|
+
- top-level element declarations (`schema.element`)
|
|
46
|
+
- inline element declarations, recursively through complex types'
|
|
47
|
+
sequence/choice/all particles and nested complex content
|
|
48
|
+
|
|
49
|
+
Matching: local name equality, plus namespace compatibility — a schema
|
|
50
|
+
with a target namespace matches only that namespace; a no-namespace schema
|
|
51
|
+
matches a no-namespace instance element. Top-level declarations win over
|
|
52
|
+
inline ones. `resolve_type(element)` finds a named complex/simple type for
|
|
53
|
+
`element.type` references across the repository's schemas.
|
|
54
|
+
|
|
55
|
+
Known limits (documented, deliberately deferred): dynamic typing
|
|
56
|
+
(`xsi:type`) and derivation closure are not resolved; UPA/identity
|
|
57
|
+
constraints remain rule-engine backlog items.
|
|
58
|
+
|
|
59
|
+
## 4. Registry population
|
|
60
|
+
|
|
61
|
+
`Validator#build_rule_registry` registers the five rules, gated by the
|
|
62
|
+
existing feature flags:
|
|
63
|
+
|
|
64
|
+
| rule | category | gate |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| ElementStructureRule | structure | always |
|
|
67
|
+
| ContentModelValidationRule | structure | `validate_content_models` |
|
|
68
|
+
| TypeValidationRule | type | `validate_types` |
|
|
69
|
+
| AttributeValidationRule | constraint | `validate_attributes` |
|
|
70
|
+
| OccurrenceValidationRule | constraint | `validate_occurrences` |
|
|
71
|
+
|
|
72
|
+
## 5. Testing
|
|
73
|
+
|
|
74
|
+
- Resolver specs with a real parsed schema and a minimal repository
|
|
75
|
+
stand-in (top-level, inline, namespaced, unknown, type references).
|
|
76
|
+
- Engine integration: a `ValidationJob` with `engine_validation: false`
|
|
77
|
+
and an invalid instance must now produce rule findings (previously an
|
|
78
|
+
empty result). Valid instances stay clean.
|
|
79
|
+
- Conformance harness re-run; pipeline-mode pass-rate recorded.
|
|
80
|
+
|
|
81
|
+
## 6. Non-goals
|
|
82
|
+
|
|
83
|
+
- No new validation rules; this PR only makes the existing rules execute.
|
|
84
|
+
- No change to the engine fast-path or finding provenance.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "moxml"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Xsd
|
|
7
|
+
module Conformance
|
|
8
|
+
# Runs the conformance corpus: one directory per case under
|
|
9
|
+
# spec/conformance/cases/, each holding schema.xsd plus
|
|
10
|
+
# valid-*.xml / invalid-*.xml instances.
|
|
11
|
+
#
|
|
12
|
+
# Two modes are measured independently:
|
|
13
|
+
# - :engine — Moxml::XSD tier-1 validation (when the adapter
|
|
14
|
+
# ships an engine)
|
|
15
|
+
# - :pipeline — the production Validator stack (ValidationJob with
|
|
16
|
+
# the default rule registry)
|
|
17
|
+
#
|
|
18
|
+
# The report is a pass-rate per mode; conformance is a number.
|
|
19
|
+
class Runner
|
|
20
|
+
MODES = %i[engine pipeline].freeze
|
|
21
|
+
|
|
22
|
+
Case = Struct.new(:name, :schema_path, :valid, :invalid,
|
|
23
|
+
keyword_init: true)
|
|
24
|
+
Result = Struct.new(:case_name, :mode, :passed, :total, :failures,
|
|
25
|
+
:unsupported, keyword_init: true) do
|
|
26
|
+
def pass_rate
|
|
27
|
+
total.zero? ? 0.0 : passed.to_f / total
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Minimal repository shape the validation stack consumes.
|
|
32
|
+
class Repository
|
|
33
|
+
def initialize(schemas)
|
|
34
|
+
@schemas = schemas
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :schemas
|
|
38
|
+
|
|
39
|
+
def all_schemas
|
|
40
|
+
@schemas
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(cases_dir)
|
|
45
|
+
@cases_dir = cases_dir
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def cases
|
|
49
|
+
Dir.glob(File.join(@cases_dir, "*")).filter_map do |dir|
|
|
50
|
+
next unless File.directory?(dir)
|
|
51
|
+
|
|
52
|
+
schema = File.join(dir, "schema.xsd")
|
|
53
|
+
next unless File.exist?(schema)
|
|
54
|
+
|
|
55
|
+
Case.new(
|
|
56
|
+
name: File.basename(dir),
|
|
57
|
+
schema_path: schema,
|
|
58
|
+
valid: Dir.glob(File.join(dir, "valid-*.xml")),
|
|
59
|
+
invalid: Dir.glob(File.join(dir, "invalid-*.xml")),
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Array<Result>]
|
|
65
|
+
def run
|
|
66
|
+
cases.flat_map do |kase|
|
|
67
|
+
MODES.map { |mode| run_case(kase, mode) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [String] human-readable report
|
|
72
|
+
def report(results)
|
|
73
|
+
lines = ["Conformance report", "=" * 60]
|
|
74
|
+
results.group_by(&:mode).each do |mode, mode_results|
|
|
75
|
+
supported = mode_results.reject(&:unsupported)
|
|
76
|
+
passed = supported.sum(&:passed)
|
|
77
|
+
total = supported.sum(&:total)
|
|
78
|
+
rate = total.zero? ? 0.0 : (passed.to_f / total * 100).round(1)
|
|
79
|
+
lines << format("%-10s %3d/%-3d checks (%5.1f%%)%s",
|
|
80
|
+
mode, passed, total, rate,
|
|
81
|
+
skipped_note(mode_results))
|
|
82
|
+
mode_results.each do |r|
|
|
83
|
+
next if r.unsupported
|
|
84
|
+
|
|
85
|
+
marker = r.failures.empty? ? "ok " : "FAIL"
|
|
86
|
+
lines << format(" %s %-26s %d/%d",
|
|
87
|
+
marker, r.case_name, r.passed, r.total)
|
|
88
|
+
r.failures.each { |f| lines << " - #{f}" }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
lines.join("\n")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def skipped_note(results)
|
|
97
|
+
skipped = results.count(&:unsupported)
|
|
98
|
+
skipped.zero? ? "" : " (#{skipped} cases unsupported)"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def run_case(kase, mode)
|
|
102
|
+
checks = kase.valid.map { |path| [path, true] } +
|
|
103
|
+
kase.invalid.map { |path| [path, false] }
|
|
104
|
+
|
|
105
|
+
results = checks.map do |path, expected|
|
|
106
|
+
actual = validate(kase, path, mode)
|
|
107
|
+
[path, expected, actual]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
unsupported = results.any? { |_, _, actual| actual == :unsupported }
|
|
111
|
+
failures = results.reject do |_path, expected, actual|
|
|
112
|
+
actual == :unsupported || expected == actual
|
|
113
|
+
end.map { |path, expected, _| "#{File.basename(path)} (expected #{expected ? 'valid' : 'invalid'})" }
|
|
114
|
+
|
|
115
|
+
Result.new(
|
|
116
|
+
case_name: kase.name,
|
|
117
|
+
mode: mode,
|
|
118
|
+
passed: unsupported ? 0 : results.count { |_, expected, actual| expected == actual },
|
|
119
|
+
total: unsupported ? 0 : results.length,
|
|
120
|
+
failures: failures,
|
|
121
|
+
unsupported: unsupported,
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate(kase, instance_path, mode)
|
|
126
|
+
case mode
|
|
127
|
+
when :engine then engine_valid?(kase, instance_path)
|
|
128
|
+
when :pipeline then pipeline_valid?(kase, instance_path)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def engine_valid?(kase, instance_path)
|
|
133
|
+
engine = engine_for(kase)
|
|
134
|
+
return :unsupported unless engine&.supported?
|
|
135
|
+
|
|
136
|
+
engine.findings_for(File.read(instance_path)).empty?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def pipeline_valid?(kase, instance_path)
|
|
140
|
+
config = Validation::ValidationConfiguration.new
|
|
141
|
+
job = Validation::ValidationJob.new(
|
|
142
|
+
xml_content: File.read(instance_path),
|
|
143
|
+
repository: repository_for(kase),
|
|
144
|
+
rule_registry: Validation::RuleRegistry.default(config),
|
|
145
|
+
config: config,
|
|
146
|
+
)
|
|
147
|
+
job.execute.valid?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def engine_for(kase)
|
|
151
|
+
@engine_cache ||= {}
|
|
152
|
+
@engine_cache[kase.name] ||= Validation::EngineContext.compile(
|
|
153
|
+
repository_for(kase),
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def repository_for(kase)
|
|
158
|
+
@repository_cache ||= {}
|
|
159
|
+
@repository_cache[kase.name] ||= begin
|
|
160
|
+
schema = Lutaml::Xml::Schema::Xsd.parse(
|
|
161
|
+
File.read(kase.schema_path), location: kase.schema_path
|
|
162
|
+
)
|
|
163
|
+
Repository.new(schema.target_namespace.to_s => schema)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Xsd
|
|
7
|
+
# Exports an XSD schema to a JSON Schema 2020-12 document.
|
|
8
|
+
#
|
|
9
|
+
# The mapping covers the common core and deliberately records its
|
|
10
|
+
# losses: XSD regular expressions are not ECMA-262 patterns, identity
|
|
11
|
+
# constraints and substitution groups have no direct JSON Schema
|
|
12
|
+
# equivalent, and derivation is flattened. See docs/JSON_SCHEMA.adoc.
|
|
13
|
+
class JsonSchemaExporter
|
|
14
|
+
DRAFT = "https://json-schema.org/draft/2020-12/schema"
|
|
15
|
+
|
|
16
|
+
BUILTIN_TYPES = {
|
|
17
|
+
"string" => { "type" => "string" },
|
|
18
|
+
"normalizedString" => { "type" => "string" },
|
|
19
|
+
"token" => { "type" => "string" },
|
|
20
|
+
"boolean" => { "type" => "boolean" },
|
|
21
|
+
"decimal" => { "type" => "number" },
|
|
22
|
+
"float" => { "type" => "number" },
|
|
23
|
+
"double" => { "type" => "number" },
|
|
24
|
+
"integer" => { "type" => "integer" },
|
|
25
|
+
"positiveInteger" => { "type" => "integer", "minimum" => 1 },
|
|
26
|
+
"negativeInteger" => { "type" => "integer", "maximum" => -1 },
|
|
27
|
+
"nonPositiveInteger" => { "type" => "integer", "maximum" => 0 },
|
|
28
|
+
"nonNegativeInteger" => { "type" => "integer", "minimum" => 0 },
|
|
29
|
+
"long" => { "type" => "integer" },
|
|
30
|
+
"int" => { "type" => "integer" },
|
|
31
|
+
"short" => { "type" => "integer" },
|
|
32
|
+
"byte" => { "type" => "integer" },
|
|
33
|
+
"unsignedLong" => { "type" => "integer", "minimum" => 0 },
|
|
34
|
+
"unsignedInt" => { "type" => "integer", "minimum" => 0 },
|
|
35
|
+
"unsignedShort" => { "type" => "integer", "minimum" => 0 },
|
|
36
|
+
"unsignedByte" => { "type" => "integer", "minimum" => 0 },
|
|
37
|
+
"date" => { "type" => "string", "format" => "date" },
|
|
38
|
+
"dateTime" => { "type" => "string", "format" => "date-time" },
|
|
39
|
+
"time" => { "type" => "string", "format" => "time" },
|
|
40
|
+
"anyURI" => { "type" => "string", "format" => "uri" },
|
|
41
|
+
"QName" => { "type" => "string" },
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
FACET_MAP = {
|
|
45
|
+
min_length: "minLength",
|
|
46
|
+
max_length: "maxLength",
|
|
47
|
+
min_inclusive: "minimum",
|
|
48
|
+
max_inclusive: "maximum",
|
|
49
|
+
min_exclusive: "exclusiveMinimum",
|
|
50
|
+
max_exclusive: "exclusiveMaximum",
|
|
51
|
+
pattern: "pattern",
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
def initialize(schema)
|
|
55
|
+
@schema = schema
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash] JSON Schema 2020-12 document
|
|
59
|
+
def export
|
|
60
|
+
document = {
|
|
61
|
+
"$schema" => DRAFT,
|
|
62
|
+
"$defs" => {},
|
|
63
|
+
}
|
|
64
|
+
id = @schema.target_namespace
|
|
65
|
+
document["$id"] = id if id && !id.empty?
|
|
66
|
+
|
|
67
|
+
Array(@schema.complex_type).each do |complex_type|
|
|
68
|
+
next unless complex_type.name
|
|
69
|
+
|
|
70
|
+
document["$defs"][complex_type.name] =
|
|
71
|
+
complex_type_schema(complex_type)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Array(@schema.simple_type).each do |simple_type|
|
|
75
|
+
next unless simple_type.name
|
|
76
|
+
|
|
77
|
+
document["$defs"][simple_type.name] =
|
|
78
|
+
simple_type_schema(simple_type)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
elements = Array(@schema.element)
|
|
82
|
+
elements.each do |element|
|
|
83
|
+
next unless element.name
|
|
84
|
+
|
|
85
|
+
document["$defs"][element.name] = element_schema(element)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
unless elements.empty?
|
|
89
|
+
document["oneOf"] = elements.map do |element|
|
|
90
|
+
{ "$ref" => "#/$defs/#{element.name}" }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
document
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [String] pretty JSON
|
|
98
|
+
def to_json(pretty: true)
|
|
99
|
+
pretty ? JSON.pretty_generate(export) : JSON.generate(export)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def complex_type_schema(complex_type)
|
|
105
|
+
if complex_type.simple_content
|
|
106
|
+
return simple_content_schema(complex_type.simple_content)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
schema = { "type" => "object" }
|
|
110
|
+
properties, required = properties_for(complex_type)
|
|
111
|
+
schema["properties"] = properties unless properties.empty?
|
|
112
|
+
schema["required"] = required unless required.empty?
|
|
113
|
+
schema["additionalProperties"] = false
|
|
114
|
+
schema
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def simple_content_schema(simple_content)
|
|
118
|
+
base = simple_content.extension || simple_content.restriction
|
|
119
|
+
content = base && type_schema_for(base)
|
|
120
|
+
content ||= { "type" => "string" }
|
|
121
|
+
content.merge("type" => content["type"] || "string")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def properties_for(complex_type)
|
|
125
|
+
properties = {}
|
|
126
|
+
required = []
|
|
127
|
+
|
|
128
|
+
each_element_particle(complex_type) do |element|
|
|
129
|
+
name = element.name || element.ref
|
|
130
|
+
next unless name
|
|
131
|
+
|
|
132
|
+
max_occurs = element.max_occurs.to_s
|
|
133
|
+
max_occurs = "1" if max_occurs.empty?
|
|
134
|
+
min_occurs = element.min_occurs.to_s
|
|
135
|
+
min_occurs = "1" if min_occurs.empty?
|
|
136
|
+
|
|
137
|
+
properties[name] = element_schema(element)
|
|
138
|
+
if max_occurs != "1" && max_occurs != "0"
|
|
139
|
+
properties[name] = { "type" => "array", "items" => properties[name] }
|
|
140
|
+
required << name if min_occurs != "0"
|
|
141
|
+
elsif min_occurs != "0"
|
|
142
|
+
required << name
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
Array(complex_type.attribute).each do |attribute|
|
|
147
|
+
name = attribute.name || attribute.ref
|
|
148
|
+
next unless name
|
|
149
|
+
|
|
150
|
+
properties[name] = attribute_schema(attribute)
|
|
151
|
+
required << name if attribute.use == "required"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
[properties, required]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def each_element_particle(node, &)
|
|
158
|
+
return unless node
|
|
159
|
+
|
|
160
|
+
%i[sequence choice all].each do |kind|
|
|
161
|
+
particle = node.public_send(kind) if node.respond_to?(kind)
|
|
162
|
+
next unless particle
|
|
163
|
+
|
|
164
|
+
Array(particle.element).each(&)
|
|
165
|
+
Array(particle.choice).each { |choice| each_element_particle(choice, &) }
|
|
166
|
+
Array(particle.sequence).each { |seq| each_element_particle(seq, &) }
|
|
167
|
+
end
|
|
168
|
+
return unless node.complex_content
|
|
169
|
+
|
|
170
|
+
%i[extension restriction].each do |kind|
|
|
171
|
+
part = node.complex_content.public_send(kind)
|
|
172
|
+
each_element_particle(part, &) if part
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def element_schema(element)
|
|
177
|
+
schema = if element.complex_type
|
|
178
|
+
complex_type_schema(element.complex_type)
|
|
179
|
+
elsif element.simple_type
|
|
180
|
+
simple_type_schema(element.simple_type)
|
|
181
|
+
elsif element.type
|
|
182
|
+
type_schema_for(element.type)
|
|
183
|
+
else
|
|
184
|
+
{ "type" => "string" }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
schema = { "type" => ["null", schema["type"]] }.merge(schema.except("type")) if ["true", true].include?(element.nillable)
|
|
188
|
+
|
|
189
|
+
schema = { "const" => element.fixed } if element.fixed && !schema.key?("const")
|
|
190
|
+
schema
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def attribute_schema(attribute)
|
|
194
|
+
schema = if attribute.simple_type
|
|
195
|
+
simple_type_schema(attribute.simple_type)
|
|
196
|
+
elsif attribute.type
|
|
197
|
+
type_schema_for(attribute.type)
|
|
198
|
+
else
|
|
199
|
+
{ "type" => "string" }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
schema = { "const" => attribute.fixed } if attribute.fixed
|
|
203
|
+
schema
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def simple_type_schema(simple_type)
|
|
207
|
+
restriction = simple_type.restriction
|
|
208
|
+
return type_schema_for(restriction.base) if restriction.nil? || restriction.base.nil?
|
|
209
|
+
|
|
210
|
+
schema = type_schema_for(restriction.base)
|
|
211
|
+
|
|
212
|
+
Array(restriction.enumeration).each { |e| (schema["enum"] ||= []) << e.value }
|
|
213
|
+
FACET_MAP.each do |facet, json_key|
|
|
214
|
+
value = Array(restriction.public_send(facet)).first
|
|
215
|
+
schema[json_key] = facet_value(value, json_key) if value
|
|
216
|
+
rescue NoMethodError
|
|
217
|
+
next
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
schema
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def facet_value(value, json_key)
|
|
224
|
+
return value.to_i if %w[minLength maxLength].include?(json_key)
|
|
225
|
+
|
|
226
|
+
return value.to_f if %w[minimum maximum exclusiveMinimum exclusiveMaximum].include?(json_key)
|
|
227
|
+
|
|
228
|
+
value
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def type_schema_for(type_name)
|
|
232
|
+
local = type_name.to_s.split(":").last
|
|
233
|
+
builtin = BUILTIN_TYPES[local]
|
|
234
|
+
return builtin.dup if builtin
|
|
235
|
+
|
|
236
|
+
{ "$ref" => "#/$defs/#{local}" }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -67,7 +67,7 @@ module Lutaml
|
|
|
67
67
|
# Clear lazy XML element references that hold non-serializable
|
|
68
68
|
# adapter nodes throughout the object graph.
|
|
69
69
|
# lutaml-model's :lazy import_declaration_plan mode stores
|
|
70
|
-
# XmlElement references (containing
|
|
70
|
+
# XmlElement references (containing XML adapter nodes) on every
|
|
71
71
|
# model instance during deserialization.
|
|
72
72
|
clear_pending_plan_references(schema)
|
|
73
73
|
Marshal.dump(schema)
|
|
@@ -201,7 +201,7 @@ module Lutaml
|
|
|
201
201
|
private
|
|
202
202
|
|
|
203
203
|
# Recursively walk an object graph and clear pending_plan_root_element
|
|
204
|
-
# on every Lutaml::Model::Serializable instance. This removes
|
|
204
|
+
# on every Lutaml::Model::Serializable instance. This removes XML
|
|
205
205
|
# adapter_node references that prevent Marshal.dump from succeeding.
|
|
206
206
|
#
|
|
207
207
|
# @param root [Object] Root of the object graph to walk
|