diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d41faa0..c34bd01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,27 +14,25 @@ concurrency: jobs: build: - name: Build (${{ matrix.os }}) + name: Build (Java ${{ matrix.java }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest] + java: ['21', '25'] steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up toolchain (Java) via mise - uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3 with: - cache: true + persist-credentials: false - - name: Cache Maven repository - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Set up Java + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: - path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: ${{ runner.os }}-maven- + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven - name: Build and verify run: ./mvnw --batch-mode --no-transfer-progress verify @@ -44,6 +42,6 @@ jobs: continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: jacoco-reports + name: jacoco-reports-java-${{ matrix.java }} path: '**/target/site/jacoco/**' if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index a51ece6..2f8dc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,16 +17,24 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - Offline integration coverage for real and synthetic DATEX II 2.x/3.x XML and JSON feeds. - Runnable, tested examples and separate user/contributor documentation paths. - Documentation checks based on `mdbook-lint` and Lychee. +- Consumer-classpath verification for applications that install a single DATEX II model. +- Adversarial XML tests covering DTDs, external entities, and recursive entity expansion. ### Changed - Documentation now reflects the per-version model architecture, complete version support, and ownership boundaries between NAP pages, source indexes, and fixture metadata. +- Java 21 is now the consumer baseline, with Java 21 and 25 both verified in CI. +- XML and JSON facades no longer pull every generated model transitively; applications select the + version artifacts they need. +- JAXB contexts and compiled XML Schemas are reused per model version and classloader. ### Fixed - Corrected stale v3-only statements, obsolete generated-model paths, broken links to integration tests, and duplicated fixture headings. +- Hardened XML reading and validation so documents containing DTDs or entity declarations are + rejected before JAXB binding or schema validation. [Unreleased]: https://github.com/juherr/datex4j/commits/main [Keep a Changelog]: https://keepachangelog.com/en/1.1.0/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6705009..2de09f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ boundary between generated standards models and handwritten library code. ## Prerequisites -The project requires Java 25. [mise](https://mise.jdx.dev) provisions the JDK, and the committed +The project requires Java 21 or newer. [mise](https://mise.jdx.dev) provisions the baseline JDK, +CI also verifies Java 25, and the committed Maven wrapper pins Maven. ```bash @@ -31,7 +32,9 @@ Each version module owns its schemas, XJC bindings, generated classes, and a sma facades to discover only the versions present on the classpath. Handwritten support code lives in the facade, helper, domain, and integration modules. The -`examples` and `datex4j-integration-tests` modules verify public behavior and are not published. +`examples`, `datex4j-consumer-tests`, and `datex4j-integration-tests` modules verify public behavior +and are not published. Consumer tests deliberately install one model version and must remain free +of the all-version aggregate. ## Generated sources diff --git a/README.md b/README.md index d25ad12..749798c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ datex4j reads, writes, validates, and converts DATEX II publications without exp its public facades. The SDK bundles generated models for DATEX II 2.0–2.3 and 3.0–3.7, provides helpers for common user domains, and has no application-framework dependency. -- **Java 25**, JPMS-compatible, and framework-free. +- **Java 21+**, JPMS-compatible, and framework-free. - **Generated DATEX II models** built reproducibly from vendored official XML Schemas. - **Small public facades** for XML, JSON, and structured validation. - **Optional domain modules** for traffic, SRTI, parking, EV charging, and UVAR. @@ -80,8 +80,9 @@ in every version transitively. The AFIR-specific `AfirEnergyInfrastructure` and packages are part of the 3.7 model; earlier versions expose only the modules defined by their own root schema. -Domain builders currently target DATEX II 3.7. The XML, JSON, and validation facades can target any -bundled version when the corresponding model artifact is present. +Domain builders currently target DATEX II 3.7. The XML and validation facades can target any +bundled version when the corresponding model artifact is present. Conformant JSON fixtures cover +DATEX II 3.6 and 3.7. See [Models and versions](docs/guides/models-and-versions.md) for dependency choices, version selection, and the differences between DATEX II 2.x and 3.x. @@ -119,7 +120,7 @@ boundaries, SPI discovery, and JPMS strategy. ## Build from source -The project requires Java 25. The committed Maven wrapper pins Maven, while +The project requires Java 21 or newer. The committed Maven wrapper pins Maven, while [mise](https://mise.jdx.dev) provisions the JDK. ```bash diff --git a/datex4j-builders/pom.xml b/datex4j-builders/pom.xml index 97df029..98dcb02 100644 --- a/datex4j-builders/pom.xml +++ b/datex4j-builders/pom.xml @@ -22,7 +22,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 dev.juherr.datex4j diff --git a/datex4j-consumer-tests/pom.xml b/datex4j-consumer-tests/pom.xml new file mode 100644 index 0000000..52e150c --- /dev/null +++ b/datex4j-consumer-tests/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + dev.juherr.datex4j + datex4j + 0.1.0-SNAPSHOT + + + datex4j-consumer-tests + jar + + datex4j-consumer-tests + Consumer-classpath tests for optional DATEX II model isolation. Not published. + + + true + true + + + + + dev.juherr.datex4j + datex4j-xml + test + + + dev.juherr.datex4j + datex4j-json + ${project.version} + test + + + dev.juherr.datex4j + datex4j-model-v3_7 + test + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/datex4j-consumer-tests/src/test/java/dev/juherr/datex4j/consumer/SingleModelClasspathTest.java b/datex4j-consumer-tests/src/test/java/dev/juherr/datex4j/consumer/SingleModelClasspathTest.java new file mode 100644 index 0000000..f1d2770 --- /dev/null +++ b/datex4j-consumer-tests/src/test/java/dev/juherr/datex4j/consumer/SingleModelClasspathTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 the datex4j authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.juherr.datex4j.consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.juherr.datex4j.core.DatexVersion; +import dev.juherr.datex4j.json.DatexJson; +import dev.juherr.datex4j.model.spi.DatexModelProvider; +import dev.juherr.datex4j.xml.DatexXml; +import java.util.ServiceLoader; +import java.util.stream.StreamSupport; +import org.junit.jupiter.api.Test; + +class SingleModelClasspathTest { + + @Test + void exposesOnlyTheModelExplicitlySelectedByTheConsumer() { + Iterable providers = ServiceLoader.load(DatexModelProvider.class); + + assertThat(StreamSupport.stream(providers.spliterator(), false).map(DatexModelProvider::version)) + .containsExactly(DatexVersion.V3_7); + } + + @Test + void createsXmlAndJsonFacadesForTheInstalledModel() { + assertThatCode(() -> DatexXml.builder().version(DatexVersion.V3_7).build()) + .doesNotThrowAnyException(); + assertThatCode(() -> DatexJson.builder().version(DatexVersion.V3_7).build()) + .doesNotThrowAnyException(); + } + + @Test + void reportsAUsefulErrorForAModelThatIsNotInstalled() { + assertThatThrownBy(() -> DatexXml.builder().version(DatexVersion.V3_6).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("datex4j-model-v3_6"); + assertThatThrownBy(() -> DatexJson.builder().version(DatexVersion.V3_6).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("datex4j-model-v3_6"); + } +} diff --git a/datex4j-domain-evcharging/pom.xml b/datex4j-domain-evcharging/pom.xml index 6289c05..6580253 100644 --- a/datex4j-domain-evcharging/pom.xml +++ b/datex4j-domain-evcharging/pom.xml @@ -25,7 +25,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-domain-parking/pom.xml b/datex4j-domain-parking/pom.xml index 9e1cf3f..8da9846 100644 --- a/datex4j-domain-parking/pom.xml +++ b/datex4j-domain-parking/pom.xml @@ -23,7 +23,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-domain-srti/pom.xml b/datex4j-domain-srti/pom.xml index 6171082..fc0a0f1 100644 --- a/datex4j-domain-srti/pom.xml +++ b/datex4j-domain-srti/pom.xml @@ -25,7 +25,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-domain-traffic/pom.xml b/datex4j-domain-traffic/pom.xml index ff5bfb7..d58f3da 100644 --- a/datex4j-domain-traffic/pom.xml +++ b/datex4j-domain-traffic/pom.xml @@ -23,7 +23,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-domain-uvar/pom.xml b/datex4j-domain-uvar/pom.xml index 701c5f0..e68369a 100644 --- a/datex4j-domain-uvar/pom.xml +++ b/datex4j-domain-uvar/pom.xml @@ -25,7 +25,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-integration-tests/pom.xml b/datex4j-integration-tests/pom.xml index 48e732f..5b9a8ac 100644 --- a/datex4j-integration-tests/pom.xml +++ b/datex4j-integration-tests/pom.xml @@ -39,6 +39,12 @@ ${project.version} test + + dev.juherr.datex4j + datex4j-model + ${project.version} + test + org.junit.jupiter junit-jupiter diff --git a/datex4j-json/pom.xml b/datex4j-json/pom.xml index 16e280b..2117eeb 100644 --- a/datex4j-json/pom.xml +++ b/datex4j-json/pom.xml @@ -23,6 +23,7 @@ dev.juherr.datex4j datex4j-model + test dev.juherr.datex4j diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJson.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJson.java index d4b14e0..f2c9267 100644 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJson.java +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJson.java @@ -40,9 +40,9 @@ * National Access Points (NAPs), anchored against a real-world Fintraffic AFIR fixture; it may * need to adapt once an official JSON binding is published. * - *

Both bundled DATEX II model versions are supported via {@link - * Builder#version(DatexVersion)}: pass {@link DatexVersion#V3_6} or {@link DatexVersion#V3_7} (the - * default is {@link DatexVersion#current()}). + *

DATEX II 3.6 and 3.7 are covered by conformant JSON fixtures. Select the model through {@link + * Builder#version(DatexVersion)} and add the matching {@code datex4j-model-vX_Y} artifact at + * runtime. The default is {@link DatexVersion#current()}. * *

Use {@link #createMapper()} for the default configuration (pretty-printed) or {@link * #builder()} to customize it. diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJsonMapper.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJsonMapper.java index dbaf3a8..2260d00 100644 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJsonMapper.java +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/DatexJsonMapper.java @@ -38,7 +38,7 @@ * DATEX II model, and maps the XML temporal types via {@link DatexTemporalModule}. Instances are * immutable and thread-safe and may be shared across an application. * - *

Any DATEX II object of any bundled version can be written; {@link #read} returns the + *

Any object from the configured DATEX II model can be written; {@link #read} returns the * requested type. A {@code MessageContainer} value is the one exception: {@link #write} and {@link * #writeToString} emit the conformant {@code payload}/{@code exchangeInformation} envelope for it * (see {@link #readContainer}), while every other type is serialized directly with no wrapper. diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/DatexJsonModule.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/DatexJsonModule.java index f6fce6d..e3df15b 100644 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/DatexJsonModule.java +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/DatexJsonModule.java @@ -90,20 +90,27 @@ public DatexJsonModule(DatexVersion version) { @SuppressWarnings("unchecked") private void registerMultilingualString(DatexVersion version) { - if (version == DatexVersion.V3_7) { - addSerializer( - dev.juherr.datex4j.model.v3_7.common.MultilingualString.class, - new MultilingualStringJson.Serializer()); - addDeserializer( - dev.juherr.datex4j.model.v3_7.common.MultilingualString.class, - new MultilingualStringJson.Deserializer()); - } else { - addSerializer( - dev.juherr.datex4j.model.v3_6.common.MultilingualString.class, - new MultilingualString36Json.Serializer()); - addDeserializer( - dev.juherr.datex4j.model.v3_6.common.MultilingualString.class, - new MultilingualString36Json.Deserializer()); + String commonPackage = MODEL_PACKAGE_PREFIX + version.packageSegment() + ".common."; + Class stringType = loadModelClass(commonPackage + "MultilingualString", version); + Class valueType = loadModelClass(commonPackage + "MultilingualStringValue", version); + addSerializer((Class) stringType, new MultilingualStringJson.Serializer()); + addDeserializer((Class) stringType, new MultilingualStringJson.Deserializer<>(stringType, valueType)); + } + + private static Class loadModelClass(String className, DatexVersion version) { + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + if (loader == null) { + loader = DatexJsonModule.class.getClassLoader(); + } + try { + return Class.forName(className, true, loader); + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + "DATEX II model " + + version + + " is not available; add dev.juherr.datex4j:datex4j-model-" + + version.packageSegment(), + e); } } diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualString36Json.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualString36Json.java deleted file mode 100644 index 75fbfc6..0000000 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualString36Json.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2026 the datex4j authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package dev.juherr.datex4j.json.internal; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import dev.juherr.datex4j.model.v3_6.common.MultilingualString; -import dev.juherr.datex4j.model.v3_6.common.MultilingualStringValue; -import java.io.IOException; -import java.util.List; - -/** - * DATEX II v3.6 counterpart of {@link MultilingualStringJson}, flattening the v3.6 {@link - * MultilingualString} model into its conformant {@code {"values":[{lang,value}]}} JSON encoding. - * - *

The v3.6 and v3.7 {@code MultilingualString} classes are distinct generated types with - * identical shape; the logic mirrors {@link MultilingualStringJson} exactly. - */ -public final class MultilingualString36Json { - - private MultilingualString36Json() {} - - /** Serializes a v3.6 {@link MultilingualString} to the flat {@code {"values":[...]}} JSON shape. */ - public static final class Serializer extends JsonSerializer { - @Override - public void serialize(MultilingualString value, JsonGenerator generator, SerializerProvider serializers) - throws IOException { - generator.writeStartObject(); - generator.writeArrayFieldStart("values"); - List values = - value.getValues() == null ? List.of() : value.getValues().getValue(); - for (MultilingualStringValue msv : values) { - generator.writeStartObject(); - generator.writeStringField("lang", msv.getLang()); - generator.writeStringField("value", msv.getValue()); - generator.writeEndObject(); - } - generator.writeEndArray(); - generator.writeEndObject(); - } - } - - /** Deserializes the flat {@code {"values":[...]}} JSON shape back into a v3.6 {@link MultilingualString}. */ - public static final class Deserializer extends JsonDeserializer { - @Override - public MultilingualString deserialize(JsonParser parser, DeserializationContext context) throws IOException { - JsonNode root = parser.getCodec().readTree(parser); - MultilingualString.Values values = new MultilingualString.Values(); - JsonNode valuesNode = root.get("values"); - if (valuesNode != null) { - for (JsonNode item : valuesNode) { - MultilingualStringValue msv = new MultilingualStringValue(); - JsonNode lang = item.get("lang"); - if (lang != null) { - msv.setLang(lang.asText()); - } - JsonNode text = item.get("value"); - if (text != null) { - msv.setValue(text.asText()); - } - values.getValue().add(msv); - } - } - MultilingualString result = new MultilingualString(); - result.setValues(values); - return result; - } - } -} diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualStringJson.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualStringJson.java index c285289..f9b6d13 100644 --- a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualStringJson.java +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/MultilingualStringJson.java @@ -22,19 +22,19 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; -import dev.juherr.datex4j.model.v3_7.common.MultilingualString; -import dev.juherr.datex4j.model.v3_7.common.MultilingualStringValue; import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; import java.util.List; /** - * Jackson (de)serializers flattening the DATEX II {@link MultilingualString} model into its - * conformant JSON encoding. + * Version-neutral Jackson (de)serializers flattening a generated DATEX II + * {@code MultilingualString} into its conformant JSON encoding. * *

The generated JAXB model nests the language/value pairs under an intermediate {@code Values} * wrapper ({@code getValues().getValue()}), mirroring the XML schema's anonymous wrapper element. * The conformant DATEX II JSON encoding flattens that wrapper away, exposing a single {@code - * values} array of {@code {lang, value}} objects directly on the {@link MultilingualString}: + * values} array of {@code {lang, value}} objects directly on the generated string: * *

{@code {"values":[{"lang":"fi","value":"Kärkitie 4"}]}}
*/ @@ -42,50 +42,97 @@ public final class MultilingualStringJson { private MultilingualStringJson() {} - /** Serializes a {@link MultilingualString} to the flat {@code {"values":[...]}} JSON shape. */ - public static final class Serializer extends JsonSerializer { + /** Serializes any generated multilingual string to the flat {@code {"values":[...]}} shape. */ + public static final class Serializer extends JsonSerializer { @Override - public void serialize(MultilingualString value, JsonGenerator generator, SerializerProvider serializers) + public void serialize(Object value, JsonGenerator generator, SerializerProvider serializers) throws IOException { - generator.writeStartObject(); - generator.writeArrayFieldStart("values"); - List values = - value.getValues() == null ? List.of() : value.getValues().getValue(); - for (MultilingualStringValue msv : values) { + try { + Method getValues = value.getClass().getMethod("getValues"); + Object wrapper = getValues.invoke(value); + List values = wrapper == null + ? List.of() + : (List) wrapper.getClass().getMethod("getValue").invoke(wrapper); + generator.writeStartObject(); - generator.writeStringField("lang", msv.getLang()); - generator.writeStringField("value", msv.getValue()); + generator.writeArrayFieldStart("values"); + for (Object item : values) { + generator.writeStartObject(); + generator.writeStringField("lang", (String) + item.getClass().getMethod("getLang").invoke(item)); + generator.writeStringField("value", (String) + item.getClass().getMethod("getValue").invoke(item)); + generator.writeEndObject(); + } + generator.writeEndArray(); generator.writeEndObject(); + } catch (ReflectiveOperationException e) { + throw new IOException( + "Failed to serialize DATEX II multilingual string " + + value.getClass().getName(), + e); } - generator.writeEndArray(); - generator.writeEndObject(); } } - /** Deserializes the flat {@code {"values":[...]}} JSON shape back into a {@link MultilingualString}. */ - public static final class Deserializer extends JsonDeserializer { + /** Deserializes the flat JSON shape into one version-specific generated string type. */ + public static final class Deserializer extends JsonDeserializer { + private final Constructor stringConstructor; + private final Constructor valuesConstructor; + private final Constructor valueConstructor; + private final Method setValues; + private final Method values; + private final Method setLang; + private final Method setValue; + + /** Creates a deserializer for the supplied generated multilingual string class. */ + public Deserializer(Class stringType, Class valueType) { + try { + Class valuesType = + Class.forName(stringType.getName() + "$Values", true, stringType.getClassLoader()); + this.stringConstructor = stringType.getDeclaredConstructor(); + this.valuesConstructor = valuesType.getDeclaredConstructor(); + this.valueConstructor = valueType.getDeclaredConstructor(); + this.setValues = stringType.getMethod("setValues", valuesType); + this.values = valuesType.getMethod("getValue"); + this.setLang = valueType.getMethod("setLang", String.class); + this.setValue = valueType.getMethod("setValue", String.class); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Unsupported DATEX II multilingual string type " + stringType.getName(), e); + } + } + @Override - public MultilingualString deserialize(JsonParser parser, DeserializationContext context) throws IOException { + public T deserialize(JsonParser parser, DeserializationContext context) throws IOException { JsonNode root = parser.getCodec().readTree(parser); - MultilingualString.Values values = new MultilingualString.Values(); - JsonNode valuesNode = root.get("values"); - if (valuesNode != null) { - for (JsonNode item : valuesNode) { - MultilingualStringValue msv = new MultilingualStringValue(); - JsonNode lang = item.get("lang"); - if (lang != null) { - msv.setLang(lang.asText()); - } - JsonNode text = item.get("value"); - if (text != null) { - msv.setValue(text.asText()); + try { + Object wrapper = valuesConstructor.newInstance(); + @SuppressWarnings("unchecked") + List generatedValues = (List) values.invoke(wrapper); + JsonNode valuesNode = root.get("values"); + if (valuesNode != null) { + for (JsonNode item : valuesNode) { + Object generatedValue = valueConstructor.newInstance(); + JsonNode lang = item.get("lang"); + if (lang != null) { + setLang.invoke(generatedValue, lang.asText()); + } + JsonNode text = item.get("value"); + if (text != null) { + setValue.invoke(generatedValue, text.asText()); + } + generatedValues.add(generatedValue); } - values.getValue().add(msv); } + Object result = stringConstructor.newInstance(); + setValues.invoke(result, wrapper); + @SuppressWarnings("unchecked") + T typedResult = (T) result; + return typedResult; + } catch (ReflectiveOperationException e) { + throw new IOException("Failed to deserialize DATEX II multilingual string", e); } - MultilingualString result = new MultilingualString(); - result.setValues(values); - return result; } } } diff --git a/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/package-info.java b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/package-info.java new file mode 100644 index 0000000..6f4c80d --- /dev/null +++ b/datex4j-json/src/main/java/dev/juherr/datex4j/json/internal/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 the datex4j authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal Jackson integration details. + * + *

Types in this package are implementation details, are not part of datex4j's supported public + * API, and may change without deprecation. Applications should use {@link + * dev.juherr.datex4j.json.DatexJson} and {@link + * dev.juherr.datex4j.json.DatexJsonMapper}. + */ +package dev.juherr.datex4j.json.internal; diff --git a/datex4j-json/src/test/java/dev/juherr/datex4j/json/internal/MultilingualStringJsonTest.java b/datex4j-json/src/test/java/dev/juherr/datex4j/json/internal/MultilingualStringJsonTest.java index 7f9dcdc..cc2515d 100644 --- a/datex4j-json/src/test/java/dev/juherr/datex4j/json/internal/MultilingualStringJsonTest.java +++ b/datex4j-json/src/test/java/dev/juherr/datex4j/json/internal/MultilingualStringJsonTest.java @@ -29,7 +29,9 @@ class MultilingualStringJsonTest { private static ObjectMapper newMapper() { SimpleModule module = new SimpleModule(); module.addSerializer(MultilingualString.class, new MultilingualStringJson.Serializer()); - module.addDeserializer(MultilingualString.class, new MultilingualStringJson.Deserializer()); + module.addDeserializer( + MultilingualString.class, + new MultilingualStringJson.Deserializer<>(MultilingualString.class, MultilingualStringValue.class)); ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(module); return mapper; diff --git a/datex4j-location/pom.xml b/datex4j-location/pom.xml index d86ac0d..1e56a79 100644 --- a/datex4j-location/pom.xml +++ b/datex4j-location/pom.xml @@ -21,7 +21,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 diff --git a/datex4j-ocpi/pom.xml b/datex4j-ocpi/pom.xml index 0d71868..31e82d9 100644 --- a/datex4j-ocpi/pom.xml +++ b/datex4j-ocpi/pom.xml @@ -37,7 +37,7 @@ dev.juherr.datex4j - datex4j-model + datex4j-model-v3_7 ${project.version} diff --git a/datex4j-validation/pom.xml b/datex4j-validation/pom.xml index 9d48d8b..f574edf 100644 --- a/datex4j-validation/pom.xml +++ b/datex4j-validation/pom.xml @@ -18,6 +18,10 @@ Structured, error-collecting validation of DATEX II documents against the official XML Schemas. + + 0.85 + + dev.juherr.datex4j @@ -33,6 +37,11 @@ junit-jupiter test + + dev.juherr.datex4j + datex4j-model + test + diff --git a/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java b/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java index 5b658a5..1ce892d 100644 --- a/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java +++ b/datex4j-validation/src/main/java/dev/juherr/datex4j/validation/DatexValidator.java @@ -20,14 +20,15 @@ import dev.juherr.datex4j.xml.DatexMarshaller; import dev.juherr.datex4j.xml.DatexSchemaFactory; import dev.juherr.datex4j.xml.DatexXml; +import dev.juherr.datex4j.xml.SecureXmlSource; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; import javax.xml.XMLConstants; +import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.Source; -import javax.xml.transform.stream.StreamSource; import javax.xml.validation.Schema; import javax.xml.validation.Validator; import org.xml.sax.ErrorHandler; @@ -88,7 +89,7 @@ public static DatexValidator forVersion(DatexVersion version) { * @throws DatexValidationException if the document cannot be read */ public ValidationResult validate(byte[] xml) { - return validate(new StreamSource(new ByteArrayInputStream(xml))); + return validate(new ByteArrayInputStream(xml)); } /** @@ -99,7 +100,11 @@ public ValidationResult validate(byte[] xml) { * @throws DatexValidationException if the document cannot be read */ public ValidationResult validate(InputStream in) { - return validate(new StreamSource(in)); + try { + return validate(SecureXmlSource.from(in)); + } catch (ParserConfigurationException | SAXException e) { + throw new DatexValidationException("Failed to configure secure XML validation", e); + } } /** @@ -127,6 +132,9 @@ private ValidationResult validate(Source source) { try { validator.validate(source); } catch (SAXException e) { + if (e.getCause() instanceof IOException io) { + throw new DatexValidationException("Failed to read the document to validate", io); + } // A fatal error was already reported to the handler before the parser gave up. handler.recordFatalIfEmpty(e); } catch (IOException e) { @@ -135,7 +143,7 @@ private ValidationResult validate(Source source) { return new ValidationResult(handler.messages()); } - private static final class CollectingErrorHandler implements ErrorHandler { + static final class CollectingErrorHandler implements ErrorHandler { private final List messages = new ArrayList<>(); diff --git a/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java index 18db5d0..58bd7eb 100644 --- a/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java +++ b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/DatexValidatorTest.java @@ -28,6 +28,7 @@ import javax.xml.datatype.DatatypeConfigurationException; import javax.xml.datatype.DatatypeFactory; import org.junit.jupiter.api.Test; +import org.xml.sax.SAXException; class DatexValidatorTest { @@ -54,6 +55,52 @@ void collectsErrorsForAnInvalidDocument() { assertThat(result.errors().get(0).lineNumber()).isPositive(); } + @Test + void rejectsDocumentsThatDeclareEntities() { + String valid = DatexXml.builder().build().writeToString(validPublication()); + String invalid = valid.replaceFirst("\\?>", "?>]>") + .replace("lang=\"en\"", "lang=\"&probe;\""); + + ValidationResult result = validator.validate(invalid.getBytes(StandardCharsets.UTF_8)); + + assertThat(result.isValid()).isFalse(); + assertThat(result.errors()) + .anySatisfy(message -> assertThat(message.message()).containsIgnoringCase("DOCTYPE")); + } + + @Test + void reportsMalformedXmlAsAFatalValidationMessage() { + ValidationResult result = validator.validate(" validator.validate(failing)) + .isInstanceOf(DatexValidationException.class) + .hasMessageContaining("Failed to read the document to validate") + .hasRootCauseMessage("simulated read failure"); + } + @Test void acceptsAValidMessageContainerDocument() { byte[] xml = resource("/messagecontainer/message-container-v3_7.xml"); diff --git a/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/ValidationResultTest.java b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/ValidationResultTest.java new file mode 100644 index 0000000..7413731 --- /dev/null +++ b/datex4j-validation/src/test/java/dev/juherr/datex4j/validation/ValidationResultTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2026 the datex4j authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.juherr.datex4j.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.juherr.datex4j.validation.ValidationMessage.Severity; +import java.util.ArrayList; +import org.junit.jupiter.api.Test; +import org.xml.sax.SAXParseException; + +class ValidationResultTest { + + @Test + void separatesWarningsFromErrorsAndFatalMessages() { + var warning = new ValidationMessage(Severity.WARNING, "warning", 1, 2); + var error = new ValidationMessage(Severity.ERROR, "error", 3, 4); + var fatal = new ValidationMessage(Severity.FATAL, "fatal", 5, 6); + + ValidationResult result = new ValidationResult(new ArrayList<>(java.util.List.of(warning, error, fatal))); + + assertThat(result.isValid()).isFalse(); + assertThat(result.messages()).containsExactly(warning, error, fatal); + assertThat(result.warnings()).containsExactly(warning); + assertThat(result.errors()).containsExactly(error, fatal); + assertThatThrownBy(() -> result.messages().add(warning)).isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void warningsAloneDoNotInvalidateADocument() { + var warning = new ValidationMessage(Severity.WARNING, "warning", 1, 2); + + ValidationResult result = new ValidationResult(java.util.List.of(warning)); + + assertThat(result.isValid()).isTrue(); + assertThat(result.errors()).isEmpty(); + } + + @Test + void preservesWarningLocationFromTheSchemaValidator() { + var handler = new DatexValidator.CollectingErrorHandler(); + + handler.warning(new SAXParseException("schema warning", null, null, 12, 34)); + + assertThat(handler.messages()) + .containsExactly(new ValidationMessage(Severity.WARNING, "schema warning", 12, 34)); + } +} diff --git a/datex4j-xml/pom.xml b/datex4j-xml/pom.xml index 626f0f7..f16e31d 100644 --- a/datex4j-xml/pom.xml +++ b/datex4j-xml/pom.xml @@ -18,16 +18,21 @@ Small, JAXB-hiding API for reading, writing, validating and pretty-printing DATEX II XML. + + 0.85 + + dev.juherr.datex4j datex4j-model-spi - + dev.juherr.datex4j datex4j-model + test dev.juherr.datex4j @@ -52,6 +57,16 @@ + + org.jacoco + jacoco-maven-plugin + + + + **/ClasspathSchemas$StreamLsInput* + + + - 25 + 21 UTF-8 UTF-8 @@ -97,6 +98,7 @@ 3.8.0 2.96.0 0.8.15 + 0.00 3.4.0 @@ -358,6 +360,27 @@ report + + jacoco-check + verify + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + ${jacoco.line.minimum} + + + + + +