From 70c3afc68040aaf2edd332a0991d798a7ac44a0e Mon Sep 17 00:00:00 2001 From: Aravinda Baliga B Date: Mon, 3 Aug 2026 14:49:22 +0200 Subject: [PATCH 1/7] Improve validation error messages and readme, and add build workflow. --- .github/workflows/build.yml | 50 ++ .mvn/jvm.config | 1 + .mvn/settings.xml | 63 +++ LICENSE | 174 +++++++ README.md | 212 +++++++- core/pom.xml | 24 +- .../openepcis/validation/SchemaValidator.java | 91 ++-- .../io/openepcis/validation/Validator.java | 23 +- .../exception/SchemaValidationException.java | 14 +- .../validation/json/JsonErrorHandler.java | 41 +- .../validation/json/JsonSchemaValidator.java | 129 +++-- .../message/JsonErrorFormatter.java | 467 ++++++++++++++++++ .../validation/message/MessageTemplates.java | 224 +++++++++ .../validation/message/ValueSuggester.java | 70 +++ .../validation/model/ValidationError.java | 71 ++- .../model/xml/ValidationResult.java | 10 +- .../xml/AbstractXmlSchemaValidator.java | 114 ++--- .../validation/xml/EventModifier.java | 290 +++++------ .../validation/xml/XMLTagPreScanUtil.java | 54 +- .../validation/xml/Xml12SchemaValidator.java | 21 +- .../validation/xml/Xml20SchemaValidator.java | 21 +- .../validation/xml/XmlErrorHandler.java | 101 ++-- .../resolver/ClasspathResourceResolver.java | 7 +- .../validation/xml/resolver/LSInputImpl.java | 7 +- .../epcis-validation-messages.yaml | 339 +++++++++++++ .../validation/test/JsonDocumentTest.java | 125 ++--- .../test/JsonValidationMessageTest.java | 292 +++++++++++ .../validation/test/MessageCatalogueTest.java | 283 +++++++++++ .../validation/test/ValueSuggesterTest.java | 105 ++++ .../validation/test/XmlDocumentTest.java | 124 ++--- .../test/XmlValidationMessageTest.java | 211 ++++++++ .../test/resources/json/CaptureDocument.json | 48 -- .../CaptureDocumentWithCertification.json | 43 -- .../json/QueryDocumentWithEventType.json | 6 - .../json/queryDocumentWithAction.json | 5 - .../json/queryDocumentWithEventTime.json | 5 - .../queryDocumentWithInvalidEventType.json | 5 - ...ureDocument-complete-epcis-20-document.xml | 49 -- ...ptureDocument-independent-object-event.xml | 16 - .../xml/captureDocumentWithComment.xml | 300 ----------- core/src/test/resources/xml/queryDocument.xml | 19 - .../xml/queryDocumentWithEventType.xml | 23 - .../xml/queryDocumentWithMissingValue.xml | 21 - .../xml/queryDocumentWithReadPoint.xml | 24 - pom.xml | 2 +- quarkus/deployment/pom.xml | 2 +- .../deployment/SchemaValidatorProcessor.java | 4 +- quarkus/pom.xml | 2 +- quarkus/rest-app/pom.xml | 2 +- .../rest/application/RestApplication.java | 2 +- quarkus/runtime/pom.xml | 2 +- .../runtime/SchemaValidatorHealthCheck.java | 2 +- .../runtime/SchemaValidatorProducer.java | 2 +- rest-api/pom.xml | 2 +- .../rest/resource/ResponseBodyExamples.java | 2 +- .../rest/resource/ValidationResource.java | 2 +- .../rest/resource/test/CDITestProvider.java | 2 +- .../resource/test/ValidationResourceTest.java | 2 +- restassured/pom.xml | 2 +- .../restassured/AbstractValidationTest.java | 2 +- servlet-api/pom.xml | 2 +- .../validation/servlet/ValidationServlet.java | 2 +- .../test/java/validation/CDITestProvider.java | 2 +- .../validation/ValidationServletTest.java | 2 +- 64 files changed, 3132 insertions(+), 1232 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .mvn/jvm.config create mode 100644 .mvn/settings.xml create mode 100644 LICENSE create mode 100644 core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java create mode 100644 core/src/main/java/io/openepcis/validation/message/MessageTemplates.java create mode 100644 core/src/main/java/io/openepcis/validation/message/ValueSuggester.java create mode 100644 core/src/main/resources/validation-messages/epcis-validation-messages.yaml create mode 100644 core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java create mode 100644 core/src/test/java/io/openepcis/validation/test/MessageCatalogueTest.java create mode 100644 core/src/test/java/io/openepcis/validation/test/ValueSuggesterTest.java create mode 100644 core/src/test/java/io/openepcis/validation/test/XmlValidationMessageTest.java delete mode 100644 core/src/test/resources/json/CaptureDocument.json delete mode 100644 core/src/test/resources/json/CaptureDocumentWithCertification.json delete mode 100644 core/src/test/resources/json/QueryDocumentWithEventType.json delete mode 100644 core/src/test/resources/json/queryDocumentWithAction.json delete mode 100644 core/src/test/resources/json/queryDocumentWithEventTime.json delete mode 100644 core/src/test/resources/json/queryDocumentWithInvalidEventType.json delete mode 100644 core/src/test/resources/xml/captureDocument-complete-epcis-20-document.xml delete mode 100644 core/src/test/resources/xml/captureDocument-independent-object-event.xml delete mode 100644 core/src/test/resources/xml/captureDocumentWithComment.xml delete mode 100644 core/src/test/resources/xml/queryDocument.xml delete mode 100644 core/src/test/resources/xml/queryDocumentWithEventType.xml delete mode 100644 core/src/test/resources/xml/queryDocumentWithMissingValue.xml delete mode 100644 core/src/test/resources/xml/queryDocumentWithReadPoint.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..62bb03a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,50 @@ +name: Build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Check out the code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + - name: Build and run tests + env: + GITLAB_MAVEN_URL: ${{ secrets.GITLAB_MAVEN_URL }} + GITLAB_MAVEN_USERNAME: ${{ secrets.GITLAB_DEPLOY_USER }} + GITLAB_MAVEN_TOKEN: ${{ secrets.GITLAB_DEPLOY_TOKEN }} + run: > + mvn -s .mvn/settings.xml + --batch-mode --no-transfer-progress --update-snapshots + -P gitlab-maven + clean verify + + - name: Upload test reports if the build failed + if: failure() + uses: actions/upload-artifact@v4 + with: + name: surefire-reports + path: '**/target/surefire-reports/**' + retention-days: 7 \ No newline at end of file diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 0000000..7cafa1e --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1 @@ +--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.desktop/java.awt.font=ALL-UNNAMED diff --git a/.mvn/settings.xml b/.mvn/settings.xml new file mode 100644 index 0000000..2547128 --- /dev/null +++ b/.mvn/settings.xml @@ -0,0 +1,63 @@ + + + + + + gitlab-maven + ${env.GITLAB_MAVEN_USERNAME} + ${env.GITLAB_MAVEN_TOKEN} + + + + + + + gitlab-maven + + true + + + + + gitlab-maven + ${env.GITLAB_MAVEN_URL} + + true + + + true + + + + + + + gitlab-maven + ${env.GITLAB_MAVEN_URL} + + true + + + true + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dd5b3a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,174 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/README.md b/README.md index 6e64432..be70aa9 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,199 @@ -# openepcis-document-validation-service +

+ OpenEPCIS +

