diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..94c9ce4 --- /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 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + 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..2d603ea 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 - - io.netty - netty-buffer + io.openepcis + openepcis-reactive-event-publisher - --> - + - org.projectlombok - lombok + io.quarkus + quarkus-core @@ -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..3815219 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,72 +17,75 @@ 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.config.ValidationDefaults; import io.openepcis.validation.json.JsonSchemaValidator; import io.openepcis.validation.model.ValidationError; 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(mapper, factory, ValidationDefaults.MAX_REPORTED_ERRORS); } - if (epcisDocumentSchema.equals(EPCISDocumentType.QUERY)) { - return validator.validateAgainstQuerySchema(epcisInputData); + 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); } - throw new UnsupportedOperationException("Unsupported schema type : " + epcisDocumentSchema); - } + /** + * 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); - private Validator getValidator(final String mediaType, EPCISVersion epcisVersion) { + if (epcisDocumentSchema.equals(EPCISDocumentType.CAPTURE)) { + return validator.validateAgainstCaptureSchema(epcisInputData); + } - if (mediaType.toLowerCase().contains("json")) { - return jsonSchemaValidator; + if (epcisDocumentSchema.equals(EPCISDocumentType.QUERY)) { + return validator.validateAgainstQuerySchema(epcisInputData); + } + + 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/config/ValidationDefaults.java b/core/src/main/java/io/openepcis/validation/config/ValidationDefaults.java new file mode 100644 index 0000000..c869957 --- /dev/null +++ b/core/src/main/java/io/openepcis/validation/config/ValidationDefaults.java @@ -0,0 +1,28 @@ +/* + * 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; + +/** + * Configuration for validation settings + */ +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/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 deleted file mode 100644 index e5b33c9..0000000 --- a/core/src/main/java/io/openepcis/validation/json/JsonErrorHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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. - */ -package io.openepcis.validation.json; - -import com.networknt.schema.ValidationMessage; -import io.openepcis.validation.model.ValidationError; -import java.util.Collection; - -/** - * 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; - - public JsonErrorHandler() { - this.exceptions = new java.util.ArrayList<>(); - } - - 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); - } - } -} 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..6042825 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. @@ -15,90 +15,240 @@ */ 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.networknt.schema.*; +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 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 lombok.extern.slf4j.Slf4j; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; -@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; - - 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)); - } - - @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)); - - // If there are any validation errors then throw the errors - if (!captureErrors.isEmpty()) { - jsonErrorHandler.jsonValidationFormat(captureErrors); + // 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 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.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 JsonSchemaFactory validationFactory, final String path) { + return validationFactory.getSchema(getResourceAsStream(path)); + } + + @Override + public Multi validateAgainstCaptureSchema(final InputStream epcisInputData) throws SchemaValidationException { + return validate(epcisInputData, false, "Capture"); + } + + @Override + 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); } - 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)); - - // If there are any validation errors then throw the errors - if (!queryErrors.isEmpty()) { - jsonErrorHandler.jsonValidationFormat(queryErrors); + // 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() + .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; - 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..4ee6d24 --- /dev/null +++ b/core/src/main/java/io/openepcis/validation/message/JsonErrorFormatter.java @@ -0,0 +1,539 @@ +/* + * 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; + + /** + * 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 record EventContext(String prefix, int index, String type) { + } + + /** + * 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(); + } + + final Map> byLocation = new LinkedHashMap<>(); + for (final ValidationMessage message : messages) { + // 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, branchLocations, context))); + results.sort(Comparator.comparing(error -> error.getLocation() == null ? "" : error.getLocation())); + return results; + } + + /** + * Records every position at which each anyOf/oneOf choice failed, keyed by the choice. + * + *

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 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()); + } + } + 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 branch of this choice got past the presence check + } + } + return false; + } + + // 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, + 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. + 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")) { + 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 + } 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", + context)); + } else { + others.addAll(leftoverRequired.values()); // a single missing field stays an ordinary message + } + + if (!others.isEmpty()) { + results.add(build(document, location, others, context)); + } + 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 EventContext context) { + final ValidationError error = new ValidationError(); + error.setType("required"); + error.setSchemaPath(siblings.getFirst().getSchemaLocation().toString()); + error.setLocation(absolute(location, context)); // full document path even while streaming + + final String joined = String.join(", ", fields); + error.setField(joined); + applyEventContext(error, document, location, context); + 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 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.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 + // 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, context); + + 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 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 + } + 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 + } + } + } + + /** + * 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. + */ + 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..b0573e5 --- /dev/null +++ b/core/src/main/java/io/openepcis/validation/message/MessageTemplates.java @@ -0,0 +1,192 @@ +/* + * 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 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. + */ +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(); + } + + 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 new file mode 100644 index 0000000..db21ce8 --- /dev/null +++ b/core/src/main/java/io/openepcis/validation/message/ValueSuggester.java @@ -0,0 +1,63 @@ +/* + * 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 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. + */ +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; + } + + 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 ee56633..4b60cee 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,246 @@ */ package io.openepcis.validation.model; -import jakarta.xml.bind.annotation.XmlAttribute; +import com.fasterxml.jackson.annotation.JsonInclude; 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; -@Data +import java.util.List; +import java.util.regex.Pattern; + +@JsonInclude(JsonInclude.Include.NON_NULL) @XmlRootElement(name = "ValidationError") -@XmlType( - name = "ValidationError", - propOrder = {"type", "line", "location", "message"}) +@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*"); + + 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; + } - @XmlAttribute private String type; + public void setAllowedValues(List allowedValues) { + this.allowedValues = allowedValues; + } - @XmlElement private String line; + @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; + } - @XmlElement private String location; + protected boolean canEqual(Object other) { + return other instanceof ValidationError; + } - @XmlElement private String message; + @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; + } - public String filterErrorMessage(String message) { - String[] messageParts = message.split(":", 2); - return messageParts.length > 1 ? messageParts[1].trim() : message; - } + @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 7c613dc..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 @@ -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,20 +16,62 @@ package io.openepcis.validation.model.xml; import io.openepcis.validation.model.ValidationError; -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 java.util.List; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; @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/AbstractXmlSchemaValidator.java b/core/src/main/java/io/openepcis/validation/xml/AbstractXmlSchemaValidator.java index 01b5b13..319083e 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. @@ -16,78 +16,75 @@ 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; -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 int maxReportedErrors = ValidationDefaults.MAX_REPORTED_ERRORS; + protected Schema captureXmlSchema; + protected Schema queryXmlSchema; + protected EventModifier eventModifier = new EventModifier(); + 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(); + // Fresh handler to avoid reused validation polluting with old errors + final XmlErrorHandler errorHandler = new XmlErrorHandler(maxReportedErrors); - @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)); - } - }); - } + validator.setErrorHandler(errorHandler); + validator.validate(new StreamSource(inputEvent)); + 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)); + } + }); + } - @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(); + // 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)); + 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)); + } + }); + } - 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..b0df467 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,162 @@ */ package io.openepcis.validation.xml; +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.*; 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()); } - } - - // 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 InputStream modifyEvent(final InputStream input) { + final BufferedInputStream captureInput = BufferedInputStream.class.isAssignableFrom(input.getClass()) + ? (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); + + // 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); } - }); - return pipe; - } catch (Exception e) { - throw new SchemaValidationException( - "Exception occurred during the addition of Document wrapper to event : " + e.getMessage(), - e); + + // 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); + } } - } - - 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()); + + 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); + } + } + + // 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_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_NAMESPACE_PREFIX, 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) { + // 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++) { + 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) { + 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..8d606ed 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. @@ -15,17 +15,27 @@ */ package io.openepcis.validation.xml; +import io.openepcis.validation.config.ValidationDefaults; 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() { + // 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.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 89198bf..ddc5c11 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. @@ -15,17 +15,27 @@ */ package io.openepcis.validation.xml; +import io.openepcis.validation.config.ValidationDefaults; 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() { + // 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.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 b186aeb..3cce594 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,161 @@ */ package io.openepcis.validation.xml; +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 org.apache.commons.lang3.StringUtils; import org.xml.sax.ErrorHandler; import org.xml.sax.SAXParseException; -/** custom error handler while validating xml against xsd */ +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 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); - } + 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_.\\-]*)}?\'"); + + 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 + 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) { + 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) { + 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; + } + } + 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.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. + */ + 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 + } + + // 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; + } + + // 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; + } + + public List getExceptions() { + return this.exceptions; + } } 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..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 @@ -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,14 +15,11 @@ */ package io.openepcis.validation.xml.resolver; +import org.w3c.dom.ls.LSInput; + import java.io.InputStream; import java.io.Reader; -import lombok.Getter; -import lombok.Setter; -import org.w3c.dom.ls.LSInput; -@Getter -@Setter public class LSInputImpl implements LSInput { private Reader characterStream; private InputStream byteStream; @@ -37,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/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/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..0b4f0af --- /dev/null +++ b/core/src/main/resources/validation-messages/epcis-validation-messages.yaml @@ -0,0 +1,344 @@ +# +# 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}.' + + # 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 +# +# 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/EPCISQueryTest.java b/core/src/test/java/io/openepcis/validation/test/EPCISQueryTest.java new file mode 100644 index 0000000..7370ad0 --- /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(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()))); + } + + @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 1e5fca4..8bb2ab3 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,75 @@ */ 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 java.util.List; + +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 { - private ObjectMapper mapper = new ObjectMapper(); - private SchemaValidator validator = - new SchemaValidator( - mapper, - JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)) - .jsonMapper(mapper) - .build()); + // 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/"; - @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); - } + 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 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); - }); + /** + * 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 - 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 validateCaptureDocumentTest() { + 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 - 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 validateCaptureDocumentWithCertificationTest() { + 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 - 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 validateQueryDocumentTest() { + 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()); + } - @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 = 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()); + 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 new file mode 100644 index 0000000..ab925be --- /dev/null +++ b/core/src/test/java/io/openepcis/validation/test/JsonValidationMessageTest.java @@ -0,0 +1,339 @@ +/* + * 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.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.*; + +/** + * 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); + return validate(document); + } + + private List validate(final InputStream document) { + 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(); + + // 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 " + + "(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 could not be read")); + } + + @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); + } + + // 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/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..986bcdc 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,70 @@ */ 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()); - } +import java.io.InputStream; +import java.util.stream.Stream; - @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 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; +import static org.junit.jupiter.api.Assertions.assertNotNull; - @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()); - } +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_PATH_EVENT = "2.0/EPCIS/XML/Capture/Events"; + private static final String CAPTURE_1_2_PATH = "1.2/EPCIS/XML/Capture/Documents"; - @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()); - } + @ParameterizedTest(name = "{index}: {0}") + @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()); + } - @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()); - } + static Stream validationTestCases() { + return Stream.of( + 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("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), + Arguments.of("EPCIS bare event with no error", CAPTURE_PATH_EVENT + "/TransactionEvent.xml", CAPTURE, VERSION_2_0_0, 0), - @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()); - } + // 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/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..63193eb --- /dev/null +++ b/core/src/test/java/io/openepcis/validation/test/XmlValidationMessageTest.java @@ -0,0 +1,285 @@ +/* + * 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 + System.out.println(error); + assertNotNull(error.getLine()); + 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 + @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()); + } + + // --------------------------------------------------------------------------- + // 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. + */ + @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..741dfc5 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,7 @@ - 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/deployment/pom.xml b/quarkus/deployment/pom.xml index 1d6891c..749b6d6 100644 --- a/quarkus/deployment/pom.xml +++ b/quarkus/deployment/pom.xml @@ -1,7 +1,7 @@