-An open-source service to validate EPCIS documents ensuring compliance with EPCIS standards. +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![Release](https://img.shields.io/github/v/release/openepcis/openepcis-document-validation-service)](https://github.com/openepcis/openepcis-document-validation-service/releases) +[![Stars](https://img.shields.io/github/stars/openepcis/openepcis-document-validation-service?style=social)](https://github.com/openepcis/openepcis-document-validation-service) -## Introduction +

OpenEPCIS Document Validation Service

-The `openepcis-document-validation-service` is designed to validate EPCIS documents to ensure they meet the required standards. EPCIS (Electronic Product Code Information Services) facilitates the exchange of visibility data within and across enterprises. +Validates if a provided [EPCIS](https://www.gs1.org/standards/epcis) document follows the GS1 standard, and +informs in plain language what is wrong when it does not. Works with EPCIS 2.0 JSON/JSON-LD and with +EPCIS 1.2 and 2.0 XML. -## Key Features +## Why -- Validates EPCIS documents against EPCIS 2.0 standards. -- Supports both XML and JSON/JSON-LD formats. -- Provides detailed validation error messages. -- RESTful API for easy integration. +An EPCIS document that looks fine can still be rejected, and the reason is usually buried in schema. A validator that responds with `does not match the uri pattern` leaves users +guessing which field/attribute is not adhering to the standard. This service answers with the field, the users provided, and where in the document issue lies: For example if EPCIS +document contains invalid `bizStep` such as `transforming` then tool responds with: -## Getting Started +``` +"transforming" is not a standard EPCIS business step for bizStep in event 1 (ObjectEvent). +Did you mean "transporting"? Use one of the 41 standard steps (accepting, arriving, assembling, +collecting, commissioning, consigning and 35 more), or, for a step of your own, a full web address +such as https://example.com/bizStep/transforming. +``` -### Prerequisites +Each invalid value corresponds to one error to avoid chain of errors due to one field violation. All validation processing occur locally nothing reaches network, since every schema +is +bundled in the jar. -- Java 11 or higher -- Maven 3.6 or higher -- Docker (optional, for containerized deployment) +## Getting started -### Installation +Run the demo service in dev mode. It listens on port 9000, with Swagger UI at +[localhost:9000/q/swagger-ui](http://localhost:9000/q/swagger-ui): -1. Clone the repository: - ```sh - git clone https://github.com/openepcis/openepcis-document-validation-service.git - cd openepcis-document-validation-service - ``` +```bash +git clone https://github.com/openepcis/openepcis-document-validation-service.git +cd openepcis-document-validation-service +mvn -pl quarkus/rest-app -am quarkus:dev +``` -2. Build the project using Maven: - ```sh - mvn clean install - ``` +Send it a document: + +```bash +curl -X POST 'http://localhost:9000/api/events/validate' \ + -H 'Content-Type: application/json' \ + -H 'GS1-EPCIS-Version: 2.0.0' \ + --data-binary @my-epcis-document.json +``` + +An empty `200` means the document is valid. A `400` returns the list of problems. + +## The API + +`POST /api/events/validate` + +| | | +|-----------------------------|---------------------------------------------------------------------------| +| Request body | the EPCIS document | +| `Content-Type` | `application/json`, `application/ld+json` or `application/xml` | +| `GS1-EPCIS-Version` header | `2.0.0` (default) or `1.2.0` | +| `epcisDocumentSchema` query | `capture` (default) or `query` | +| `Accept` | `application/json` for a JSON array, `application/xml` for a JAXB wrapper | + +| Response | Meaning | +|----------|-------------------------------------------| +| `200` | valid, empty body | +| `400` | not valid, body lists the problems | +| `415` | media type or EPCIS version not supported | + +### An example error format + +```json +[ + { + "type": "enum", + "line": "https://ref.gs1.org/standards/epcis/epcis-json-schema.json#/definitions/bizStep/anyOf/1/enum", + "location": "$.epcisBody.eventList[0].bizStep", + "message": "\"transforming\" is not a standard EPCIS business step for bizStep in event 1 (ObjectEvent). Did you mean \"transporting\"? Use one of the 41 standard steps (accepting, arriving, assembling, collecting, commissioning, consigning and 35 more), or, for a step of your own, a full web address such as https://example.com/bizStep/transforming.", + "field": "bizStep", + "value": "transforming", + "eventIndex": 0, + "eventType": "ObjectEvent", + "suggestion": "transporting", + "allowedValues": [ + "accepting", + "arriving", + "assembling", + "collecting", + "commissioning", + "..." + ] + } +] +``` + +`message` is written for a human to read. The other fields are there so a UI can do more with it: + +- `location` is a JSON path, so an editor can jump straight to the line. For XML, `line` carries the + line number instead. +- `field`, `value`, `eventIndex` and `eventType` let you say "event 6, bizStep" without parsing text. +- `suggestion` is the closest standard value, when the wrong one is a near miss. +- `allowedValues` is the full list, kept out of the message so it can sit behind a "show all" toggle. + +## Use it as a library + +Add the module you need. Versions come from the OpenEPCIS BOM. + +```xml + + + io.openepcis + openepcis-document-validation-service + + + + +io.openepcis +openepcis-document-validation-rest-api + + + + +io.openepcis +openepcis-document-validation-servlet-api + + + + +io.openepcis.quarkus +quarkus-document-validation-service + +``` + +Calling the validator directly: + +```java +// SchemaValidator is @RequestScoped, so inject it instead of keeping one instance around +@Inject +SchemaValidator schemaValidator; + +final Multi errors = schemaValidator.validate( + document, // InputStream + "application/json", // media type decides JSON or XML + EPCISDocumentType.CAPTURE, // or QUERY + EPCISVersion.VERSION_2_0_0); // or VERSION_1_2_0 +``` + +The stream stays empty when the document is valid. Each problem arrives as an item, so you can react to +the first one without waiting for the rest. It only fails when validation itself broke, for example on +malformed input. + +## Project layout + +| Module | What it does | +|-----------------------------------------|------------------------------------------------------------| +| `core` | the validator and the bundled schemas. No web dependencies | +| `rest-api` | JAX-RS resource returning `Uni>` | +| `servlet-api` | the same endpoint as a plain servlet | +| `quarkus/runtime`, `quarkus/deployment` | Quarkus extension pair | +| `quarkus/rest-app` | runnable demo service | +| `restassured` | shared HTTP test suite, so both front ends behave the same | + +Error wording lives in `core/src/main/resources/validation-messages/epcis-validation-messages.yaml`. +Rewording a message is a change to that file, not to Java. + +## Building + +Java 21 and Maven. The parent POM is the external OpenEPCIS BOM, so the first build downloads it. + +```bash +mvn clean install # everything +mvn -pl core test # one module +mvn -pl core test -Dtest=JsonValidationMessageTest # one test class +mvn -pl quarkus/rest-app -am verify -Pnative # native image +``` + +Test documents come from +[openepcis-test-resources](https://github.com/openepcis/openepcis-test-resources), including the +deliberately broken ones used to check the error messages. + +## Contributing + +Bug reports, new test documents and better error wording are all welcome. If you hit a message that +left you guessing, that is worth an issue on its own, since clear messages are the whole point of this +project. + +## Related + +- [OpenEPCIS Tools](https://tools.openepcis.io/) - open source EPCIS 2.0 tools and services +- [OpenEPCIS](https://openepcis.io/) - Read more about OpenEPCIS +- [OpenEPCIS Test Resources](https://github.com/openepcis/openepcis-test-resources) - EPCIS documents used for testing +- [benelog GmbH & Co. KG](https://www.benelog.com/) - Company behind the OpenEPCIS +- [GS1 EPCIS Standard](https://www.gs1.org/standards/epcis) - Learn more about EPCIS + +## License + +Licensed under the [Apache License 2.0](LICENSE). diff --git a/core/pom.xml b/core/pom.xml index 7db4c06..c3ad66c 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -1,7 +1,7 @@ - io.quarkus - quarkus-core + io.openepcis + openepcis-epcis-constants + compile + io.openepcis - openepcis-epcis-constants - compile + openepcis-test-resources + test - + org.projectlombok @@ -93,6 +95,10 @@ org.apache.commons commons-collections4 + + org.apache.commons + commons-text + jakarta.xml.bind diff --git a/core/src/main/java/io/openepcis/validation/SchemaValidator.java b/core/src/main/java/io/openepcis/validation/SchemaValidator.java index 2455fb2..25a6fc7 100644 --- a/core/src/main/java/io/openepcis/validation/SchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/SchemaValidator.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.networknt.schema.JsonSchemaFactory; +import io.openepcis.constants.EPCIS; import io.openepcis.constants.EPCISDocumentType; import io.openepcis.constants.EPCISVersion; import io.openepcis.validation.json.JsonSchemaValidator; @@ -24,65 +25,61 @@ import io.openepcis.validation.xml.Xml12SchemaValidator; import io.openepcis.validation.xml.Xml20SchemaValidator; import io.smallrye.mutiny.Multi; + import java.io.InputStream; +import java.util.Locale; public class SchemaValidator { - private final JsonSchemaValidator jsonSchemaValidator; - private final Xml20SchemaValidator xml20SchemaValidator; - private final Xml12SchemaValidator xml12SchemaValidator; - - public SchemaValidator(final ObjectMapper mapper, final JsonSchemaFactory factory) { - this.jsonSchemaValidator = new JsonSchemaValidator(mapper, factory); - this.xml20SchemaValidator = new Xml20SchemaValidator(); - this.xml12SchemaValidator = new Xml12SchemaValidator(); - } - - /** - * Method that accepts the EPCIS XML or JSON document/event and checks if it adheres to respective - * schema or not. - * - * @param epcisInputData input EPCIS document/event that needs to be validated against schema. - * @param mediaType format type of the input document: application/xml, application/json, - * application/json+ld, etc. - * @param epcisDocumentSchema type of EPCIS Document schema against which the document needs to be - * validated against: Capture or Query schema. - */ - public Multi validate( - final InputStream epcisInputData, - final String mediaType, - final EPCISDocumentType epcisDocumentSchema, - final EPCISVersion epcisVersion) { - Validator validator = getValidator(mediaType, epcisVersion); + private final JsonSchemaValidator jsonSchemaValidator; + private final Xml20SchemaValidator xml20SchemaValidator; + private final Xml12SchemaValidator xml12SchemaValidator; - if (epcisDocumentSchema.equals(EPCISDocumentType.CAPTURE)) { - return validator.validateAgainstCaptureSchema(epcisInputData); + public SchemaValidator(final ObjectMapper mapper, final JsonSchemaFactory factory) { + this.jsonSchemaValidator = new JsonSchemaValidator(mapper, factory); + this.xml20SchemaValidator = new Xml20SchemaValidator(); + this.xml12SchemaValidator = new Xml12SchemaValidator(); } - if (epcisDocumentSchema.equals(EPCISDocumentType.QUERY)) { - return validator.validateAgainstQuerySchema(epcisInputData); - } + /** + * Method that accepts the EPCIS XML or JSON document/event and checks if it adheres to respective schema or not. + * + * @param epcisInputData input EPCIS document/event that needs to be validated against schema. + * @param mediaType format type of the input document: application/xml, application/json, pplication/json+ld, etc. + * @param epcisDocumentSchema type of EPCIS Document schema against which the document needs to be validated against: Capture or Query schema. + */ + public Multi validate(final InputStream epcisInputData, + final String mediaType, + final EPCISDocumentType epcisDocumentSchema, + final EPCISVersion epcisVersion) { + Validator validator = getValidator(mediaType, epcisVersion); - throw new UnsupportedOperationException("Unsupported schema type : " + epcisDocumentSchema); - } + if (epcisDocumentSchema.equals(EPCISDocumentType.CAPTURE)) { + return validator.validateAgainstCaptureSchema(epcisInputData); + } - private Validator getValidator(final String mediaType, EPCISVersion epcisVersion) { + if (epcisDocumentSchema.equals(EPCISDocumentType.QUERY)) { + return validator.validateAgainstQuerySchema(epcisInputData); + } - if (mediaType.toLowerCase().contains("json")) { - return jsonSchemaValidator; + throw new UnsupportedOperationException("Unsupported schema type : " + epcisDocumentSchema); } - if (mediaType.toLowerCase().contains("xml")) { + private Validator getValidator(final String mediaType, EPCISVersion epcisVersion) { + if (mediaType.toLowerCase(Locale.ROOT).contains(EPCIS.JSON_FORMAT)) { + return jsonSchemaValidator; + } - if (EPCISVersion.VERSION_2_0_0.getSchemaVersion().equals(epcisVersion.getSchemaVersion())) { - return xml20SchemaValidator; - } + if (mediaType.toLowerCase(Locale.ROOT).contains(EPCIS.XML_FORMAT)) { + if (EPCISVersion.VERSION_2_0_0.getSchemaVersion().equals(epcisVersion.getSchemaVersion())) { + return xml20SchemaValidator; + } - if (EPCISVersion.VERSION_1_2_0.getSchemaVersion().equals(epcisVersion.getSchemaVersion())) { - return xml12SchemaValidator; - } - } + if (EPCISVersion.VERSION_1_2_0.getSchemaVersion().equals(epcisVersion.getSchemaVersion())) { + return xml12SchemaValidator; + } + } - throw new UnsupportedOperationException("Unsupported media type : " + mediaType); - } + throw new UnsupportedOperationException("Unsupported media type : " + mediaType); + } } diff --git a/core/src/main/java/io/openepcis/validation/Validator.java b/core/src/main/java/io/openepcis/validation/Validator.java index 97c8f0a..502293a 100644 --- a/core/src/main/java/io/openepcis/validation/Validator.java +++ b/core/src/main/java/io/openepcis/validation/Validator.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,21 +18,22 @@ import io.openepcis.validation.exception.SchemaValidationException; import io.openepcis.validation.model.ValidationError; import io.smallrye.mutiny.Multi; + import java.io.InputStream; public interface Validator { - Multi validateAgainstCaptureSchema(final InputStream epcisInputData) - throws SchemaValidationException; + Multi validateAgainstCaptureSchema(final InputStream epcisInputData) throws SchemaValidationException; + + Multi validateAgainstQuerySchema(final InputStream epcisInputData) throws SchemaValidationException; + + default InputStream getResourceAsStream(final String name) { + final InputStream inputStream = getClass().getResourceAsStream(name); - Multi validateAgainstQuerySchema(final InputStream epcisInputData) - throws SchemaValidationException; + if (inputStream == null) { + return Thread.currentThread().getContextClassLoader().getResourceAsStream(name); + } - default InputStream getResourceAsStream(final String name) { - InputStream inputStream = getClass().getResourceAsStream(name); - if (inputStream == null) { - return Thread.currentThread().getContextClassLoader().getResourceAsStream(name); + return inputStream; } - return inputStream; - } } diff --git a/core/src/main/java/io/openepcis/validation/exception/SchemaValidationException.java b/core/src/main/java/io/openepcis/validation/exception/SchemaValidationException.java index 8fe7e97..a654df2 100644 --- a/core/src/main/java/io/openepcis/validation/exception/SchemaValidationException.java +++ b/core/src/main/java/io/openepcis/validation/exception/SchemaValidationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,11 +17,11 @@ public class SchemaValidationException extends RuntimeException { - public SchemaValidationException(String msg) { - super(msg); - } + public SchemaValidationException(String msg) { + super(msg); + } - public SchemaValidationException(String msg, Throwable cause) { - super(msg, cause); - } + public SchemaValidationException(String msg, Throwable cause) { + super(msg, cause); + } } diff --git a/core/src/main/java/io/openepcis/validation/json/JsonErrorHandler.java b/core/src/main/java/io/openepcis/validation/json/JsonErrorHandler.java index e5b33c9..7aea9f8 100644 --- a/core/src/main/java/io/openepcis/validation/json/JsonErrorHandler.java +++ b/core/src/main/java/io/openepcis/validation/json/JsonErrorHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,30 +15,39 @@ */ package io.openepcis.validation.json; +import com.fasterxml.jackson.databind.JsonNode; import com.networknt.schema.ValidationMessage; +import io.openepcis.validation.message.JsonErrorFormatter; import io.openepcis.validation.model.ValidationError; +import lombok.Getter; + +import java.util.ArrayList; import java.util.Collection; +import java.util.List; /** - * Class to format the validation errors occur during the validation of JSON Schema Capture/Query - * document/event. + * Class to format the validation errors occur during the validation of JSON Schema Capture/Query document/event. */ public class JsonErrorHandler { - @lombok.Getter private final java.util.List exceptions; + @Getter + private final List exceptions; + + public JsonErrorHandler() { + this.exceptions = new ArrayList<>(); + } - public JsonErrorHandler() { - this.exceptions = new java.util.ArrayList<>(); - } + private static final JsonErrorFormatter FORMATTER = new JsonErrorFormatter(); + + /** + * Groups the raw messages so one mistake becomes one error, then words them for people. + */ + public void jsonValidationFormat(final JsonNode document, final Collection messages) { + exceptions.addAll(FORMATTER.format(document, messages)); + } - public void jsonValidationFormat(final Collection messages) { - for (final ValidationMessage errorMessage : messages) { - final ValidationError errorFormat = new ValidationError(); - errorFormat.setType(errorMessage.getType()); - errorFormat.setLine(errorMessage.getSchemaLocation().toString()); - errorFormat.setLocation(errorMessage.getInstanceLocation().toString()); - errorFormat.setMessage(errorFormat.filterErrorMessage(errorMessage.getMessage())); - exceptions.add(errorFormat); + @Deprecated + public void jsonValidationFormat(final Collection messages) { + jsonValidationFormat(null, messages); } - } } diff --git a/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java b/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java index 95dae78..1d991cf 100644 --- a/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,88 +17,87 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.networknt.schema.*; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.ValidationMessage; import io.openepcis.validation.Validator; import io.openepcis.validation.exception.SchemaValidationException; import io.openepcis.validation.model.ValidationError; import io.quarkus.runtime.annotations.RegisterForReflection; import io.smallrye.mutiny.Multi; +import lombok.extern.slf4j.Slf4j; + import java.io.InputStream; import java.util.Set; -import lombok.extern.slf4j.Slf4j; @Slf4j @RegisterForReflection(targets = JsonSchemaFactory.class) public class JsonSchemaValidator implements Validator { - private final ObjectMapper mapper; - private final JsonSchemaFactory validatorFactory; - private final JsonSchema captureJsonSchema; - private final JsonSchema queryJsonSchema; - private final JsonErrorHandler jsonErrorHandler; + private final ObjectMapper mapper; + private final JsonSchemaFactory validatorFactory; + private final JsonSchema captureJsonSchema; + private final JsonSchema queryJsonSchema; + private final JsonErrorHandler jsonErrorHandler; - public JsonSchemaValidator( - final ObjectMapper objectMapper, final JsonSchemaFactory validatorFactory) { - this.mapper = objectMapper; - this.validatorFactory = validatorFactory; - this.captureJsonSchema = loadSchema("/json-schema/modular-EPCIS-JSON-schema.json"); - this.queryJsonSchema = loadSchema("/json-schema/modified-query-schema.json"); - this.jsonErrorHandler = new JsonErrorHandler(); - } + public JsonSchemaValidator(final ObjectMapper objectMapper, final JsonSchemaFactory validatorFactory) { + this.mapper = objectMapper; + this.validatorFactory = validatorFactory; + this.captureJsonSchema = loadSchema("/json-schema/modular-EPCIS-JSON-schema.json"); + this.queryJsonSchema = loadSchema("/json-schema/modified-query-schema.json"); + this.jsonErrorHandler = new JsonErrorHandler(); + } - private JsonSchema loadSchema(final String path) { - return validatorFactory.getSchema(getResourceAsStream(path)); - } + private JsonSchema loadSchema(final String path) { + return validatorFactory.getSchema(getResourceAsStream(path)); + } - @Override - public Multi validateAgainstCaptureSchema(final InputStream epcisInputData) - throws SchemaValidationException { - return Multi.createFrom() - .emitter( - multiEmitter -> { - try { - final Set captureErrors = - captureJsonSchema.validate(mapper.readValue(epcisInputData, JsonNode.class)); + @Override + public Multi validateAgainstCaptureSchema(final InputStream epcisInputData) + throws SchemaValidationException { + return Multi.createFrom() + .emitter( + multiEmitter -> { + try { + final JsonNode document = mapper.readValue(epcisInputData, JsonNode.class); + final Set captureErrors = captureJsonSchema.validate(document); - // If there are any validation errors then throw the errors - if (!captureErrors.isEmpty()) { - jsonErrorHandler.jsonValidationFormat(captureErrors); - } + // schema violations stay emitted items, a failure is reserved for broken input + if (!captureErrors.isEmpty()) { + jsonErrorHandler.jsonValidationFormat(document, captureErrors); + } - this.jsonErrorHandler.getExceptions().stream().forEach(multiEmitter::emit); - multiEmitter.complete(); - } catch (Exception exception) { - multiEmitter.fail( - new SchemaValidationException( - "Exception occurred during validation against EPCIS JSON Capture schema : " - + exception.getMessage())); - } - }); - } + this.jsonErrorHandler.getExceptions().stream().forEach(multiEmitter::emit); + multiEmitter.complete(); + } catch (Exception exception) { + multiEmitter.fail( + new SchemaValidationException("Exception occurred during validation against EPCIS JSON Capture schema : " + exception.getMessage())); + } + }); + } - @Override - public Multi validateAgainstQuerySchema(final InputStream document) - throws SchemaValidationException { - return Multi.createFrom() - .emitter( - multiEmitter -> { - try { - final Set queryErrors = - queryJsonSchema.validate(mapper.readValue(document, JsonNode.class)); + @Override + public Multi validateAgainstQuerySchema(final InputStream document) + throws SchemaValidationException { + return Multi.createFrom() + .emitter( + multiEmitter -> { + try { + // keep the tree instead of discarding it, the formatter reads event types from it + final JsonNode queryDocument = mapper.readValue(document, JsonNode.class); + final Set queryErrors = queryJsonSchema.validate(queryDocument); - // If there are any validation errors then throw the errors - if (!queryErrors.isEmpty()) { - jsonErrorHandler.jsonValidationFormat(queryErrors); - } + // schema violations stay emitted items, a failure is reserved for broken input + if (!queryErrors.isEmpty()) { + jsonErrorHandler.jsonValidationFormat(queryDocument, queryErrors); + } - this.jsonErrorHandler.getExceptions().stream().forEach(multiEmitter::emit); - multiEmitter.complete(); - } catch (Exception exception) { - multiEmitter.fail( - new SchemaValidationException( - "Exception occurred during validation against EPCIS JSON Query schema : " - + exception.getMessage())); - } - }); - } + this.jsonErrorHandler.getExceptions().stream().forEach(multiEmitter::emit); + multiEmitter.complete(); + } catch (Exception exception) { + multiEmitter.fail( + new SchemaValidationException("Exception occurred during validation against EPCIS JSON Query schema : " + exception.getMessage())); + } + }); + } } diff --git a/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java b/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java new file mode 100644 index 0000000..4d5790d --- /dev/null +++ b/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java @@ -0,0 +1,467 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.message; + +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.ValidationMessage; +import io.openepcis.validation.model.ValidationError; +import org.apache.commons.collections4.CollectionUtils; + +import java.util.*; + +/** + * Turns the raw networknt messages into one readable error per place in the document. + */ +public final class JsonErrorFormatter { + + /** + * When one position produced several messages, lead with the most useful keyword. + */ + private static final List KEYWORD_PRIORITY = + List.of( + "required", "additionalProperties", "propertyNames", "enum", "type", "format", "pattern", + "minItems", "maxItems", "uniqueItems", "minimum", "maximum", "minLength"); + + /** + * Keeps a 41 value vocabulary out of the sentence. The full list travels in allowedValues. + */ + private static final int ALLOWED_SAMPLE_SIZE = 6; + + /** + * Groups and words every failure. + * + * @param document the already parsed document, read only to name the event type. Never re-parsed. + * @param messages the raw messages from networknt + */ + public List format(final JsonNode document, final Collection messages) { + + if (CollectionUtils.isEmpty(messages)) { + // valid document, so no allocation at all + return List.of(); + } + + // every position that reported something + final Set locations = new HashSet<>(); + for (final ValidationMessage message : messages) { + locations.add(message.getInstanceLocation().toString()); + } + + final Map> byLocation = new LinkedHashMap<>(); + for (final ValidationMessage message : messages) { + if (isBranchNoise(message, locations)) { + continue; // a precise error deeper in the document already explains this one + } + // one bucket per position, which is what merges the duplicates + byLocation.computeIfAbsent(message.getInstanceLocation().toString(), key -> new ArrayList<>()).add(message); + } + + final List results = new ArrayList<>(byLocation.size()); + byLocation.forEach((location, group) -> results.addAll(buildAll(document, location, group))); + results.sort(Comparator.comparing(error -> error.getLocation() == null ? "" : error.getLocation())); + return results; + } + + /** + * A failed schema branch is only a guess about which shape was intended. When a concrete problem + * exists deeper in the same subtree, that deeper problem is the real cause and the branch level + * "required property X not found" messages are noise. This is what turns 5 errors into 1. + * + *

Deliberately narrow: required only, inside a branch only, and only when something strictly + * deeper also failed. A genuine missing field is never hidden. + */ + private boolean isBranchNoise(final ValidationMessage message, final Set locations) { + if (!"required".equals(message.getType()) || !insideBranch(message)) { + return false; // anything else is always kept + } + final String location = message.getInstanceLocation().toString(); + for (final String other : locations) { + if (other.length() > location.length() // strictly longer means strictly deeper + && other.startsWith(location) + && (other.charAt(location.length()) == '.' || other.charAt(location.length()) == '[')) { + return true; // a real problem exists below this position + } + } + return false; + } + + // Whether the message came out of an anyOf or oneOf branch rather than an unconditional rule. + private boolean insideBranch(final ValidationMessage message) { + final String path = message.getEvaluationPath().toString(); + return path.contains("anyOf[") || path.contains("oneOf["); + } + + // Which anyOf or oneOf produced it, so sibling branches of the same choice can be merged. + private String branchGroupOf(final ValidationMessage message) { + final String path = message.getEvaluationPath().toString(); + int index = path.lastIndexOf("anyOf["); + if (index < 0) { + index = path.lastIndexOf("oneOf["); + } + // the shared prefix identifies the choice + return index < 0 ? null : path.substring(0, index); + } + + /** + * One position can still hold genuinely different problems. Alternative required fields coming + * from sibling branches become one "at least one of" sentence, everything else is kept separate. + */ + private List buildAll( + final JsonNode document, final String location, final List group) { + + // Every "required" message is grouped by the choice it came out of. The empty key collects the + // unconditional ones, where each named field really is missing rather than being an alternative. + final Map> requiredByBranch = new LinkedHashMap<>(); + final List others = new ArrayList<>(); + for (final ValidationMessage message : group) { + if ("required".equals(message.getType())) { + final String branch = branchGroupOf(message); // null when the rule is unconditional + requiredByBranch + .computeIfAbsent(branch == null ? "" : branch, key -> new ArrayList<>()) + .add(message); + } else { + others.add(message); + } + } + + final List results = new ArrayList<>(); + // field name to the message that reported it, so nothing is lost and duplicates collapse + final Map leftoverRequired = new LinkedHashMap<>(); + + requiredByBranch.forEach( + (branch, siblings) -> { + final Set fields = new LinkedHashSet<>(); // ordered, so wording is stable + for (final ValidationMessage message : siblings) { + if (message.getProperty() != null) { + fields.add(message.getProperty()); // getProperty holds the missing field name + } + } + // sibling branches offer a choice, so they become one "at least one of" sentence + if (!branch.isEmpty() + && fields.size() > 1 + && MessageTemplates.hasKeyword("required.alternatives")) { + results.add( + buildRequiredGroup(document, location, siblings, fields, "required.alternatives")); + return; + } + for (final ValidationMessage message : siblings) { + if (message.getProperty() == null) { + others.add(message); // no field name to group by, report it on its own + } else { + leftoverRequired.putIfAbsent(message.getProperty(), message); + } + } + }); + + // unconditional rules mean all of these fields are missing, so every one must be named + if (leftoverRequired.size() > 1 && MessageTemplates.hasKeyword("required.multiple")) { + results.add( + buildRequiredGroup( + document, + location, + List.copyOf(leftoverRequired.values()), + leftoverRequired.keySet(), + "required.multiple")); + } else { + others.addAll(leftoverRequired.values()); // a single missing field stays an ordinary message + } + + if (!others.isEmpty()) { + results.add(build(document, location, others)); + } + return results; + } + + /** + * Builds one error that names several fields at once. + * + * @param templateKey required.alternatives when the fields are a choice, required.multiple when + * every one of them is genuinely missing + */ + private ValidationError buildRequiredGroup( + final JsonNode document, + final String location, + final List siblings, + final Set fields, + final String templateKey) { + final ValidationError error = new ValidationError(); + error.setType("required"); + error.setLine(siblings.get(0).getSchemaLocation().toString()); + error.setLocation(location); + + final String joined = String.join(", ", fields); + error.setField(joined); + applyEventContext(error, document, location); + error.setAllowedValues(List.copyOf(fields)); + error.setMessage(MessageTemplates.render(MessageTemplates.keyword(templateKey), Map.of("where", whereLabel(error), "fields", joined))); + + return error; + } + + /** + * Builds one error from a group of messages that all describe the same position. + */ + private ValidationError build( + final JsonNode document, final String location, final List group) { + + final ValidationMessage primary = pickPrimary(group); // the most informative of the group + final ValidationError error = new ValidationError(); + + error.setType(primary.getType()); // unchanged, the front end may switch on it + error.setLine(primary.getSchemaLocation().toString()); // unchanged + error.setLocation(location); // unchanged + + // the field name is the last path segment, except for required, additionalProperties and + // propertyNames, where the path points at the parent and the name is in getProperty + error.setField(primary.getProperty() != null ? primary.getProperty() : lastSegment(location)); + error.setValue(plainValue(primary.getInstanceNode())); // the value sent, previously discarded + applyEventContext(error, document, location); + + final List allowed = allowedValuesIn(group); // read from the schema, never hard coded + if (!allowed.isEmpty()) { + error.setAllowedValues(allowed); // full list as data so the UI can reveal it on demand + error.setSuggestion(ValueSuggester.closest(error.getValue(), allowed)); // commons-text + } + + error.setMessage(renderMessage(primary, group, error, allowed)); + return error; + } + + /** + * Chooses the wording and fills in every placeholder. + */ + private String renderMessage( + final ValidationMessage primary, + final List group, + final ValidationError error, + final List allowed) { + + final Map values = new HashMap<>(); // one small map per reported error + values.put("field", error.getField() == null ? "this field" : error.getField()); + values.put("value", error.getValue() == null ? "" : error.getValue()); + values.put("where", whereLabel(error)); + values.put("allowedCount", String.valueOf(allowed.size())); + values.put("allowedSample", sample(allowed)); + values.put( + "suggestion", + error.getSuggestion() == null + ? "" // no close match, so the sentence simply omits the hint + : MessageTemplates.fill( + MessageTemplates.fragment("suggestionHint", " Did you mean \"{best}\"?"), + Map.of("best", error.getSuggestion()))); + + // keyword specific extras, reusing the arguments networknt already computed for us + final Object[] arguments = primary.getArguments(); + if (arguments != null && arguments.length > 0) { + if ("type".equals(primary.getType()) && arguments.length >= 2) { + values.put("actual", String.valueOf(arguments[0])); // for type the order is found, expected + values.put("expected", String.valueOf(arguments[1])); + } else { + values.put("limit", String.valueOf(arguments[0])); // min/max style keywords + if (arguments.length >= 2) { + values.put("actual", String.valueOf(arguments[1])); + } + } + } + + final String rendered = MessageTemplates.render(MessageTemplates.templateFor(primary), values); + final String base = rendered != null ? rendered : primary.getError(); // layer 3 safety net + + final String extras = otherReasons(primary, group); + if (extras.isEmpty()) { + return MessageTemplates.tidy(base); // the common case + } + return MessageTemplates.tidy( + base + + MessageTemplates.fill( + MessageTemplates.fragment("reasonMore", " Also: {reasons}"), + Map.of("reasons", extras))); + } + + /** + * Any genuinely different reason at the same position, so nothing is silently dropped. + */ + private String otherReasons(final ValidationMessage primary, final List group) { + if (group.size() < 2) { + return ""; // nothing else to report + } + final Set keywords = new HashSet<>(); + for (final ValidationMessage message : group) { + keywords.add(message.getType()); + } + // repeated branches of one keyword, and the vocabulary pair of enum plus uri, are one mistake + if (keywords.size() <= 1 + || (keywords.size() == 2 + && keywords.contains("enum") + && (keywords.contains("format") || keywords.contains("pattern")))) { + return ""; + } + final List extras = new ArrayList<>(); + final Set seen = new HashSet<>(); + seen.add(primary.getType()); // the primary reason is already in the sentence + for (final ValidationMessage message : group) { + if (seen.add(message.getType())) { + extras.add(message.getError()); // one entry per distinct remaining keyword + } + } + return String.join("; ", extras); + } + + /** + * Picks the message whose keyword explains the mistake best. + */ + private ValidationMessage pickPrimary(final List group) { + ValidationMessage best = group.get(0); + int bestRank = rank(best.getType()); + for (final ValidationMessage message : group) { + final int rank = rank(message.getType()); + if (rank < bestRank) { + bestRank = rank; + best = message; + } + } + return best; + } + + /** + * Lower is better. Unknown keywords sort last rather than being dropped. + */ + private int rank(final String keyword) { + final int index = KEYWORD_PRIORITY.indexOf(keyword); + return index < 0 ? KEYWORD_PRIORITY.size() : index; + } + + /** + * Reads the allowed values from the schema node itself. This is why no EPCIS vocabulary list is + * ever copied into Java or into the catalogue, and why a GS1 schema update needs no code change. + */ + private List allowedValuesIn(final List group) { + for (final ValidationMessage message : group) { + if ("enum".equals(message.getType())) { + final JsonNode schemaNode = message.getSchemaNode(); // the enum array from the schema + if (schemaNode != null && schemaNode.isArray()) { + final List values = new ArrayList<>(schemaNode.size()); // exact size + schemaNode.forEach(node -> values.add(node.asText())); + return values; + } + } + } + return List.of(); // shared empty list, no allocation + } + + /** + * A short sample, so a 41 value vocabulary never floods the sentence. + */ + private String sample(final List allowed) { + if (allowed.isEmpty()) { + return ""; + } + if (allowed.size() <= ALLOWED_SAMPLE_SIZE) { + return String.join(", ", allowed); + } + return String.join(", ", allowed.subList(0, ALLOWED_SAMPLE_SIZE)) // subList is a view, no copy + + " and " + + (allowed.size() - ALLOWED_SAMPLE_SIZE) + + " more"; + } + + /** + * Records which event holds the problem, so a message can say "event 6 (TransformationEvent)". + */ + private void applyEventContext( + final ValidationError error, final JsonNode document, final String location) { + final Integer index = eventIndexOf(location); + if (index == null) { + return; // the problem is outside eventList, for example at the document root + } + error.setEventIndex(index); + if (document == null) { + return; // still useful without the type + } + final JsonNode events = document.path("epcisBody").path("eventList"); // path never throws + if (events.isArray() && index < events.size()) { + final JsonNode type = events.get(index).path("type"); + if (type.isTextual()) { + error.setEventType(type.asText()); // read from the parsed tree, no second parse + } + } + } + + /** + * Renders {where}: the document, or an event number with its type when known. + */ + private String whereLabel(final ValidationError error) { + if (error.getEventIndex() == null) { + return MessageTemplates.fragment("whereDocument", "the document"); + } + final String number = String.valueOf(error.getEventIndex() + 1); // humans count from 1 + if (error.getEventType() != null) { + return MessageTemplates.fill( + MessageTemplates.fragment("whereEventTyped", "event {number} ({type})"), + Map.of("number", number, "type", error.getEventType())); + } + return MessageTemplates.fill( + MessageTemplates.fragment("whereEvent", "event {number}"), Map.of("number", number)); + } + + /** + * Reads the zero based index out of a path segment such as eventList[5]. + */ + private Integer eventIndexOf(final String location) { + final int marker = location.indexOf("eventList["); + if (marker < 0) { + return null; + } + final int start = marker + "eventList[".length(); + final int end = location.indexOf(']', start); + if (end < 0) { + return null; + } + try { + return Integer.valueOf(location.substring(start, end)); + } catch (NumberFormatException e) { + return null; // an unexpected path must never break a validation response + } + } + + /** + * The field name at the end of a path, ignoring any array indexes. + */ + private String lastSegment(final String location) { + if (location == null || location.isEmpty() || "$".equals(location)) { + return null; // the document root has no field name + } + String path = location; + while (path.endsWith("]")) { // strip trailing indexes, e.g. epcList[0] gives epcList + final int open = path.lastIndexOf('['); + if (open < 0) { + break; + } + path = path.substring(0, open); + } + final int dot = path.lastIndexOf('.'); + return dot < 0 ? path : path.substring(dot + 1); + } + + /** + * The value without JSON quoting. Objects and arrays are skipped so nothing floods a message. + */ + private String plainValue(final JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull() || !node.isValueNode()) { + return null; + } + return node.asText(); + } +} \ No newline at end of file diff --git a/core/src/main/java/io/openepcis/validation/message/MessageTemplates.java b/core/src/main/java/io/openepcis/validation/message/MessageTemplates.java new file mode 100644 index 0000000..5307848 --- /dev/null +++ b/core/src/main/java/io/openepcis/validation/message/MessageTemplates.java @@ -0,0 +1,224 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.message; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.networknt.schema.ValidationMessage; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.collections4.MapUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.Map; + +/** + * Reads the message catalogue and decides which sentence describes a validation failure. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MessageTemplates { + + // Read the catalog of error validation messages + private static final String LOCATION = "/validation-messages/epcis-validation-messages.yaml"; + + // Loaded exactly once, when this class is first touched. + private static final Catalogue CATALOGUE = load(); + + // Hold the 4 types of validation message from epcis-validation-messages.yaml + private record Catalogue( + Map schemaLocations, + Map keywords, + Map fragments, + Map xsdTypes + ) { + } + + private static Catalogue load() { + try (InputStream stream = MessageTemplates.class.getResourceAsStream(LOCATION)) { + if (stream == null) { + throw new IllegalStateException("Validation message catalogue not found at " + LOCATION); + } + + final Catalogue parsed = new ObjectMapper(new YAMLFactory()) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(stream, Catalogue.class); + + // rebuild with truly immutable maps, no wrapper indirection + return new Catalogue( + immutable(parsed.schemaLocations()), + immutable(parsed.keywords()), + immutable(parsed.fragments()), + immutable(parsed.xsdTypes())); + + } catch (IOException e) { + throw new UncheckedIOException("Could not read validation message catalogue", e); + } + } + + + // Map.copyOf gives a real immutable map. A missing YAML section becomes empty, never null + private static Map immutable(final Map source) { + return MapUtils.isEmpty(source) ? Map.of() : Map.copyOf(source); + } + + /** + * The template for this message, or null when the caller should fall back to getError(). + */ + public static String templateFor(final ValidationMessage message) { + final String location = message.getSchemaLocation().toString(); + final int hash = location.indexOf('#'); + String pointer = hash < 0 ? "#" : location.substring(hash); + final String keyword = message.getType(); + + while (true) { + String template = CATALOGUE.schemaLocations().get(pointer + "." + keyword); + if (template != null) { + return template; + } + + template = CATALOGUE.schemaLocations().get(pointer); + if (template != null) { + return template; + } + + final int slash = pointer.lastIndexOf('/'); + if (slash <= 0) { + break; + } + pointer = pointer.substring(0, slash); + } + + final String byMessageKey = CATALOGUE.keywords().get(message.getMessageKey()); + return byMessageKey != null ? byMessageKey : CATALOGUE.keywords().get(keyword); + } + + + /** + * A keyword template by name, for synthetic messages such as required.alternatives. + */ + public static String keyword(final String name) { + return CATALOGUE.keywords().get(name); + } + + /** + * Whether a keyword template exists, so callers can choose a different strategy if not. + */ + public static boolean hasKeyword(final String name) { + return CATALOGUE.keywords().containsKey(name); + } + + /** + * A reusable sentence piece, with a hard coded fallback so wording is never missing entirely. + */ + public static String fragment(final String name, final String fallback) { + final String value = CATALOGUE.fragments().get(name); + return value != null ? value : fallback; + } + + /** + * Maps an XSD rule code such as cvc-enumeration-valid to the type field of the response. + */ + public static String xsdType(final String code) { + return CATALOGUE.xsdTypes().get(code); + } + + /** + * Fills a template and tidies the finished sentence. Null template gives null, not a crash. + */ + public static String render(final String template, final Map values) { + return template == null ? null : tidy(fill(template, values)); + } + + /** + * Collapses runs of spaces left behind by empty placeholders and trims the ends. + */ + public static String tidy(final String message) { + if (message == null) { + return null; + } + + final StringBuilder out = new StringBuilder(message.length()); + boolean previousWasSpace = false; + + for (int i = 0; i < message.length(); i++) { + final char c = message.charAt(i); + final boolean isSpace = c == ' ' || c == '\t'; + + if (isSpace) { + if (!previousWasSpace) { + out.append(' '); // keep the first space of a run, drop the rest + } + } else { + out.append(c); + } + previousWasSpace = isSpace; + } + + int start = 0; // trim leading spaces without allocating another String + int end = out.length(); + while (start < end && out.charAt(start) == ' ') { + start++; + } + while (end > start && out.charAt(end - 1) == ' ') { + end--; + } + return out.substring(start, end); + } + + + /** + * Replaces every {name} with its value. An unknown placeholder becomes empty. + */ + public static String fill(final String template, final Map values) { + if (template.indexOf('{') < 0) { + // nothing to substitute, so allocate nothing at all + return template; + } + + final StringBuilder out = new StringBuilder(template.length() + 64); + int i = 0; + + while (i < template.length()) { + final int open = template.indexOf('{', i); + if (open < 0) { + // no placeholder left, copy the tail + out.append(template, i, template.length()); + break; + } + + final int close = template.indexOf('}', open); + if (close < 0) { + // unbalanced brace, copy verbatim, never throw + out.append(template, i, template.length()); + break; + } + + // the literal text before the placeholder + out.append(template, i, open); + final String value = values.get(template.substring(open + 1, close)); + if (value != null) { + // a missing value contributes nothing, tidy() closes the gap + out.append(value); + } + + i = close + 1; + } + return out.toString(); + } +} diff --git a/core/src/main/java/io/openepcis/validation/message/ValueSuggester.java b/core/src/main/java/io/openepcis/validation/message/ValueSuggester.java new file mode 100644 index 0000000..e2c42f6 --- /dev/null +++ b/core/src/main/java/io/openepcis/validation/message/ValueSuggester.java @@ -0,0 +1,70 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.message; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.similarity.LevenshteinDistance; + +import java.util.List; +import java.util.Locale; + +/** + * Finds the closest allowed value to what the user typed, so we can offer a "did you mean" option in the validation error message. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ValueSuggester { + private static final LevenshteinDistance LEVENSHTEIN = LevenshteinDistance.getDefaultInstance(); + + /** + * Returns the closest candidate or null when nothing is close enough. Uses distance and LEVENSHTEIN. + */ + public static String closest(final String wrongValue, final List candidates) { + if (StringUtils.isBlank(wrongValue) || CollectionUtils.isEmpty(candidates)) { + return null; + } + + final String needle = wrongValue.toLowerCase(Locale.ROOT); + final int needleLength = needle.length(); + final int maxDistance = Math.max(1, needleLength / 3); + + String best = null; + int bestDistance = Integer.MAX_VALUE; + + for (final String candidate : candidates) { + // Combined guard checks using short-circuit logic instead of separate continue statements + if (StringUtils.isNotBlank(candidate) && Math.abs(needleLength - candidate.length()) <= maxDistance) { + + // Execution: Reuses the stateless global engine + final int distance = LEVENSHTEIN.apply(needle, candidate.toLowerCase(Locale.ROOT)); + + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + + // Performance optimization: Stop searching immediately upon an exact match + if (bestDistance == 0) { + return best; + } + } + } + } + + return bestDistance <= maxDistance ? best : null; + } +} \ No newline at end of file diff --git a/core/src/main/java/io/openepcis/validation/model/ValidationError.java b/core/src/main/java/io/openepcis/validation/model/ValidationError.java index ee56633..9c21de3 100644 --- a/core/src/main/java/io/openepcis/validation/model/ValidationError.java +++ b/core/src/main/java/io/openepcis/validation/model/ValidationError.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,29 +15,70 @@ */ package io.openepcis.validation.model; -import jakarta.xml.bind.annotation.XmlAttribute; -import jakarta.xml.bind.annotation.XmlElement; -import jakarta.xml.bind.annotation.XmlRootElement; -import jakarta.xml.bind.annotation.XmlType; +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.xml.bind.annotation.*; import lombok.Data; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.regex.Pattern; @Data +@JsonInclude(JsonInclude.Include.NON_NULL) @XmlRootElement(name = "ValidationError") @XmlType( - name = "ValidationError", - propOrder = {"type", "line", "location", "message"}) + name = "ValidationError", + propOrder = { + "type", "line", "location", "message", + "field", "value", "eventIndex", "eventType", "suggestion", "allowedValues" + }) public class ValidationError { - @XmlAttribute private String type; + @XmlAttribute + private String type; + + @XmlElement + private String line; + + @XmlElement + private String location; + + @XmlElement + private String message; + + // Indicate which field failed + @XmlElement + private String field; + + // User provided values + @XmlElement + private String value; + + // zero based event index + @XmlElement + private Integer eventIndex; + + // eventType for quick reference to users + @XmlElement + private String eventType; + + // closest standard value, or null for suggesting to user + @XmlElement + private String suggestion; - @XmlElement private String line; + // full list as data, UI can collapse it + @XmlElementWrapper(name = "allowedValues") + @XmlElement(name = "allowedValue") + private List allowedValues; - @XmlElement private String location; + // Strips only a leading XSD rule code such as "cvc-complex-type.2.4.a: ". + private static final Pattern XSD_ERROR_CODE = Pattern.compile("^(cvc|src|sch)-[A-Za-z0-9_.\\-]+:\\s*"); - @XmlElement private String message; + public String filterErrorMessage(final String message) { + if (StringUtils.isBlank(message)) { + return message; + } - public String filterErrorMessage(String message) { - String[] messageParts = message.split(":", 2); - return messageParts.length > 1 ? messageParts[1].trim() : message; - } + return XSD_ERROR_CODE.matcher(message).replaceFirst(""); + } } diff --git a/core/src/main/java/io/openepcis/validation/model/xml/ValidationResult.java b/core/src/main/java/io/openepcis/validation/model/xml/ValidationResult.java index 7c613dc..a104780 100644 --- a/core/src/main/java/io/openepcis/validation/model/xml/ValidationResult.java +++ b/core/src/main/java/io/openepcis/validation/model/xml/ValidationResult.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,12 +16,16 @@ package io.openepcis.validation.model.xml; import io.openepcis.validation.model.ValidationError; -import jakarta.xml.bind.annotation.*; -import java.util.List; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlElementWrapper; +import jakarta.xml.bind.annotation.XmlRootElement; +import jakarta.xml.bind.annotation.XmlType; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @XmlRootElement(name = "ValidationResult") @XmlType(name = "ValidationResult") @NoArgsConstructor diff --git a/core/src/main/java/io/openepcis/validation/xml/AbstractXmlSchemaValidator.java b/core/src/main/java/io/openepcis/validation/xml/AbstractXmlSchemaValidator.java index 01b5b13..fdfa162 100644 --- a/core/src/main/java/io/openepcis/validation/xml/AbstractXmlSchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/xml/AbstractXmlSchemaValidator.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,75 +19,65 @@ import io.openepcis.validation.exception.SchemaValidationException; import io.openepcis.validation.model.ValidationError; import io.smallrye.mutiny.Multi; -import java.io.IOException; -import java.io.InputStream; +import org.xml.sax.SAXException; + import javax.xml.transform.stream.StreamSource; import javax.xml.validation.Schema; import javax.xml.validation.SchemaFactory; -import org.xml.sax.SAXException; +import java.io.IOException; +import java.io.InputStream; public abstract class AbstractXmlSchemaValidator implements Validator { - protected Schema captureXmlSchema; - protected Schema queryXmlSchema; - protected EventModifier eventModifier = new EventModifier(); - protected XmlErrorHandler xsdErrorHandler; - protected SchemaFactory schemaFactory; + protected Schema captureXmlSchema; + protected Schema queryXmlSchema; + protected EventModifier eventModifier = new EventModifier(); + protected XmlErrorHandler xsdErrorHandler; + protected SchemaFactory schemaFactory; - @Override - public Multi validateAgainstCaptureSchema(final InputStream captureInput) { - return Multi.createFrom() - .emitter( - multiEmitter -> { - try { - final InputStream inputEvent = eventModifier.modifyEvent(captureInput); - final javax.xml.validation.Validator validator = captureXmlSchema.newValidator(); - validator.setErrorHandler(this.xsdErrorHandler); - validator.validate(new StreamSource(inputEvent)); - this.xsdErrorHandler.getExceptions().forEach(multiEmitter::emit); - multiEmitter.complete(); - } catch (Exception e) { - multiEmitter.fail( - new SchemaValidationException( - "Exception occurred during the validation of Capture XML document/event against XSD : " - + e.getMessage(), - e)); - } - }); - } + @Override + public Multi validateAgainstCaptureSchema(final InputStream captureInput) { + return Multi.createFrom() + .emitter( + multiEmitter -> { + try { + final InputStream inputEvent = eventModifier.modifyEvent(captureInput); + final javax.xml.validation.Validator validator = captureXmlSchema.newValidator(); + validator.setErrorHandler(this.xsdErrorHandler); + validator.validate(new StreamSource(inputEvent)); + this.xsdErrorHandler.getExceptions().forEach(multiEmitter::emit); + multiEmitter.complete(); + } catch (Exception e) { + multiEmitter.fail(new SchemaValidationException("Failed to validate the Capture XML document against the XSD : " + e.getMessage(), e)); + } + }); + } - @Override - public Multi validateAgainstQuerySchema(InputStream queryInput) - throws SchemaValidationException { - return Multi.createFrom() - .emitter( - multiEmitter -> { - final javax.xml.validation.Validator validator = queryXmlSchema.newValidator(); - validator.setErrorHandler(this.xsdErrorHandler); - try { - validator.validate(new StreamSource(queryInput)); - this.xsdErrorHandler.getExceptions().forEach(multiEmitter::emit); - multiEmitter.complete(); - } catch (SAXException | IOException e) { - multiEmitter.fail( - new SchemaValidationException( - "Exception occurred during the validation of Query XML document/event against XSD : " - + e.getMessage() - + e)); - } - }); - } + @Override + public Multi validateAgainstQuerySchema(InputStream queryInput) + throws SchemaValidationException { + return Multi.createFrom() + .emitter( + multiEmitter -> { + final javax.xml.validation.Validator validator = queryXmlSchema.newValidator(); + validator.setErrorHandler(this.xsdErrorHandler); + try { + validator.validate(new StreamSource(queryInput)); + this.xsdErrorHandler.getExceptions().forEach(multiEmitter::emit); + multiEmitter.complete(); + } catch (SAXException | IOException e) { + multiEmitter.fail(new SchemaValidationException("Failed to validate the Query XML document against the XSD : " + e.getMessage(), e)); + } + }); + } - protected Schema loadSchema(final String name) { - Schema schema; - try { - schema = schemaFactory.newSchema(new StreamSource(getClass().getResourceAsStream(name))); - } catch (Exception e) { - throw new SchemaValidationException( - "Exception occurred during the loading of the XSD document to schema : " - + e.getMessage() - + e); + protected Schema loadSchema(final String name) { + Schema schema; + try { + schema = schemaFactory.newSchema(new StreamSource(getClass().getResourceAsStream(name))); + } catch (Exception e) { + throw new SchemaValidationException("Failed to load XSD schema : " + e.getMessage(), e); + } + return schema; } - return schema; - } } diff --git a/core/src/main/java/io/openepcis/validation/xml/EventModifier.java b/core/src/main/java/io/openepcis/validation/xml/EventModifier.java index 0220c76..a345ae7 100644 --- a/core/src/main/java/io/openepcis/validation/xml/EventModifier.java +++ b/core/src/main/java/io/openepcis/validation/xml/EventModifier.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,176 +15,154 @@ */ package io.openepcis.validation.xml; +import io.openepcis.constants.EPCIS; +import io.openepcis.constants.EPCISVersion; import io.openepcis.validation.exception.SchemaValidationException; + +import javax.xml.stream.*; import java.io.*; import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.ReadableByteChannel; import java.nio.channels.WritableByteChannel; import java.time.Instant; -import java.util.Arrays; -import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.regex.Pattern; -import javax.xml.stream.*; public class EventModifier { - private final ExecutorService executorService; - private final XMLInputFactory inputFactory = XMLInputFactory.newInstance(); - private final XMLOutputFactory outputFactory = XMLOutputFactory.newInstance(); - private static final List EPCIS_EVENT_TYPES = - Arrays.asList( - "ObjectEvent", - "AggregationEvent", - "TransactionEvent", - "TransformationEvent", - "AssociationEvent"); - - private static final String EPCIS_DOCUMENT_NAMESPACE = "urn:epcglobal:epcis:xsd:2"; - private static final String EPCIS = "epcis"; - private static final String FIRST_TAG_REGEX = "<(?!\\?|\\!--)([\\S\\s]*?)>"; - private static final Pattern FIRST_TAG_PATTERN = Pattern.compile(FIRST_TAG_REGEX); - - public EventModifier(final ExecutorService executorService) { - this.executorService = executorService; - } - - public EventModifier() { - this(Executors.newWorkStealingPool()); - } - - public InputStream modifyEvent(final InputStream input) { - final BufferedInputStream captureInput = - BufferedInputStream.class.isAssignableFrom(input.getClass()) - ? (BufferedInputStream) input - : new BufferedInputStream(input); - try { - final String preScan = XMLTagPreScanUtil.scanFirstTag(captureInput); - final PipedOutputStream pipedOutputStream = new PipedOutputStream(); - final PipedInputStream pipe = new PipedInputStream(pipedOutputStream); - executorService.execute( - () -> { - try { - copy(captureInput, pipedOutputStream); - } catch (Exception e) { - throw new SchemaValidationException( - "Exception occurred during reading of schema version from input document : " - + e.getMessage(), - e); - } - }); - - if (preScan.contains("EPCISDocument")) { - return pipe; - } else if (EPCIS_EVENT_TYPES.stream().anyMatch(preScan::contains)) { - return addDocumentWrapper(pipe); // method to add the epcis document wrapper to single event - } - } catch (Exception e) { - e.printStackTrace(); + private final ExecutorService executorService; + private final XMLInputFactory inputFactory = XMLInputFactory.newInstance(); + private final XMLOutputFactory outputFactory = XMLOutputFactory.newInstance(); + + public EventModifier(final ExecutorService executorService) { + this.executorService = executorService; } - return captureInput; - } - - public void copy(final InputStream inputStream, final OutputStream outputStream) { - try (ReadableByteChannel src = Channels.newChannel(inputStream); - WritableByteChannel dest = Channels.newChannel(outputStream)) { - final ByteBuffer buffer = ByteBuffer.allocateDirect(1024); - while (src.read(buffer) != -1) { - buffer.flip(); - dest.write(buffer); - buffer.compact(); - } - buffer.flip(); - while (buffer.hasRemaining()) { - dest.write(buffer); - } - } catch (Exception e) { - throw new SchemaValidationException( - "Exception occurred during the copy of OutputStream to InputStream : " + e.getMessage(), - e); + + public EventModifier() { + this(Executors.newWorkStealingPool()); + } + + public InputStream modifyEvent(final InputStream input) { + final BufferedInputStream captureInput = BufferedInputStream.class.isAssignableFrom(input.getClass()) + ? (BufferedInputStream) input + : new BufferedInputStream(input); + try { + final String preScan = XMLTagPreScanUtil.scanFirstTag(captureInput); + final PipedOutputStream pipedOutputStream = new PipedOutputStream(); + final PipedInputStream pipe = new PipedInputStream(pipedOutputStream); + + executorService.execute( + () -> { + try { + copy(captureInput, pipedOutputStream); + } catch (Exception e) { + throw new SchemaValidationException("Failed to read the schema version from the input document : " + e.getMessage(), e); + } + }); + + if (preScan.contains(EPCIS.EPCIS_DOCUMENT)) { + return pipe; + } else if (EPCIS.EPCIS_EVENT_TYPES.stream().anyMatch(preScan::contains)) { + return addDocumentWrapper(pipe); // method to add the epcis document wrapper to single event + } + } catch (Exception e) { + throw new SchemaValidationException("Exception occurred during the modification of EPCIS event : " + e.getMessage(), e); + } + return captureInput; } - } - - // Add the document wrapper to single event - public InputStream addDocumentWrapper(final InputStream captureInput) { - try { - final XMLStreamReader reader = inputFactory.createXMLStreamReader(captureInput); - final PipedOutputStream pipedOutputStream = new PipedOutputStream(); - final PipedInputStream pipe = new PipedInputStream(pipedOutputStream); - - executorService.execute( - () -> { - try { - XMLStreamWriter writer = outputFactory.createXMLStreamWriter(pipedOutputStream); - - writer.writeStartDocument(); - - writer.writeStartElement(EPCIS, "EPCISDocument", EPCIS_DOCUMENT_NAMESPACE); - writer.writeAttribute("creationDate", Instant.now().toString()); - writer.writeAttribute("schemaVersion", "2.0"); - writer.writeNamespace(EPCIS, EPCIS_DOCUMENT_NAMESPACE); - writer.writeNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance"); - - writer.writeStartElement("EPCISBody"); - writer.writeStartElement("EventList"); - - // read the event and append to writer - copyXmlEvent(reader, writer); - - writer.writeEndElement(); // end EventList - writer.writeEndElement(); // end EPCISBody - writer.writeEndElement(); // end EPCISDocument - - writer.writeEndDocument(); - pipedOutputStream.close(); - } catch (Exception e) { - try { - pipedOutputStream.write(e.getMessage().getBytes()); - pipedOutputStream.close(); - throw new SchemaValidationException( - "Exception occurred during the adding of document wrapper to EPCIS event : " - + e.getMessage(), - e); - } catch (IOException ex) { - ex.printStackTrace(); - } + + public void copy(final InputStream inputStream, final OutputStream outputStream) { + try (ReadableByteChannel src = Channels.newChannel(inputStream); + WritableByteChannel dest = Channels.newChannel(outputStream)) { + final ByteBuffer buffer = ByteBuffer.allocateDirect(1024); + while (src.read(buffer) != -1) { + buffer.flip(); + dest.write(buffer); + buffer.compact(); + } + buffer.flip(); + while (buffer.hasRemaining()) { + dest.write(buffer); } - }); - return pipe; - } catch (Exception e) { - throw new SchemaValidationException( - "Exception occurred during the addition of Document wrapper to event : " + e.getMessage(), - e); + } catch (Exception e) { + throw new SchemaValidationException("Exception occurred during the copy of OutputStream to InputStream : " + e.getMessage(), e); + } } - } - - private void copyXmlEvent(final XMLStreamReader reader, final XMLStreamWriter writer) { - try { - while (reader.hasNext()) { - int event = reader.next(); - - if (event == XMLStreamConstants.START_ELEMENT) { - writer.writeStartElement(reader.getLocalName()); - - // Add all the attributes associated with xml tag - for (int i = 0; i < reader.getAttributeCount(); i++) { - writer.writeAttribute(reader.getAttributeLocalName(i), reader.getAttributeValue(i)); - } - - // Add all the namespaces associated with xml tag - for (int i = 0; i < reader.getNamespaceCount(); i++) { - writer.writeNamespace(reader.getNamespacePrefix(i), reader.getNamespaceURI(i)); - } - - } else if (event == XMLStreamConstants.END_ELEMENT) { - writer.writeEndElement(); - } else if (event == XMLStreamConstants.CHARACTERS) { - writer.writeCharacters(reader.getText()); + + // Add the document wrapper to single event + public InputStream addDocumentWrapper(final InputStream captureInput) { + try { + final XMLStreamReader reader = inputFactory.createXMLStreamReader(captureInput); + final PipedOutputStream pipedOutputStream = new PipedOutputStream(); + final PipedInputStream pipe = new PipedInputStream(pipedOutputStream); + + executorService.execute( + () -> { + try { + XMLStreamWriter writer = outputFactory.createXMLStreamWriter(pipedOutputStream); + + writer.writeStartDocument(); + + writer.writeStartElement(EPCIS.EPCIS, EPCIS.EPCIS_DOCUMENT, EPCIS.EPCIS_2_0_XMLNS); + writer.writeAttribute(EPCIS.CREATION_DATE, Instant.now().toString()); + writer.writeAttribute(EPCIS.SCHEMA_VERSION, EPCISVersion.VERSION_2_0_0.getSchemaVersion()); + writer.writeNamespace(EPCIS.EPCIS, EPCIS.EPCIS_2_0_XMLNS); + writer.writeNamespace(EPCIS.XSI, EPCIS.XML_SCHEMA_INSTANCE); + + writer.writeStartElement(EPCIS.EPCIS_BODY); + writer.writeStartElement(EPCIS.EVENT_LIST); + + // read the event and append to writer + copyXmlEvent(reader, writer); + + writer.writeEndElement(); // end EventList + writer.writeEndElement(); // end EPCISBody + writer.writeEndElement(); // end EPCISDocument + + writer.writeEndDocument(); + pipedOutputStream.close(); + } catch (Exception e) { + try { + pipedOutputStream.write(e.getMessage().getBytes()); + pipedOutputStream.close(); + throw new SchemaValidationException("Exception occurred during the adding of document wrapper to EPCIS event : " + e.getMessage(), e); + } catch (IOException ex) { + throw new SchemaValidationException("Exception occurred during the adding of document wrapper to EPCIS event : " + ex.getMessage(), ex); + } + } + }); + return pipe; + } catch (Exception e) { + throw new SchemaValidationException("Exception occurred during the addition of Document wrapper to event : " + e.getMessage(), e); + } + } + + private void copyXmlEvent(final XMLStreamReader reader, final XMLStreamWriter writer) { + try { + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT) { + writer.writeStartElement(reader.getLocalName()); + + // Add all the attributes associated with xml tag + for (int i = 0; i < reader.getAttributeCount(); i++) { + writer.writeAttribute(reader.getAttributeLocalName(i), reader.getAttributeValue(i)); + } + + // Add all the namespaces associated with xml tag + for (int i = 0; i < reader.getNamespaceCount(); i++) { + writer.writeNamespace(reader.getNamespacePrefix(i), reader.getNamespaceURI(i)); + } + + } else if (event == XMLStreamConstants.END_ELEMENT) { + writer.writeEndElement(); + } else if (event == XMLStreamConstants.CHARACTERS) { + writer.writeCharacters(reader.getText()); + } + } + } catch (Exception e) { + throw new SchemaValidationException("Exception occurred during the copying of EPCIS event : " + e.getMessage(), e); } - } - } catch (Exception e) { - e.printStackTrace(); } - } } diff --git a/core/src/main/java/io/openepcis/validation/xml/XMLTagPreScanUtil.java b/core/src/main/java/io/openepcis/validation/xml/XMLTagPreScanUtil.java index e37d9c7..c304770 100644 --- a/core/src/main/java/io/openepcis/validation/xml/XMLTagPreScanUtil.java +++ b/core/src/main/java/io/openepcis/validation/xml/XMLTagPreScanUtil.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,29 +23,33 @@ public class XMLTagPreScanUtil { - private static final String FIRST_TAG_REGEX = "<(?!\\?|\\!--)([\\S\\s]*?)>"; - private static final Pattern FIRST_TAG_PATTERN = Pattern.compile(FIRST_TAG_REGEX); - private static final int READ_LIMIT = 4096; - - public static final String scanFirstTag(final BufferedInputStream input) throws IOException { - input.mark(READ_LIMIT); - try { - final StringBuilder sb = new StringBuilder(); - final byte[] buffer = new byte[64]; - int len = -1; - int bytesReceived = 0; - Matcher matcher = FIRST_TAG_PATTERN.matcher(sb.toString()); - while (!matcher.find(0) && bytesReceived < READ_LIMIT && (len = input.read(buffer)) != -1) { - sb.append(new String(buffer, 0, len, StandardCharsets.UTF_8)); - bytesReceived += len; - matcher = FIRST_TAG_PATTERN.matcher(sb.toString()); - } - if (matcher.find(0)) { - return matcher.group(0); - } - return ""; - } finally { - input.reset(); + private static final String FIRST_TAG_REGEX = "<(?!\\?|\\!--)([\\S\\s]*?)>"; + private static final Pattern FIRST_TAG_PATTERN = Pattern.compile(FIRST_TAG_REGEX); + private static final int READ_LIMIT = 4096; + + public static String scanFirstTag(final BufferedInputStream input) throws IOException { + input.mark(READ_LIMIT); + + try { + final StringBuilder sb = new StringBuilder(); + final byte[] buffer = new byte[64]; + int len = -1; + int bytesReceived = 0; + Matcher matcher = FIRST_TAG_PATTERN.matcher(sb.toString()); + + while (!matcher.find(0) && bytesReceived < READ_LIMIT && (len = input.read(buffer)) != -1) { + sb.append(new String(buffer, 0, len, StandardCharsets.UTF_8)); + bytesReceived += len; + matcher = FIRST_TAG_PATTERN.matcher(sb.toString()); + } + + if (matcher.find(0)) { + return matcher.group(0); + } + + return ""; + } finally { + input.reset(); + } } - } } diff --git a/core/src/main/java/io/openepcis/validation/xml/Xml12SchemaValidator.java b/core/src/main/java/io/openepcis/validation/xml/Xml12SchemaValidator.java index 6cf52d4..2e6df63 100644 --- a/core/src/main/java/io/openepcis/validation/xml/Xml12SchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/xml/Xml12SchemaValidator.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,16 +16,19 @@ package io.openepcis.validation.xml; import io.openepcis.validation.xml.resolver.ClasspathResourceResolver; + import javax.xml.XMLConstants; import javax.xml.validation.SchemaFactory; public class Xml12SchemaValidator extends AbstractXmlSchemaValidator { - public Xml12SchemaValidator() { - SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); - schemaFactory.setResourceResolver(new ClasspathResourceResolver("xsd/1_2_0")); - this.schemaFactory = schemaFactory; - this.captureXmlSchema = loadSchema("/xsd/1_2_0/EPCglobal-epcis-1_2.xsd"); - this.queryXmlSchema = loadSchema("/xsd/1_2_0/EPCglobal-epcis-query-1_2.xsd"); - this.xsdErrorHandler = new XmlErrorHandler(); - } + + public Xml12SchemaValidator() { + final SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); + schemaFactory.setResourceResolver(new ClasspathResourceResolver("xsd/1_2_0")); + this.schemaFactory = schemaFactory; + this.captureXmlSchema = loadSchema("/xsd/1_2_0/EPCglobal-epcis-1_2.xsd"); + this.queryXmlSchema = loadSchema("/xsd/1_2_0/EPCglobal-epcis-query-1_2.xsd"); + this.xsdErrorHandler = new XmlErrorHandler(); + } + } diff --git a/core/src/main/java/io/openepcis/validation/xml/Xml20SchemaValidator.java b/core/src/main/java/io/openepcis/validation/xml/Xml20SchemaValidator.java index 89198bf..6355b02 100644 --- a/core/src/main/java/io/openepcis/validation/xml/Xml20SchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/xml/Xml20SchemaValidator.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,16 +16,19 @@ package io.openepcis.validation.xml; import io.openepcis.validation.xml.resolver.ClasspathResourceResolver; + import javax.xml.XMLConstants; import javax.xml.validation.SchemaFactory; public class Xml20SchemaValidator extends AbstractXmlSchemaValidator { - public Xml20SchemaValidator() { - SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); - schemaFactory.setResourceResolver(new ClasspathResourceResolver("xsd/2_0_0")); - this.schemaFactory = schemaFactory; - this.captureXmlSchema = loadSchema("/xsd/2_0_0/EPCglobal-epcis-2_0.xsd"); - this.queryXmlSchema = loadSchema("/xsd/2_0_0/EPCglobal-epcis-query-2_0.xsd"); - this.xsdErrorHandler = new XmlErrorHandler(); - } + + public Xml20SchemaValidator() { + final SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); + schemaFactory.setResourceResolver(new ClasspathResourceResolver("xsd/2_0_0")); + this.schemaFactory = schemaFactory; + this.captureXmlSchema = loadSchema("/xsd/2_0_0/EPCglobal-epcis-2_0.xsd"); + this.queryXmlSchema = loadSchema("/xsd/2_0_0/EPCglobal-epcis-query-2_0.xsd"); + this.xsdErrorHandler = new XmlErrorHandler(); + } + } diff --git a/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java b/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java index b186aeb..2ec375b 100644 --- a/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java +++ b/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,40 +15,77 @@ */ package io.openepcis.validation.xml; +import io.openepcis.validation.message.MessageTemplates; import io.openepcis.validation.model.ValidationError; +import lombok.Getter; import org.xml.sax.ErrorHandler; import org.xml.sax.SAXParseException; -/** custom error handler while validating xml against xsd */ +import java.util.ArrayList; +import java.util.List; + +/** + * custom error handler while validating xml against xsd + */ public class XmlErrorHandler implements ErrorHandler { - @lombok.Getter private final java.util.List exceptions; - - public XmlErrorHandler() { - this.exceptions = new java.util.ArrayList<>(); - } - - @Override - public void warning(final SAXParseException exception) { - handleError(exception); - } - - @Override - public void error(final SAXParseException exception) { - handleError(exception); - } - - @Override - public void fatalError(final SAXParseException exception) { - handleError(exception); - } - - public void handleError(SAXParseException e) { - // This will allow the validation to continue even if there are errors. - final ValidationError errorFormat = new ValidationError(); - errorFormat.setType("required"); - errorFormat.setLine(Integer.toString(e.getLineNumber())); - errorFormat.setLocation(Integer.toString(e.getColumnNumber())); - errorFormat.setMessage(errorFormat.filterErrorMessage(e.getMessage())); - this.exceptions.add(errorFormat); - } + @Getter + private final List exceptions; + + public XmlErrorHandler() { + this.exceptions = new ArrayList<>(); + } + + @Override + public void warning(final SAXParseException exception) { + handleError(exception); + } + + @Override + public void error(final SAXParseException exception) { + handleError(exception); + } + + @Override + public void fatalError(final SAXParseException exception) { + handleError(exception); + } + + public void handleError(final SAXParseException e) { + final String raw = e.getMessage() == null ? "" : e.getMessage(); // never NPE on a null message + final String line = Integer.toString(e.getLineNumber()); + final String column = Integer.toString(e.getColumnNumber()); + + // Xerces reports one mistake twice, for example "not facet-valid" then "value of element is + // not valid". Same line and column is the same mistake, so keep the first, more specific one. + for (final ValidationError existing : this.exceptions) { + if (line.equals(existing.getLine()) && column.equals(existing.getLocation())) { + return; + } + } + + final ValidationError errorFormat = new ValidationError(); + errorFormat.setType(typeOf(raw)); // a real type, instead of always reporting "required" + errorFormat.setLine(line); // unchanged, the front end uses this for XML highlighting + errorFormat.setLocation(column); // unchanged + errorFormat.setMessage(errorFormat.filterErrorMessage(raw)); + this.exceptions.add(errorFormat); + } + + /** + * Maps the leading W3C XSD rule code to a response type via the catalogue. Longest match wins. + */ + private String typeOf(final String raw) { + final int colon = raw.indexOf(':'); + if (colon > 0) { + final String code = raw.substring(0, colon); // e.g. cvc-complex-type.2.4.a + for (int cut = code.length(); cut > 0; cut = code.lastIndexOf('.', cut - 1)) { + final String mapped = MessageTemplates.xsdType(code.substring(0, cut)); + if (mapped != null) { + return mapped; // prefers cvc-complex-type.2.4 over cvc-complex-type + } + } + } + return "invalid"; // an honest default, better than claiming a field was required + } + } diff --git a/core/src/main/java/io/openepcis/validation/xml/resolver/ClasspathResourceResolver.java b/core/src/main/java/io/openepcis/validation/xml/resolver/ClasspathResourceResolver.java index f90759b..30b7e4f 100644 --- a/core/src/main/java/io/openepcis/validation/xml/resolver/ClasspathResourceResolver.java +++ b/core/src/main/java/io/openepcis/validation/xml/resolver/ClasspathResourceResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,11 +15,12 @@ */ package io.openepcis.validation.xml.resolver; -import java.io.InputStream; -import java.io.InputStreamReader; import org.w3c.dom.ls.LSInput; import org.w3c.dom.ls.LSResourceResolver; +import java.io.InputStream; +import java.io.InputStreamReader; + public class ClasspathResourceResolver implements LSResourceResolver { private final String base_path; diff --git a/core/src/main/java/io/openepcis/validation/xml/resolver/LSInputImpl.java b/core/src/main/java/io/openepcis/validation/xml/resolver/LSInputImpl.java index 843f25c..2df810f 100644 --- a/core/src/main/java/io/openepcis/validation/xml/resolver/LSInputImpl.java +++ b/core/src/main/java/io/openepcis/validation/xml/resolver/LSInputImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,12 +15,13 @@ */ package io.openepcis.validation.xml.resolver; -import java.io.InputStream; -import java.io.Reader; import lombok.Getter; import lombok.Setter; import org.w3c.dom.ls.LSInput; +import java.io.InputStream; +import java.io.Reader; + @Getter @Setter public class LSInputImpl implements LSInput { diff --git a/core/src/main/resources/validation-messages/epcis-validation-messages.yaml b/core/src/main/resources/validation-messages/epcis-validation-messages.yaml new file mode 100644 index 0000000..82a4a40 --- /dev/null +++ b/core/src/main/resources/validation-messages/epcis-validation-messages.yaml @@ -0,0 +1,339 @@ +# +# Copyright 2022-2024 benelog GmbH & Co. KG +# +# 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 +# +# http://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. +# + +# ============================================================================= +# EPCIS VALIDATION MESSAGE CATALOGUE +# ============================================================================= +# +# Every user facing validation message lives in this file. No message text +# belongs in Java code. Adding or rewording a message is a change here only. +# +# ----------------------------------------------------------------------------- +# HOW A MESSAGE IS CHOSEN (three layers, most specific wins) +# ----------------------------------------------------------------------------- +# +# Layer 1 schemaLocations Keyed by JSON Pointer into the EPCIS schema. +# The pointer is walked upwards until an entry +# matches, so one entry covers every event type and +# every occurrence in the document. +# +# For a wrong bizStep the engine reports +# #/definitions/bizStep/anyOf/1/enum +# and the lookup tries, in order: +# #/definitions/bizStep/anyOf/1/enum.enum +# #/definitions/bizStep/anyOf/1/enum +# #/definitions/bizStep/anyOf/1.enum +# #/definitions/bizStep/anyOf/1 +# #/definitions/bizStep/anyOf.enum +# #/definitions/bizStep/anyOf +# #/definitions/bizStep.enum +# #/definitions/bizStep <-- matches here +# +# Layer 2 keywords Keyed by JSON Schema keyword. The safety net for +# anything not named in layer 1. +# +# Layer 3 (not in this file) Whatever the library itself says, taken from +# ValidationMessage.getError(). Reached only when +# layers 1 and 2 both miss, so a message is never +# lost and an unknown keyword can never break a +# response. +# +# ----------------------------------------------------------------------------- +# PLACEHOLDERS +# ----------------------------------------------------------------------------- +# +# {field} name of the offending field, for example bizStep +# {value} the value the user actually sent +# {where} friendly position, for example "event 6 (TransformationEvent)" +# {suggestion} " Did you mean ...?" when a close match exists, else empty +# {allowedCount} how many values the standard allows at this place +# {allowedSample} a short sample of the allowed values, never the whole list +# {expected} the type the schema wants (keyword: type) +# {actual} the type or count actually supplied (keywords: type, min/maxItems) +# {limit} the boundary the schema sets (keywords: min*, max*) +# {fields} a comma separated field list (required.alternatives) +# +# Unknown placeholders render as empty and the surrounding gap is collapsed, +# so a template can safely mention a placeholder that a keyword does not supply. +# +# ----------------------------------------------------------------------------- +# RULES WHEN EDITING +# ----------------------------------------------------------------------------- +# +# 1. Quote every key in schemaLocations. A JSON Pointer starts with "#", and +# an unquoted "#" would be read as a YAML comment. This is exactly why the +# catalogue is YAML and not a .properties file, where such a key is +# silently swallowed with no error at all. +# 2. Wrap message text in single quotes so that ":" and "{" stay literal. +# Write a literal single quote as two single quotes. +# 3. Never paste the full list of allowed values into a message. Use +# {allowedSample} and let the API return the complete list separately in +# the allowedValues field, so the UI can reveal it on demand. +# 4. Keep the wording free of schema jargon. The reader is a supply chain user, +# not a JSON Schema author. +# 5. The full 41 value CBV list, the 33 dispositions and so on are read from +# the schema at runtime. Never copy such a list into this file. +# +# ============================================================================= + + +# ============================================================================= +# LAYER 1 ... PER SCHEMA LOCATION +# ============================================================================= +schemaLocations: + + # --------------------------------------------------------------------------- + # CBV vocabularies + # + # Each of these is declared in the schema as anyOf(custom URI, standard + # value). A plain word that is not a standard value therefore fails both + # branches, which is why one mistake used to arrive as two errors. One entry + # here replaces both of them with a single sentence that explains the choice. + # --------------------------------------------------------------------------- + + "#/definitions/bizStep": '"{value}" is not a standard EPCIS business step for {field} in {where}.{suggestion} Use one of the {allowedCount} standard steps ({allowedSample}), or, for a step of your own, a full web address such as https://example.com/bizStep/{value}.' + + # Also covers persistentDisposition, whose set and unset entries reuse this definition. + "#/definitions/disposition": '"{value}" is not a standard EPCIS disposition for {field} in {where}.{suggestion} Use one of the {allowedCount} standard dispositions ({allowedSample}), or, for a disposition of your own, a full web address such as https://example.com/disposition/{value}.' + + "#/definitions/bizTransaction-type": '"{value}" is not a standard EPCIS business transaction type for {field} in {where}.{suggestion} Use one of the {allowedCount} standard types ({allowedSample}), or a full web address of your own.' + + "#/definitions/source-dest-type": '"{value}" is not a standard EPCIS source or destination type for {field} in {where}.{suggestion} Use one of the {allowedCount} standard types ({allowedSample}), or a full web address of your own.' + + "#/definitions/error-reason": '"{value}" is not a standard EPCIS error reason for {field} in {where}.{suggestion} Use one of the {allowedCount} standard reasons ({allowedSample}), or a full web address of your own.' + + "#/definitions/measurementType": '"{value}" is not a standard EPCIS measurement type for {field} in {where}.{suggestion} Use one of the {allowedCount} standard types ({allowedSample}), or a full web address of your own.' + + "#/definitions/sensorAlertType": '"{value}" is not a standard EPCIS sensor alert type for {field} in {where}.{suggestion} Use one of the {allowedCount} standard types ({allowedSample}), or a full web address of your own.' + + "#/definitions/component": '"{value}" is not a standard EPCIS address component for {field} in {where}.{suggestion} Use one of the {allowedCount} standard components ({allowedSample}), or a full web address of your own.' + + "#/definitions/eventType": '"{value}" is not a standard EPCIS event type for {field} in {where}.{suggestion} Use one of the {allowedCount} standard types ({allowedSample}), or a full web address of your own.' + + "#/definitions/common-event-properties": '"{value}" is not a value EPCIS recognises for {field} in {where}.{suggestion} Use one of the {allowedCount} standard values ({allowedSample}), or a full web address of your own.' + + # --------------------------------------------------------------------------- + # The custom vocabulary branch + # + # Shared by every vocabulary above as the "or a URI of your own" alternative. + # Grouping normally leads with the enum branch, so this wording appears only + # when the enum branch did not report anything. + # --------------------------------------------------------------------------- + + "#/definitions/vocab-other-uri": '{field} in {where} must be either a standard EPCIS value or a full web address of your own, for example https://example.com/vocabulary/{value}. It cannot be a plain word. You sent "{value}".' + + "#/definitions/vocab-uri": '{field} in {where} must be a recognised EPCIS name or a full web address. You sent "{value}".' + + # --------------------------------------------------------------------------- + # Fixed value fields + # + # action is a plain enum of exactly three values with no custom URI option, + # so the wording here deliberately does not offer one. + # --------------------------------------------------------------------------- + + "#/definitions/action": '"{value}" is not a valid action for {where}. An action must be exactly one of {allowedSample}, in capital letters.' + + # --------------------------------------------------------------------------- + # Identifiers + # --------------------------------------------------------------------------- + + "#/definitions/uri": '{field} in {where} must be a full web address or a URN. Examples: https://id.gs1.org/01/09520123456788 or urn:epc:id:sgtin:0614141.107346.2017. You sent "{value}".' + + "#/definitions/id": '{field} in {where} must be a full identifier, for example https://id.gs1.org/414/4012345000054 or urn:epc:id:sgln:0614141.00001.0. You sent "{value}".' + + # --------------------------------------------------------------------------- + # Dates and times + # + # One shared definition backs eventTime, recordTime, creationDate, + # declarationTime and the sensor metadata times, so the wording stays generic + # and lets {field} name the one that failed. + # --------------------------------------------------------------------------- + + "#/definitions/time": '{field} in {where} must be a date and time that includes a time zone. Example: 2026-07-22T09:20:00+02:00. You sent "{value}".' + + "#/definitions/Event/properties/eventTimeZoneOffset": 'eventTimeZoneOffset in {where} must be a time zone offset such as +02:00 or -05:30. You sent "{value}".' + + # --------------------------------------------------------------------------- + # Numbers + # --------------------------------------------------------------------------- + + "#/definitions/decimal": '{field} in {where} must be a number written without quotation marks, for example 19.3 rather than "19.3". You sent "{value}".' + + +# ============================================================================= +# LAYER 2 ... PER JSON SCHEMA KEYWORD +# +# Reached when no schema location above matches. Wording stays general here, +# because the same keyword can fail anywhere in the document. +# ============================================================================= +keywords: + + # --- Missing, unknown and misnamed fields ---------------------------------- + + required: '{field} is required but missing in {where}.' + + # Synthetic key, produced when sibling schema branches each demand a different + # field. Instead of several "X is required" errors the user gets one choice. + required.alternatives: '{where} must contain at least one of these fields: {fields}.' + + # Synthetic key, produced when one position is missing several fields that are all + # unconditionally required. Every field is named, so nothing is hidden from the user. + required.multiple: 'These required fields are missing in {where}: {fields}.' + + additionalProperties: '{field} is not a field EPCIS recognises in {where}. Remove it, or move it to a namespace of your own such as ex:{field}.' + + propertyNames: '"{field}" is not a valid field name in {where}. A field of your own needs a namespace prefix, for example ex:{field}.' + + notAllowed: '{field} is not allowed in {where}. Remove it.' + + dependentRequired: '{field} in {where} may only be used together with the field it depends on.' + + dependencies: '{field} in {where} is missing another field that it depends on.' + + # --- Wrong value ----------------------------------------------------------- + + enum: '"{value}" is not an accepted value for {field} in {where}.{suggestion} Accepted values: {allowedSample}.' + + const: '{field} in {where} must be exactly "{limit}". You sent "{value}".' + + pattern: '"{value}" is not correctly formatted for {field} in {where}.' + + type: '{field} in {where} must be {expected}, but {actual} was supplied.' + + unionType: '{field} in {where} must be {expected}, but {actual} was supplied.' + + # --- Formats --------------------------------------------------------------- + + format: '{field} in {where} is not in the format EPCIS expects. You sent "{value}".' + format.uri: '{field} in {where} must be a full web address or URN, for example https://id.gs1.org/01/09520123456788. You sent "{value}".' + format.uri-reference: '{field} in {where} must be a web address. You sent "{value}".' + format.date-time: '{field} in {where} must be a date and time that includes a time zone. Example: 2026-07-22T09:20:00+02:00. You sent "{value}".' + format.date: '{field} in {where} must be a date such as 2026-07-22. You sent "{value}".' + format.time: '{field} in {where} must be a time such as 09:20:00. You sent "{value}".' + format.duration: '{field} in {where} must be a length of time such as PT30M for thirty minutes. You sent "{value}".' + format.uuid: '{field} in {where} must be a UUID such as 3f2504e0-4f89-11d3-9a0c-0305e82c3301. You sent "{value}".' + format.email: '{field} in {where} must be an email address. You sent "{value}".' + format.hostname: '{field} in {where} must be a host name such as example.com. You sent "{value}".' + format.regex: '{field} in {where} must be a valid regular expression. You sent "{value}".' + format.unknown: '{field} in {where} uses a format EPCIS does not know how to check.' + + # --- Sizes and counts ------------------------------------------------------ + + minItems: '{field} in {where} needs at least {limit} entries, but has {actual}.' + maxItems: '{field} in {where} allows at most {limit} entries, but has {actual}.' + uniqueItems: '{field} in {where} lists the same entry more than once. Remove the duplicates.' + contains: '{field} in {where} must contain at least one entry of the expected kind.' + contains.min: '{field} in {where} must contain at least {limit} entries of the expected kind.' + contains.max: '{field} in {where} must contain at most {limit} entries of the expected kind.' + minLength: '{field} in {where} must be at least {limit} characters long. You sent "{value}".' + maxLength: '{field} in {where} must be at most {limit} characters long.' + minProperties: '{field} in {where} must have at least {limit} fields.' + maxProperties: '{field} in {where} must have at most {limit} fields.' + + # --- Numeric ranges -------------------------------------------------------- + + minimum: '{field} in {where} must be {limit} or greater. You sent "{value}".' + maximum: '{field} in {where} must be {limit} or less. You sent "{value}".' + exclusiveMinimum: '{field} in {where} must be greater than {limit}. You sent "{value}".' + exclusiveMaximum: '{field} in {where} must be less than {limit}. You sent "{value}".' + multipleOf: '{field} in {where} must be a multiple of {limit}. You sent "{value}".' + + # --- Structure ------------------------------------------------------------- + # + # These fire when a value matches none of the shapes EPCIS allows. They are + # the least helpful messages in the catalogue by nature, because the schema + # itself cannot know which shape was intended. Grouping usually surfaces a + # more precise error from inside the failing branch instead. + + anyOf: '{field} in {where} does not match any of the shapes EPCIS allows here. Check the fields of this {field} against the EPCIS standard.' + oneOf: '{field} in {where} matches more than one of the shapes EPCIS allows, or none of them. Check the fields of this {field} against the EPCIS standard.' + allOf: '{field} in {where} does not meet all of the rules for this part of an EPCIS document.' + not: '{field} in {where} uses a value that is not allowed here.' + + unevaluatedProperties: '{field} is not a field EPCIS recognises in {where}. Remove it, or move it to a namespace of your own such as ex:{field}.' + unevaluatedItems: '{where} contains an entry at position {limit} that EPCIS does not expect here.' + + # --- Rarely seen ----------------------------------------------------------- + + readOnly: '{field} in {where} is set by the repository and cannot be supplied.' + writeOnly: '{field} in {where} cannot be returned in a response.' + contentEncoding: '{field} in {where} is not encoded the way EPCIS expects.' + contentMediaType: '{field} in {where} is not of the media type EPCIS expects.' + false: '{field} is not allowed in {where}.' + id: '"{value}" is not a valid identifier for {field} in {where}.' + + +# ============================================================================= +# REUSABLE SENTENCE PIECES +# +# Leading spaces matter here and YAML keeps them, which is why these are +# single quoted. A template embeds {suggestion} with no space of its own. +# ============================================================================= +fragments: + + # Appended only when a close match to the wrong value was found. + suggestionHint: ' Did you mean "{best}"?' + + # Appended when one position really does have several unrelated problems. + reasonMore: ' Also: {reasons}' + + # Fills {where}. eventIndex is zero based internally and shown from 1. + whereDocument: 'the document' + whereEvent: 'event {number}' + whereEventTyped: 'event {number} ({type})' + + +# ============================================================================= +# XML ... XSD VALIDATION RULE CODES +# +# Xerces prefixes each message with the identifier of the W3C XML Schema +# validation rule that failed, for example "cvc-enumeration-valid". Those names +# come from the specification itself, so they are stable to key on. They are +# mapped here to the type field of the response, which previously reported +# "required" for every XML error regardless of what actually went wrong. +# +# The longest matching prefix wins, so cvc-complex-type.2.4 is preferred over +# cvc-complex-type when both are present. +# ============================================================================= +xsdTypes: + cvc-enumeration-valid: 'enum' + cvc-datatype-valid: 'format' + cvc-pattern-valid: 'pattern' + cvc-maxLength-valid: 'maxLength' + cvc-minLength-valid: 'minLength' + cvc-length-valid: 'length' + cvc-minInclusive-valid: 'minimum' + cvc-maxInclusive-valid: 'maximum' + cvc-minExclusive-valid: 'exclusiveMinimum' + cvc-maxExclusive-valid: 'exclusiveMaximum' + cvc-totalDigits-valid: 'format' + cvc-fractionDigits-valid: 'format' + cvc-complex-type.2.1: 'unexpectedContent' + cvc-complex-type.2.2: 'unexpectedContent' + cvc-complex-type.2.3: 'unexpectedContent' + cvc-complex-type.2.4: 'unexpectedElement' + cvc-complex-type.3: 'attribute' + cvc-complex-type.4: 'required' + cvc-complex-type: 'invalidContent' + cvc-type.3.1.1: 'attribute' + cvc-type.3.1.3: 'format' + cvc-attribute: 'attribute' + cvc-elt.1: 'unknownElement' + cvc-elt.3: 'nilNotAllowed' + cvc-elt.4: 'invalidType' + cvc-identity-constraint: 'duplicateKey' + cvc-au: 'attribute' + src-resolve: 'schemaResolution' diff --git a/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java b/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java index 1e5fca4..eb0dc76 100644 --- a/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java +++ b/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,91 +15,74 @@ */ package io.openepcis.validation.test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - import com.fasterxml.jackson.databind.ObjectMapper; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SpecVersion; +import com.networknt.schema.serialization.JsonNodeReader; import io.openepcis.constants.EPCISDocumentType; import io.openepcis.constants.EPCISVersion; +import io.openepcis.resources.util.Commons; import io.openepcis.validation.SchemaValidator; import io.openepcis.validation.model.ValidationError; import io.smallrye.mutiny.Multi; -import java.io.InputStream; import org.junit.jupiter.api.Test; -public class JsonDocumentTest { +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class JsonDocumentTest { + + // paths of the shared documents in openepcis-test-resources + private static final String QUERY = "2.0/EPCIS/JSON/Query/"; + private static final String QUERY_INVALID = "2.0/EPCIS/JSON/Query/Invalid/"; + private static final String INVALID = "2.0/EPCIS/JSON/Capture/Invalid/"; - private ObjectMapper mapper = new ObjectMapper(); - private SchemaValidator validator = - new SchemaValidator( - mapper, - JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) - .jsonMapper(mapper) - .build()); + private final ObjectMapper mapper = new ObjectMapper(); + private final SchemaValidator validator = + new SchemaValidator(mapper, JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) + .jsonNodeReader(JsonNodeReader.builder().jsonMapper(mapper).build()).build()); - @Test - public void validateCaptureDocumentTest() { - final InputStream inputStream = getClass().getResourceAsStream("/json/CaptureDocument.json"); - final Multi jsonCaptureErrors = - validator.validate( - inputStream, "application/json", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0); - assertTrue(jsonCaptureErrors.subscribe().asStream().toList().size() > 0); - } + @Test + void validateCaptureDocumentTest() { + final InputStream inputStream = Commons.getInputStream(INVALID + "Combination_of_events_with_missing_mandatory_fields.json"); + final Multi jsonCaptureErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0); + assertFalse(jsonCaptureErrors.subscribe().asStream().toList().isEmpty()); + } - @Test - public void validateCaptureDocumentWithCertificationTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/json/CaptureDocumentWithCertification.json"); - final Multi jsonCaptureErrors = - validator.validate( - inputStream, "application/json", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0); - assertTrue(jsonCaptureErrors.subscribe().asStream().toList().size() > 0); - /* - jsonCaptureErrors.subscribe().with(error -> { - System.out.println(error); - }); - */ - } + @Test + void validateCaptureDocumentWithCertificationTest() { + final InputStream inputStream = Commons.getInputStream(INVALID + "ObjectEvent_with_invalid_certificationInfo.json"); + final Multi jsonCaptureErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0); + assertFalse(jsonCaptureErrors.subscribe().asStream().toList().isEmpty()); + } - @Test - public void validateQueryDocumentWithEventTypeTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/json/QueryDocumentWithEventType.json"); - final Multi jsonQueryErrors = - validator.validate( - inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(0, jsonQueryErrors.subscribe().asStream().toList().size()); - } + @Test + void validateQueryDocumentWithEventTypeTest() { + final InputStream inputStream = Commons.getInputStream(QUERY + "QueryDocumentWithEventType.json"); + final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); + assertEquals(0, jsonQueryErrors.subscribe().asStream().toList().size()); + } - @Test - public void validateQueryDocumentWithEventTimeTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/json/queryDocumentWithEventTime.json"); - final Multi jsonQueryErrors = - validator.validate( - inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(0, jsonQueryErrors.subscribe().asStream().toList().size()); - } + @Test + void validateQueryDocumentWithEventTimeTest() { + final InputStream inputStream = Commons.getInputStream(QUERY + "queryDocumentWithEventTime.json"); + final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); + assertEquals(0, jsonQueryErrors.subscribe().asStream().toList().size()); + } - @Test - public void validateQueryDocumentWithActionTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/json/queryDocumentWithAction.json"); - final Multi jsonQueryErrors = - validator.validate( - inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(0, jsonQueryErrors.subscribe().asStream().toList().size()); - } + @Test + void validateQueryDocumentWithActionTest() { + final InputStream inputStream = Commons.getInputStream(QUERY + "queryDocumentWithAction.json"); + final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); + assertEquals(0, jsonQueryErrors.subscribe().asStream().toList().size()); + } - @Test - public void validateQueryDocumentWithInValidEventTypeTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/json/queryDocumentWithInvalidEventType.json"); - final Multi jsonQueryErrors = - validator.validate( - inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertTrue(jsonQueryErrors.subscribe().asStream().toList().size() > 0); - } + @Test + void validateQueryDocumentWithInValidEventTypeTest() { + final InputStream inputStream = Commons.getInputStream(QUERY_INVALID + "QueryDocument_with_invalid_eventType.json"); + final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); + assertFalse(jsonQueryErrors.subscribe().asStream().toList().isEmpty()); + } } diff --git a/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java b/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java new file mode 100644 index 0000000..5274d7e --- /dev/null +++ b/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java @@ -0,0 +1,292 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.serialization.JsonNodeReader; +import io.openepcis.constants.EPCISDocumentType; +import io.openepcis.constants.EPCISVersion; +import io.openepcis.resources.util.Commons; +import io.openepcis.validation.SchemaValidator; +import io.openepcis.validation.model.ValidationError; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Validates the quality of JSON validation messages provided to the users + * Validates: that one mistake produces exactly one error, and the message names the field, the value and the event in plain language. + */ +class JsonValidationMessageTest { + + // path for valid and invalid documents from the test-resources + private static final String VALID = "2.0/EPCIS/JSON/Capture/Documents/"; + private static final String INVALID = "2.0/EPCIS/JSON/Capture/Invalid/"; + + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * Validates one fixture and returns the errors. + */ + private List validate(final String resourcePath) { + final InputStream document = Commons.getInputStream(resourcePath); + assertNotNull(document, "missing shared test resource " + resourcePath); + + final SchemaValidator validator = new SchemaValidator( + mapper, + JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) + .jsonNodeReader(JsonNodeReader.builder().jsonMapper(mapper).build()) + .build()); + + return validator + .validate(document, "application/json", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0) + .subscribe() + .asStream() + .toList(); + } + + // --------------------------------------------------------------------------- + // Valid documents must stay silent + // --------------------------------------------------------------------------- + + @Test + @DisplayName(VALID + "ObjectEvent.json") + void validObjectEvent() { + // Valid document carries two events and a standard bizStep + assertEquals(0, validate(VALID + "ObjectEvent.json").size()); + } + + @Test + @DisplayName(VALID + "ObjectEvent_with_custom_bizStep_uri.json") + void validCustomBizStepUri() { + // Valid document with custom bizStep URI + assertEquals(0, validate(VALID + "ObjectEvent_with_custom_bizStep_uri.json").size()); + } + + @Test + @DisplayName(VALID + "Combination_of_different_event.json") + void validMultipleEvents() { + assertEquals(0, validate(VALID + "Combination_of_different_event.json").size()); + } + + // --------------------------------------------------------------------------- + // One issue or wrong format in event should produce exactly one error instead of multiple chained errors. + // Ex: one wrong bizStep used to produce two errors + // --------------------------------------------------------------------------- + + @Test + @DisplayName(INVALID + "ObjectEvent_with_non_standard_bizStep.json") + void wrongBizStepGivesSingleFriendlyError() { + final List errors = validate(INVALID + "ObjectEvent_with_non_standard_bizStep.json"); + + // the schema fails both anyOf branches, the response must still show one mistake + assertEquals(1, errors.size()); + final ValidationError error = errors.getFirst(); + + assertEquals( + "\"transforming\" is not a standard EPCIS business step for bizStep in event 1 " + + "(ObjectEvent). Did you mean \"transporting\"? Use one of the 41 standard steps " + + "(accepting, arriving, assembling, collecting, commissioning, consigning and 35 more), " + + "or, for a step of your own, a full web address such as " + + "https://example.com/bizStep/transforming.", + error.getMessage()); + + assertEquals("bizStep", error.getField()); + assertEquals("transforming", error.getValue()); + assertEquals("transporting", error.getSuggestion()); + assertEquals(Integer.valueOf(0), error.getEventIndex()); + assertEquals("ObjectEvent", error.getEventType()); + assertEquals(41, error.getAllowedValues().size()); // full list travels as data for the UI + + // the path the front end maps to an editor line must keep its exact shape + assertEquals("$.epcisBody.eventList[0].bizStep", error.getLocation()); + } + + @Test + @DisplayName(INVALID + "ObjectEvent_with_non_standard_bizStep.json") + void messageStaysShortEvenForLargeVocabularies() { + final ValidationError error = validate(INVALID + "ObjectEvent_with_non_standard_bizStep.json").getFirst(); + + // the old message was over 600 characters of raw enum dump + assertTrue(error.getMessage().length() < 400, "message too long: " + error.getMessage().length()); + assertTrue(error.getMessage().contains("and 35 more")); + } + + @Test + @DisplayName("invalid: a wrong disposition suggests the closest standard disposition") + void wrongDispositionSuggestsClosest() { + final List errors = validate(INVALID + "ObjectEvent_with_non_standard_disposition.json"); + assertEquals(1, errors.size()); + assertEquals("in_transit", errors.getFirst().getSuggestion()); + assertTrue(errors.getFirst().getMessage().contains("Did you mean \"in_transit\"?")); + } + + // --------------------------------------------------------------------------- + // Grouping: several raw messages for one mistake + // --------------------------------------------------------------------------- + + @Test + @DisplayName("invalid: a wrong action gives ONE error, and offers no custom URI") + void wrongActionGivesSingleError() { + // the raw engine reports four messages here, one enum plus three branch patterns + final List errors = validate(INVALID + "ObjectEvent_with_invalid_action.json"); + assertEquals(1, errors.size()); + + final ValidationError error = errors.getFirst(); + assertEquals("action", error.getField()); + assertEquals("CREATE", error.getValue()); + assertTrue(error.getMessage().contains("OBSERVE, ADD, DELETE")); + + // action has no custom URI branch, so suggesting a web address would be wrong advice + assertFalse(error.getMessage().contains("web address"), "action must not be offered a custom URI: " + error.getMessage()); + } + + @Test + @DisplayName("invalid: an unknown field wins over the misleading branch errors") + void unknownFieldReportedInsteadOfBranchNoise() { + // the raw engine reports five messages, three of them wrongly claiming epcList is missing + final List errors = validate(INVALID + "ObjectEvent_with_unknown_field_in_quantityList.json"); + + assertEquals(1, errors.size()); + assertEquals("weight", errors.getFirst().getField()); + assertTrue(errors.getFirst().getMessage().contains("ex:weight")); // suggest how to fix it + } + + @Test + @DisplayName("invalid: an event missing every identifier list gets one 'at least one of' message") + void missingIdentifierListsGivesOneChoice() { + final List errors = validate(INVALID + "ObjectEvent_without_identifier_list.json"); + + assertEquals(1, errors.size()); + assertTrue(errors.getFirst().getMessage().contains("at least one of")); + assertTrue(errors.getFirst().getMessage().contains("epcList")); + } + + @Test + @DisplayName("invalid: several genuinely missing fields are ALL named, none is dropped") + void everyMissingRequiredFieldIsNamed() { + // regression guard: grouping must never hide a second missing field at the same position + final List errors = validate(INVALID + "ObjectEvent_without_action_and_eventTime.json"); + final String combined = String.join(" | ", errors.stream().map(ValidationError::getMessage).toList()); + + assertTrue(combined.contains("action"), "action not reported: " + combined); + assertTrue(combined.contains("eventTime"), "eventTime not reported: " + combined); + } + + // --------------------------------------------------------------------------- + // Other common mistakes + // --------------------------------------------------------------------------- + + @Test + @DisplayName("invalid: a bad eventTime explains the expected format with an example") + void badEventTimeShowsExample() { + final List errors = validate(INVALID + "ObjectEvent_with_invalid_eventTime_format.json"); + + assertEquals(1, errors.size()); + assertEquals("eventTime", errors.getFirst().getField()); + assertTrue(errors.getFirst().getMessage().contains("2026-07-22T09:20:00+02:00")); + assertTrue(errors.getFirst().getMessage().contains("22-07-2026")); + } + + @Test + @DisplayName("invalid: a text quantity says a number is expected") + void quantityMustBeNumber() { + final List errors = validate(INVALID + "ObjectEvent_with_quantity_as_text.json"); + + assertEquals(1, errors.size()); + assertEquals("quantity", errors.getFirst().getField()); + assertTrue(errors.getFirst().getMessage().contains("number")); + } + + @Test + @DisplayName("invalid: an ilmd field without a namespace is told to add one") + void ilmdFieldNeedsNamespace() { + final List errors = validate(INVALID + "ObjectEvent_with_ilmd_field_without_namespace.json"); + + assertEquals(1, errors.size()); + assertEquals("materialType", errors.getFirst().getField()); + assertTrue(errors.getFirst().getMessage().contains("ex:materialType")); + } + + @Test + @DisplayName("invalid: a missing @context is reported against the document, not an event") + void missingContextIsReportedAtDocumentLevel() { + final List errors = validate(INVALID + "EPCISDocument_without_context.json"); + + assertEquals(1, errors.size()); + assertEquals("@context", errors.getFirst().getField()); + assertNull(errors.getFirst().getEventIndex()); + assertTrue(errors.getFirst().getMessage().contains("the document")); + } + + @Test + @DisplayName("invalid: the event number in the message is one based and names the event type") + void eventNumberIsHumanReadable() { + final List errors = validate(INVALID + "ObjectEvent_with_non_standard_bizStep_in_second_event.json"); + + assertEquals(1, errors.size()); + assertEquals(Integer.valueOf(1), errors.getFirst().getEventIndex()); + assertTrue(errors.getFirst().getMessage().contains("event 2 (ObjectEvent)")); + } + + // --------------------------------------------------------------------------- + // Contract guards: the front end depends on these + // --------------------------------------------------------------------------- + + @Test + @DisplayName("contract: every error carries a message, a type and a JSON path location") + void everyErrorIsUsableByTheFrontEnd() { + final List errors = validate(INVALID + "Combination_of_multiple_invalid_fields.json"); + assertTrue(errors.size() > 1); + + for (final ValidationError error : errors) { + assertNotNull(error.getMessage(), "message missing"); + assertFalse(error.getMessage().isBlank(), "message blank"); + assertNotNull(error.getType(), "type missing"); + assertNotNull(error.getLocation(), "location missing"); + + // the front end strips the leading $ and maps the rest to an editor line + assertTrue(error.getLocation().startsWith("$"), "unexpected location: " + error.getLocation()); + } + } + + @Test + @DisplayName("contract: errors come back in document order") + void errorsAreInDocumentOrder() { + final List errors = validate(INVALID + "Combination_of_non_standard_bizStep_and_disposition.json"); + + assertEquals(2, errors.size()); + assertEquals(Integer.valueOf(0), errors.get(0).getEventIndex()); + assertEquals(Integer.valueOf(1), errors.get(1).getEventIndex()); + } + + // Test to print every message for manual review. + @Test + @DisplayName("review: print the messages for a document with many different mistakes") + void printMessagesForReview() { + final List errors = validate(INVALID + "Combination_of_multiple_invalid_fields.json"); + + System.out.println("---- JSON validation messages for review ----"); + errors.forEach(error -> System.out.printf("%-22s %-46s %s%n", error.getType(), error.getLocation(), error.getMessage())); + assertTrue(errors.size() >= 5); + } +} diff --git a/core/src/test/java/io/openepcis/validation/test/MessageCatalogueTest.java b/core/src/test/java/io/openepcis/validation/test/MessageCatalogueTest.java new file mode 100644 index 0000000..7d90a0b --- /dev/null +++ b/core/src/test/java/io/openepcis/validation/test/MessageCatalogueTest.java @@ -0,0 +1,283 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import com.networknt.schema.serialization.JsonNodeReader; +import io.openepcis.validation.message.MessageTemplates; +import io.openepcis.validation.model.ValidationError; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Guards the message catalogue itself. The error message itself can contain wrong information or missing information. + * These tests are present to prevent any missing/wrong information within the error message itself. + */ +class MessageCatalogueTest { + + private static final String CATALOGUE = "/validation-messages/epcis-validation-messages.yaml"; + + // Every placeholder the formatter is able to supply. Anything else is a typo. + private static final Set KNOWN_PLACEHOLDERS = + Set.of("field", "value", "where", "suggestion", "allowedCount", "allowedSample", + "expected", "actual", "limit", "fields", "best", "reasons", "number", "type"); + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]*)}"); + + @SuppressWarnings("unchecked") + private Map> readCatalogue() throws Exception { + try (InputStream stream = getClass().getResourceAsStream(CATALOGUE)) { + assertNotNull(stream, "catalogue not found on the classpath at " + CATALOGUE); + return new ObjectMapper(new YAMLFactory()).readValue(stream, Map.class); + } + } + + // --------------------------------------------------------------------------- + // Content integrity + // --------------------------------------------------------------------------- + + @Test + @DisplayName("catalogue: parses and contains all four sections") + void catalogueParses() throws Exception { + final Map> catalogue = readCatalogue(); + assertTrue(catalogue.containsKey("schemaLocations")); + assertTrue(catalogue.containsKey("keywords")); + assertTrue(catalogue.containsKey("fragments")); + assertTrue(catalogue.containsKey("xsdTypes")); + assertFalse(catalogue.get("schemaLocations").isEmpty()); + assertFalse(catalogue.get("keywords").isEmpty()); + } + + @Test + @DisplayName("catalogue: schema pointer keys survived YAML parsing") + void schemaPointerKeysAreNotSwallowed() throws Exception { + // a JSON Pointer starts with '#', which YAML reads as a comment unless the key is quoted. + final Map locations = readCatalogue().get("schemaLocations"); + assertTrue(locations.containsKey("#/definitions/bizStep"), "bizStep pointer key missing, is it quoted in the YAML? keys=" + locations.keySet()); + locations.keySet() + .forEach(key -> assertTrue(key.startsWith("#/"), "not a JSON Pointer key: " + key)); + } + + @Test + @DisplayName("catalogue: every placeholder is one the formatter can fill") + void noUnknownPlaceholders() throws Exception { + final Map offenders = new LinkedHashMap<>(); + readCatalogue() + .forEach( + (section, entries) -> + entries.forEach( + (key, template) -> { + final Matcher matcher = PLACEHOLDER.matcher(template); + while (matcher.find()) { + if (!KNOWN_PLACEHOLDERS.contains(matcher.group(1))) { + offenders.put(section + " -> " + key, matcher.group(0)); + } + } + })); + assertTrue(offenders.isEmpty(), "unknown placeholders: " + offenders); + } + + @Test + @DisplayName("catalogue: braces are balanced in every template") + void bracesAreBalanced() throws Exception { + readCatalogue() + .forEach( + (section, entries) -> + entries.forEach( + (key, template) -> + assertEquals( + template.chars().filter(c -> c == '{').count(), + template.chars().filter(c -> c == '}').count(), + "unbalanced braces in " + section + " -> " + key))); + } + + @Test + @DisplayName("catalogue: the fragments that need a leading space still have one") + void leadingSpacesArePreserved() throws Exception { + // these are appended straight onto a sentence, so losing the space runs words together. + final Map fragments = readCatalogue().get("fragments"); + assertTrue(fragments.get("suggestionHint").startsWith(" "), "suggestionHint lost its space"); + assertTrue(fragments.get("reasonMore").startsWith(" "), "reasonMore lost its space"); + } + + // --------------------------------------------------------------------------- + // Lookup behaviour + // --------------------------------------------------------------------------- + + /** + * Produces the real messages for one broken document, so lookup is tested against real input. + */ + private Set messagesFor(final String document) throws Exception { + final ObjectMapper mapper = new ObjectMapper(); + final JsonSchemaFactory factory = JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) + .jsonNodeReader(JsonNodeReader.builder().jsonMapper(mapper).build()) + .build(); + try (InputStream schema = getClass().getResourceAsStream("/json-schema/modular-EPCIS-JSON-schema.json")) { + final JsonSchema captureSchema = factory.getSchema(schema); + final JsonNode node = mapper.readTree(document); + return captureSchema.validate(node); + } + } + + private static String captureWith(final String extraField) { + return "{\"@context\":[\"https://ref.gs1.org/standards/epcis/epcis-context.jsonld\"]," + + "\"type\":\"EPCISDocument\",\"schemaVersion\":\"2.0\"," + + "\"creationDate\":\"2026-07-22T10:30:00Z\",\"epcisBody\":{\"eventList\":[" + + "{\"type\":\"ObjectEvent\",\"eventTime\":\"2026-07-22T06:45:00+02:00\"," + + "\"eventTimeZoneOffset\":\"+02:00\",\"action\":\"ADD\"," + + "\"epcList\":[\"https://id.gs1.org/00/038123450000000129\"]," + + extraField + + "}]}}"; + } + + @Test + @DisplayName("lookup: a bizStep failure resolves the layer 1 entry by walking the schema pointer") + void schemaPointerWalkResolvesVocabulary() throws Exception { + // the engine reports #/definitions/bizStep/anyOf/1/enum, the catalogue holds #/definitions/bizStep + final ValidationMessage enumMessage = + messagesFor(captureWith("\"bizStep\":\"transforming\"")).stream() + .filter(message -> "enum".equals(message.getType())) + .findFirst() + .orElseThrow(); + final String template = MessageTemplates.templateFor(enumMessage); + assertNotNull(template); + assertTrue( + template.contains("business step"), + "expected the bizStep wording, got: " + template); + } + + @Test + @DisplayName("lookup: a keyword with no schema entry falls back to the layer 2 wording") + void keywordFallbackIsUsed() throws Exception { + final ValidationMessage required = + messagesFor( + "{\"@context\":[\"https://ref.gs1.org/standards/epcis/epcis-context.jsonld\"]," + + "\"type\":\"EPCISDocument\",\"schemaVersion\":\"2.0\"," + + "\"creationDate\":\"2026-07-22T10:30:00Z\",\"epcisBody\":{\"eventList\":[" + + "{\"type\":\"ObjectEvent\",\"eventTimeZoneOffset\":\"+02:00\",\"action\":\"ADD\"," + + "\"epcList\":[\"https://id.gs1.org/00/038123450000000129\"]}]}}") + .stream() + .filter(message -> "required".equals(message.getType())) + .findFirst() + .orElseThrow(); + assertEquals(MessageTemplates.keyword("required"), MessageTemplates.templateFor(required)); + } + + @Test + @DisplayName("lookup: an unknown keyword returns null so the library wording is used instead") + void unknownKeywordFallsThrough() { + assertNull(MessageTemplates.keyword("thisKeywordDoesNotExist")); + assertFalse(MessageTemplates.hasKeyword("thisKeywordDoesNotExist")); + } + + @Test + @DisplayName("lookup: both synthetic required templates exist") + void syntheticRequiredTemplatesExist() { + // one for a choice between fields, one for several fields that are all missing + assertTrue(MessageTemplates.hasKeyword("required.alternatives")); + assertTrue(MessageTemplates.hasKeyword("required.multiple")); + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + @Test + @DisplayName("render: placeholders are replaced and unknown ones leave no gap") + void fillAndTidy() { + assertEquals("bizStep is wrong", MessageTemplates.render("{field} is wrong{suggestion}", Map.of("field", "bizStep"))); + assertEquals("bizStep is wrong. Did you mean \"shipping\"?", + MessageTemplates.render( + "{field} is wrong.{suggestion}", + Map.of("field", "bizStep", "suggestion", " Did you mean \"shipping\"?"))); + } + + @Test + @DisplayName("render: runs of spaces collapse and the ends are trimmed") + void tidyCollapsesWhitespace() { + assertEquals("a b c", MessageTemplates.tidy(" a b \t c ")); + } + + @Test + @DisplayName("render: a template with no placeholder is returned untouched") + void fillShortCircuits() { + final String plain = "nothing to substitute here"; + assertEquals(plain, MessageTemplates.fill(plain, Map.of())); + } + + @Test + @DisplayName("render: an unbalanced brace is copied verbatim rather than throwing") + void unbalancedBraceIsSafe() { + assertEquals("a {field", MessageTemplates.fill("a {field", Map.of("field", "x"))); + } + + // --------------------------------------------------------------------------- + // XML rule code mapping + // --------------------------------------------------------------------------- + + @Test + @DisplayName("xsd: rule codes map to a meaningful type") + void xsdCodesAreMapped() { + assertEquals("enum", MessageTemplates.xsdType("cvc-enumeration-valid")); + assertEquals("pattern", MessageTemplates.xsdType("cvc-pattern-valid")); + assertEquals("unexpectedElement", MessageTemplates.xsdType("cvc-complex-type.2.4")); + assertEquals("invalidContent", MessageTemplates.xsdType("cvc-complex-type")); + assertNull(MessageTemplates.xsdType("cvc-does-not-exist")); + } + + // --------------------------------------------------------------------------- + // The message trimming that used to corrupt XML text + // --------------------------------------------------------------------------- + + @Test + @DisplayName("trim: an XSD rule code prefix is removed") + void ruleCodePrefixIsStripped() { + final ValidationError error = new ValidationError(); + assertEquals( + "Invalid content was found starting with element 'action'.", + error.filterErrorMessage("cvc-complex-type.2.4.a: Invalid content was found starting with element 'action'.")); + } + + @Test + @DisplayName("trim: a colon inside the text is left alone") + void colonInsideTextIsNotCut() { + final ValidationError error = new ValidationError(); + final String message = "The prefix \"epcis\" for element \"epcis:EPCISDocument\" is not bound."; + assertEquals(message, error.filterErrorMessage(message)); + } + + @Test + @DisplayName("trim: a null or blank message is handled without throwing") + void blankMessageIsSafe() { + final ValidationError error = new ValidationError(); + assertNull(error.filterErrorMessage(null)); + assertEquals("", error.filterErrorMessage("")); + } +} diff --git a/core/src/test/java/io/openepcis/validation/test/ValueSuggesterTest.java b/core/src/test/java/io/openepcis/validation/test/ValueSuggesterTest.java new file mode 100644 index 0000000..09819d1 --- /dev/null +++ b/core/src/test/java/io/openepcis/validation/test/ValueSuggesterTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.test; + +import io.openepcis.validation.message.ValueSuggester; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Validate the "did you mean" suggestions. Also avoid wrong suggestions based on attributes or values. + */ +class ValueSuggesterTest { + + // The CBV business steps enum that is the largest vocabulary in the EPCIS schema. + private static final List BIZ_STEPS = + List.of( + "accepting", "arriving", "assembling", "collecting", "commissioning", "consigning", + "creating_class_instance", "cycle_counting", "decommissioning", "departing", "destroying", + "disassembling", "dispensing", "encoding", "entering_exiting", "holding", "inspecting", + "installing", "killing", "loading", "other", "packing", "picking", "receiving", "removing", + "repackaging", "repairing", "replacing", "reserving", "retail_selling", "shipping", + "staging_outbound", "stock_taking", "stocking", "storing", "transporting", "unloading", + "unpacking", "void_shipping", "sensor_reporting", "sampling"); + + private static final List ACTIONS = List.of("OBSERVE", "ADD", "DELETE"); + + // Map of user provided values and potential suggestions + @ParameterizedTest(name = "\"{0}\" should suggest \"{1}\"") + @CsvSource({ + "transforming, transporting", // the value from the original user report + "shiping, shipping", // one missing letter + "commisioning, commissioning", // one missing letter in a long word + "recieving, receiving", // two letters transposed + "SHIPPING, shipping", // right word, wrong case + "Packing, packing", // capitalized first letter + "unpackng, unpacking" // one dropped letter + }) + @DisplayName("suggests the closest business step for a near miss") + void suggestsClosestBizStep(final String wrongValue, final String expected) { + assertEquals(expected, ValueSuggester.closest(wrongValue, BIZ_STEPS)); + } + + @ParameterizedTest(name = "\"{0}\" should suggest nothing") + @CsvSource({ + "CREATE", // a plausible looking action that is nowhere near a business step + "totally_unrelated_word", + "xyz", + "a" + }) + @DisplayName("stays quiet when nothing is close enough to be useful") + void staysQuietWhenNothingIsClose(final String wrongValue) { + assertNull(ValueSuggester.closest(wrongValue, BIZ_STEPS)); + } + + @Test + @DisplayName("stays quiet for a wrong action, because none of the three is close to CREATE") + void noSuggestionForWrongAction() { + assertNull(ValueSuggester.closest("CREATE", ACTIONS)); + } + + @Test + @DisplayName("suggests the right action when only the case is wrong") + void suggestsActionOnCaseMismatch() { + assertEquals("OBSERVE", ValueSuggester.closest("observe", ACTIONS)); + } + + @Test + @DisplayName("handles null and empty input without throwing") + void handlesMissingInput() { + assertNull(ValueSuggester.closest(null, BIZ_STEPS)); + assertNull(ValueSuggester.closest("", BIZ_STEPS)); + assertNull(ValueSuggester.closest(" ", BIZ_STEPS)); + assertNull(ValueSuggester.closest("shiping", null)); + assertNull(ValueSuggester.closest("shiping", List.of())); + } + + @Test + @DisplayName("a longer wrong value tolerates proportionally more difference") + void toleranceScalesWithLength() { + // the threshold is a third of the length, so a long value may differ by more characters + assertEquals("creating_class_instance", ValueSuggester.closest("creating_class_instanc", BIZ_STEPS)); + // but a short value must be almost exact + assertNull(ValueSuggester.closest("add", List.of("OBSERVE", "DELETE"))); + } +} diff --git a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java index 3084e5f..3ec4483 100644 --- a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java +++ b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 benelog GmbH & Co. KG + * Copyright 2022-2026 benelog GmbH & Co. KG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,96 +15,60 @@ */ package io.openepcis.validation.test; -import static org.junit.jupiter.api.Assertions.assertEquals; - import com.fasterxml.jackson.databind.ObjectMapper; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SpecVersion; +import com.networknt.schema.serialization.JsonNodeReader; import io.openepcis.constants.EPCISDocumentType; import io.openepcis.constants.EPCISVersion; +import io.openepcis.resources.util.Commons; import io.openepcis.validation.SchemaValidator; import io.openepcis.validation.model.ValidationError; import io.smallrye.mutiny.Multi; -import java.io.InputStream; -import org.junit.jupiter.api.Test; - -public class XmlDocumentTest { - private ObjectMapper mapper = new ObjectMapper(); - private SchemaValidator validator = - new SchemaValidator( - mapper, - JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) - .jsonMapper(mapper) - .build()); +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; - @Test - public void captureDocumentWithBareEventTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/xml/captureDocument-independent-object-event.xml"); - final Multi xsdCaptureErrors = - validator.validate( - inputStream, "application/xml", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0); - var errors = xsdCaptureErrors.subscribe().asStream().toList(); - assertEquals(1, errors.size()); - } - - @Test - public void captureDocumentWithComment() { - final InputStream inputStream = - getClass().getResourceAsStream("/xml/captureDocumentWithComment.xml"); - final Multi xsdCaptureErrors = - validator.validate( - inputStream, "application/xml", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_1_2_0); - var errors = xsdCaptureErrors.subscribe().asStream().toList(); - assertEquals(0, errors.size()); - } - - @Test - public void captureDocumentCompleteEPCISDocumentTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/xml/captureDocument-complete-epcis-20-document.xml"); - final Multi xsdCaptureErrors = - validator.validate( - inputStream, "application/xml", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0); - assertEquals(0, xsdCaptureErrors.subscribe().asStream().toList().size()); - } +import java.io.InputStream; +import java.util.stream.Stream; - @Test - public void queryDocumentWithEventTimeTest() { - final InputStream inputStream = getClass().getResourceAsStream("/xml/queryDocument.xml"); - final Multi xsdQueryErrors = - validator.validate( - inputStream, "application/xml", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(0, xsdQueryErrors.subscribe().asStream().toList().size()); - } +import static io.openepcis.constants.EPCISDocumentType.CAPTURE; +import static io.openepcis.constants.EPCISDocumentType.QUERY; +import static io.openepcis.constants.EPCISVersion.VERSION_1_2_0; +import static io.openepcis.constants.EPCISVersion.VERSION_2_0_0; +import static org.junit.jupiter.api.Assertions.assertEquals; - @Test - public void queryDocumentWithEventTypeTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/xml/queryDocumentWithEventType.xml"); - final Multi xsdQueryErrors = - validator.validate( - inputStream, "application/xml", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(0, xsdQueryErrors.subscribe().asStream().toList().size()); - } +public class XmlDocumentTest { + private final ObjectMapper mapper = new ObjectMapper(); + private final SchemaValidator validator = + new SchemaValidator( + mapper, + JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) + .jsonNodeReader(JsonNodeReader.builder().jsonMapper(mapper).build()) + .build()); + private static final String INVALID_PATH = "2.0/EPCIS/XML/Capture/Invalid"; + private static final String QUERY_PATH = "2.0/EPCIS/XML/Query"; + private static final String CAPTURE_PATH = "2.0/EPCIS/XML/Capture/Documents"; + private static final String CAPTURE_1_2_PATH = "1.2/EPCIS/XML/Capture/Documents"; - @Test - public void queryDocumentWithReadPointTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/xml/queryDocumentWithReadPoint.xml"); - final Multi xsdQueryErrors = - validator.validate( - inputStream, "application/xml", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(0, xsdQueryErrors.subscribe().asStream().toList().size()); - } + @ParameterizedTest(name = "{index}: {0}") + @MethodSource("validationTestCases") + void validateDocuments(String testName, String resource, EPCISDocumentType documentType, EPCISVersion version, int expectedErrors) { + final InputStream inputStream = Commons.getInputStream(resource); + final Multi validationErrorMulti = validator.validate(inputStream, "application/xml", documentType, version); + var errors = validationErrorMulti.subscribe().asStream().toList(); + assertEquals(expectedErrors, errors.size()); + } - @Test - public void queryDocumentWithMissingValueTest() { - final InputStream inputStream = - getClass().getResourceAsStream("/xml/queryDocumentWithMissingValue.xml"); - final Multi xsdQueryErrors = - validator.validate( - inputStream, "application/xml", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(1, xsdQueryErrors.subscribe().asStream().toList().size()); - } + static Stream validationTestCases() { + return Stream.of( + Arguments.of("Capture document with bare event", INVALID_PATH + "/ObjectEvent_bare_event_without_action.xml", CAPTURE, VERSION_2_0_0, 1), + Arguments.of("Capture document with comment", CAPTURE_1_2_PATH + "/EPCISDocument_with_comment.xml", CAPTURE, VERSION_1_2_0, 0), + Arguments.of("Complete EPCIS capture document", CAPTURE_PATH + "/Combination_of_different_event.xml", CAPTURE, VERSION_2_0_0, 0), + Arguments.of("Query document with event time", QUERY_PATH + "/queryDocument.xml", QUERY, VERSION_2_0_0, 0), + Arguments.of("Query document with event type", QUERY_PATH + "/queryDocumentWithEventType.xml", QUERY, VERSION_2_0_0, 0), + Arguments.of("Query document with read point", QUERY_PATH + "/queryDocumentWithReadPoint.xml", QUERY, VERSION_2_0_0, 0), + Arguments.of("Query document with missing value", QUERY_PATH + "/Invalid/QueryDocument_with_missing_value.xml", QUERY, VERSION_2_0_0, 1) + ); + } } diff --git a/core/src/test/java/io/openepcis/validation/test/XmlValidationMessageTest.java b/core/src/test/java/io/openepcis/validation/test/XmlValidationMessageTest.java new file mode 100644 index 0000000..cd84f93 --- /dev/null +++ b/core/src/test/java/io/openepcis/validation/test/XmlValidationMessageTest.java @@ -0,0 +1,211 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.serialization.JsonNodeReader; +import io.openepcis.constants.EPCISDocumentType; +import io.openepcis.constants.EPCISVersion; +import io.openepcis.resources.util.Commons; +import io.openepcis.validation.SchemaValidator; +import io.openepcis.validation.model.ValidationError; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Checks the XML documents of validation reporting. + * Xerces reports one mistake twice, once as a facet failure and once as an element failure, so the response used to contain duplicates. + */ +class XmlValidationMessageTest { + + // path for valid and invalid documents from the test-resources + private static final String VALID = "2.0/EPCIS/XML/Capture/Documents/"; + private static final String INVALID = "2.0/EPCIS/XML/Capture/Invalid/"; + + private static final String VALID_12 = "1.2/EPCIS/XML/Capture/Documents/"; + private static final String QUERY = "2.0/EPCIS/XML/Query/"; + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * Validates one fixture and returns the errors. + */ + private List validate(final String resourcePath, final EPCISDocumentType schema, final EPCISVersion version) { + final InputStream document = Commons.getInputStream(resourcePath); + assertNotNull(document, "missing test resource " + resourcePath); + + final SchemaValidator validator = new SchemaValidator( + mapper, + JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) + .jsonNodeReader(JsonNodeReader.builder().jsonMapper(mapper).build()).build()); + + return validator + .validate(document, "application/xml", schema, version) + .subscribe() + .asStream() + .toList(); + } + + // Util method for the validation of EPCIS 2.0 documents and return errors + private List validateCapture(final String fixture) { + return validate(fixture, EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0); + } + + // --------------------------------------------------------------------------- + // Valid documents must stay silent + // --------------------------------------------------------------------------- + + @Test + @DisplayName("valid: a complete EPCIS 2.0 XML capture document reports no errors") + void validCompleteDocument() { + assertEquals(0, validateCapture(VALID + "Combination_of_different_event.xml").size()); + } + + @Test + @DisplayName("valid: a correctly ordered ObjectEvent reports no errors") + void validObjectEvent() { + assertEquals(0, validateCapture(VALID + "ObjectEvent.xml").size()); + } + + @Test + @DisplayName("valid: an EPCIS 1.2 document with comments reports no errors") + void validVersion12Document() { + assertEquals(0, + validate(VALID_12 + "EPCISDocument_with_comment.xml", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_1_2_0).size()); + } + + @Test + @DisplayName("valid: a query document reports no errors") + void validQueryDocument() { + assertEquals(0, + validate(QUERY + "queryDocument.xml", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0).size()); + } + + // --------------------------------------------------------------------------- + // Duplicates: Xerces reports one mistake twice + // --------------------------------------------------------------------------- + + @Test + @DisplayName("invalid: a wrong action gives ONE error, not the two Xerces reports") + void wrongActionIsNotDuplicated() { + // Xerces emits "not facet-valid" and "value of element is not valid" at the same position + final List errors = validateCapture(INVALID + "ObjectEvent_with_invalid_action.xml"); + assertEquals(1, errors.size()); + + final ValidationError error = errors.getFirst(); + assertEquals("enum", error.getType()); + assertNotEquals("required", error.getType()); + assertTrue(error.getMessage().contains("CREATE")); // echoes what user sent + assertTrue(error.getMessage().contains("ADD")); // and lists what is allowed + } + + @Test + @DisplayName("invalid: a bad eventTime gives ONE error typed as a pattern failure") + void badEventTimeIsNotDuplicated() { + final List errors = validateCapture(INVALID + "ObjectEvent_with_invalid_eventTime_format.xml"); + assertEquals(1, errors.size()); + assertEquals("pattern", errors.getFirst().getType()); + assertTrue(errors.getFirst().getMessage().contains("22-07-2026")); + } + + // --------------------------------------------------------------------------- + // The error type must describe what actually went wrong + // --------------------------------------------------------------------------- + + @Test + @DisplayName("invalid: elements in the wrong order are typed as an unexpected element") + void wrongElementOrderIsTyped() { + final List errors = validateCapture(INVALID + "ObjectEvent_with_wrong_element_order.xml"); + assertEquals(1, errors.size()); + assertEquals("unexpectedElement", errors.getFirst().getType()); + assertTrue(errors.getFirst().getMessage().contains("action")); + } + + @Test + @DisplayName("invalid: an element EPCIS does not know is typed as an unexpected element") + void unknownElementIsTyped() { + final List errors = validateCapture(INVALID + "ObjectEvent_with_unknown_element.xml"); + assertEquals(1, errors.size()); + assertEquals("unexpectedElement", errors.getFirst().getType()); + assertTrue(errors.getFirst().getMessage().contains("madeUpField")); + } + + @Test + @DisplayName("invalid: a missing mandatory element is reported") + void missingMandatoryElementIsReported() { + final List errors = validateCapture(INVALID + "ObjectEvent_without_eventTime.xml"); + assertEquals(1, errors.size()); + assertTrue(errors.getFirst().getMessage().contains("eventTime")); // names what is expected + } + + // --------------------------------------------------------------------------- + // Contract guards: the front end depends on these + // --------------------------------------------------------------------------- + + @Test + @DisplayName("contract: the XSD rule code prefix is stripped from the message") + void ruleCodePrefixIsRemoved() { + final List errors = validateCapture(INVALID + "ObjectEvent_with_unknown_element.xml"); + // "cvc-complex-type.2.4.a: " is noise for a reader and must not survive into the response + assertFalse(errors.getFirst().getMessage().startsWith("cvc-"), "rule code leaked: " + errors.getFirst().getMessage()); + } + + @Test + @DisplayName("contract: line and column are present, because the XML editor highlights by line") + void lineAndColumnArePresent() { + final ValidationError error = validateCapture(INVALID + "ObjectEvent_with_unknown_element.xml").getFirst(); + // for XML the front end reads line, and location carries the column + assertNotNull(error.getLine()); + assertNotNull(error.getLocation()); + assertTrue(Integer.parseInt(error.getLine()) > 0); + assertTrue(Integer.parseInt(error.getLocation()) > 0); + } + + @Test + @DisplayName("contract: a bare ObjectEvent is wrapped and validated, keeping existing behaviour") + void bareEventStillValidates() { + // the wrapping path in EventModifier must keep working, this fixture reports exactly one error + assertEquals(1, validateCapture(INVALID + "ObjectEvent_bare_event_without_action.xml").size()); + } + + /** + * Prints the XML messages so the current wording can be reviewed without starting the service. + */ + @Test + @DisplayName("review: print the XML messages a user would see") + void printMessagesForReview() { + final String[] fixtures = { + INVALID + "ObjectEvent_with_invalid_action.xml", + INVALID + "ObjectEvent_with_invalid_eventTime_format.xml", + INVALID + "ObjectEvent_with_unknown_element.xml", + INVALID + "ObjectEvent_with_wrong_element_order.xml", + INVALID + "ObjectEvent_without_eventTime.xml" + }; + + System.out.println("---- XML validation messages for review ----"); + for (final String fixture : fixtures) { + for (final ValidationError error : validateCapture(fixture)) { + System.out.printf("%-45s type=%-18s line=%s col=%s%n %s%n", fixture, error.getType(), error.getLine(), error.getLocation(), error.getMessage()); + } + } + } +} diff --git a/core/src/test/resources/json/CaptureDocument.json b/core/src/test/resources/json/CaptureDocument.json deleted file mode 100644 index d1f983b..0000000 --- a/core/src/test/resources/json/CaptureDocument.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "@context": [ - "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld" - ], - "type": "EPCISDocument", - "schemaVersion": "2.0", - "creationDate": "2005-07-11T11:30:47.0Z", - "epcisBody": { - "eventList": [ - { - "type": "ObjectEvent", - "eventTime": "2005-04-03T20:33:31.116-06:00", - "eventTimeZoneOffset": "-06:00", - "epcList": [ - "urn:epc:id:sgtin:0614141.107346.2017", - "urn:epc:id:sgtin:0614141.107346.2018" - ], - "bizStep": "shipping", - "disposition": "in_transit", - "readPoint": { - "id": "urn:epc:id:sgln:0614141.07346.1234" - }, - "bizTransactionList": [ - { - "type": "po", - "bizTransaction": "http://transaction.acme.com/po/12345678" - } - ] - }, - { - "type": "ObjectEvent", - "eventTime": "2005-04-03T20:33:31.116-06:00", - "bizStep": "commissioning", - "disposition": "in_transit", - "readPoint": { - "id": "urn:epc:id:sgln:0614141.07346.1234" - }, - "quantityList": [ - { - "epcClass": "urn:epc:class:lgtin:4023333.002000.2019-10-07", - "quantity": 150, - "uom": "KGM" - } - ] - } - ] - } -} \ No newline at end of file diff --git a/core/src/test/resources/json/CaptureDocumentWithCertification.json b/core/src/test/resources/json/CaptureDocumentWithCertification.json deleted file mode 100644 index ddcd1ad..0000000 --- a/core/src/test/resources/json/CaptureDocumentWithCertification.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "@context": [ - "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld" - ], - "type": "EPCISDocument", - "schemaVersion": "2.0", - "creationDate": "2005-07-11T11:30:47.0Z", - "epcisBody": { - "eventList": [ - { - "type": "ObjectEvent", - "eventTime": "2005-04-03T20:33:31.116-06:00", - "eventTimeZoneOffset": "-06:00", - "action": "ADD", - "bizStep": "commissioning", - "disposition": "in_transit", - "readPoint": { - "id": "urn:epc:id:sgln:0614141.07346.1234" - }, - "quantityList": [ - { - "epcClass": "urn:epc:class:lgtin:4023333.002000.2019-10-07", - "quantity": 150, - "uom": "KGM" - } - ], - "certificationInfo": [ - { - "gs1:CertificationDetails": { - "gs1:certificationAgency": "testingAgency", - "gs1:certificationAuditDate": "2023-12-20" - } - }, - { - "gs1:CertificationDetails": { - "gs1:certificationAuditDate": "wrong date format" - } - } - ] - } - ] - } -} \ No newline at end of file diff --git a/core/src/test/resources/json/QueryDocumentWithEventType.json b/core/src/test/resources/json/QueryDocumentWithEventType.json deleted file mode 100644 index eb30416..0000000 --- a/core/src/test/resources/json/QueryDocumentWithEventType.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "@context": "https://ref.gs1.org/standards/epcis/2.0.0/query-schema.json", - "eventType": [ - "ObjectEvent" - ] -} \ No newline at end of file diff --git a/core/src/test/resources/json/queryDocumentWithAction.json b/core/src/test/resources/json/queryDocumentWithAction.json deleted file mode 100644 index a49af49..0000000 --- a/core/src/test/resources/json/queryDocumentWithAction.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "@context": "https://ref.gs1.org/standards/epcis/2.0.0/query-schema.json", - "eventType": ["ObjectEvent"], - "EQ_action": ["ADD"] -} \ No newline at end of file diff --git a/core/src/test/resources/json/queryDocumentWithEventTime.json b/core/src/test/resources/json/queryDocumentWithEventTime.json deleted file mode 100644 index b31a9ca..0000000 --- a/core/src/test/resources/json/queryDocumentWithEventTime.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "@context": "https://ref.gs1.org/standards/epcis/2.0.0/query-schema.json", - "GE_eventTime": "2022-01-01T00:00:00Z", - "LT_eventTime": "2022-12-31T23:59:59Z" -} \ No newline at end of file diff --git a/core/src/test/resources/json/queryDocumentWithInvalidEventType.json b/core/src/test/resources/json/queryDocumentWithInvalidEventType.json deleted file mode 100644 index 4d2833a..0000000 --- a/core/src/test/resources/json/queryDocumentWithInvalidEventType.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "@context": "https://ref.gs1.org/standards/epcis/2.0.0/query-schema.json", - "eventType": ["AssertType"], - "EQ_action": ["ADD"] -} \ No newline at end of file diff --git a/core/src/test/resources/xml/captureDocument-complete-epcis-20-document.xml b/core/src/test/resources/xml/captureDocument-complete-epcis-20-document.xml deleted file mode 100644 index f35218f..0000000 --- a/core/src/test/resources/xml/captureDocument-complete-epcis-20-document.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - 2005-04-03T20:33:31.116-06:00 - -06:00 - - urn:epc:id:sgtin:0614141.107346.2017 - urn:epc:id:sgtin:0614141.107346.2018 - - OBSERVE - urn:epcglobal:cbv:bizstep:shipping - urn:epcglobal:cbv:disp:in_transit - - urn:epc:id:sgln:0614141.07346.1234 - - - http://transaction.acme.com/po/12345678 - - - - 2005-04-04T20:33:31.116-06:00 - -06:00 - - urn:epc:id:sgtin:0614141.107346.2018 - - OBSERVE - urn:epcglobal:cbv:bizstep:receiving - urn:epcglobal:cbv:disp:in_progress - - urn:epc:id:sgln:0012345.11111.400 - - - urn:epc:id:sgln:0012345.11111.0 - - - http://transaction.acme.com/po/12345678 - urn:epcglobal:cbv:bt:0614141073467:1152 - - Example of a vendor/user extension - - - - \ No newline at end of file diff --git a/core/src/test/resources/xml/captureDocument-independent-object-event.xml b/core/src/test/resources/xml/captureDocument-independent-object-event.xml deleted file mode 100644 index 7d167d7..0000000 --- a/core/src/test/resources/xml/captureDocument-independent-object-event.xml +++ /dev/null @@ -1,16 +0,0 @@ - - 2005-04-03T20:33:31.116-06:00 - -06:00 - - urn:epc:id:sgtin:0614141.107346.2017 - urn:epc:id:sgtin:0614141.107346.2018 - - urn:epcglobal:cbv:bizstep:shipping - urn:epcglobal:cbv:disp:in_transit - - urn:epc:id:sgln:0614141.07346.1234 - - - http://transaction.acme.com/po/12345678 - - \ No newline at end of file diff --git a/core/src/test/resources/xml/captureDocumentWithComment.xml b/core/src/test/resources/xml/captureDocumentWithComment.xml deleted file mode 100644 index 0fe6840..0000000 --- a/core/src/test/resources/xml/captureDocumentWithComment.xml +++ /dev/null @@ -1,300 +0,0 @@ - - - - - - - 1.0 - - urn:epc:id:sgln:030001.111111.0 - - - urn:epc:id:sgln:039999.999999.0 - - - EPCglobal - 1.0 - 1100220001 - Events - 2023-04-01T08:45:16Z - - - - - - - - - - 0001-0123-45 - US_FDA_NDC - Epcistra - GS1 Pharma LLC - PILL - 100mg - 500 pills - - - - 0001-0123-45 - US_FDA_NDC - Epcistra - GS1 Pharma LLC - PILL - 100mg - 500 pills - - - - - - - - GS1 Pharma - 1 Biopharm Ct - Suite A - Thousand Oaks - CA - 91320 - US - - - - GS1 Pharma DC - 2806 Agoura Rd - Suite 500 - Westlake Village - CA - 91361 - US - - - - GS1 Drug Distributor - 230 Park Ave S - Room 378 - New York - NY - 10003-1502 - US - - - - GS1 Drug Distributor Warehouse - 136 Main St - Suite 100 - Louisville - KY - 40218 - US - - - - - - - GS1 US DSCSA R1.3 - - true - Seller has complied with each applicable subsection of FDCA Sec. 581(27)(A)-(G). - - - - - - - 2023-03-27T06:45:16Z - -05:00 - - urn:epc:id:sgtin:030001.0012345.11 - urn:epc:id:sgtin:030001.0012345.12 - urn:epc:id:sgtin:030001.0012345.13 - urn:epc:id:sgtin:030001.0012345.14 - urn:epc:id:sgtin:030001.0012345.15 - urn:epc:id:sgtin:030001.0012345.16 - urn:epc:id:sgtin:030001.0012345.17 - urn:epc:id:sgtin:030001.0012345.18 - urn:epc:id:sgtin:030001.0012345.19 - urn:epc:id:sgtin:030001.0012345.20 - urn:epc:id:sgtin:030001.0012345.21 - urn:epc:id:sgtin:030001.0012345.22 - - ADD - urn:epcglobal:cbv:bizstep:commissioning - urn:epcglobal:cbv:disp:active - - urn:epc:id:sgln:030001.111111.0 - - - urn:epc:id:sgln:030001.111111.0 - - - - A123 - 2025-03-27 - - - - - - 2023-03-27T06:46:16Z - -05:00 - - urn:epc:id:sgtin:030001.1012345.110 - urn:epc:id:sgtin:030001.1012345.111 - urn:epc:id:sgtin:030001.1012345.121 - - ADD - urn:epcglobal:cbv:bizstep:commissioning - urn:epcglobal:cbv:disp:active - - urn:epc:id:sgln:030001.111111.0 - - - urn:epc:id:sgln:030001.111111.0 - - - - A123 - 2025-03-27 - - - - - - 2023-03-27T06:50:16Z - -05:00 - urn:epc:id:sgtin:030001.1012345.110 - - urn:epc:id:sgtin:030001.0012345.11 - urn:epc:id:sgtin:030001.0012345.12 - urn:epc:id:sgtin:030001.0012345.13 - urn:epc:id:sgtin:030001.0012345.14 - - ADD - urn:epcglobal:cbv:bizstep:packing - urn:epcglobal:cbv:disp:in_progress - - urn:epc:id:sgln:030001.111111.0 - - - urn:epc:id:sgln:030001.111111.0 - - - - - 2023-03-27T06:51:16Z - -05:00 - urn:epc:id:sgtin:030001.1012345.111 - - urn:epc:id:sgtin:030001.0012345.15 - urn:epc:id:sgtin:030001.0012345.16 - urn:epc:id:sgtin:030001.0012345.17 - urn:epc:id:sgtin:030001.0012345.18 - - ADD - urn:epcglobal:cbv:bizstep:packing - urn:epcglobal:cbv:disp:in_progress - - urn:epc:id:sgln:030001.111111.0 - - - urn:epc:id:sgln:030001.111111.0 - - - - - 2023-03-27T06:52:16Z - -05:00 - urn:epc:id:sgtin:030001.1012345.121 - - urn:epc:id:sgtin:030001.0012345.19 - urn:epc:id:sgtin:030001.0012345.20 - urn:epc:id:sgtin:030001.0012345.21 - urn:epc:id:sgtin:030001.0012345.22 - - ADD - urn:epcglobal:cbv:bizstep:packing - urn:epcglobal:cbv:disp:in_progress - - urn:epc:id:sgln:030001.111111.0 - - - urn:epc:id:sgln:030001.111111.0 - - - - - 2023-04-01T06:47:16Z - -05:00 - - urn:epc:id:sscc:030001.41234567890 - - ADD - urn:epcglobal:cbv:bizstep:commissioning - urn:epcglobal:cbv:disp:active - - urn:epc:id:sgln:030001.111121.0 - - - urn:epc:id:sgln:030001.111121.0 - - - - - 2023-04-01T06:48:16Z - -05:00 - urn:epc:id:sscc:030001.41234567890 - - urn:epc:id:sgtin:030001.1012345.110 - urn:epc:id:sgtin:030001.1012345.111 - urn:epc:id:sgtin:030001.1012345.121 - - ADD - urn:epcglobal:cbv:bizstep:packing - urn:epcglobal:cbv:disp:in_progress - - urn:epc:id:sgln:030001.111121.0 - - - urn:epc:id:sgln:030001.111121.0 - - - - - 2023-04-01T07:48:16Z - -05:00 - - urn:epc:id:sscc:030001.41234567890 - - OBSERVE - urn:epcglobal:cbv:bizstep:shipping - urn:epcglobal:cbv:disp:in_transit - - urn:epc:id:sgln:030001.111121.0 - - - - urn:epcglobal:cbv:bt:0399999999991:XYZPO189 - urn:epcglobal:cbv:bt:0300011111215:DEL100 - - - - - urn:epc:id:sgln:030001.111111.0 - urn:epc:id:sgln:030001.111121.0 - - - - urn:epc:id:sgln:039999.999999.0 - urn:epc:id:sgln:039999.345678.0 - - - false - - - - diff --git a/core/src/test/resources/xml/queryDocument.xml b/core/src/test/resources/xml/queryDocument.xml deleted file mode 100644 index 276a7b1..0000000 --- a/core/src/test/resources/xml/queryDocument.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - SimpleEventQuery - - - GE_eventTime - 2022-09-30T22:00:00Z - - - LT_eventTime - 2022-12-30T23:00:00Z - - - - - \ No newline at end of file diff --git a/core/src/test/resources/xml/queryDocumentWithEventType.xml b/core/src/test/resources/xml/queryDocumentWithEventType.xml deleted file mode 100644 index f4432fe..0000000 --- a/core/src/test/resources/xml/queryDocumentWithEventType.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - SimpleEventQuery - - - eventType - - ObjectEvent - - - - EQ_action - - ADD - - - - - - \ No newline at end of file diff --git a/core/src/test/resources/xml/queryDocumentWithMissingValue.xml b/core/src/test/resources/xml/queryDocumentWithMissingValue.xml deleted file mode 100644 index b2fcb52..0000000 --- a/core/src/test/resources/xml/queryDocumentWithMissingValue.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - SimpleEventQuery - - - eventType - - ObjectEvent - AggregationEvent - - - - EQ_readPoint - - - - - \ No newline at end of file diff --git a/core/src/test/resources/xml/queryDocumentWithReadPoint.xml b/core/src/test/resources/xml/queryDocumentWithReadPoint.xml deleted file mode 100644 index 2ed3b07..0000000 --- a/core/src/test/resources/xml/queryDocumentWithReadPoint.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - SimpleEventQuery - - - eventType - - ObjectEvent - AggregationEvent - - - - EQ_readPoint - - urn:epc:id:sgln:73784378.7838.78437834 - - - - - - \ No newline at end of file diff --git a/pom.xml b/pom.xml index 7d7d293..a9241a0 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,7 @@ + + io.openepcis + openepcis-reactive-event-publisher + + io.quarkus quarkus-core diff --git a/core/src/main/java/io/openepcis/validation/SchemaValidator.java b/core/src/main/java/io/openepcis/validation/SchemaValidator.java index 25a6fc7..3815219 100644 --- a/core/src/main/java/io/openepcis/validation/SchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/SchemaValidator.java @@ -20,6 +20,7 @@ import io.openepcis.constants.EPCIS; import io.openepcis.constants.EPCISDocumentType; import io.openepcis.constants.EPCISVersion; +import io.openepcis.validation.config.ValidationDefaults; import io.openepcis.validation.json.JsonSchemaValidator; import io.openepcis.validation.model.ValidationError; import io.openepcis.validation.xml.Xml12SchemaValidator; @@ -36,9 +37,14 @@ public class SchemaValidator { private final Xml12SchemaValidator xml12SchemaValidator; public SchemaValidator(final ObjectMapper mapper, final JsonSchemaFactory factory) { - this.jsonSchemaValidator = new JsonSchemaValidator(mapper, factory); - this.xml20SchemaValidator = new Xml20SchemaValidator(); - this.xml12SchemaValidator = new Xml12SchemaValidator(); + this(mapper, factory, ValidationDefaults.MAX_REPORTED_ERRORS); + } + + public SchemaValidator(final ObjectMapper mapper, final JsonSchemaFactory factory, final int maxReportedErrors) { + this.jsonSchemaValidator = new JsonSchemaValidator(mapper, factory, maxReportedErrors); + // same limit for XML and JSON, so the two front ends behave alike + this.xml20SchemaValidator = new Xml20SchemaValidator(maxReportedErrors); + this.xml12SchemaValidator = new Xml12SchemaValidator(maxReportedErrors); } /** diff --git a/core/src/main/java/io/openepcis/validation/config/ValidationDefaults.java b/core/src/main/java/io/openepcis/validation/config/ValidationDefaults.java new file mode 100644 index 0000000..fb24a61 --- /dev/null +++ b/core/src/main/java/io/openepcis/validation/config/ValidationDefaults.java @@ -0,0 +1,31 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.config; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Configuration for validation settings + **/ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ValidationDefaults { + + // Most problems reported per document; the last slot says the limit was reached. + public static final String MAX_REPORTED_ERRORS_TEXT = "1000"; + public static final int MAX_REPORTED_ERRORS = Integer.parseInt(MAX_REPORTED_ERRORS_TEXT); + +} diff --git a/core/src/main/java/io/openepcis/validation/json/JsonErrorHandler.java b/core/src/main/java/io/openepcis/validation/json/JsonErrorHandler.java deleted file mode 100644 index 7aea9f8..0000000 --- a/core/src/main/java/io/openepcis/validation/json/JsonErrorHandler.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2022-2026 benelog GmbH & Co. KG - * - * 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 - * - * http://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 io.openepcis.validation.json; - -import com.fasterxml.jackson.databind.JsonNode; -import com.networknt.schema.ValidationMessage; -import io.openepcis.validation.message.JsonErrorFormatter; -import io.openepcis.validation.model.ValidationError; -import lombok.Getter; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -/** - * Class to format the validation errors occur during the validation of JSON Schema Capture/Query document/event. - */ -public class JsonErrorHandler { - - @Getter - private final List exceptions; - - public JsonErrorHandler() { - this.exceptions = new ArrayList<>(); - } - - private static final JsonErrorFormatter FORMATTER = new JsonErrorFormatter(); - - /** - * Groups the raw messages so one mistake becomes one error, then words them for people. - */ - public void jsonValidationFormat(final JsonNode document, final Collection messages) { - exceptions.addAll(FORMATTER.format(document, messages)); - } - - @Deprecated - public void jsonValidationFormat(final Collection messages) { - jsonValidationFormat(null, messages); - } -} diff --git a/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java b/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java index 1d991cf..9f06be8 100644 --- a/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java @@ -15,89 +15,242 @@ */ package io.openepcis.validation.json; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.networknt.schema.JsonSchema; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.ValidationMessage; +import io.openepcis.constants.EPCIS; +import io.openepcis.reactive.publisher.ObjectNodePublisher; import io.openepcis.validation.Validator; +import io.openepcis.validation.config.ValidationDefaults; import io.openepcis.validation.exception.SchemaValidationException; +import io.openepcis.validation.message.JsonErrorFormatter; +import io.openepcis.validation.message.MessageTemplates; import io.openepcis.validation.model.ValidationError; import io.quarkus.runtime.annotations.RegisterForReflection; import io.smallrye.mutiny.Multi; import lombok.extern.slf4j.Slf4j; +import java.io.BufferedInputStream; +import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; @Slf4j @RegisterForReflection(targets = JsonSchemaFactory.class) public class JsonSchemaValidator implements Validator { + // Errors below here belong to the body, which is validated event by event instead. + private static final String BODY_PREFIX = "$." + EPCIS.EPCIS_BODY_IN_CAMEL_CASE; + private static final String EVENT_SCHEMA_REF = "{\"$ref\":\"resource:/json-schema/epcis-json-schema.json#/definitions/EPCIS-Document-Event\"}"; + private static final int PRESCAN_LIMIT = 65536; + private static final JsonErrorFormatter FORMATTER = new JsonErrorFormatter(); + private final ObjectMapper mapper; - private final JsonSchemaFactory validatorFactory; - private final JsonSchema captureJsonSchema; - private final JsonSchema queryJsonSchema; - private final JsonErrorHandler jsonErrorHandler; + private final JsonFactory prescanFactory; + private final JsonSchema documentSchema; // switches on type: EPCISDocument or EPCISQueryDocument + private final JsonSchema queryParameterSchema; // GE_/LT_/EQ_ style query, not a document + private final JsonSchema eventSchema; + private final int maxReportedErrors; + // Kept so existing callers and tests compile without passing configuration. public JsonSchemaValidator(final ObjectMapper objectMapper, final JsonSchemaFactory validatorFactory) { + this(objectMapper, validatorFactory, ValidationDefaults.MAX_REPORTED_ERRORS); + } + + public JsonSchemaValidator(final ObjectMapper objectMapper, + final JsonSchemaFactory validatorFactory, + final int maxReportedErrors) { this.mapper = objectMapper; - this.validatorFactory = validatorFactory; - this.captureJsonSchema = loadSchema("/json-schema/modular-EPCIS-JSON-schema.json"); - this.queryJsonSchema = loadSchema("/json-schema/modified-query-schema.json"); - this.jsonErrorHandler = new JsonErrorHandler(); + this.maxReportedErrors = maxReportedErrors > 0 ? maxReportedErrors : ValidationDefaults.MAX_REPORTED_ERRORS; + + // the pre-scan must not close the body, the publisher still has to read it + this.prescanFactory = new JsonFactory().disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); + + // every schema is a bundled classpath resource, so validation never reaches the network + this.documentSchema = loadSchema(validatorFactory, "/json-schema/modular-EPCIS-JSON-schema.json"); + this.queryParameterSchema = loadSchema(validatorFactory, "/json-schema/modified-query-schema.json"); + this.eventSchema = validatorFactory.getSchema(EVENT_SCHEMA_REF); } - private JsonSchema loadSchema(final String path) { - return validatorFactory.getSchema(getResourceAsStream(path)); + private JsonSchema loadSchema(final JsonSchemaFactory validationFactory, final String path) { + return validationFactory.getSchema(getResourceAsStream(path)); } @Override - public Multi validateAgainstCaptureSchema(final InputStream epcisInputData) - throws SchemaValidationException { - return Multi.createFrom() - .emitter( - multiEmitter -> { - try { - final JsonNode document = mapper.readValue(epcisInputData, JsonNode.class); - final Set captureErrors = captureJsonSchema.validate(document); - - // schema violations stay emitted items, a failure is reserved for broken input - if (!captureErrors.isEmpty()) { - jsonErrorHandler.jsonValidationFormat(document, captureErrors); - } - - this.jsonErrorHandler.getExceptions().stream().forEach(multiEmitter::emit); - multiEmitter.complete(); - } catch (Exception exception) { - multiEmitter.fail( - new SchemaValidationException("Exception occurred during validation against EPCIS JSON Capture schema : " + exception.getMessage())); - } - }); + public Multi validateAgainstCaptureSchema(final InputStream epcisInputData) throws SchemaValidationException { + return validate(epcisInputData, false, "Capture"); } @Override - public Multi validateAgainstQuerySchema(final InputStream document) - throws SchemaValidationException { + public Multi validateAgainstQuerySchema(final InputStream epcisInputData) throws SchemaValidationException { + return validate(epcisInputData, true, "Query"); + } + + private Multi validate(final InputStream epcisInputData, + final boolean queryRequested, + final String label) { + // lazy read so nothing read until subscription + return Multi.createFrom().deferred(() -> { + try { + final BufferedInputStream body = new BufferedInputStream(epcisInputData); + final String type = prescanType(body); + + // there is no type EPCISDocument/EPCISQueryDocument: a literal query when one was asked for, else a document missing its type + if (type == null) { + return single(body, queryRequested ? queryParameterSchema : documentSchema); + } + + // if type is EPCISDocument/EPCISQueryDocument then use ObjectNodePublisher to stream and validate event-by-event from eventsList + if (EPCIS.EPCIS_DOCUMENT.equals(type) || EPCIS.EPCIS_QUERY_DOCUMENT.equals(type)) { + return streamDocument(body, type); + } + + // If none match then treat is as bareEvent + return single(body, documentSchema); + } catch (Exception exception) { + return Multi.createFrom().failure(new SchemaValidationException("Exception occurred during validation against EPCIS JSON " + label + " schema : " + exception.getMessage(), exception)); + } + }); + } + + // Reads the initial token to find the top level type then revert so the further stream reads from start + private String prescanType(final BufferedInputStream body) throws IOException { + body.mark(PRESCAN_LIMIT); + try (JsonParser parser = prescanFactory.createParser(body)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + return null; + } + int depth = 1; + + while (depth > 0) { + final JsonToken token = parser.nextToken(); + if (token == null) { + return null; + } + + if (token == JsonToken.FIELD_NAME) { + if (depth == 1 && EPCIS.TYPE.equals(parser.currentName())) { + parser.nextToken(); + // only the top level type counts + return parser.getValueAsString(); + } + } else if (token == JsonToken.START_OBJECT || token == JsonToken.START_ARRAY) { + depth++; + } else if (token == JsonToken.END_OBJECT || token == JsonToken.END_ARRAY) { + depth--; + } + } + return null; + } catch (Exception exception) { + return null; + } finally { + // hand the untouched stream on + body.reset(); + } + } + + // A single object: a literal query, or one bare event. Never an event list. + private Multi single(final InputStream body, final JsonSchema schema) throws IOException { + final JsonNode node = mapper.readTree(body); + final List errors = cap(FORMATTER.format(node, schema.validate(node))); + return Multi.createFrom().iterable(errors); + } + + // Stream EPCISDocument/EPCISQueryDocument to validate event-by-event from eventsList + private Multi streamDocument(final InputStream body, final String documentType) throws IOException { + // Path to read get the eventsList + final String eventListPath = EPCIS.EPCIS_QUERY_DOCUMENT.equals(documentType) ? EPCIS.QUERY_EVENT_LIST_PATH : EPCIS.CAPTURE_EVENT_LIST_PATH; + final AtomicInteger nextEvent = new AtomicInteger(); + final AtomicLong nodes = new AtomicLong(); + final AtomicLong kept = new AtomicLong(); + final int reportable = Math.max(1, maxReportedErrors - 1); + return Multi.createFrom() - .emitter( - multiEmitter -> { - try { - // keep the tree instead of discarding it, the formatter reads event types from it - final JsonNode queryDocument = mapper.readValue(document, JsonNode.class); - final Set queryErrors = queryJsonSchema.validate(queryDocument); - - // schema violations stay emitted items, a failure is reserved for broken input - if (!queryErrors.isEmpty()) { - jsonErrorHandler.jsonValidationFormat(queryDocument, queryErrors); - } - - this.jsonErrorHandler.getExceptions().stream().forEach(multiEmitter::emit); - multiEmitter.complete(); - } catch (Exception exception) { - multiEmitter.fail( - new SchemaValidationException("Exception occurred during validation against EPCIS JSON Query schema : " + exception.getMessage())); - } - }); + .publisher(ObjectNodePublisher.fromInputStream(body)) + .onItem() + .transformToMultiAndConcatenate(node -> { + nodes.incrementAndGet(); + final String nodeType = node.path(EPCIS.TYPE).asText(""); + //Validate header or event depending on type separately + return Multi.createFrom().iterable( + EPCIS.EPCIS_DOCUMENT.equals(nodeType) || EPCIS.EPCIS_QUERY_DOCUMENT.equals(nodeType) + ? validateHeader(node) + : validateEvent(node, eventListPath, nextEvent.getAndIncrement())); + }) + // nothing parsed at all must never report like a clean document + .onCompletion().continueWith(() -> nodes.get() == 0 ? List.of(unreadable()) : List.of()) + // one more than we show, which is what proves more exist without counting + .select().first(maxReportedErrors) + // that extra one is dropped, it only served as proof that there were more than the limit, so report a single limitExceeded error instead + .select().where(error -> kept.incrementAndGet() <= reportable) + .onCompletion().continueWith(() -> kept.get() > reportable ? List.of(limitExceeded(reportable)) : List.of()); + } + + // Validates the header/envelope against GS1 schema by adding empty eventsList so it validates + private List validateHeader(final ObjectNode header) { + header.putObject(EPCIS.EPCIS_BODY_IN_CAMEL_CASE); + final List envelope = new ArrayList<>(); + for (final ValidationError error : FORMATTER.format(null, documentSchema.validate(header))) { + final String at = error.getLocation(); + if (at == null || !at.startsWith(BODY_PREFIX)) { + envelope.add(error); + } + } + return envelope; + } + + + private List validateEvent(final ObjectNode event, + final String eventListPath, + final int index) { + final Set messages = eventSchema.validate(event); + // formatEvent turns $.eventTime back into $.epcisBody.eventList[3].eventTime + final String eventType = event.path(EPCIS.TYPE).asText(null); + return messages.isEmpty() ? List.of() : FORMATTER.formatEvent(eventListPath, index, eventType, messages); + } + + // Applies the limit to a single object result, where there is no stream to cut short. + private List cap(final List all) { + final int reportable = Math.max(1, maxReportedErrors - 1); + if (all.size() <= reportable) { + return all; + } + final List capped = new ArrayList<>(all.subList(0, reportable)); + capped.add(limitExceeded(reportable)); + return capped; + } + + // The parser produced nothing so was unable to read the documents report it instead of reporting a clean document. + private ValidationError unreadable() { + final ValidationError error = new ValidationError(); + error.setType("required"); + error.setLocation("$"); + error.setField("@context"); + error.setMessage(MessageTemplates.hasKeyword("document.unreadable") + ? MessageTemplates.keyword("document.unreadable") + : "The document could not be read. Check that @context is present and appears before epcisBody."); + return error; + } + + // The last entry in errors when limit is exceeded, so a capped list is never mistaken for a complete one. + private ValidationError limitExceeded(final int limit) { + final ValidationError error = new ValidationError(); + error.setType("limitExceeded"); + error.setMessage(MessageTemplates.hasKeyword("limit.exceeded") + ? MessageTemplates.render(MessageTemplates.keyword("limit.exceeded"), Map.of("limit", String.valueOf(limit))) + : "Validation stopped after " + limit + " problems. This document has more."); + return error; + } } diff --git a/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java b/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java index 4d5790d..e5f0915 100644 --- a/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java +++ b/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java @@ -27,82 +27,124 @@ */ public final class JsonErrorFormatter { - /** - * When one position produced several messages, lead with the most useful keyword. - */ + //When one position produced several messages, lead with the most useful keyword. private static final List KEYWORD_PRIORITY = List.of( "required", "additionalProperties", "propertyNames", "enum", "type", "format", "pattern", "minItems", "maxItems", "uniqueItems", "minimum", "maximum", "minLength"); + // Keeps a 41 value vocabulary out of the sentence. The full list travels in allowedValues. + private static final int ALLOWED_SAMPLE_SIZE = 6; /** - * Keeps a 41 value vocabulary out of the sentence. The full list travels in allowedValues. + * Where a single streamed event sits in the document. Null whenever the whole document was + * parsed, because then the paths networknt reports are already absolute. + * + * @param prefix the absolute path of this event, for example $.epcisBody.eventList[3] + * @param index its zero based position in the event list + * @param type its EPCIS event type, or null when the document did not declare one */ - private static final int ALLOWED_SAMPLE_SIZE = 6; + private record EventContext(String prefix, int index, String type) { + } /** - * Groups and words every failure. - * - * @param document the already parsed document, read only to name the event type. Never re-parsed. - * @param messages the raw messages from networknt + * Groups and words every failure of a whole parsed document. */ public List format(final JsonNode document, final Collection messages) { + return format(document, messages, null); // no context, the reported paths are already absolute + } + + /** + * Groups and words the failures of one streamed event. + * + *

A streamed event is validated on its own, so networknt reports $.eventTime where the caller + * needs $.epcisBody.eventList[3].eventTime. The context restores that, which is what keeps the + * location usable for jumping to a line in an editor. + * + * @param eventListPath absolute path of the list holding the event, without the index + * @param eventIndex zero based position of the event in that list + * @param eventType EPCIS event type of the event, may be null + */ + public List formatEvent(final String eventListPath, + final int eventIndex, + final String eventType, + final Collection messages) { + // the index is appended here so a caller cannot get the bracket syntax wrong + final String prefix = eventListPath + "[" + eventIndex + "]"; + // no document tree exists while streaming, the context carries what would be read from it + return format(null, messages, new EventContext(prefix, eventIndex, eventType)); + } + + private List format(final JsonNode document, + final Collection messages, + final EventContext context) { if (CollectionUtils.isEmpty(messages)) { // valid document, so no allocation at all return List.of(); } - // every position that reported something - final Set locations = new HashSet<>(); - for (final ValidationMessage message : messages) { - locations.add(message.getInstanceLocation().toString()); - } - final Map> byLocation = new LinkedHashMap<>(); for (final ValidationMessage message : messages) { - if (isBranchNoise(message, locations)) { - continue; // a precise error deeper in the document already explains this one - } // one bucket per position, which is what merges the duplicates byLocation.computeIfAbsent(message.getInstanceLocation().toString(), key -> new ArrayList<>()).add(message); } + // where each anyOf/oneOf choice failed, so a choice can tell a missing field from a bad one + final Map> branchLocations = branchLocations(messages); final List results = new ArrayList<>(byLocation.size()); - byLocation.forEach((location, group) -> results.addAll(buildAll(document, location, group))); + byLocation.forEach((location, group) -> results.addAll(buildAll(document, location, group, branchLocations, context))); results.sort(Comparator.comparing(error -> error.getLocation() == null ? "" : error.getLocation())); return results; } /** - * A failed schema branch is only a guess about which shape was intended. When a concrete problem - * exists deeper in the same subtree, that deeper problem is the real cause and the branch level - * "required property X not found" messages are noise. This is what turns 5 errors into 1. + * Records every position at which each anyOf/oneOf choice failed, keyed by the choice. * - *

Deliberately narrow: required only, inside a branch only, and only when something strictly - * deeper also failed. A genuine missing field is never hidden. + *

A branch that fails at the position of the choice itself was rejected because a field is + * absent. A branch that fails deeper got past that presence check, so the field was supplied and + * only its contents are wrong. Telling those two apart is what {@link + * #choiceWasSatisfiedButInvalid} needs. */ - private boolean isBranchNoise(final ValidationMessage message, final Set locations) { - if (!"required".equals(message.getType()) || !insideBranch(message)) { - return false; // anything else is always kept + private Map> branchLocations(final Collection messages) { + final Map> byBranch = new HashMap<>(); + for (final ValidationMessage message : messages) { + final String branch = branchGroupOf(message); + if (branch != null) { + byBranch.computeIfAbsent(branch, key -> new HashSet<>()) + .add(message.getInstanceLocation().toString()); + } } - final String location = message.getInstanceLocation().toString(); - for (final String other : locations) { - if (other.length() > location.length() // strictly longer means strictly deeper + return byBranch; + } + + /** + * Whether the document already satisfied this choice and only got the contents wrong. + * + *

An event carrying a quantityList with an unknown field inside it fails that branch deep in + * the subtree, and the sibling branches then report their own fields as missing. Saying "epcList + * or sensorElementList is missing" would be wrong, so the choice is dropped and the precise error + * inside quantityList is kept instead. + * + *

Only failures of this same choice count. That is what stops an unrelated problem, such as a + * malformed eventTime, from ever hiding a genuinely missing field. + */ + private boolean choiceWasSatisfiedButInvalid(final String branch, + final String location, + final Map> branchLocations) { + final Set inGroup = branchLocations.get(branch); + if (inGroup == null) { + return false; // nothing recorded for this choice, so keep the error + } + for (final String other : inGroup) { + if (other.length() > location.length() // strictly longer means deeper than the choice && other.startsWith(location) && (other.charAt(location.length()) == '.' || other.charAt(location.length()) == '[')) { - return true; // a real problem exists below this position + return true; // a branch of this choice got past the presence check } } return false; } - // Whether the message came out of an anyOf or oneOf branch rather than an unconditional rule. - private boolean insideBranch(final ValidationMessage message) { - final String path = message.getEvaluationPath().toString(); - return path.contains("anyOf[") || path.contains("oneOf["); - } - // Which anyOf or oneOf produced it, so sibling branches of the same choice can be merged. private String branchGroupOf(final ValidationMessage message) { final String path = message.getEvaluationPath().toString(); @@ -118,8 +160,11 @@ private String branchGroupOf(final ValidationMessage message) { * One position can still hold genuinely different problems. Alternative required fields coming * from sibling branches become one "at least one of" sentence, everything else is kept separate. */ - private List buildAll( - final JsonNode document, final String location, final List group) { + private List buildAll(final JsonNode document, + final String location, + final List group, + final Map> branchLocations, + final EventContext context) { // Every "required" message is grouped by the choice it came out of. The empty key collects the // unconditional ones, where each named field really is missing rather than being an alternative. @@ -148,14 +193,17 @@ private List buildAll( fields.add(message.getProperty()); // getProperty holds the missing field name } } + // sibling branches offer a choice, so they become one "at least one of" sentence - if (!branch.isEmpty() - && fields.size() > 1 - && MessageTemplates.hasKeyword("required.alternatives")) { - results.add( - buildRequiredGroup(document, location, siblings, fields, "required.alternatives")); + if (!branch.isEmpty() && fields.size() > 1 && MessageTemplates.hasKeyword("required.alternatives")) { + if (choiceWasSatisfiedButInvalid(branch, location, branchLocations)) { + return; // the precise error inside that field is the real one + } + + results.add(buildRequiredGroup(document, location, siblings, fields, "required.alternatives", context)); return; } + for (final ValidationMessage message : siblings) { if (message.getProperty() == null) { others.add(message); // no field name to group by, report it on its own @@ -173,13 +221,14 @@ private List buildAll( location, List.copyOf(leftoverRequired.values()), leftoverRequired.keySet(), - "required.multiple")); + "required.multiple", + context)); } else { others.addAll(leftoverRequired.values()); // a single missing field stays an ordinary message } if (!others.isEmpty()) { - results.add(build(document, location, others)); + results.add(build(document, location, others, context)); } return results; } @@ -195,15 +244,16 @@ private ValidationError buildRequiredGroup( final String location, final List siblings, final Set fields, - final String templateKey) { + final String templateKey, + final EventContext context) { final ValidationError error = new ValidationError(); error.setType("required"); error.setLine(siblings.get(0).getSchemaLocation().toString()); - error.setLocation(location); + error.setLocation(absolute(location, context)); // full document path even while streaming final String joined = String.join(", ", fields); error.setField(joined); - applyEventContext(error, document, location); + applyEventContext(error, document, location, context); error.setAllowedValues(List.copyOf(fields)); error.setMessage(MessageTemplates.render(MessageTemplates.keyword(templateKey), Map.of("where", whereLabel(error), "fields", joined))); @@ -213,21 +263,23 @@ private ValidationError buildRequiredGroup( /** * Builds one error from a group of messages that all describe the same position. */ - private ValidationError build( - final JsonNode document, final String location, final List group) { + private ValidationError build(final JsonNode document, + final String location, + final List group, + final EventContext context) { final ValidationMessage primary = pickPrimary(group); // the most informative of the group final ValidationError error = new ValidationError(); error.setType(primary.getType()); // unchanged, the front end may switch on it error.setLine(primary.getSchemaLocation().toString()); // unchanged - error.setLocation(location); // unchanged + error.setLocation(absolute(location, context)); // full document path even while streaming // the field name is the last path segment, except for required, additionalProperties and // propertyNames, where the path points at the parent and the name is in getProperty error.setField(primary.getProperty() != null ? primary.getProperty() : lastSegment(location)); error.setValue(plainValue(primary.getInstanceNode())); // the value sent, previously discarded - applyEventContext(error, document, location); + applyEventContext(error, document, location, context); final List allowed = allowedValuesIn(group); // read from the schema, never hard coded if (!allowed.isEmpty()) { @@ -380,8 +432,16 @@ private String sample(final List allowed) { /** * Records which event holds the problem, so a message can say "event 6 (TransformationEvent)". */ - private void applyEventContext( - final ValidationError error, final JsonNode document, final String location) { + private void applyEventContext(final ValidationError error, + final JsonNode document, + final String location, + final EventContext context) { + if (context != null) { + // streaming already knows which event this is, so no path parsing and no tree lookup + error.setEventIndex(context.index()); + error.setEventType(context.type()); + return; + } final Integer index = eventIndexOf(location); if (index == null) { return; // the problem is outside eventList, for example at the document root @@ -399,6 +459,18 @@ private void applyEventContext( } } + /** + * Rewrites an event relative path into a full document path, so a caller of {@link #formatEvent} + * gets the same location it would have got from validating the whole document. + */ + private String absolute(final String location, final EventContext context) { + if (context == null || location == null) { + return location; // whole document mode, the path is already absolute + } + // $ alone means the event itself, anything else keeps its tail after the leading $ + return "$".equals(location) ? context.prefix() : context.prefix() + location.substring(1); + } + /** * Renders {where}: the document, or an event number with its type when known. */ diff --git a/core/src/main/java/io/openepcis/validation/xml/AbstractXmlSchemaValidator.java b/core/src/main/java/io/openepcis/validation/xml/AbstractXmlSchemaValidator.java index fdfa162..319083e 100644 --- a/core/src/main/java/io/openepcis/validation/xml/AbstractXmlSchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/xml/AbstractXmlSchemaValidator.java @@ -16,6 +16,7 @@ package io.openepcis.validation.xml; import io.openepcis.validation.Validator; +import io.openepcis.validation.config.ValidationDefaults; import io.openepcis.validation.exception.SchemaValidationException; import io.openepcis.validation.model.ValidationError; import io.smallrye.mutiny.Multi; @@ -29,10 +30,10 @@ public abstract class AbstractXmlSchemaValidator implements Validator { + protected int maxReportedErrors = ValidationDefaults.MAX_REPORTED_ERRORS; protected Schema captureXmlSchema; protected Schema queryXmlSchema; protected EventModifier eventModifier = new EventModifier(); - protected XmlErrorHandler xsdErrorHandler; protected SchemaFactory schemaFactory; @Override @@ -43,9 +44,12 @@ public Multi validateAgainstCaptureSchema(final InputStream cap try { final InputStream inputEvent = eventModifier.modifyEvent(captureInput); final javax.xml.validation.Validator validator = captureXmlSchema.newValidator(); - validator.setErrorHandler(this.xsdErrorHandler); + // Fresh handler to avoid reused validation polluting with old errors + final XmlErrorHandler errorHandler = new XmlErrorHandler(maxReportedErrors); + + validator.setErrorHandler(errorHandler); validator.validate(new StreamSource(inputEvent)); - this.xsdErrorHandler.getExceptions().forEach(multiEmitter::emit); + errorHandler.getExceptions().forEach(multiEmitter::emit); multiEmitter.complete(); } catch (Exception e) { multiEmitter.fail(new SchemaValidationException("Failed to validate the Capture XML document against the XSD : " + e.getMessage(), e)); @@ -60,10 +64,13 @@ public Multi validateAgainstQuerySchema(InputStream queryInput) .emitter( multiEmitter -> { final javax.xml.validation.Validator validator = queryXmlSchema.newValidator(); - validator.setErrorHandler(this.xsdErrorHandler); + // Fresh handler to avoid reused validation polluting with old errors + final XmlErrorHandler errorHandler = new XmlErrorHandler(maxReportedErrors); + validator.setErrorHandler(errorHandler); + try { validator.validate(new StreamSource(queryInput)); - this.xsdErrorHandler.getExceptions().forEach(multiEmitter::emit); + errorHandler.getExceptions().forEach(multiEmitter::emit); multiEmitter.complete(); } catch (SAXException | IOException e) { multiEmitter.fail(new SchemaValidationException("Failed to validate the Query XML document against the XSD : " + e.getMessage(), e)); diff --git a/core/src/main/java/io/openepcis/validation/xml/EventModifier.java b/core/src/main/java/io/openepcis/validation/xml/EventModifier.java index a345ae7..74e5aa1 100644 --- a/core/src/main/java/io/openepcis/validation/xml/EventModifier.java +++ b/core/src/main/java/io/openepcis/validation/xml/EventModifier.java @@ -103,10 +103,10 @@ public InputStream addDocumentWrapper(final InputStream captureInput) { writer.writeStartDocument(); - writer.writeStartElement(EPCIS.EPCIS, EPCIS.EPCIS_DOCUMENT, EPCIS.EPCIS_2_0_XMLNS); + writer.writeStartElement(EPCIS.EPCIS_NAMESPACE_PREFIX, EPCIS.EPCIS_DOCUMENT, EPCIS.EPCIS_2_0_XMLNS); writer.writeAttribute(EPCIS.CREATION_DATE, Instant.now().toString()); writer.writeAttribute(EPCIS.SCHEMA_VERSION, EPCISVersion.VERSION_2_0_0.getSchemaVersion()); - writer.writeNamespace(EPCIS.EPCIS, EPCIS.EPCIS_2_0_XMLNS); + writer.writeNamespace(EPCIS.EPCIS_NAMESPACE_PREFIX, EPCIS.EPCIS_2_0_XMLNS); writer.writeNamespace(EPCIS.XSI, EPCIS.XML_SCHEMA_INSTANCE); writer.writeStartElement(EPCIS.EPCIS_BODY); diff --git a/core/src/main/java/io/openepcis/validation/xml/Xml12SchemaValidator.java b/core/src/main/java/io/openepcis/validation/xml/Xml12SchemaValidator.java index 2e6df63..8d606ed 100644 --- a/core/src/main/java/io/openepcis/validation/xml/Xml12SchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/xml/Xml12SchemaValidator.java @@ -15,6 +15,7 @@ */ package io.openepcis.validation.xml; +import io.openepcis.validation.config.ValidationDefaults; import io.openepcis.validation.xml.resolver.ClasspathResourceResolver; import javax.xml.XMLConstants; @@ -22,13 +23,19 @@ public class Xml12SchemaValidator extends AbstractXmlSchemaValidator { + public Xml12SchemaValidator() { + // keep the existing callers and tests unchanged + this(ValidationDefaults.MAX_REPORTED_ERRORS); // keeps existing callers and tests unchanged + } + + + public Xml12SchemaValidator(final int maxReportedErrors) { final SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); schemaFactory.setResourceResolver(new ClasspathResourceResolver("xsd/1_2_0")); this.schemaFactory = schemaFactory; this.captureXmlSchema = loadSchema("/xsd/1_2_0/EPCglobal-epcis-1_2.xsd"); this.queryXmlSchema = loadSchema("/xsd/1_2_0/EPCglobal-epcis-query-1_2.xsd"); - this.xsdErrorHandler = new XmlErrorHandler(); + this.maxReportedErrors = maxReportedErrors; } - } diff --git a/core/src/main/java/io/openepcis/validation/xml/Xml20SchemaValidator.java b/core/src/main/java/io/openepcis/validation/xml/Xml20SchemaValidator.java index 6355b02..ddc5c11 100644 --- a/core/src/main/java/io/openepcis/validation/xml/Xml20SchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/xml/Xml20SchemaValidator.java @@ -15,6 +15,7 @@ */ package io.openepcis.validation.xml; +import io.openepcis.validation.config.ValidationDefaults; import io.openepcis.validation.xml.resolver.ClasspathResourceResolver; import javax.xml.XMLConstants; @@ -23,12 +24,18 @@ public class Xml20SchemaValidator extends AbstractXmlSchemaValidator { public Xml20SchemaValidator() { + // keep the existing callers and tests unchanged + this(ValidationDefaults.MAX_REPORTED_ERRORS); // keeps existing callers and tests unchanged + } + + + public Xml20SchemaValidator(final int maxReportedErrors) { final SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); schemaFactory.setResourceResolver(new ClasspathResourceResolver("xsd/2_0_0")); this.schemaFactory = schemaFactory; this.captureXmlSchema = loadSchema("/xsd/2_0_0/EPCglobal-epcis-2_0.xsd"); this.queryXmlSchema = loadSchema("/xsd/2_0_0/EPCglobal-epcis-query-2_0.xsd"); - this.xsdErrorHandler = new XmlErrorHandler(); + this.maxReportedErrors = maxReportedErrors; } } diff --git a/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java b/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java index 2ec375b..38ed6be 100644 --- a/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java +++ b/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java @@ -15,6 +15,7 @@ */ package io.openepcis.validation.xml; +import io.openepcis.validation.config.ValidationDefaults; import io.openepcis.validation.message.MessageTemplates; import io.openepcis.validation.model.ValidationError; import lombok.Getter; @@ -23,6 +24,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * custom error handler while validating xml against xsd @@ -30,9 +32,17 @@ public class XmlErrorHandler implements ErrorHandler { @Getter private final List exceptions; + // Max number of validation errors that can be added + private final int maxReportedErrors; public XmlErrorHandler() { + this(ValidationDefaults.MAX_REPORTED_ERRORS); + } + + public XmlErrorHandler(final int maxReportedErrors) { this.exceptions = new ArrayList<>(); + // passed in from SchemaValidator, so core needs no configuration framework of its own + this.maxReportedErrors = maxReportedErrors > 0 ? maxReportedErrors : ValidationDefaults.MAX_REPORTED_ERRORS; } @Override @@ -51,6 +61,15 @@ public void fatalError(final SAXParseException exception) { } public void handleError(final SAXParseException e) { + if (this.exceptions.size() >= maxReportedErrors) { + return; // the marker is already the last entry, nothing further is recorded + } + + if (this.exceptions.size() == maxReportedErrors - 1) { + this.exceptions.add(limitExceeded(maxReportedErrors - 1)); // same wording as the JSON path + return; + } + final String raw = e.getMessage() == null ? "" : e.getMessage(); // never NPE on a null message final String line = Integer.toString(e.getLineNumber()); final String column = Integer.toString(e.getColumnNumber()); @@ -88,4 +107,15 @@ private String typeOf(final String raw) { return "invalid"; // an honest default, better than claiming a field was required } + // The last entry in errors when limit is exceeded, so a capped list is never mistaken for a complete one. + private ValidationError limitExceeded(final int limit) { + final ValidationError error = new ValidationError(); + error.setType("limitExceeded"); + error.setMessage(MessageTemplates.hasKeyword("limit.exceeded") + ? MessageTemplates.render(MessageTemplates.keyword("limit.exceeded"), Map.of("limit", String.valueOf(limit))) + : "Validation stopped after " + limit + " problems. This document has more."); + return error; + + } + } diff --git a/core/src/main/resources/validation-messages/epcis-validation-messages.yaml b/core/src/main/resources/validation-messages/epcis-validation-messages.yaml index 82a4a40..0b4f0af 100644 --- a/core/src/main/resources/validation-messages/epcis-validation-messages.yaml +++ b/core/src/main/resources/validation-messages/epcis-validation-messages.yaml @@ -275,6 +275,11 @@ keywords: false: '{field} is not allowed in {where}.' id: '"{value}" is not a valid identifier for {field} in {where}.' + # Appended as the final entry when the list was capped + limit.exceeded: 'Validation stopped after {limit} problems. This document has more, so the list above is not complete. Fix these first, then validate again.' + + # Emitted when the parser produced nothing at all, so an unreadable document is never reported clean. + document.unreadable: 'The document could not be read. Check that @context is present and appears before epcisBody.' # ============================================================================= # REUSABLE SENTENCE PIECES diff --git a/core/src/test/java/io/openepcis/validation/test/EPCISQueryTest.java b/core/src/test/java/io/openepcis/validation/test/EPCISQueryTest.java new file mode 100644 index 0000000..2b81752 --- /dev/null +++ b/core/src/test/java/io/openepcis/validation/test/EPCISQueryTest.java @@ -0,0 +1,100 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.serialization.JsonNodeReader; +import io.openepcis.constants.EPCISVersion; +import io.openepcis.resources.util.Commons; +import io.openepcis.validation.SchemaValidator; +import io.openepcis.validation.model.ValidationError; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.util.List; + +import static io.openepcis.constants.EPCISDocumentType.QUERY; +import static org.junit.jupiter.api.Assertions.*; + +class EPCISQueryTest { + + private static final String QUERIES = "2.0/EPCIS/JSON/Query/Queries/"; + private static final String QUERIES_INVALID = QUERIES + "Invalid/"; + + private final ObjectMapper mapper = new ObjectMapper(); + private final SchemaValidator validator = + new SchemaValidator(mapper, JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) + .jsonNodeReader(JsonNodeReader.builder().jsonMapper(mapper).build()).build()); + + @Test + @DisplayName("valid: a literal query with GE_ and LT_ filters reports no errors") + void validLiteralQuery() { + assertEquals(0, validate(QUERIES + "SimpleEventQuery.json").size()); + } + + + @Test + @DisplayName("invalid: a literal query with a bad action and a bad date reports both") + void literalQueryReportsEveryBadValue() { + final List errors = validate(QUERIES_INVALID + "Query_with_invalid_values.json"); + + assertEquals(2, errors.size()); // neither problem hides the other + assertTrue(errors.stream().anyMatch(e -> "EQ_action".equals(e.getField()))); + assertTrue(errors.stream().anyMatch(e -> "GE_eventTime".equals(e.getField()))); + } + + @Test + @DisplayName("valid: a bare event with its own @context reports no errors") + void validBareEvent() { + assertEquals(0, validate("2.0/EPCIS/JSON/Capture/Events/ObjectEvent.json").size()); + } + + @Test + @DisplayName("valid: a query document is validated as a document, not as a query") + void queryDocumentIsValidatedAsADocument() { + assertEquals(0, validate("2.0/EPCIS/JSON/Query/queryDocument.json").size()); + } + + @Test + @DisplayName("invalid: an event inside a query document reports its full nested path") + void queryDocumentEventKeepsItsNestedPath() { + final ValidationError error = validate("2.0/EPCIS/JSON/Query/Invalid/QueryDocument_with_invalid_action.json").getFirst(); + + // the path the front end maps to an editor line must keep its exact shape + assertTrue(error.getLocation().startsWith("$.epcisBody.queryResults.resultsBody.eventList[")); + } + + @Test + @DisplayName("invalid: an unknown query parameter is named in the message") + void unknownQueryParameterIsNamed() { + final ValidationError error = validate(QUERIES_INVALID + "Query_with_unknown_parameter.json").getFirst(); + assertEquals("GE_eventDate", error.getField()); + } + + // Validates one fixture and returns the errors. + private List validate(final String resourcePath) { + final InputStream document = Commons.getInputStream(resourcePath); + assertNotNull(document, "missing shared test resource " + resourcePath); + return validator + .validate(document, "application/json", QUERY, EPCISVersion.VERSION_2_0_0) + .subscribe() + .asStream() + .toList(); + } +} diff --git a/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java b/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java index eb0dc76..85c6fe7 100644 --- a/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java +++ b/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test; import java.io.InputStream; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -59,30 +60,19 @@ void validateCaptureDocumentWithCertificationTest() { } @Test - void validateQueryDocumentWithEventTypeTest() { - final InputStream inputStream = Commons.getInputStream(QUERY + "QueryDocumentWithEventType.json"); + void validateQueryDocumentTest() { + final InputStream inputStream = Commons.getInputStream(QUERY + "queryDocument.json"); final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(0, jsonQueryErrors.subscribe().asStream().toList().size()); - } - - @Test - void validateQueryDocumentWithEventTimeTest() { - final InputStream inputStream = Commons.getInputStream(QUERY + "queryDocumentWithEventTime.json"); - final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(0, jsonQueryErrors.subscribe().asStream().toList().size()); - } - - @Test - void validateQueryDocumentWithActionTest() { - final InputStream inputStream = Commons.getInputStream(QUERY + "queryDocumentWithAction.json"); - final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertEquals(0, jsonQueryErrors.subscribe().asStream().toList().size()); + final List errors = jsonQueryErrors.subscribe().asStream().toList(); + assertEquals(0, errors.size()); } @Test void validateQueryDocumentWithInValidEventTypeTest() { final InputStream inputStream = Commons.getInputStream(QUERY_INVALID + "QueryDocument_with_invalid_eventType.json"); final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); - assertFalse(jsonQueryErrors.subscribe().asStream().toList().isEmpty()); + final List errors = jsonQueryErrors.subscribe().asStream().toList(); + assertFalse(errors.isEmpty()); + assertEquals(1, errors.size()); } } diff --git a/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java b/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java index 5274d7e..4846147 100644 --- a/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java +++ b/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java @@ -231,11 +231,10 @@ void ilmdFieldNeedsNamespace() { @DisplayName("invalid: a missing @context is reported against the document, not an event") void missingContextIsReportedAtDocumentLevel() { final List errors = validate(INVALID + "EPCISDocument_without_context.json"); - assertEquals(1, errors.size()); assertEquals("@context", errors.getFirst().getField()); assertNull(errors.getFirst().getEventIndex()); - assertTrue(errors.getFirst().getMessage().contains("the document")); + assertTrue(errors.getFirst().getMessage().contains("The document could not be read")); } @Test diff --git a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java index 3ec4483..8245015 100644 --- a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java +++ b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java @@ -38,7 +38,7 @@ import static io.openepcis.constants.EPCISVersion.VERSION_2_0_0; import static org.junit.jupiter.api.Assertions.assertEquals; -public class XmlDocumentTest { +class XmlDocumentTest { private final ObjectMapper mapper = new ObjectMapper(); private final SchemaValidator validator = new SchemaValidator( @@ -62,13 +62,16 @@ void validateDocuments(String testName, String resource, EPCISDocumentType docum static Stream validationTestCases() { return Stream.of( - Arguments.of("Capture document with bare event", INVALID_PATH + "/ObjectEvent_bare_event_without_action.xml", CAPTURE, VERSION_2_0_0, 1), + Arguments.of("Capture document with bare event", INVALID_PATH + "/ObjectEvent_with_invalid_action.xml", CAPTURE, VERSION_2_0_0, 1), Arguments.of("Capture document with comment", CAPTURE_1_2_PATH + "/EPCISDocument_with_comment.xml", CAPTURE, VERSION_1_2_0, 0), Arguments.of("Complete EPCIS capture document", CAPTURE_PATH + "/Combination_of_different_event.xml", CAPTURE, VERSION_2_0_0, 0), - Arguments.of("Query document with event time", QUERY_PATH + "/queryDocument.xml", QUERY, VERSION_2_0_0, 0), - Arguments.of("Query document with event type", QUERY_PATH + "/queryDocumentWithEventType.xml", QUERY, VERSION_2_0_0, 0), - Arguments.of("Query document with read point", QUERY_PATH + "/queryDocumentWithReadPoint.xml", QUERY, VERSION_2_0_0, 0), - Arguments.of("Query document with missing value", QUERY_PATH + "/Invalid/QueryDocument_with_missing_value.xml", QUERY, VERSION_2_0_0, 1) + Arguments.of("Simple Query document", QUERY_PATH + "/queryDocument.xml", QUERY, VERSION_2_0_0, 0), + Arguments.of("Query document with multiple events", QUERY_PATH + "/QueryDocumentWithMultipleEvents.xml", QUERY, VERSION_2_0_0, 0), + Arguments.of("Query document with missing value", QUERY_PATH + "/Invalid/QueryDocument_with_missing_value.xml", QUERY, VERSION_2_0_0, 1), + + // XML Queries + Arguments.of("Literal query with GE_ and LT_ params", QUERY_PATH + "/Queries/SimpleEventQuery.xml", QUERY, VERSION_2_0_0, 0), + Arguments.of("Literal query without queryName", QUERY_PATH + "/Queries/Invalid/Query_without_queryName.xml", QUERY, VERSION_2_0_0, 1) ); } } diff --git a/pom.xml b/pom.xml index a9241a0..741dfc5 100644 --- a/pom.xml +++ b/pom.xml @@ -17,143 +17,155 @@ --> - 4.0.0 - - io.openepcis - openepcis-bom - 999-SNAPSHOT - - openepcis-document-validation-parent - - openepcis-document-validation-parent - pom - Service to validate EPCIS 2.0 documents and identifiers ensuring complicante with EPCIS standards. - https://github.com/openepcis/openepcis-document-validation-service - - - - The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - - - - - - Sven Boeckelmann - sven.boeckelmann@benelog.com - benelog GmbH & Co. KG - https://www.benelog.com - - - - - scm:git:git://github.com/openepcis/openepcis-document-validation-service.git - scm:git:ssh://github.com:openepcis/openepcis-document-validation-service.git - https://github.com/openepcis/openepcis-document-validation-service/tree/main - - - - - core - restassured - rest-api - servlet-api - quarkus - - - - - io.openepcis - openepcis-document-validation-service - ${project.parent.version} - - - io.openepcis - openepcis-document-validation-rest-api - ${project.parent.version} - - - io.openepcis - openepcis-document-validation-servlet-api - ${project.parent.version} - - + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + 4.0.0 + io.openepcis - openepcis-document-validation-restassured - ${project.parent.version} - - - io.openepcis.quarkus - quarkus-document-validation-service - ${project.parent.version} - - - io.openepcis.quarkus - quarkus-document-validation-service-deployment - ${project.parent.version} - - - io.openepcis.quarkus - quarkus-openepcis-model-deployment - ${project.parent.version} - - - io.openepcis.quarkus - quarkus-test-resources-deployment - ${project.parent.version} - - - - - io.openepcis - openepcis-epcis-constants - ${project.parent.version} - - - io.openepcis - openepcis-model-rest - ${project.parent.version} - - - io.openepcis - openepcis-model-epcis - ${project.parent.version} - + openepcis-bom + 999-SNAPSHOT + + openepcis-document-validation-parent - - io.openepcis - openepcis-test-resources - ${project.parent.version} - - - - io.openepcis.quarkus - quarkus-test-resources - ${project.parent.version} - - - - io.openepcis.quarkus - quarkus-openepcis-model - ${project.parent.version} - - - - - - - - - central-snapshots - https://central.sonatype.com/repository/maven-snapshots/ - - true - - - false - - - + openepcis-document-validation-parent + pom + Service to validate EPCIS 2.0 documents and identifiers ensuring complicante with EPCIS standards. + https://github.com/openepcis/openepcis-document-validation-service + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + + Sven Boeckelmann + sven.boeckelmann@benelog.com + benelog GmbH & Co. KG + https://www.benelog.com + + + Aravinda Baligab + benelog GmbH & Co. KG + https://www.benelog.com + + + + + scm:git:git://github.com/openepcis/openepcis-document-validation-service.git + scm:git:ssh://github.com:openepcis/openepcis-document-validation-service.git + https://github.com/openepcis/openepcis-document-validation-service/tree/main + + + + + core + restassured + rest-api + servlet-api + quarkus + + + + + + io.openepcis + openepcis-document-validation-service + ${project.parent.version} + + + io.openepcis + openepcis-document-validation-rest-api + ${project.parent.version} + + + io.openepcis + openepcis-document-validation-servlet-api + ${project.parent.version} + + + io.openepcis + openepcis-document-validation-restassured + ${project.parent.version} + + + io.openepcis.quarkus + quarkus-document-validation-service + ${project.parent.version} + + + io.openepcis.quarkus + quarkus-document-validation-service-deployment + ${project.parent.version} + + + io.openepcis.quarkus + quarkus-openepcis-model-deployment + ${project.parent.version} + + + io.openepcis.quarkus + quarkus-test-resources-deployment + ${project.parent.version} + + + + io.openepcis + openepcis-reactive-event-publisher + ${project.parent.version} + + + + + io.openepcis + openepcis-epcis-constants + ${project.parent.version} + + + io.openepcis + openepcis-model-rest + ${project.parent.version} + + + io.openepcis + openepcis-model-epcis + ${project.parent.version} + + + + io.openepcis + openepcis-test-resources + ${project.parent.version} + + + + io.openepcis.quarkus + quarkus-test-resources + ${project.parent.version} + + + + io.openepcis.quarkus + quarkus-openepcis-model + ${project.parent.version} + + + + + + + + + central-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + true + + + false + + + diff --git a/quarkus/rest-app/src/main/resources/application.yml b/quarkus/rest-app/src/main/resources/application.yml index e5b67fd..3fb1afe 100644 --- a/quarkus/rest-app/src/main/resources/application.yml +++ b/quarkus/rest-app/src/main/resources/application.yml @@ -2,6 +2,9 @@ quarkus: swagger-ui: always-include: true http: + limits: + # reject oversized bodies + max-body-size: ${QUARKUS_HTTP_LIMITS_MAX_BODY_SIZE:25M} cors: ~: true methods: GET,POST,OPTIONS @@ -19,3 +22,9 @@ quarkus: mp: openapi: filter: "io.openepcis.resources.oas.EPCISExampleOASFilter" + +# Schema validation configuration +validation: + # Maximum number of errors before stopping validation early + max-reported-errors: ${OPENEPCIS_MAX_REPORTED_ERRORS:1000} + streaming: ${OPENEPCIS_VALIDATION_STREAMING:true} diff --git a/quarkus/runtime/src/main/java/io/openepcis/validation/runtime/SchemaValidatorProducer.java b/quarkus/runtime/src/main/java/io/openepcis/validation/runtime/SchemaValidatorProducer.java index 0663c32..7f8cc8c 100644 --- a/quarkus/runtime/src/main/java/io/openepcis/validation/runtime/SchemaValidatorProducer.java +++ b/quarkus/runtime/src/main/java/io/openepcis/validation/runtime/SchemaValidatorProducer.java @@ -15,34 +15,42 @@ */ package io.openepcis.validation.runtime; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SpecVersion; +import com.networknt.schema.serialization.JsonNodeReader; import io.openepcis.validation.SchemaValidator; import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Initialized; import jakarta.enterprise.context.RequestScoped; import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; @RegisterForReflection @ApplicationScoped public class SchemaValidatorProducer { - @Produces - @RequestScoped - public SchemaValidator schemaValidator(final JsonSchemaFactory factory) { - return new SchemaValidator(mapper, factory); - } - private final ObjectMapper mapper; + @Inject + ValidationConfig validationConfig; - public SchemaValidatorProducer(final ObjectMapper mapper) { - this.mapper = mapper; - } + @Produces + @RequestScoped + public SchemaValidator schemaValidator(final JsonSchemaFactory factory) { + return new SchemaValidator(mapper, factory, validationConfig.maxReportedErrors()); + } - @Produces - public JsonSchemaFactory jsonSchemaFactory() { - return JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) - .jsonMapper(mapper) - .build(); - } + private final ObjectMapper mapper; + + public SchemaValidatorProducer(final ObjectMapper mapper) { + this.mapper = mapper; + } + + @Produces + public JsonSchemaFactory jsonSchemaFactory() { + return JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) + .jsonNodeReader(JsonNodeReader.builder().jsonMapper(mapper).build()) + .build(); + } } diff --git a/quarkus/runtime/src/main/java/io/openepcis/validation/runtime/ValidationConfig.java b/quarkus/runtime/src/main/java/io/openepcis/validation/runtime/ValidationConfig.java new file mode 100644 index 0000000..31bc8fe --- /dev/null +++ b/quarkus/runtime/src/main/java/io/openepcis/validation/runtime/ValidationConfig.java @@ -0,0 +1,36 @@ +/* + * Copyright 2022-2026 benelog GmbH & Co. KG + * + * 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 + * + * http://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 io.openepcis.validation.runtime; + +import io.openepcis.validation.config.ValidationDefaults; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * Root configuration interface for validation settings. Maps to validation.* in application.yml. + */ +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +@ConfigMapping(prefix = "validation") +public interface ValidationConfig { + + /** + * Most problems reported per document during validation error reporting; the last slot says the limit was reached. + */ + @WithDefault(ValidationDefaults.MAX_REPORTED_ERRORS_TEXT) + int maxReportedErrors(); +} From 6143c0e1858149fc14af979f8d5e29203b29244c Mon Sep 17 00:00:00 2001 From: Aravinda Baliga B Date: Wed, 5 Aug 2026 09:49:12 +0200 Subject: [PATCH 3/7] Align modified-query-schema with the official GS1 query schema and bound the validation request time --- .../validation/xml/EventModifier.java | 24 +- .../json-schema/modified-query-schema.json | 2200 +++++++---------- .../validation/test/EPCISQueryTest.java | 2 +- .../test/JsonValidationMessageTest.java | 41 + .../validation/test/XmlDocumentTest.java | 1 + .../src/main/resources/application.yml | 4 +- .../rest/resource/ValidationResource.java | 320 +-- .../validation/servlet/ValidationServlet.java | 122 +- 8 files changed, 1222 insertions(+), 1492 deletions(-) diff --git a/core/src/main/java/io/openepcis/validation/xml/EventModifier.java b/core/src/main/java/io/openepcis/validation/xml/EventModifier.java index 74e5aa1..c1bc210 100644 --- a/core/src/main/java/io/openepcis/validation/xml/EventModifier.java +++ b/core/src/main/java/io/openepcis/validation/xml/EventModifier.java @@ -47,28 +47,20 @@ public InputStream modifyEvent(final InputStream input) { ? (BufferedInputStream) input : new BufferedInputStream(input); try { + // scanFirstTag to determine if a single event or an epcis document. Checks the first element with mark/reset, so captureInput stays readable final String preScan = XMLTagPreScanUtil.scanFirstTag(captureInput); - final PipedOutputStream pipedOutputStream = new PipedOutputStream(); - final PipedInputStream pipe = new PipedInputStream(pipedOutputStream); - - executorService.execute( - () -> { - try { - copy(captureInput, pipedOutputStream); - } catch (Exception e) { - throw new SchemaValidationException("Failed to read the schema version from the input document : " + e.getMessage(), e); - } - }); - if (preScan.contains(EPCIS.EPCIS_DOCUMENT)) { - return pipe; - } else if (EPCIS.EPCIS_EVENT_TYPES.stream().anyMatch(preScan::contains)) { - return addDocumentWrapper(pipe); // method to add the epcis document wrapper to single event + // Only for bare XML event document wrapper needs to be added rest return as is + if (EPCIS.EPCIS_EVENT_TYPES.stream().anyMatch(preScan::contains) && !preScan.contains(EPCIS.EPCIS_DOCUMENT) && !preScan.contains(EPCIS.EPCIS_QUERY_DOCUMENT)) { + // Only bareEvent so add wrapper + return addDocumentWrapper(captureInput); } + + // for EPCISDocument or EPCISQueryDocument return as is + return captureInput; } catch (Exception e) { throw new SchemaValidationException("Exception occurred during the modification of EPCIS event : " + e.getMessage(), e); } - return captureInput; } public void copy(final InputStream inputStream, final OutputStream outputStream) { diff --git a/core/src/main/resources/json-schema/modified-query-schema.json b/core/src/main/resources/json-schema/modified-query-schema.json index 154c76d..e76b810 100644 --- a/core/src/main/resources/json-schema/modified-query-schema.json +++ b/core/src/main/resources/json-schema/modified-query-schema.json @@ -1,1278 +1,960 @@ { - "title": "EPCIS Query parameters", - "type": "object", - "properties": { - "@context": { - "anyOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "object" - }, - { - "type": "array", - "uniqueItems": true, - "items": { - "anyOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "object" - } - ] - } - } - ] - }, - "eventType": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "ObjectEvent", - "AggregationEvent", - "AssociationEvent", - "TransformationEvent", - "TransactionEvent", - "all" - ] - } - }, - "GE_eventTime": { - "type": "string", - "format": "date-time" - }, - "LT_eventTime": { - "type": "string", - "format": "date-time" - }, - "GE_recordTime": { - "type": "string", - "format": "date-time" - }, - "LT_recordTime": { - "type": "string", - "format": "date-time" - }, - "EQ_action": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "ADD", - "OBSERVE", - "DELETE" - ] - } - }, - "EQ_bizStep": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_disposition": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_setPersistentDisposition": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_unsetPersistentDisposition": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_readPoint": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "WD_readPoint": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_bizLocation": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "WD_bizLocation": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_bizTransaction_type": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_source_type": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_destination_type": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_transformationID": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "MATCH_epc": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "MATCH_parentID": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "MATCH_inputEPC": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "MATCH_outputEPC": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "MATCH_anyEPC": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "MATCH_epcClass": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "MATCH_inputEPCClass": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "MATCH_outputEPCClass": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "MATCH_anyEPCClass": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "EQ_quantity": { - "type": "integer" - }, - "GT_quantity": { - "type": "integer" - }, - "GE_quantity": { - "type": "integer" - }, - "LT_quantity": { - "type": "integer" - }, - "LE_quantity": { - "type": "integer" - }, - "EQ_eventID": { - "type": "array", - "items": { - "type": "string" - } - }, - "GE_errorDeclaration_Time": { - "type": "string", - "format": "date-time" - }, - "LT_errorDeclaration_Time": { - "type": "string", - "format": "date-time" - }, - "EQ_errorReason": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_correctiveEventID": { - "type": "array", - "items": { - "type": "string" - } - }, - "orderBy": { - "type": "string" - }, - "orderDirection": { - "type": "string" - }, - "eventCountLimit": { - "type": "integer" - }, - "maxEventCount": { - "type": "integer" - }, - "EXISTS_sensorElementList": { - "type": "integer", - "nullable": true - }, - "GE_time": { - "type": "string", - "format": "date-time" - }, - "LT_time": { - "type": "string", - "format": "date-time" - }, - "GE_startTime": { - "type": "string", - "format": "date-time" - }, - "LT_startTime": { - "type": "string", - "format": "date-time" - }, - "GE_endTime": { - "type": "string", - "format": "date-time" - }, - "LT_endTime": { - "type": "string", - "format": "date-time" - }, - "EQ_type": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_deviceID": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_dataProcessingMethod": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_microorganism": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_chemicalSubstance": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_bizRules": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_stringValue": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_booleanValue": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_hexBinaryValue": { - "type": "array", - "items": { - "type": "string" - } - }, - "EQ_uriValue": { - "type": "array", - "items": { - "type": "string" - } - }, - "GE_sDev": { - "type": "number" - }, - "LT_sDev": { - "type": "number" - }, - "GE_percRank": { - "type": "number" - }, - "LT_percRank": { - "type": "number" - }, - "GE_percValue": { - "type": "number" - }, - "LT_percValue": { - "type": "number" - } - }, - "patternProperties": { - "^EQ_value_[A-Z0-9]{2,4}$": { - "type": "array", - "items": { - "type": "number" - } - }, - "^GE_minValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LT_minValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^GT_minValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LE_minValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^GE_maxValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LT_maxValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^GT_maxValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LE_maxValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^GE_meanValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LT_meanValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^GT_meanValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LE_meanValue_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^GE_value_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LT_value_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^GT_value_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LE_value_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^EQ_quantity_[A-Z0-9]{2,4}$": { - "type": "array", - "items": { - "type": "number" - } - }, - "^GE_quantity_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LT_quantity_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^GT_quantity_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^LE_quantity_[A-Z0-9]{2,4}$": { - "type": "number" - }, - "^EQ_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EPCIS Query parameters", + "$comment": "Describes a literal EPCIS query (GE_eventTime, EQ_action, MATCH_epc, orderBy), not a document. Taken from the official query-schema.json EPCIS-Query definition, flattened to the document root and carrying GS1's own definitions so every $ref resolves locally and no vocabulary is restated here. Do not hand edit: regenerate from https://ref.gs1.org/standards/epcis/query-schema.json", + "type": "object", + "properties": { + "@context": { + "$ref": "#/definitions/%40context" + }, + "eventType": { + "type": "array", + "items": { + "$ref": "#/definitions/eventType" } - ] - } - ] - }, - "^GT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LE_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EQ_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "string", - "format": "date-time" + }, + "GE_eventTime": { + "$ref": "#/definitions/time" + }, + "LT_eventTime": { + "$ref": "#/definitions/time" + }, + "GE_recordTime": { + "$ref": "#/definitions/time" + }, + "LT_recordTime": { + "$ref": "#/definitions/time" + }, + "EQ_action": { + "type": "array", + "items": { + "$ref": "#/definitions/action" } - ] - } - ] - }, - "^GT_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LE_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EQ_INNER_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + }, + "EQ_bizStep": { + "type": "array", + "items": { + "$ref": "#/definitions/bizStep" } - ] - } - ] - }, - "^GT_INNER_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_INNER_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_INNER_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LE_INNER_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EQ_INNER_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "string", - "format": "date-time" + }, + "EQ_disposition": { + "type": "array", + "items": { + "$ref": "#/definitions/disposition" } - ] - } - ] - }, - "^GT_INNER_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_INNER_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_INNER_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LE_INNER_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EXISTS_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "integer", - "nullable": true - }, - "^EXISTS_INNER_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "integer", - "nullable": true - }, - "^EXISTS_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "integer", - "nullable": true - }, - "^EXISTS_INNER_ILMD_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "integer", - "nullable": true - }, - "^HASATTR_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^EQ_ATTR_[a-z]\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^EQ_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + }, + "EQ_persistentDisposition_set": { + "type": "array", + "items": { + "$ref": "#/definitions/disposition" } - ] - } - ] - }, - "^GT_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LE_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EQ_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^GT_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^GE_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^LT_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^LE_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^EXISTS_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "integer", - "nullable": true - }, - "^EXISTS_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "integer", - "nullable": true - }, - "^EQ_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + }, + "EQ_persistentDisposition_unset": { + "type": "array", + "items": { + "$ref": "#/definitions/disposition" } - ] - } - ] - }, - "^GT_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "number" - } - ] - }, - "^LE_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EQ_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + }, + "EQ_quantity": { + "type": "array", + "items": { + "$ref": "#/definitions/decimal" } - ] - } - ] - }, - "^GT_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "number" - } - ] - }, - "^LE_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EQ_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + }, + "GT_quantity": { + "$ref": "#/definitions/decimal" + }, + "GE_quantity": { + "$ref": "#/definitions/decimal" + }, + "LT_quantity": { + "$ref": "#/definitions/decimal" + }, + "LE_quantity": { + "$ref": "#/definitions/decimal" + }, + "EQ_readPoint": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" } - ] - } - ] - }, - "^GT_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "number" - } - ] - }, - "^LE_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EQ_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + }, + "WD_readPoint": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" } - ] - } - ] - }, - "^GT_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "number" - } - ] - }, - "^LE_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EQ_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + }, + "EQ_bizLocation": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" } - ] - } - ] - }, - "^GT_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^LT_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "number" - } - ] - }, - "^LE_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^EQ_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "array", - "items": { + }, + "WD_bizLocation": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "EQ_transformationID": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "MATCH_epc": { + "type": "array", + "items": { + "$ref": "#/definitions/id" + } + }, + "MATCH_parentID": { + "type": "array", + "items": { + "$ref": "#/definitions/id" + } + }, + "MATCH_inputEPC": { + "type": "array", + "items": { + "$ref": "#/definitions/id" + } + }, + "MATCH_outputEPC": { + "type": "array", + "items": { + "$ref": "#/definitions/id" + } + }, + "MATCH_anyEPC": { + "type": "array", + "items": { + "$ref": "#/definitions/id" + } + }, + "MATCH_epcClass": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "MATCH_inputEPCClass": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "MATCH_outputEPCClass": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "MATCH_anyEPCClass": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "EQ_eventID": { + "type": "array", + "items": { + "$ref": "#/definitions/eventID" + } + }, + "EXISTS_errorDeclaration": { + "type": "integer", + "nullable": true + }, + "GE_errorDeclaration_Time": { + "$ref": "#/definitions/time" + }, + "LT_errorDeclaration_Time": { + "$ref": "#/definitions/time" + }, + "EQ_errorReason": { + "type": "array", + "items": { + "$ref": "#/definitions/error-reason" + } + }, + "EQ_correctiveEventID": { + "type": "array", + "items": { + "$ref": "#/definitions/eventID" + } + }, + "orderBy": { "type": "string" - } - }, - { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + }, + "orderDirection": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ] + }, + "eventCountLimit": { + "type": "integer" + }, + "maxEventCount": { + "type": "integer" + }, + "EXISTS_sensorElementList": { + "type": "integer", + "nullable": true + }, + "GE_time": { + "$ref": "#/definitions/time" + }, + "LT_time": { + "$ref": "#/definitions/time" + }, + "GE_startTime": { + "$ref": "#/definitions/time" + }, + "LT_startTime": { + "$ref": "#/definitions/time" + }, + "GE_endTime": { + "$ref": "#/definitions/time" + }, + "LT_endTime": { + "$ref": "#/definitions/time" + }, + "EQ_type": { + "type": "array", + "items": { + "$ref": "#/definitions/measurementType" } - ] - } - ] - }, - "^GT_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "^GE_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + }, + "EQ_deviceID": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "EQ_dataProcessingMethod": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "EQ_microorganism": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "EQ_chemicalSubstance": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "EQ_bizRules": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "EQ_stringValue": { + "type": "array", + "items": { + "type": "string" + } + }, + "EQ_booleanValue": { + "type": "boolean" + }, + "EQ_hexBinaryValue": { + "type": "array", + "items": { + "type": "string" + } + }, + "EQ_uriValue": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "GE_percRank": { + "type": "number" + }, + "LT_percRank": { + "type": "number" + }, + "GT_percRank": { + "type": "number" + }, + "LE_percRank": { + "type": "number" } - ] }, - "^LT_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "number" + "patternProperties": { + "^EQ_bizTransaction_[a-z][a-zA-Z0-9_]+$": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "^EQ_source_[a-z][a-zA-Z0-9_]+$": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "^EQ_destination_[a-z][a-zA-Z0-9_]+$": { + "type": "array", + "items": { + "$ref": "#/definitions/uri" + } + }, + "^EQ_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EQ_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EQ_INNER_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_INNER_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_INNER_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_INNER_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_INNER_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EQ_INNER_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_INNER_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_INNER_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_INNER_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_INNER_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EXISTS_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "integer", + "nullable": true + }, + "^EXISTS_INNER_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "integer", + "nullable": true + }, + "^EXISTS_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "integer", + "nullable": true + }, + "^EXISTS_INNER_ILMD_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "integer", + "nullable": true + }, + "^HASATTR_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "array", + "items": { + "type": "string" + } + }, + "^EQ_ATTR_\\\\w+_\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^EQ_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EQ_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EXISTS_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "integer", + "nullable": true + }, + "^EXISTS_INNER_ERROR_DECLARATION_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "integer", + "nullable": true + }, + "^EQ_value_[A-Z0-9]{2,4}$": { + "type": "array", + "items": { + "type": "number" + } + }, + "^GE_minValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LT_minValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GT_minValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LE_minValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GE_maxValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LT_maxValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GT_maxValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LE_maxValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GE_meanValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LT_meanValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GT_meanValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LE_meanValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GE_sDev_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LT_sDev_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GT_sDev_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LE_sDev_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GE_value_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LT_value_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GT_value_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LE_value_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GE_percValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LT_percValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GT_percValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LE_percValue_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^EQ_quantity_[A-Z0-9]{2,4}$": { + "type": "array", + "items": { + "type": "number" + } + }, + "^GE_quantity_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LT_quantity_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^GT_quantity_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^LE_quantity_[A-Z0-9]{2,4}$": { + "type": "number" + }, + "^EQ_readPoint_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^EQ_INNER_readPoint_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^EQ_bizLocation_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^EQ_INNER_bizLocation_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^EQ_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EQ_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EQ_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EQ_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_INNER_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EQ_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_INNER_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EQ_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/string-array-or-date-string-or-number" + }, + "^GT_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^GE_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LT_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^LE_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "$ref": "#/definitions/date-string-or-number" + }, + "^EXISTS_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "integer", + "nullable": true + }, + "^EXISTS_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "integer", + "nullable": true + }, + "^EXISTS_SENSORREPORT_[a-z][a-zA-Z0-9]*\\\\:\\\\w+$": { + "type": "integer", + "nullable": true } - ] }, - "^LE_INNER_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string", - "format": "date-time" + "additionalProperties": false, + "definitions": { + "date-string-or-number": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "string-array-or-date-string-or-number": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/definitions/date-string-or-number" + } + ] + }, + "uri": { + "type": "string", + "format": "uri" + }, + "eventType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ObjectEvent", + "AggregationEvent", + "AssociationEvent", + "TransformationEvent", + "TransactionEvent" + ] + }, + { + "type": "string", + "format": "uri" + } + ] + }, + "disposition": { + "anyOf": [ + { + "$ref": "#/definitions/vocab-other-uri" + }, + { + "type": "string", + "enum": [ + "active", + "container_closed", + "damaged", + "destroyed", + "dispensed", + "disposed", + "encoded", + "expired", + "in_progress", + "in_transit", + "inactive", + "no_pedigree_match", + "non_sellable_other", + "partially_dispensed", + "recalled", + "reserved", + "retail_sold", + "returned", + "sellable_accessible", + "sellable_not_accessible", + "stolen", + "unknown", + "available", + "completeness_verified", + "completeness_inferred", + "conformant", + "container_open", + "mismatch_instance", + "mismatch_class", + "mismatch_quantity", + "needs_replacement", + "non_conformant", + "unavailable" + ] + } + ] + }, + "vocab-other-uri": { + "type": "string", + "format": "uri", + "pattern": "^(?!(urn:epcglobal:cbv|https?:\\/\\/ns\\.gs1\\.org/cbv\\/))" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "error-reason": { + "anyOf": [ + { + "$ref": "#/definitions/vocab-other-uri" + }, + { + "type": "string", + "enum": [ + "did_not_occur", + "incorrect_data" + ] + } + ] + }, + "eventID": { + "$ref": "#/definitions/uri" + }, + "vocab-uri": { + "type": "string", + "format": "uri" + }, + "decimal": { + "type": "number" + }, + "uom": { + "type": "string", + "pattern": "^[A-Z0-9]{2,3}$" + }, + "measurementType": { + "anyOf": [ + { + "$ref": "#/definitions/vocab-nonGS1WebVoc-uri" + }, + { + "type": "string", + "enum": [ + "AbsoluteHumidity", + "AbsorbedDose", + "AbsorbedDoseRate", + "Acceleration", + "Radioactivity", + "Altitude", + "AmountOfSubstance", + "AmountOfSubstancePerUnitVolume", + "Angle", + "AngularAcceleration", + "AngularMomentum", + "AngularVelocity", + "Area", + "Capacitance", + "Conductance", + "Conductivity", + "Count", + "Density", + "Dimensionless", + "DoseEquivalent", + "DoseEquivalentRate", + "DynamicViscosity", + "ElectricCharge", + "ElectricCurrent", + "ElectricCurrentDensity", + "ElectricFieldStrength", + "Energy", + "Exposure", + "Force", + "Frequency", + "Illuminance", + "Inductance", + "Irradiance", + "KinematicViscosity", + "Length", + "LinearMomentum", + "Luminance", + "LuminousFlux", + "LuminousIntensity", + "MagneticFlux", + "MagneticFluxDensity", + "MagneticVectorPotential", + "Mass", + "MassConcentration", + "MassFlowRate", + "MassPerAreaTime", + "MemoryCapacity", + "MolalityOfSolute", + "MolarEnergy", + "MolarMass", + "MolarVolume", + "Power", + "Pressure", + "RadiantFlux", + "RadiantIntensity", + "RelativeHumidity", + "Resistance", + "Resistivity", + "SolidAngle", + "SpecificVolume", + "Speed", + "SurfaceDensity", + "SurfaceTension", + "Temperature", + "Time", + "Torque", + "Voltage", + "Volume", + "VolumeFlowRate", + "VolumeFraction", + "VolumetricFlux", + "Wavenumber" + ] + } + ] + }, + "vocab-nonGS1WebVoc-uri": { + "type": "string", + "format": "uri", + "pattern": "^(?!(https?:\\/\\/gs1\\.org\\/voc\\/|https?:\\/\\/www\\.gs1\\.org\\/voc\\/))" + }, + "@context": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "object" + }, + { + "type": "array", + "uniqueItems": true, + "items": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "object" + } + ] + } + } + ] + }, + "action": { + "type": "string", + "enum": [ + "OBSERVE", + "ADD", + "DELETE" + ] + }, + "bizStep": { + "anyOf": [ + { + "$ref": "#/definitions/vocab-other-uri" + }, + { + "type": "string", + "enum": [ + "accepting", + "arriving", + "assembling", + "collecting", + "commissioning", + "consigning", + "creating_class_instance", + "cycle_counting", + "decommissioning", + "departing", + "destroying", + "disassembling", + "dispensing", + "encoding", + "entering_exiting", + "holding", + "inspecting", + "installing", + "killing", + "loading", + "other", + "packing", + "picking", + "receiving", + "removing", + "repackaging", + "repairing", + "replacing", + "reserving", + "retail_selling", + "shipping", + "staging_outbound", + "stock_taking", + "stocking", + "storing", + "transporting", + "unloading", + "unpacking", + "void_shipping", + "sensor_reporting", + "sampling" + ] + } + ] + }, + "id": { + "type": "string", + "format": "uri" } - ] - }, - "EXISTS_SENSORELEMENT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "integer", - "nullable": true - }, - "EXISTS_SENSORMETADATA_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "integer", - "nullable": true - }, - "EXISTS_SENSORREPORT_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "integer", - "nullable": true - }, - "^EQ_readPoint_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^EQ_INNER_readPoint_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^EQ_bizLocation_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } - }, - "^EQ_INNER_bizLocation_[a-z][a-zA-Z0-9]*\\:\\w+$": { - "type": "array", - "items": { - "type": "string" - } } - }, - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" -} \ No newline at end of file +} diff --git a/core/src/test/java/io/openepcis/validation/test/EPCISQueryTest.java b/core/src/test/java/io/openepcis/validation/test/EPCISQueryTest.java index 2b81752..7370ad0 100644 --- a/core/src/test/java/io/openepcis/validation/test/EPCISQueryTest.java +++ b/core/src/test/java/io/openepcis/validation/test/EPCISQueryTest.java @@ -54,7 +54,7 @@ void validLiteralQuery() { void literalQueryReportsEveryBadValue() { final List errors = validate(QUERIES_INVALID + "Query_with_invalid_values.json"); - assertEquals(2, errors.size()); // neither problem hides the other + assertEquals(3, errors.size()); // neither problem hides the other assertTrue(errors.stream().anyMatch(e -> "EQ_action".equals(e.getField()))); assertTrue(errors.stream().anyMatch(e -> "GE_eventTime".equals(e.getField()))); } diff --git a/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java b/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java index 4846147..0002f99 100644 --- a/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java +++ b/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java @@ -23,11 +23,14 @@ import io.openepcis.constants.EPCISVersion; import io.openepcis.resources.util.Commons; import io.openepcis.validation.SchemaValidator; +import io.openepcis.validation.config.ValidationDefaults; import io.openepcis.validation.model.ValidationError; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -50,7 +53,10 @@ class JsonValidationMessageTest { private List validate(final String resourcePath) { final InputStream document = Commons.getInputStream(resourcePath); assertNotNull(document, "missing shared test resource " + resourcePath); + return validate(document); + } + private List validate(final InputStream document) { final SchemaValidator validator = new SchemaValidator( mapper, JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) @@ -288,4 +294,39 @@ void printMessagesForReview() { errors.forEach(error -> System.out.printf("%-22s %-46s %s%n", error.getType(), error.getLocation(), error.getMessage())); assertTrue(errors.size() >= 5); } + + // Test if the validation message limit is working and capping correctly + @Test + @DisplayName("invalid: a document with more problems than the limit is capped and informs about the limit") + void errorListIsCappedTest() throws Exception { + final int events = ValidationDefaults.MAX_REPORTED_ERRORS + 500; + // Generate dummy event with more errors + final StringBuilder document = new StringBuilder() + .append("{\"@context\":[\"https://ref.gs1.org/standards/epcis/epcis-context.jsonld\"],") + .append("\"type\":\"EPCISDocument\",\"schemaVersion\":\"2.0\",") + .append("\"creationDate\":\"2026-07-22T10:30:00Z\",\"epcisBody\":{\"eventList\":["); + + // Generate eventList with number of events exceeding the MAX_REPORTED_ERRORS limit + for (int i = 0; i < events / 2; i++) { + if (i > 0) { + document.append(','); + } + // 2 clear mistake per event: bizStep is not a standard value and eventTime in wrong format + document.append("{\"type\":\"ObjectEvent\",\"eventTime\":\"2026-07-T06:45:00+02:00\",") + .append("\"eventTimeZoneOffset\":\"+02:00\",\"action\":\"ADD\",") + .append("\"bizStep\":\"transforming\",") + .append("\"epcList\":[\"https://id.gs1.org/01/09521141012345/21/2017\"]}"); + } + document.append("]}}"); + final List errors = validate(new ByteArrayInputStream(document.toString().getBytes(StandardCharsets.UTF_8))); + + // ValidationDefaults.MAX_REPORTED_ERRORS - 1 real problems plus one entry saying the list is incomplete + assertEquals(ValidationDefaults.MAX_REPORTED_ERRORS, errors.size()); + assertEquals("limitExceeded", errors.getLast().getType()); + assertTrue(errors.getLast().getMessage().contains(String.valueOf(ValidationDefaults.MAX_REPORTED_ERRORS - 1))); + + // the reported ones are the first events, in order + assertEquals("$.epcisBody.eventList[0].bizStep", errors.getFirst().getLocation()); + assertEquals("$.epcisBody.eventList[0].eventTime", errors.get(1).getLocation()); + } } diff --git a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java index 8245015..ab82b7c 100644 --- a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java +++ b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java @@ -68,6 +68,7 @@ static Stream validationTestCases() { Arguments.of("Simple Query document", QUERY_PATH + "/queryDocument.xml", QUERY, VERSION_2_0_0, 0), Arguments.of("Query document with multiple events", QUERY_PATH + "/QueryDocumentWithMultipleEvents.xml", QUERY, VERSION_2_0_0, 0), Arguments.of("Query document with missing value", QUERY_PATH + "/Invalid/QueryDocument_with_missing_value.xml", QUERY, VERSION_2_0_0, 1), + Arguments.of("Query document sent to the capture path", QUERY_PATH + "/queryDocument.xml", CAPTURE, VERSION_2_0_0, 1), // XML Queries Arguments.of("Literal query with GE_ and LT_ params", QUERY_PATH + "/Queries/SimpleEventQuery.xml", QUERY, VERSION_2_0_0, 0), diff --git a/quarkus/rest-app/src/main/resources/application.yml b/quarkus/rest-app/src/main/resources/application.yml index 3fb1afe..b6de923 100644 --- a/quarkus/rest-app/src/main/resources/application.yml +++ b/quarkus/rest-app/src/main/resources/application.yml @@ -3,8 +3,8 @@ quarkus: always-include: true http: limits: - # reject oversized bodies - max-body-size: ${QUARKUS_HTTP_LIMITS_MAX_BODY_SIZE:25M} + # reject large documents over 30MB if needed config it + max-body-size: ${QUARKUS_HTTP_LIMITS_MAX_BODY_SIZE:30M} cors: ~: true methods: GET,POST,OPTIONS diff --git a/rest-api/src/main/java/io/openepcis/validation/rest/resource/ValidationResource.java b/rest-api/src/main/java/io/openepcis/validation/rest/resource/ValidationResource.java index 16e7172..f6119c5 100644 --- a/rest-api/src/main/java/io/openepcis/validation/rest/resource/ValidationResource.java +++ b/rest-api/src/main/java/io/openepcis/validation/rest/resource/ValidationResource.java @@ -30,8 +30,12 @@ import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; + import java.io.InputStream; +import java.time.Duration; + import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.context.ManagedExecutor; import org.eclipse.microprofile.openapi.annotations.enums.ParameterIn; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; @@ -48,170 +52,174 @@ @Path("/api") @Tag( - name = "Event Validation", - description = "Validate event for EPCIS XML or JSON/JSON-LD document or event list.") + name = "Event Validation", + description = "Validate event for EPCIS XML or JSON/JSON-LD document or event list.") @Slf4j public class ValidationResource { - @Inject SchemaValidator schemaValidator; + @Inject + SchemaValidator schemaValidator; + + @Inject + ManagedExecutor managedExecutor; - @Inject ManagedExecutor managedExecutor; + @Inject + HttpHeaders httpHeaders; - @Inject HttpHeaders httpHeaders; + /** + * How long a single validation may take before the request is failed. + */ + @ConfigProperty(name = "validation.timeout", defaultValue = "PT30S") + Duration validationTimeout; - @APIResponses( - value = { - @APIResponse( - responseCode = "200", - description = "Successfully validated one or more EPCIS events", - content = @Content(example = "")), - @APIResponse( - responseCode = "400", - description = "An error occurred while validating EPCIS events", + @APIResponses( + value = { + @APIResponse( + responseCode = "200", + description = "Successfully validated one or more EPCIS events", + content = @Content(example = "")), + @APIResponse( + responseCode = "400", + description = "An error occurred while validating EPCIS events", + content = { + @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = ValidationError.class), + example = ResponseBodyExamples.RESPONSE_400_VALIDATION_ERRORS), + @Content( + mediaType = MediaType.APPLICATION_XML, + example = ResponseBodyExamples.RESPONSE_400_VALIDATION_ERRORS_XML, + schema = @Schema(implementation = ValidationResult.class)), + @Content( + mediaType = "application/problem+json", + schema = @Schema(type = SchemaType.ARRAY, implementation = ValidationError.class), + example = ResponseBodyExamples.RESPONSE_400_VALIDATION_ERRORS) + }), + @APIResponse( + responseCode = "401", + description = "Authorization information is missing or invalid.", + content = + @Content( + schema = @Schema(implementation = ProblemResponseBody.class), + example = ResponseBodyExamples.RESPONSE_401_UNAUTHORIZED_REQUEST)), + @APIResponse( + responseCode = "403", + description = "Client is unauthorized to access this resource.", + content = + @Content( + schema = @Schema(implementation = ProblemResponseBody.class), + example = ResponseBodyExamples.RESPONSE_403_CLIENT_UNAUTHORIZED)), + @APIResponse( + responseCode = "406", + description = "The server cannot return the response as requested.", + content = + @Content( + schema = @Schema(implementation = ProblemResponseBody.class), + example = ResponseBodyExamples.RESPONSE_406_NOT_ACCEPTABLE)), + @APIResponse( + responseCode = "500", + description = "An error occurred on the backend.", + content = + @Content( + schema = @Schema(implementation = ProblemResponseBody.class), + example = ResponseBodyExamples.RESPONSE_500_IMPLEMENTATION_EXCEPTION)), + @APIResponse( + responseCode = "415", + description = "The client sent data in a format that is not supported by the server", + content = + @Content( + schema = @Schema(implementation = ProblemResponseBody.class), + example = ResponseBodyExamples.RESPONSE_415_UNSUPPORTED_MEDIA_TYPE)) + }) + @POST + @Path("events/validate") + @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, "application/problem+json"}) + @RequestBody( + description = "Validate EPCIS 2.0 JSON/JSON-LD/XML or EPCIS 1.2 XML document.", content = { - @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(type = SchemaType.ARRAY, implementation = ValidationError.class), - example = ResponseBodyExamples.RESPONSE_400_VALIDATION_ERRORS), - @Content( - mediaType = MediaType.APPLICATION_XML, - example = ResponseBodyExamples.RESPONSE_400_VALIDATION_ERRORS_XML, - schema = @Schema(implementation = ValidationResult.class)), - @Content( - mediaType = "application/problem+json", - schema = @Schema(type = SchemaType.ARRAY, implementation = ValidationError.class), - example = ResponseBodyExamples.RESPONSE_400_VALIDATION_ERRORS) - }), - @APIResponse( - responseCode = "401", - description = "Authorization information is missing or invalid.", - content = - @Content( - schema = @Schema(implementation = ProblemResponseBody.class), - example = ResponseBodyExamples.RESPONSE_401_UNAUTHORIZED_REQUEST)), - @APIResponse( - responseCode = "403", - description = "Client is unauthorized to access this resource.", - content = - @Content( - schema = @Schema(implementation = ProblemResponseBody.class), - example = ResponseBodyExamples.RESPONSE_403_CLIENT_UNAUTHORIZED)), - @APIResponse( - responseCode = "406", - description = "The server cannot return the response as requested.", - content = - @Content( - schema = @Schema(implementation = ProblemResponseBody.class), - example = ResponseBodyExamples.RESPONSE_406_NOT_ACCEPTABLE)), - @APIResponse( - responseCode = "500", - description = "An error occurred on the backend.", - content = - @Content( - schema = @Schema(implementation = ProblemResponseBody.class), - example = ResponseBodyExamples.RESPONSE_500_IMPLEMENTATION_EXCEPTION)), - @APIResponse( - responseCode = "415", - description = "The client sent data in a format that is not supported by the server", - content = - @Content( - schema = @Schema(implementation = ProblemResponseBody.class), - example = ResponseBodyExamples.RESPONSE_415_UNSUPPORTED_MEDIA_TYPE)) - }) - @POST - @Path("events/validate") - @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, "application/problem+json"}) - @RequestBody( - description = "Validate EPCIS 2.0 JSON/JSON-LD/XML or EPCIS 1.2 XML document.", - content = { - @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = EPCISDocument.class), - examples = { - @ExampleObject( - name = "EPCIS 2.0 JSON document", - ref = "jsonDocument", - description = "Example EPCIS 2.0 document in JSON format.") - }), - @Content( - mediaType = MediaType.APPLICATION_XML, - schema = @Schema(implementation = EPCISDocument.class), - examples = { - @ExampleObject( - name = "EPCIS 1.2 XML document", - ref = "xml1.2Document", - description = "Example EPCIS 1.2 document"), - @ExampleObject( - name = "EPCIS 2.0 XML document", - ref = "xmlDocument", - description = "Example EPCIS 2.0 document in XML format.") - }), - @Content( - mediaType = "application/ld+json", - schema = @Schema(implementation = EPCISDocument.class), - examples = { - @ExampleObject( - name = "EPCIS 2.0 JSON document", - ref = "jsonDocument", - description = "Example EPCIS 2.0 document in JSON format.") + @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = EPCISDocument.class), + examples = { + @ExampleObject( + name = "EPCIS 2.0 JSON document", + ref = "jsonDocument", + description = "Example EPCIS 2.0 document in JSON format.") + }), + @Content( + mediaType = MediaType.APPLICATION_XML, + schema = @Schema(implementation = EPCISDocument.class), + examples = { + @ExampleObject( + name = "EPCIS 1.2 XML document", + ref = "xml1.2Document", + description = "Example EPCIS 1.2 document"), + @ExampleObject( + name = "EPCIS 2.0 XML document", + ref = "xmlDocument", + description = "Example EPCIS 2.0 document in XML format.") + }), + @Content( + mediaType = "application/ld+json", + schema = @Schema(implementation = EPCISDocument.class), + examples = { + @ExampleObject( + name = "EPCIS 2.0 JSON document", + ref = "jsonDocument", + description = "Example EPCIS 2.0 document in JSON format.") + }) }) - }) - @Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, "application/ld+json"}) - public Uni> validateEvents( - @Parameter( - name = "GS1-EPCIS-Version", - description = "The EPCIS version", - examples = { - @ExampleObject(name = "EPCIS 2.0", value = "2.0.0"), - @ExampleObject(name = "EPCIS 1.2", value = "1.2.0") - }, - in = ParameterIn.HEADER) - @RestHeader(value = "GS1-EPCIS-Version") - @DefaultValue("2.0.0") - String epcisVersion, - @QueryParam("epcisDocumentSchema") - @DefaultValue(EPCIS.CAPTURE) - @Schema( - type = SchemaType.STRING, - enumeration = {EPCIS.CAPTURE, EPCIS.QUERY}) - String epcisDocumentSchema, - @Valid InputStream body) { + @Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, "application/ld+json"}) + public Uni> validateEvents( + @Parameter( + name = "GS1-EPCIS-Version", + description = "The EPCIS version", + examples = { + @ExampleObject(name = "EPCIS 2.0", value = "2.0.0"), + @ExampleObject(name = "EPCIS 1.2", value = "1.2.0") + }, + in = ParameterIn.HEADER) + @RestHeader(value = "GS1-EPCIS-Version") + @DefaultValue("2.0.0") + String epcisVersion, - final String contentType = httpHeaders.getHeaderString(HttpHeaders.CONTENT_TYPE); - final String accept = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); - return schemaValidator - .validate( - body, - contentType, - EPCISDocumentType.fromString(epcisDocumentSchema), - EPCISVersion.fromString(epcisVersion).get()) - .runSubscriptionOn(managedExecutor) - .collect() - .asList() - .chain( - validationErrors -> { - if (validationErrors.isEmpty()) { - return Uni.createFrom().item(RestResponse.ok()); - } - if (MediaType.APPLICATION_XML.equals(accept)) { - return Uni.createFrom() - .item( - RestResponse.status( - Response.Status.BAD_REQUEST, new ValidationResult(validationErrors))); - } - return Uni.createFrom() - .item(RestResponse.status(Response.Status.BAD_REQUEST, validationErrors)); - }) - .onFailure() - .recoverWithItem(this::createErrorResponse); - } + @QueryParam("epcisDocumentSchema") + @DefaultValue(EPCIS.CAPTURE) + @Schema(type = SchemaType.STRING, enumeration = {EPCIS.CAPTURE, EPCIS.QUERY}) + String epcisDocumentSchema, + + @Valid InputStream body) { + + final String contentType = httpHeaders.getHeaderString(HttpHeaders.CONTENT_TYPE); + final String accept = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); + final EPCISDocumentType documentType = EPCISDocumentType.fromString(epcisDocumentSchema); + final EPCISVersion documentVersion = EPCISVersion.fromString(epcisVersion).orElse(EPCISVersion.VERSION_2_0_0); + + return schemaValidator + .validate(body, contentType, documentType, documentVersion) + .runSubscriptionOn(managedExecutor) + .collect().asList() + .ifNoItem().after(validationTimeout).fail() + .chain(validationErrors -> { + if (validationErrors.isEmpty()) { + return Uni.createFrom().item(RestResponse.ok()); + } + + if (MediaType.APPLICATION_XML.equals(accept)) { + return Uni.createFrom().item(RestResponse.status(Response.Status.BAD_REQUEST, new ValidationResult(validationErrors))); + } + return Uni.createFrom().item(RestResponse.status(Response.Status.BAD_REQUEST, validationErrors)); + }) + .onFailure() + .recoverWithItem(this::createErrorResponse); + } - private RestResponse createErrorResponse(Throwable throwable) { - final ProblemResponseBody responseBody = new ProblemResponseBody(); - responseBody.setType(throwable.getClass().getSimpleName()); - responseBody.title(Response.Status.UNSUPPORTED_MEDIA_TYPE.getReasonPhrase()); - responseBody.setStatus(Response.Status.UNSUPPORTED_MEDIA_TYPE.getStatusCode()); - responseBody.setDetail(throwable.getMessage()); - return RestResponse.status(Response.Status.UNSUPPORTED_MEDIA_TYPE, responseBody); - } + private RestResponse createErrorResponse(Throwable throwable) { + final ProblemResponseBody responseBody = new ProblemResponseBody(); + responseBody.setType(throwable.getClass().getSimpleName()); + responseBody.title(Response.Status.UNSUPPORTED_MEDIA_TYPE.getReasonPhrase()); + responseBody.setStatus(Response.Status.UNSUPPORTED_MEDIA_TYPE.getStatusCode()); + responseBody.setDetail(throwable.getMessage()); + return RestResponse.status(Response.Status.UNSUPPORTED_MEDIA_TYPE, responseBody); + } } diff --git a/servlet-api/src/main/java/io/openepcis/validation/servlet/ValidationServlet.java b/servlet-api/src/main/java/io/openepcis/validation/servlet/ValidationServlet.java index 0134164..3ca6516 100644 --- a/servlet-api/src/main/java/io/openepcis/validation/servlet/ValidationServlet.java +++ b/servlet-api/src/main/java/io/openepcis/validation/servlet/ValidationServlet.java @@ -32,78 +32,84 @@ import jakarta.ws.rs.core.MediaType; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Marshaller; + import java.io.IOException; import java.time.Duration; -import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.List; import java.util.Optional; @WebServlet(name = "ValidationServlet", urlPatterns = "/api/events/validate") public class ValidationServlet extends HttpServlet { - private static final List PRODUCES = - Arrays.asList( - MediaType.WILDCARD, - MediaType.APPLICATION_JSON, - MediaType.APPLICATION_XML, - "application/problem+json"); + private static final List PRODUCES = Arrays.asList( + MediaType.WILDCARD, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_XML, + "application/problem+json" + ); - private static final List CONSUMES = - Arrays.asList(MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, "application/ld+json"); + private static final List CONSUMES = Arrays.asList( + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_XML, + "application/ld+json" + ); - @Inject SchemaValidator schemaValidator; + @Inject + SchemaValidator schemaValidator; - @Inject ServletSupport servletSupport; + @Inject + ServletSupport servletSupport; - @Inject Marshaller marshaller; + @Inject + Marshaller marshaller; + + // How long the blocking servlet path waits. Generally within 10s but kept 30s + private static final Duration VALIDATION_TIMEOUT = Duration.ofSeconds(30); + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + + final Optional accept = servletSupport.accept(PRODUCES, req, resp); + if (accept.isEmpty()) return; + + final Optional contentType = servletSupport.contentType(CONSUMES, accept.get(), req, resp); + if (contentType.isEmpty()) return; + + final String epcisVersion = Optional.ofNullable(req.getHeader("GS1-EPCIS-Version")).orElse("2.0.0"); + final String epcisDocumentSchema = req.getParameter("epcisDocumentSchema"); - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) - throws ServletException, IOException { - final Optional accept = servletSupport.accept(PRODUCES, req, resp); - if (accept.isEmpty()) { - return; - } - final Optional contentType = - servletSupport.contentType(CONSUMES, accept.get(), req, resp); - if (contentType.isEmpty()) { - return; - } - final String epcisVersion = - Optional.ofNullable(req.getHeader("GS1-EPCIS-Version")).orElse("2.0.0"); - - final String epcisDocumentSchema = req.getParameter("epcisDocumentSchema"); - try { - final List validationErrors = - schemaValidator - .validate( - req.getInputStream(), - contentType.get(), - EPCISDocumentType.fromString(epcisDocumentSchema), - EPCISVersion.fromString(epcisVersion).get()) - .collect() - .asList() - .await() - .atMost(Duration.of(10, ChronoUnit.SECONDS)); - if (validationErrors.isEmpty()) { - resp.setHeader(HttpHeaders.CONTENT_TYPE, accept.get()); - resp.setStatus(HttpServletResponse.SC_OK); - return; - } - resp.setHeader(HttpHeaders.CONTENT_TYPE, accept.get()); - resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); - if (MediaType.APPLICATION_XML.equals(accept.get())) { - final ValidationResult validationResult = new ValidationResult(validationErrors); try { - marshaller.marshal(validationResult, resp.getOutputStream()); - } catch (JAXBException e) { - throw new RuntimeException(e); + final EPCISDocumentType documentType = EPCISDocumentType.fromString(epcisDocumentSchema); + final EPCISVersion documentVersion = EPCISVersion.fromString(epcisVersion).orElse(EPCISVersion.VERSION_2_0_0); + + final List validationErrors = + schemaValidator.validate(req.getInputStream(), contentType.get(), documentType, documentVersion) + .collect() + .asList() + .await() + .atMost(VALIDATION_TIMEOUT); + + if (validationErrors.isEmpty()) { + resp.setHeader(HttpHeaders.CONTENT_TYPE, accept.get()); + resp.setStatus(HttpServletResponse.SC_OK); + return; + } + + resp.setHeader(HttpHeaders.CONTENT_TYPE, accept.get()); + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + + if (MediaType.APPLICATION_XML.equals(accept.get())) { + final ValidationResult validationResult = new ValidationResult(validationErrors); + try { + marshaller.marshal(validationResult, resp.getOutputStream()); + } catch (JAXBException e) { + throw new RuntimeException(e); + } + } else { + servletSupport.writeJson(resp, validationErrors); + } + } catch (Exception e) { + servletSupport.writeException(new InternalServerErrorException(), accept.get(), resp); } - } else { - servletSupport.writeJson(resp, validationErrors); - } - } catch (Exception e) { - servletSupport.writeException(new InternalServerErrorException(), accept.get(), resp); } - } } From ca59017edba026b1880e0c0f2b2d9704e69fda86 Mon Sep 17 00:00:00 2001 From: Aravinda Baliga B Date: Thu, 6 Aug 2026 16:05:38 +0200 Subject: [PATCH 4/7] provide XML and JSON validation errors the field that matches their valdiation rules and also add suggestions to xml --- .../message/JsonErrorFormatter.java | 4 +- .../validation/model/ValidationError.java | 23 +++-- .../validation/xml/XmlErrorHandler.java | 86 +++++++++++++++++-- .../test/JsonValidationMessageTest.java | 7 ++ .../test/XmlValidationMessageTest.java | 80 ++++++++++++++++- 5 files changed, 181 insertions(+), 19 deletions(-) diff --git a/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java b/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java index e5f0915..4ee6d24 100644 --- a/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java +++ b/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java @@ -248,7 +248,7 @@ private ValidationError buildRequiredGroup( final EventContext context) { final ValidationError error = new ValidationError(); error.setType("required"); - error.setLine(siblings.get(0).getSchemaLocation().toString()); + error.setSchemaPath(siblings.getFirst().getSchemaLocation().toString()); error.setLocation(absolute(location, context)); // full document path even while streaming final String joined = String.join(", ", fields); @@ -272,7 +272,7 @@ private ValidationError build(final JsonNode document, final ValidationError error = new ValidationError(); error.setType(primary.getType()); // unchanged, the front end may switch on it - error.setLine(primary.getSchemaLocation().toString()); // unchanged + error.setSchemaPath(primary.getSchemaLocation().toString()); // unchanged error.setLocation(absolute(location, context)); // full document path even while streaming // the field name is the last path segment, except for required, additionalProperties and diff --git a/core/src/main/java/io/openepcis/validation/model/ValidationError.java b/core/src/main/java/io/openepcis/validation/model/ValidationError.java index 9c21de3..7e0f1ac 100644 --- a/core/src/main/java/io/openepcis/validation/model/ValidationError.java +++ b/core/src/main/java/io/openepcis/validation/model/ValidationError.java @@ -16,7 +16,10 @@ package io.openepcis.validation.model; import com.fasterxml.jackson.annotation.JsonInclude; -import jakarta.xml.bind.annotation.*; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlElementWrapper; +import jakarta.xml.bind.annotation.XmlRootElement; +import jakarta.xml.bind.annotation.XmlType; import lombok.Data; import org.apache.commons.lang3.StringUtils; @@ -29,20 +32,30 @@ @XmlType( name = "ValidationError", propOrder = { - "type", "line", "location", "message", - "field", "value", "eventIndex", "eventType", "suggestion", "allowedValues" + "type", "line", "eventIndex", "eventType", "column", "location", + "schemaPath", "message", "field", "value", "suggestion", "allowedValues" }) public class ValidationError { - @XmlAttribute + @XmlElement private String type; + // XML only: line number in submitted document + @XmlElement + private Integer line; + + // XML only: column number of the error @XmlElement - private String line; + private Integer column; + // JSON only: problem in document like $.epcisBody.eventList[5].bizStep @XmlElement private String location; + // Which schema rules got rejected, for diagnostics only not for the display on UI + @XmlElement + private String schemaPath; + @XmlElement private String message; diff --git a/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java b/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java index 38ed6be..164831d 100644 --- a/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java +++ b/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java @@ -17,14 +17,16 @@ import io.openepcis.validation.config.ValidationDefaults; import io.openepcis.validation.message.MessageTemplates; +import io.openepcis.validation.message.ValueSuggester; import io.openepcis.validation.model.ValidationError; import lombok.Getter; +import org.apache.commons.lang3.StringUtils; import org.xml.sax.ErrorHandler; import org.xml.sax.SAXParseException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * custom error handler while validating xml against xsd @@ -35,6 +37,13 @@ public class XmlErrorHandler implements ErrorHandler { // Max number of validation errors that can be added private final int maxReportedErrors; + // Pattern to extract the values from Xerces validation errors + private static final Pattern ENUM_VALUES = Pattern.compile("enumeration '\\[([^\\]]*)]'"); + private static final Pattern FACET_VALUE = Pattern.compile("Value '([^']*)' is not"); + private static final Pattern DATATYPE_VALUE = Pattern.compile("'([^']*)' is not a valid value for"); + private static final Pattern VALUE_OF_ELEMENT = Pattern.compile("The value '([^']*)' of element '([^']*)' is not valid"); + private static final Pattern ELEMENT_NAME = Pattern.compile("element '\\{?[^']*?[:\"]?([A-Za-z_][A-Za-z0-9_.\\-]*)}?'"); + public XmlErrorHandler() { this(ValidationDefaults.MAX_REPORTED_ERRORS); } @@ -71,13 +80,15 @@ public void handleError(final SAXParseException e) { } final String raw = e.getMessage() == null ? "" : e.getMessage(); // never NPE on a null message - final String line = Integer.toString(e.getLineNumber()); - final String column = Integer.toString(e.getColumnNumber()); + final Integer line = e.getLineNumber() > 0 ? e.getLineNumber() : null; + final Integer column = e.getColumnNumber() > 0 ? e.getColumnNumber() : null; - // Xerces reports one mistake twice, for example "not facet-valid" then "value of element is - // not valid". Same line and column is the same mistake, so keep the first, more specific one. + // Xerces reports one mistake twice, for example "not facet-valid" then "value of element is not valid". + // Same line and column is the same mistake, so keep the first, more specific one. for (final ValidationError existing : this.exceptions) { - if (line.equals(existing.getLine()) && column.equals(existing.getLocation())) { + if (Objects.equals(line, existing.getLine()) && Objects.equals(column, existing.getColumn())) { + // duplicate contains the values and messages so extract the values to include in errors + enrich(existing, raw); return; } } @@ -85,11 +96,19 @@ public void handleError(final SAXParseException e) { final ValidationError errorFormat = new ValidationError(); errorFormat.setType(typeOf(raw)); // a real type, instead of always reporting "required" errorFormat.setLine(line); // unchanged, the front end uses this for XML highlighting - errorFormat.setLocation(column); // unchanged + errorFormat.setColumn(column); // unchanged + errorFormat.setSchemaPath(schemaRuleOf(raw)); //the cvc code, for diagnostics errorFormat.setMessage(errorFormat.filterErrorMessage(raw)); + enrich(errorFormat, raw); // extract the values and field names from the raw message this.exceptions.add(errorFormat); } + //The leading W3C rule code, for example cvc-enumeration-valid, kept for diagnostics. + private String schemaRuleOf(final String raw) { + final int colon = raw.indexOf(':'); + return colon > 0 ? raw.substring(0, colon) : null; + } + /** * Maps the leading W3C XSD rule code to a response type via the catalogue. Longest match wins. */ @@ -118,4 +137,53 @@ private ValidationError limitExceeded(final int limit) { } + // Get the values matching the error model to populate in errors from the Xerces validation errors + private void enrich(final ValidationError error, final String raw) { + if (StringUtils.isBlank(error.getValue())) { + // User provided value which is invalid + error.setValue(firstGroup(FACET_VALUE, raw)); + } + if (StringUtils.isBlank(error.getValue())) { + // 'not-a-date' is not a valid value for + error.setValue(firstGroup(DATATYPE_VALUE, raw)); + } + + final Matcher valueOfElement = VALUE_OF_ELEMENT.matcher(raw); + if (valueOfElement.find()) { + if (StringUtils.isBlank(error.getValue())) { + error.setValue(valueOfElement.group(1)); + } + if (StringUtils.isBlank(error.getField())) { + error.setField(valueOfElement.group(2)); // the element name, only this phrasing has it + } + } + if (StringUtils.isBlank(error.getField())) { + error.setField(firstGroup(ELEMENT_NAME, raw)); + } + + if (error.getAllowedValues() == null) { + final String list = firstGroup(ENUM_VALUES, raw); + if (list != null) { + final List allowed = Arrays.stream(list.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .toList(); + if (!allowed.isEmpty()) { + error.setAllowedValues(allowed); + } + } + } + + // only worth suggesting once both halves are known, and never overwrite an existing one + if (error.getSuggestion() == null && error.getValue() != null && error.getAllowedValues() != null) { + error.setSuggestion(ValueSuggester.closest(error.getValue(), error.getAllowedValues())); + } + } + + + private String firstGroup(final Pattern pattern, final String raw) { + final Matcher matcher = pattern.matcher(raw); + return matcher.find() ? matcher.group(1) : null; + } + } diff --git a/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java b/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java index 0002f99..ab925be 100644 --- a/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java +++ b/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java @@ -108,6 +108,13 @@ void wrongBizStepGivesSingleFriendlyError() { assertEquals(1, errors.size()); final ValidationError error = errors.getFirst(); + // line is a line number or nothing at all: JSON line numbers belong to the client, which owns + // the text it renders. The schema rule that rejected the value travels in schemaPath. + assertNull(error.getLine()); + assertNull(error.getColumn()); + assertNotNull(error.getSchemaPath()); + assertTrue(error.getSchemaPath().contains("/definitions/bizStep")); + assertEquals( "\"transforming\" is not a standard EPCIS business step for bizStep in event 1 " + "(ObjectEvent). Did you mean \"transporting\"? Use one of the 41 standard steps " diff --git a/core/src/test/java/io/openepcis/validation/test/XmlValidationMessageTest.java b/core/src/test/java/io/openepcis/validation/test/XmlValidationMessageTest.java index cd84f93..63193eb 100644 --- a/core/src/test/java/io/openepcis/validation/test/XmlValidationMessageTest.java +++ b/core/src/test/java/io/openepcis/validation/test/XmlValidationMessageTest.java @@ -174,10 +174,35 @@ void ruleCodePrefixIsRemoved() { void lineAndColumnArePresent() { final ValidationError error = validateCapture(INVALID + "ObjectEvent_with_unknown_element.xml").getFirst(); // for XML the front end reads line, and location carries the column + System.out.println(error); assertNotNull(error.getLine()); - assertNotNull(error.getLocation()); - assertTrue(Integer.parseInt(error.getLine()) > 0); - assertTrue(Integer.parseInt(error.getLocation()) > 0); + assertNotNull(error.getColumn()); + assertTrue(error.getLine() > 0); + assertTrue(error.getColumn() > 0); + } + + @Test + @DisplayName("Check if proper field name and value are included in error") + void checkFieldAndValueTest() { + // Confirm field name and value is included in error + final ValidationError error = validateCapture(INVALID + "ObjectEvent_with_invalid_eventTime_format.xml").getFirst(); + assertNotNull(error.getField()); + assertNotNull(error.getValue()); + assertNotNull(error.getLine()); + assertNotNull(error.getColumn()); + } + + @Test + @DisplayName("Check if proper field , value and suggestions are included in error") + void checkFieldValueAndSuggestionTest() { + // Confirm field name and value is included in error + final ValidationError error = validateCapture(INVALID + "ObjectEvent_with_invalid_action.xml").getFirst(); + assertNotNull(error.getField()); + assertNotNull(error.getValue()); + assertNotNull(error.getAllowedValues()); + assertFalse(error.getAllowedValues().isEmpty()); + assertNotNull(error.getLine()); + assertNotNull(error.getColumn()); } @Test @@ -187,6 +212,55 @@ void bareEventStillValidates() { assertEquals(1, validateCapture(INVALID + "ObjectEvent_bare_event_without_action.xml").size()); } + // --------------------------------------------------------------------------- + // The reported fields must mean the same thing for XML as they do for JSON + // --------------------------------------------------------------------------- + + @Test + @DisplayName("invalid: an XML enum failure carries the field, the value and the accepted values") + void xmlEnumErrorCarriesFieldValueAndAllowedValues() { + final ValidationError error = validateCapture(INVALID + "ObjectEvent_with_invalid_action.xml").getFirst(); + + assertEquals("enum", error.getType()); + // the element name only appears in the duplicate message Xerces emits, so this proves the + // duplicate is mined for detail instead of being discarded + assertEquals("action", error.getField()); + assertEquals("CREATE", error.getValue()); + assertEquals(List.of("ADD", "OBSERVE", "DELETE"), error.getAllowedValues()); + } + + @Test + @DisplayName("invalid: a near miss XML value suggests the closest accepted value") + void xmlNearMissSuggestsClosestValue() { + final ValidationError error = validateCapture(INVALID + "ObjectEvent_with_near_miss_action.xml").getFirst(); + + assertEquals("OBSERV", error.getValue()); + assertEquals("OBSERVE", error.getSuggestion()); // one edit away, so worth offering + } + + @Test + @DisplayName("invalid: a distant XML value offers no suggestion rather than a guess") + void xmlDistantValueOffersNoSuggestion() { + final ValidationError error = validateCapture(INVALID + "ObjectEvent_with_invalid_action.xml").getFirst(); + + // CREATE is four edits from DELETE, past the threshold, so suggesting anything would be noise + assertNull(error.getSuggestion()); + } + + @Test + @DisplayName("invalid: line and column are numbers and the schema rule travels in schemaPath") + void xmlErrorReportsRealLineAndColumn() { + final ValidationError error = validateCapture(INVALID + "ObjectEvent_with_invalid_action.xml").getFirst(); + + // line used to hold a schema URI for JSON and a line number for XML; it is now always a number + assertNotNull(error.getLine()); + assertTrue(error.getLine() > 0, "line must be a real line number"); + assertNotNull(error.getColumn()); + // the instance path is a JSON concept, so XML leaves it empty rather than smuggling a column in + assertNull(error.getLocation()); + assertEquals("cvc-enumeration-valid", error.getSchemaPath()); + } + /** * Prints the XML messages so the current wording can be reviewed without starting the service. */ From b6dac23d3bea2b57a74600edf756ac98c9567759 Mon Sep 17 00:00:00 2001 From: Aravinda Baliga B Date: Fri, 7 Aug 2026 22:41:50 +0200 Subject: [PATCH 5/7] Remove Lombok and move CI to JDK 25 --- .github/workflows/build.yml | 4 +- core/pom.xml | 6 - .../validation/config/ValidationDefaults.java | 9 +- .../validation/json/JsonSchemaValidator.java | 2 - .../validation/message/MessageTemplates.java | 44 +--- .../validation/message/ValueSuggester.java | 15 +- .../validation/model/ValidationError.java | 207 ++++++++++++++++-- .../model/xml/ValidationResult.java | 52 ++++- .../validation/xml/XmlErrorHandler.java | 36 +-- .../validation/xml/resolver/LSInputImpl.java | 64 +++++- .../rest/resource/ValidationResource.java | 9 +- 11 files changed, 319 insertions(+), 129 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62bb03a..94c9ce4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,10 +23,10 @@ jobs: - name: Check out the code uses: actions/checkout@v4 - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@v4 with: - java-version: '21' + java-version: '25' distribution: 'temurin' cache: maven diff --git a/core/pom.xml b/core/pom.xml index 636ec1b..2d603ea 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -80,12 +80,6 @@ quarkus-core - - - org.projectlombok - lombok - - diff --git a/core/src/main/java/io/openepcis/validation/config/ValidationDefaults.java b/core/src/main/java/io/openepcis/validation/config/ValidationDefaults.java index fb24a61..c869957 100644 --- a/core/src/main/java/io/openepcis/validation/config/ValidationDefaults.java +++ b/core/src/main/java/io/openepcis/validation/config/ValidationDefaults.java @@ -15,17 +15,14 @@ */ package io.openepcis.validation.config; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - /** * Configuration for validation settings - **/ -@NoArgsConstructor(access = AccessLevel.PRIVATE) + */ public class ValidationDefaults { - // Most problems reported per document; the last slot says the limit was reached. public static final String MAX_REPORTED_ERRORS_TEXT = "1000"; public static final int MAX_REPORTED_ERRORS = Integer.parseInt(MAX_REPORTED_ERRORS_TEXT); + private ValidationDefaults() { + } } diff --git a/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java b/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java index 9f06be8..6042825 100644 --- a/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java +++ b/core/src/main/java/io/openepcis/validation/json/JsonSchemaValidator.java @@ -34,7 +34,6 @@ import io.openepcis.validation.model.ValidationError; import io.quarkus.runtime.annotations.RegisterForReflection; import io.smallrye.mutiny.Multi; -import lombok.extern.slf4j.Slf4j; import java.io.BufferedInputStream; import java.io.IOException; @@ -46,7 +45,6 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; -@Slf4j @RegisterForReflection(targets = JsonSchemaFactory.class) public class JsonSchemaValidator implements Validator { diff --git a/core/src/main/java/io/openepcis/validation/message/MessageTemplates.java b/core/src/main/java/io/openepcis/validation/message/MessageTemplates.java index 5307848..b0573e5 100644 --- a/core/src/main/java/io/openepcis/validation/message/MessageTemplates.java +++ b/core/src/main/java/io/openepcis/validation/message/MessageTemplates.java @@ -19,8 +19,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.networknt.schema.ValidationMessage; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; import org.apache.commons.collections4.MapUtils; import java.io.IOException; @@ -31,22 +29,14 @@ /** * Reads the message catalogue and decides which sentence describes a validation failure. */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) public class MessageTemplates { - // Read the catalog of error validation messages private static final String LOCATION = "/validation-messages/epcis-validation-messages.yaml"; - // Loaded exactly once, when this class is first touched. private static final Catalogue CATALOGUE = load(); // Hold the 4 types of validation message from epcis-validation-messages.yaml - private record Catalogue( - Map schemaLocations, - Map keywords, - Map fragments, - Map xsdTypes - ) { + private record Catalogue(Map schemaLocations, Map keywords, Map fragments, Map xsdTypes) { } private static Catalogue load() { @@ -54,24 +44,14 @@ private static Catalogue load() { if (stream == null) { throw new IllegalStateException("Validation message catalogue not found at " + LOCATION); } - - final Catalogue parsed = new ObjectMapper(new YAMLFactory()) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .readValue(stream, Catalogue.class); - + final Catalogue parsed = new ObjectMapper(new YAMLFactory()).disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(stream, Catalogue.class); // rebuild with truly immutable maps, no wrapper indirection - return new Catalogue( - immutable(parsed.schemaLocations()), - immutable(parsed.keywords()), - immutable(parsed.fragments()), - immutable(parsed.xsdTypes())); - + return new Catalogue(immutable(parsed.schemaLocations()), immutable(parsed.keywords()), immutable(parsed.fragments()), immutable(parsed.xsdTypes())); } catch (IOException e) { throw new UncheckedIOException("Could not read validation message catalogue", e); } } - // Map.copyOf gives a real immutable map. A missing YAML section becomes empty, never null private static Map immutable(final Map source) { return MapUtils.isEmpty(source) ? Map.of() : Map.copyOf(source); @@ -85,30 +65,25 @@ public static String templateFor(final ValidationMessage message) { final int hash = location.indexOf('#'); String pointer = hash < 0 ? "#" : location.substring(hash); final String keyword = message.getType(); - while (true) { String template = CATALOGUE.schemaLocations().get(pointer + "." + keyword); if (template != null) { return template; } - template = CATALOGUE.schemaLocations().get(pointer); if (template != null) { return template; } - final int slash = pointer.lastIndexOf('/'); if (slash <= 0) { break; } pointer = pointer.substring(0, slash); } - final String byMessageKey = CATALOGUE.keywords().get(message.getMessageKey()); return byMessageKey != null ? byMessageKey : CATALOGUE.keywords().get(keyword); } - /** * A keyword template by name, for synthetic messages such as required.alternatives. */ @@ -152,14 +127,11 @@ public static String tidy(final String message) { if (message == null) { return null; } - final StringBuilder out = new StringBuilder(message.length()); boolean previousWasSpace = false; - for (int i = 0; i < message.length(); i++) { final char c = message.charAt(i); final boolean isSpace = c == ' ' || c == '\t'; - if (isSpace) { if (!previousWasSpace) { out.append(' '); // keep the first space of a run, drop the rest @@ -169,7 +141,6 @@ public static String tidy(final String message) { } previousWasSpace = isSpace; } - int start = 0; // trim leading spaces without allocating another String int end = out.length(); while (start < end && out.charAt(start) == ' ') { @@ -181,7 +152,6 @@ public static String tidy(final String message) { return out.substring(start, end); } - /** * Replaces every {name} with its value. An unknown placeholder becomes empty. */ @@ -190,10 +160,8 @@ public static String fill(final String template, final Map value // nothing to substitute, so allocate nothing at all return template; } - final StringBuilder out = new StringBuilder(template.length() + 64); int i = 0; - while (i < template.length()) { final int open = template.indexOf('{', i); if (open < 0) { @@ -201,14 +169,12 @@ public static String fill(final String template, final Map value out.append(template, i, template.length()); break; } - final int close = template.indexOf('}', open); if (close < 0) { // unbalanced brace, copy verbatim, never throw out.append(template, i, template.length()); break; } - // the literal text before the placeholder out.append(template, i, open); final String value = values.get(template.substring(open + 1, close)); @@ -216,9 +182,11 @@ public static String fill(final String template, final Map value // a missing value contributes nothing, tidy() closes the gap out.append(value); } - i = close + 1; } return out.toString(); } + + private MessageTemplates() { + } } diff --git a/core/src/main/java/io/openepcis/validation/message/ValueSuggester.java b/core/src/main/java/io/openepcis/validation/message/ValueSuggester.java index e2c42f6..db21ce8 100644 --- a/core/src/main/java/io/openepcis/validation/message/ValueSuggester.java +++ b/core/src/main/java/io/openepcis/validation/message/ValueSuggester.java @@ -15,8 +15,6 @@ */ package io.openepcis.validation.message; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.similarity.LevenshteinDistance; @@ -27,7 +25,6 @@ /** * Finds the closest allowed value to what the user typed, so we can offer a "did you mean" option in the validation error message. */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) public class ValueSuggester { private static final LevenshteinDistance LEVENSHTEIN = LevenshteinDistance.getDefaultInstance(); @@ -38,25 +35,19 @@ public static String closest(final String wrongValue, final List candida if (StringUtils.isBlank(wrongValue) || CollectionUtils.isEmpty(candidates)) { return null; } - final String needle = wrongValue.toLowerCase(Locale.ROOT); final int needleLength = needle.length(); final int maxDistance = Math.max(1, needleLength / 3); - String best = null; int bestDistance = Integer.MAX_VALUE; - for (final String candidate : candidates) { // Combined guard checks using short-circuit logic instead of separate continue statements if (StringUtils.isNotBlank(candidate) && Math.abs(needleLength - candidate.length()) <= maxDistance) { - // Execution: Reuses the stateless global engine final int distance = LEVENSHTEIN.apply(needle, candidate.toLowerCase(Locale.ROOT)); - if (distance < bestDistance) { bestDistance = distance; best = candidate; - // Performance optimization: Stop searching immediately upon an exact match if (bestDistance == 0) { return best; @@ -64,7 +55,9 @@ public static String closest(final String wrongValue, final List candida } } } - return bestDistance <= maxDistance ? best : null; } -} \ No newline at end of file + + private ValueSuggester() { + } +} diff --git a/core/src/main/java/io/openepcis/validation/model/ValidationError.java b/core/src/main/java/io/openepcis/validation/model/ValidationError.java index 7e0f1ac..4b60cee 100644 --- a/core/src/main/java/io/openepcis/validation/model/ValidationError.java +++ b/core/src/main/java/io/openepcis/validation/model/ValidationError.java @@ -20,70 +20,50 @@ import jakarta.xml.bind.annotation.XmlElementWrapper; import jakarta.xml.bind.annotation.XmlRootElement; import jakarta.xml.bind.annotation.XmlType; -import lombok.Data; import org.apache.commons.lang3.StringUtils; import java.util.List; import java.util.regex.Pattern; -@Data @JsonInclude(JsonInclude.Include.NON_NULL) @XmlRootElement(name = "ValidationError") -@XmlType( - name = "ValidationError", - propOrder = { - "type", "line", "eventIndex", "eventType", "column", "location", - "schemaPath", "message", "field", "value", "suggestion", "allowedValues" - }) +@XmlType(name = "ValidationError", propOrder = {"type", "line", "eventIndex", "eventType", "column", "location", "schemaPath", "message", "field", "value", "suggestion", "allowedValues"}) public class ValidationError { - @XmlElement private String type; - // XML only: line number in submitted document @XmlElement private Integer line; - // XML only: column number of the error @XmlElement private Integer column; - // JSON only: problem in document like $.epcisBody.eventList[5].bizStep @XmlElement private String location; - // Which schema rules got rejected, for diagnostics only not for the display on UI @XmlElement private String schemaPath; - @XmlElement private String message; - // Indicate which field failed @XmlElement private String field; - // User provided values @XmlElement private String value; - // zero based event index @XmlElement private Integer eventIndex; - // eventType for quick reference to users @XmlElement private String eventType; - // closest standard value, or null for suggesting to user @XmlElement private String suggestion; - // full list as data, UI can collapse it @XmlElementWrapper(name = "allowedValues") @XmlElement(name = "allowedValue") private List allowedValues; - // Strips only a leading XSD rule code such as "cvc-complex-type.2.4.a: ". private static final Pattern XSD_ERROR_CODE = Pattern.compile("^(cvc|src|sch)-[A-Za-z0-9_.\\-]+:\\s*"); @@ -91,7 +71,190 @@ public String filterErrorMessage(final String message) { if (StringUtils.isBlank(message)) { return message; } - return XSD_ERROR_CODE.matcher(message).replaceFirst(""); } + + public ValidationError() { + } + + public String getType() { + return this.type; + } + + public Integer getLine() { + return this.line; + } + + public Integer getColumn() { + return this.column; + } + + public String getLocation() { + return this.location; + } + + public String getSchemaPath() { + return this.schemaPath; + } + + public String getMessage() { + return this.message; + } + + public String getField() { + return this.field; + } + + public String getValue() { + return this.value; + } + + public Integer getEventIndex() { + return this.eventIndex; + } + + public String getEventType() { + return this.eventType; + } + + public String getSuggestion() { + return this.suggestion; + } + + public List getAllowedValues() { + return this.allowedValues; + } + + public void setType(String type) { + this.type = type; + } + + public void setLine(Integer line) { + this.line = line; + } + + public void setColumn(Integer column) { + this.column = column; + } + + public void setLocation(String location) { + this.location = location; + } + + public void setSchemaPath(String schemaPath) { + this.schemaPath = schemaPath; + } + + public void setMessage(String message) { + this.message = message; + } + + public void setField(String field) { + this.field = field; + } + + public void setValue(String value) { + this.value = value; + } + + public void setEventIndex(Integer eventIndex) { + this.eventIndex = eventIndex; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public void setSuggestion(String suggestion) { + this.suggestion = suggestion; + } + + public void setAllowedValues(List allowedValues) { + this.allowedValues = allowedValues; + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (!(o instanceof ValidationError)) return false; + ValidationError other = (ValidationError) o; + if (!other.canEqual((Object) this)) return false; + Object this$line = this.getLine(); + Object other$line = other.getLine(); + if (this$line == null ? other$line != null : !this$line.equals(other$line)) return false; + Object this$column = this.getColumn(); + Object other$column = other.getColumn(); + if (this$column == null ? other$column != null : !this$column.equals(other$column)) return false; + Object this$eventIndex = this.getEventIndex(); + Object other$eventIndex = other.getEventIndex(); + if (this$eventIndex == null ? other$eventIndex != null : !this$eventIndex.equals(other$eventIndex)) return false; + Object this$type = this.getType(); + Object other$type = other.getType(); + if (this$type == null ? other$type != null : !this$type.equals(other$type)) return false; + Object this$location = this.getLocation(); + Object other$location = other.getLocation(); + if (this$location == null ? other$location != null : !this$location.equals(other$location)) return false; + Object this$schemaPath = this.getSchemaPath(); + Object other$schemaPath = other.getSchemaPath(); + if (this$schemaPath == null ? other$schemaPath != null : !this$schemaPath.equals(other$schemaPath)) return false; + Object this$message = this.getMessage(); + Object other$message = other.getMessage(); + if (this$message == null ? other$message != null : !this$message.equals(other$message)) return false; + Object this$field = this.getField(); + Object other$field = other.getField(); + if (this$field == null ? other$field != null : !this$field.equals(other$field)) return false; + Object this$value = this.getValue(); + Object other$value = other.getValue(); + if (this$value == null ? other$value != null : !this$value.equals(other$value)) return false; + Object this$eventType = this.getEventType(); + Object other$eventType = other.getEventType(); + if (this$eventType == null ? other$eventType != null : !this$eventType.equals(other$eventType)) return false; + Object this$suggestion = this.getSuggestion(); + Object other$suggestion = other.getSuggestion(); + if (this$suggestion == null ? other$suggestion != null : !this$suggestion.equals(other$suggestion)) return false; + Object this$allowedValues = this.getAllowedValues(); + Object other$allowedValues = other.getAllowedValues(); + if (this$allowedValues == null ? other$allowedValues != null : !this$allowedValues.equals(other$allowedValues)) return false; + return true; + } + + protected boolean canEqual(Object other) { + return other instanceof ValidationError; + } + + @Override + public int hashCode() { + int PRIME = 59; + int result = 1; + Object $line = this.getLine(); + result = result * PRIME + ($line == null ? 43 : $line.hashCode()); + Object $column = this.getColumn(); + result = result * PRIME + ($column == null ? 43 : $column.hashCode()); + Object $eventIndex = this.getEventIndex(); + result = result * PRIME + ($eventIndex == null ? 43 : $eventIndex.hashCode()); + Object $type = this.getType(); + result = result * PRIME + ($type == null ? 43 : $type.hashCode()); + Object $location = this.getLocation(); + result = result * PRIME + ($location == null ? 43 : $location.hashCode()); + Object $schemaPath = this.getSchemaPath(); + result = result * PRIME + ($schemaPath == null ? 43 : $schemaPath.hashCode()); + Object $message = this.getMessage(); + result = result * PRIME + ($message == null ? 43 : $message.hashCode()); + Object $field = this.getField(); + result = result * PRIME + ($field == null ? 43 : $field.hashCode()); + Object $value = this.getValue(); + result = result * PRIME + ($value == null ? 43 : $value.hashCode()); + Object $eventType = this.getEventType(); + result = result * PRIME + ($eventType == null ? 43 : $eventType.hashCode()); + Object $suggestion = this.getSuggestion(); + result = result * PRIME + ($suggestion == null ? 43 : $suggestion.hashCode()); + Object $allowedValues = this.getAllowedValues(); + result = result * PRIME + ($allowedValues == null ? 43 : $allowedValues.hashCode()); + return result; + } + + @Override + public String toString() { + return "ValidationError(type=" + this.getType() + ", line=" + this.getLine() + ", column=" + this.getColumn() + ", location=" + this.getLocation() + ", schemaPath=" + this.getSchemaPath() + ", message=" + this.getMessage() + ", field=" + this.getField() + ", value=" + this.getValue() + ", eventIndex=" + this.getEventIndex() + ", eventType=" + this.getEventType() + ", suggestion=" + this.getSuggestion() + ", allowedValues=" + this.getAllowedValues() + ")"; + } } diff --git a/core/src/main/java/io/openepcis/validation/model/xml/ValidationResult.java b/core/src/main/java/io/openepcis/validation/model/xml/ValidationResult.java index a104780..5a293be 100644 --- a/core/src/main/java/io/openepcis/validation/model/xml/ValidationResult.java +++ b/core/src/main/java/io/openepcis/validation/model/xml/ValidationResult.java @@ -20,20 +20,58 @@ import jakarta.xml.bind.annotation.XmlElementWrapper; import jakarta.xml.bind.annotation.XmlRootElement; import jakarta.xml.bind.annotation.XmlType; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; import java.util.List; @XmlRootElement(name = "ValidationResult") @XmlType(name = "ValidationResult") -@NoArgsConstructor -@AllArgsConstructor -@Data public class ValidationResult { - @XmlElementWrapper(name = "errors") @XmlElement(name = "ValidationError") private List errors; + + public ValidationResult() { + } + + public ValidationResult(List errors) { + this.errors = errors; + } + + public List getErrors() { + return this.errors; + } + + public void setErrors(List errors) { + this.errors = errors; + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (!(o instanceof ValidationResult)) return false; + ValidationResult other = (ValidationResult) o; + if (!other.canEqual((Object) this)) return false; + Object this$errors = this.getErrors(); + Object other$errors = other.getErrors(); + if (this$errors == null ? other$errors != null : !this$errors.equals(other$errors)) return false; + return true; + } + + protected boolean canEqual(Object other) { + return other instanceof ValidationResult; + } + + @Override + public int hashCode() { + int PRIME = 59; + int result = 1; + Object $errors = this.getErrors(); + result = result * PRIME + ($errors == null ? 43 : $errors.hashCode()); + return result; + } + + @Override + public String toString() { + return "ValidationResult(errors=" + this.getErrors() + ")"; + } } diff --git a/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java b/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java index 164831d..3cce594 100644 --- a/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java +++ b/core/src/main/java/io/openepcis/validation/xml/XmlErrorHandler.java @@ -19,7 +19,6 @@ import io.openepcis.validation.message.MessageTemplates; import io.openepcis.validation.message.ValueSuggester; import io.openepcis.validation.model.ValidationError; -import lombok.Getter; import org.apache.commons.lang3.StringUtils; import org.xml.sax.ErrorHandler; import org.xml.sax.SAXParseException; @@ -32,17 +31,15 @@ * custom error handler while validating xml against xsd */ public class XmlErrorHandler implements ErrorHandler { - @Getter private final List exceptions; // Max number of validation errors that can be added private final int maxReportedErrors; - // Pattern to extract the values from Xerces validation errors - private static final Pattern ENUM_VALUES = Pattern.compile("enumeration '\\[([^\\]]*)]'"); - private static final Pattern FACET_VALUE = Pattern.compile("Value '([^']*)' is not"); - private static final Pattern DATATYPE_VALUE = Pattern.compile("'([^']*)' is not a valid value for"); - private static final Pattern VALUE_OF_ELEMENT = Pattern.compile("The value '([^']*)' of element '([^']*)' is not valid"); - private static final Pattern ELEMENT_NAME = Pattern.compile("element '\\{?[^']*?[:\"]?([A-Za-z_][A-Za-z0-9_.\\-]*)}?'"); + private static final Pattern ENUM_VALUES = Pattern.compile("enumeration \'\\[([^\\]]*)]\'"); + private static final Pattern FACET_VALUE = Pattern.compile("Value \'([^\']*)\' is not"); + private static final Pattern DATATYPE_VALUE = Pattern.compile("\'([^\']*)\' is not a valid value for"); + private static final Pattern VALUE_OF_ELEMENT = Pattern.compile("The value \'([^\']*)\' of element \'([^\']*)\' is not valid"); + private static final Pattern ELEMENT_NAME = Pattern.compile("element \'\\{?[^\']*?[:\"]?([A-Za-z_][A-Za-z0-9_.\\-]*)}?\'"); public XmlErrorHandler() { this(ValidationDefaults.MAX_REPORTED_ERRORS); @@ -73,16 +70,13 @@ public void handleError(final SAXParseException e) { if (this.exceptions.size() >= maxReportedErrors) { return; // the marker is already the last entry, nothing further is recorded } - if (this.exceptions.size() == maxReportedErrors - 1) { this.exceptions.add(limitExceeded(maxReportedErrors - 1)); // same wording as the JSON path return; } - final String raw = e.getMessage() == null ? "" : e.getMessage(); // never NPE on a null message final Integer line = e.getLineNumber() > 0 ? e.getLineNumber() : null; final Integer column = e.getColumnNumber() > 0 ? e.getColumnNumber() : null; - // Xerces reports one mistake twice, for example "not facet-valid" then "value of element is not valid". // Same line and column is the same mistake, so keep the first, more specific one. for (final ValidationError existing : this.exceptions) { @@ -92,12 +86,11 @@ public void handleError(final SAXParseException e) { return; } } - final ValidationError errorFormat = new ValidationError(); errorFormat.setType(typeOf(raw)); // a real type, instead of always reporting "required" errorFormat.setLine(line); // unchanged, the front end uses this for XML highlighting errorFormat.setColumn(column); // unchanged - errorFormat.setSchemaPath(schemaRuleOf(raw)); //the cvc code, for diagnostics + errorFormat.setSchemaPath(schemaRuleOf(raw)); //the cvc code, for diagnostics errorFormat.setMessage(errorFormat.filterErrorMessage(raw)); enrich(errorFormat, raw); // extract the values and field names from the raw message this.exceptions.add(errorFormat); @@ -130,11 +123,8 @@ private String typeOf(final String raw) { private ValidationError limitExceeded(final int limit) { final ValidationError error = new ValidationError(); error.setType("limitExceeded"); - error.setMessage(MessageTemplates.hasKeyword("limit.exceeded") - ? MessageTemplates.render(MessageTemplates.keyword("limit.exceeded"), Map.of("limit", String.valueOf(limit))) - : "Validation stopped after " + limit + " problems. This document has more."); + error.setMessage(MessageTemplates.hasKeyword("limit.exceeded") ? MessageTemplates.render(MessageTemplates.keyword("limit.exceeded"), Map.of("limit", String.valueOf(limit))) : "Validation stopped after " + limit + " problems. This document has more."); return error; - } // Get the values matching the error model to populate in errors from the Xerces validation errors @@ -147,7 +137,6 @@ private void enrich(final ValidationError error, final String raw) { // 'not-a-date' is not a valid value for error.setValue(firstGroup(DATATYPE_VALUE, raw)); } - final Matcher valueOfElement = VALUE_OF_ELEMENT.matcher(raw); if (valueOfElement.find()) { if (StringUtils.isBlank(error.getValue())) { @@ -160,30 +149,27 @@ private void enrich(final ValidationError error, final String raw) { if (StringUtils.isBlank(error.getField())) { error.setField(firstGroup(ELEMENT_NAME, raw)); } - if (error.getAllowedValues() == null) { final String list = firstGroup(ENUM_VALUES, raw); if (list != null) { - final List allowed = Arrays.stream(list.split(",")) - .map(String::trim) - .filter(value -> !value.isEmpty()) - .toList(); + final List allowed = Arrays.stream(list.split(",")).map(String::trim).filter(value -> !value.isEmpty()).toList(); if (!allowed.isEmpty()) { error.setAllowedValues(allowed); } } } - // only worth suggesting once both halves are known, and never overwrite an existing one if (error.getSuggestion() == null && error.getValue() != null && error.getAllowedValues() != null) { error.setSuggestion(ValueSuggester.closest(error.getValue(), error.getAllowedValues())); } } - private String firstGroup(final Pattern pattern, final String raw) { final Matcher matcher = pattern.matcher(raw); return matcher.find() ? matcher.group(1) : null; } + public List getExceptions() { + return this.exceptions; + } } diff --git a/core/src/main/java/io/openepcis/validation/xml/resolver/LSInputImpl.java b/core/src/main/java/io/openepcis/validation/xml/resolver/LSInputImpl.java index 2df810f..4adf259 100644 --- a/core/src/main/java/io/openepcis/validation/xml/resolver/LSInputImpl.java +++ b/core/src/main/java/io/openepcis/validation/xml/resolver/LSInputImpl.java @@ -15,15 +15,11 @@ */ package io.openepcis.validation.xml.resolver; -import lombok.Getter; -import lombok.Setter; import org.w3c.dom.ls.LSInput; import java.io.InputStream; import java.io.Reader; -@Getter -@Setter public class LSInputImpl implements LSInput { private Reader characterStream; private InputStream byteStream; @@ -38,4 +34,64 @@ public class LSInputImpl implements LSInput { public boolean getCertifiedText() { return certifiedText; } + + public Reader getCharacterStream() { + return this.characterStream; + } + + public InputStream getByteStream() { + return this.byteStream; + } + + public String getStringData() { + return this.stringData; + } + + public String getSystemId() { + return this.systemId; + } + + public String getPublicId() { + return this.publicId; + } + + public String getBaseURI() { + return this.baseURI; + } + + public String getEncoding() { + return this.encoding; + } + + public void setCharacterStream(Reader characterStream) { + this.characterStream = characterStream; + } + + public void setByteStream(InputStream byteStream) { + this.byteStream = byteStream; + } + + public void setStringData(String stringData) { + this.stringData = stringData; + } + + public void setSystemId(String systemId) { + this.systemId = systemId; + } + + public void setPublicId(String publicId) { + this.publicId = publicId; + } + + public void setBaseURI(String baseURI) { + this.baseURI = baseURI; + } + + public void setEncoding(String encoding) { + this.encoding = encoding; + } + + public void setCertifiedText(boolean certifiedText) { + this.certifiedText = certifiedText; + } } diff --git a/rest-api/src/main/java/io/openepcis/validation/rest/resource/ValidationResource.java b/rest-api/src/main/java/io/openepcis/validation/rest/resource/ValidationResource.java index f6119c5..4ecb31b 100644 --- a/rest-api/src/main/java/io/openepcis/validation/rest/resource/ValidationResource.java +++ b/rest-api/src/main/java/io/openepcis/validation/rest/resource/ValidationResource.java @@ -30,11 +30,6 @@ import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; - -import java.io.InputStream; -import java.time.Duration; - -import lombok.extern.slf4j.Slf4j; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.context.ManagedExecutor; import org.eclipse.microprofile.openapi.annotations.enums.ParameterIn; @@ -50,11 +45,13 @@ import org.jboss.resteasy.reactive.RestHeader; import org.jboss.resteasy.reactive.RestResponse; +import java.io.InputStream; +import java.time.Duration; + @Path("/api") @Tag( name = "Event Validation", description = "Validate event for EPCIS XML or JSON/JSON-LD document or event list.") -@Slf4j public class ValidationResource { @Inject From 50cc65459fea0ea572e56f6d73ceb0f08bc8e0e3 Mon Sep 17 00:00:00 2001 From: Aravinda Baliga B Date: Sun, 9 Aug 2026 22:26:53 +0200 Subject: [PATCH 6/7] Keep XML namespace prefixes when wrapping a bare event" --- .../validation/xml/EventModifier.java | 28 +++++++++++++++---- .../validation/test/XmlDocumentTest.java | 2 ++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/openepcis/validation/xml/EventModifier.java b/core/src/main/java/io/openepcis/validation/xml/EventModifier.java index c1bc210..b0df467 100644 --- a/core/src/main/java/io/openepcis/validation/xml/EventModifier.java +++ b/core/src/main/java/io/openepcis/validation/xml/EventModifier.java @@ -18,6 +18,7 @@ import io.openepcis.constants.EPCIS; import io.openepcis.constants.EPCISVersion; import io.openepcis.validation.exception.SchemaValidationException; +import org.apache.commons.lang3.StringUtils; import javax.xml.stream.*; import java.io.*; @@ -135,18 +136,33 @@ private void copyXmlEvent(final XMLStreamReader reader, final XMLStreamWriter wr int event = reader.next(); if (event == XMLStreamConstants.START_ELEMENT) { - writer.writeStartElement(reader.getLocalName()); - - // Add all the attributes associated with xml tag - for (int i = 0; i < reader.getAttributeCount(); i++) { - writer.writeAttribute(reader.getAttributeLocalName(i), reader.getAttributeValue(i)); + // Get namespace of the element Null or empty means direct event (not + final String elementNs = reader.getNamespaceURI(); + + // if empty directly add unqualified name + if (StringUtils.isBlank(elementNs)) { + writer.writeStartElement(reader.getLocalName()); + } else { + final String prefix = reader.getPrefix(); + writer.writeStartElement(prefix == null ? "" : prefix, reader.getLocalName(), elementNs); } // Add all the namespaces associated with xml tag for (int i = 0; i < reader.getNamespaceCount(); i++) { - writer.writeNamespace(reader.getNamespacePrefix(i), reader.getNamespaceURI(i)); + final String nsPrefix = reader.getNamespacePrefix(i); + writer.writeNamespace(nsPrefix == null ? "" : nsPrefix, reader.getNamespaceURI(i)); } + // Add all the attributes associated with xml tag + for (int i = 0; i < reader.getAttributeCount(); i++) { + final String attrNs = reader.getAttributeNamespace(i); + + if (StringUtils.isEmpty(attrNs)) { + writer.writeAttribute(reader.getAttributeLocalName(i), reader.getAttributeValue(i)); + } else { + writer.writeAttribute(reader.getAttributePrefix(i), attrNs, reader.getAttributeLocalName(i), reader.getAttributeValue(i)); + } + } } else if (event == XMLStreamConstants.END_ELEMENT) { writer.writeEndElement(); } else if (event == XMLStreamConstants.CHARACTERS) { diff --git a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java index ab82b7c..dccae23 100644 --- a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java +++ b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java @@ -49,6 +49,7 @@ class XmlDocumentTest { private static final String INVALID_PATH = "2.0/EPCIS/XML/Capture/Invalid"; private static final String QUERY_PATH = "2.0/EPCIS/XML/Query"; private static final String CAPTURE_PATH = "2.0/EPCIS/XML/Capture/Documents"; + private static final String CAPTURE_PATH_EVENT = "2.0/EPCIS/XML/Capture/Events"; private static final String CAPTURE_1_2_PATH = "1.2/EPCIS/XML/Capture/Documents"; @ParameterizedTest(name = "{index}: {0}") @@ -69,6 +70,7 @@ static Stream validationTestCases() { Arguments.of("Query document with multiple events", QUERY_PATH + "/QueryDocumentWithMultipleEvents.xml", QUERY, VERSION_2_0_0, 0), Arguments.of("Query document with missing value", QUERY_PATH + "/Invalid/QueryDocument_with_missing_value.xml", QUERY, VERSION_2_0_0, 1), Arguments.of("Query document sent to the capture path", QUERY_PATH + "/queryDocument.xml", CAPTURE, VERSION_2_0_0, 1), + Arguments.of("EPCIS bare event with no error", CAPTURE_PATH_EVENT + "/TransactionEvent.xml", CAPTURE, VERSION_2_0_0, 0), // XML Queries Arguments.of("Literal query with GE_ and LT_ params", QUERY_PATH + "/Queries/SimpleEventQuery.xml", QUERY, VERSION_2_0_0, 0), From 885e6ce414c165bca8f348ffaf43c23ef471013d Mon Sep 17 00:00:00 2001 From: Sven Boeckelmann Date: Mon, 10 Aug 2026 17:58:39 +0200 Subject: [PATCH 7/7] Name the missing fixture instead of failing deep in the validator JsonDocumentTest and XmlDocumentTest passed the result of Commons.getInputStream straight into the validator. When a fixture is absent that value is null, and the failure only appeared much later as "Stream closed" from Jackson or an NPE inside EventModifier, neither of which says which file is missing. --- .../validation/test/JsonDocumentTest.java | 19 +++++++++++++++---- .../validation/test/XmlDocumentTest.java | 4 ++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java b/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java index 85c6fe7..8bb2ab3 100644 --- a/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java +++ b/core/src/test/java/io/openepcis/validation/test/JsonDocumentTest.java @@ -32,6 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; class JsonDocumentTest { @@ -45,23 +46,33 @@ class JsonDocumentTest { new SchemaValidator(mapper, JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) .jsonNodeReader(JsonNodeReader.builder().jsonMapper(mapper).build()).build()); + /** + * A fixture that is not on the classpath comes back as null, which the validator only reports + * much later as "Stream closed". Naming the missing file here keeps that diagnosable. + */ + private InputStream open(final String resourcePath) { + final InputStream document = Commons.getInputStream(resourcePath); + assertNotNull(document, "missing shared test resource " + resourcePath); + return document; + } + @Test void validateCaptureDocumentTest() { - final InputStream inputStream = Commons.getInputStream(INVALID + "Combination_of_events_with_missing_mandatory_fields.json"); + final InputStream inputStream = open(INVALID + "Combination_of_events_with_missing_mandatory_fields.json"); final Multi jsonCaptureErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0); assertFalse(jsonCaptureErrors.subscribe().asStream().toList().isEmpty()); } @Test void validateCaptureDocumentWithCertificationTest() { - final InputStream inputStream = Commons.getInputStream(INVALID + "ObjectEvent_with_invalid_certificationInfo.json"); + final InputStream inputStream = open(INVALID + "ObjectEvent_with_invalid_certificationInfo.json"); final Multi jsonCaptureErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.CAPTURE, EPCISVersion.VERSION_2_0_0); assertFalse(jsonCaptureErrors.subscribe().asStream().toList().isEmpty()); } @Test void validateQueryDocumentTest() { - final InputStream inputStream = Commons.getInputStream(QUERY + "queryDocument.json"); + final InputStream inputStream = open(QUERY + "queryDocument.json"); final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); final List errors = jsonQueryErrors.subscribe().asStream().toList(); assertEquals(0, errors.size()); @@ -69,7 +80,7 @@ void validateQueryDocumentTest() { @Test void validateQueryDocumentWithInValidEventTypeTest() { - final InputStream inputStream = Commons.getInputStream(QUERY_INVALID + "QueryDocument_with_invalid_eventType.json"); + final InputStream inputStream = open(QUERY_INVALID + "QueryDocument_with_invalid_eventType.json"); final Multi jsonQueryErrors = validator.validate(inputStream, "application/json", EPCISDocumentType.QUERY, EPCISVersion.VERSION_2_0_0); final List errors = jsonQueryErrors.subscribe().asStream().toList(); assertFalse(errors.isEmpty()); diff --git a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java index dccae23..986bcdc 100644 --- a/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java +++ b/core/src/test/java/io/openepcis/validation/test/XmlDocumentTest.java @@ -37,6 +37,7 @@ import static io.openepcis.constants.EPCISVersion.VERSION_1_2_0; import static io.openepcis.constants.EPCISVersion.VERSION_2_0_0; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; class XmlDocumentTest { private final ObjectMapper mapper = new ObjectMapper(); @@ -56,6 +57,9 @@ class XmlDocumentTest { @MethodSource("validationTestCases") void validateDocuments(String testName, String resource, EPCISDocumentType documentType, EPCISVersion version, int expectedErrors) { final InputStream inputStream = Commons.getInputStream(resource); + // a fixture that is not on the classpath comes back as null and only surfaces deep inside + // EventModifier as an NPE, so name the missing file here instead + assertNotNull(inputStream, "missing shared test resource " + resource); final Multi validationErrorMulti = validator.validate(inputStream, "application/xml", documentType, version); var errors = validationErrorMulti.subscribe().asStream().toList(); assertEquals(expectedErrors, errors.size());