diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index a7ee9824..9567f4fa 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -39,6 +39,8 @@ * [Launch an IPO](build-a-dkg-node-ai-agent/advanced-features-and-toolkits/dkg-paranets/initial-paranet-offerings-ipos/launching-your-ipo.md) * [Incentives pool](build-a-dkg-node-ai-agent/advanced-features-and-toolkits/dkg-paranets/initial-paranet-offerings-ipos/paranets-incentives-pool.md) * [IPO voting](build-a-dkg-node-ai-agent/advanced-features-and-toolkits/dkg-paranets/initial-paranet-offerings-ipos/ipo-voting.md) +* [Plugins](build-a-dkg-node-ai-agent/plugins/README.md) + * [EPCIS Plugin](build-a-dkg-node-ai-agent/plugins/epcis-plugin.md) * [Contributing a plugin](build-a-dkg-node-ai-agent/contributing-a-plugin.md) ## Contribute to the DKG diff --git a/docs/build-a-dkg-node-ai-agent/plugins/README.md b/docs/build-a-dkg-node-ai-agent/plugins/README.md new file mode 100644 index 00000000..b2c0f26c --- /dev/null +++ b/docs/build-a-dkg-node-ai-agent/plugins/README.md @@ -0,0 +1,14 @@ +# Plugins + +This section documents plugins that extend DKG Node functionality. + +Plugins can expose: + +- API endpoints +- MCP tools +- Integration-specific behavior and configuration + +## Available Plugins + +- [EPCIS Plugin](epcis-plugin.md) + diff --git a/docs/build-a-dkg-node-ai-agent/plugins/epcis-plugin.md b/docs/build-a-dkg-node-ai-agent/plugins/epcis-plugin.md new file mode 100644 index 00000000..d2b56270 --- /dev/null +++ b/docs/build-a-dkg-node-ai-agent/plugins/epcis-plugin.md @@ -0,0 +1,155 @@ +# EPCIS Plugin + +The EPCIS plugin integrates EPCIS 2.0 supply-chain event data with the DKG Node. + +It provides both HTTP endpoints and MCP tools for: + +- capturing EPCIS documents +- checking capture status +- querying events with filters +- retrieving published assets by UAL + +## Source + +- Plugin code: `packages/plugin-epcis/src/index.ts` +- Query service: `packages/plugin-epcis/src/services/epcisQueryService.ts` +- Integration guide: `packages/plugin-epcis/docs/EPCIS-Integration-Guide.md` + +## Quick Start + +1. Ensure publisher plugin and epcis plugin is enabled in server plugin registration: + - `apps/agent/src/server/index.ts` should include `dkgPublisherPlugin` in the `plugins` array. + - `apps/agent/src/server/index.ts` should include `epcisPlugin` in the `plugins` array. +2. Run publisher plugin setup: + - `cd packages/plugin-dkg-publisher && npm run setup` + - This initializes publisher configuration (including `.env.publisher`) for the publisher flow. +3. Configure runtime environment: + - `EXPO_PUBLIC_MCP_URL=http://localhost:9200` (local same-host setup) +4. Start the DKG Node server. +5. Submit an EPCIS document via `POST /epcis/capture`. +6. Query captured events via `GET /epcis/events`. + +## Capabilities + +### API Endpoints + +- `POST /epcis/capture` + Accepts an EPCIS document and sends it to publisher flow. + Returns a numeric `captureID` on success. + +- `GET /epcis/capture/:captureID` + Gets publisher-tracked status for numeric capture IDs. + +- `GET /epcis/events` + Queries EPCIS events with filtering and pagination. + +- `GET /epcis/asset/*ual` + Retrieves an EPCIS asset by UAL. + +### MCP Tools + +- `epcis-query` +- `epcis-track-item` + +## Configuration + +Required runtime env var: + +- `EXPO_PUBLIC_MCP_URL` (example local setup: `http://localhost:9200`) + +Runtime dependency: + +- Publisher API must be available through the same server URL (or routed URL) used by `EXPO_PUBLIC_MCP_URL`. + +If `EXPO_PUBLIC_MCP_URL` is not set, capture and status calls that depend on publisher will fail. + +## Example Requests + +### Capture EPCIS document + +```bash +curl -X POST "http://localhost:9200/epcis/capture" \ + -H "Content-Type: application/json" \ + -d '{ + "epcisDocument": { + "@context": { + "@vocab": "https://gs1.github.io/EPCIS/", + "epcis": "https://gs1.github.io/EPCIS/", + "cbv": "https://ref.gs1.org/cbv/", + "type": "@type", + "id": "@id" + }, + "type": "EPCISDocument", + "schemaVersion": "2.0", + "creationDate": "2024-03-01T08:00:00Z", + "epcisBody": { + "eventList": [ + { + "type": "ObjectEvent", + "eventTime": "2024-03-01T08:00:00.000Z", + "eventTimeZoneOffset": "+00:00", + "epcList": ["urn:epc:id:sgtin:4012345.011111.1001"], + "action": "ADD", + "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving", + "disposition": "https://ref.gs1.org/cbv/Disp-in_progress", + "readPoint": { "id": "urn:epc:id:sgln:4012345.00001.0" }, + "bizLocation": { "id": "urn:epc:id:sgln:4012345.00001.0" }, + "bizTransactionList": [ + { + "type": "https://ref.gs1.org/cbv/BTT-po", + "bizTransaction": "urn:epc:id:gdti:4012345.00001.PO-2024-001" + } + ] + } + ] + } + }, + "publishOptions": { + "privacy": "private", + "epochs": 12 + } + }' +``` + +### Check capture status + +```bash +curl "http://localhost:9200/epcis/capture/123" +``` + +### Query events with filters + +```bash +curl "http://localhost:9200/epcis/events?epc=urn:epc:id:sgtin:4012345.011111.1001&fullTrace=true&limit=50&offset=0" +``` + +## Query Notes + +- `fullTrace` (HTTP query) supports: `"true"` or `"false"` +- `limit`: integer `1..1000` +- `offset`: integer `>= 0` +- `bizStep` accepts shorthand (for example `assembling`) or full URI + +## Response and Validation Notes + +- `POST /epcis/capture` validates the EPCIS document structure before publishing. +- `GET /epcis/capture/:captureID` expects numeric capture IDs from publisher responses. +- `GET /epcis/events` rejects invalid pagination and empty-string filter parameters. + +## Troubleshooting + +- `Publisher endpoint not configured. Set EXPO_PUBLIC_MCP_URL in .env` + Set `EXPO_PUBLIC_MCP_URL` in runtime environment. + +- `Invalid captureID format` + Use numeric capture IDs returned by `POST /epcis/capture`. + +- `Parameter 'limit' must be an integer between 1 and 1000` + Ensure pagination values are valid integers. + +## Related Documentation + +For full EPCIS field-level details and examples, see: + +- `packages/plugin-epcis/docs/EPCIS-Integration-Guide.md` + diff --git a/package-lock.json b/package-lock.json index 150c7fe3..374fbc59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28364,6 +28364,7 @@ "devDependencies": { "@dkg/eslint-config": "*", "@dkg/typescript-config": "*", + "supertest": "^7.2.2", "tsup": "^8.5.0" } }, @@ -28383,12 +28384,81 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "packages/plugin-epcis/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "packages/plugin-epcis/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "packages/plugin-epcis/node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/plugin-epcis/node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "packages/plugin-epcis/node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "packages/plugin-example": { "name": "@dkg/plugin-example", "version": "0.0.3", diff --git a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md index 63aef1c5..dc52d042 100644 --- a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md +++ b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md @@ -1,14 +1,26 @@ # πŸ“˜ EPCIS-DKG Integration Guide +This document explains all fields used in EPCIS 2.0 documents and provides comprehensive reference for integrating with the OriginTrail Decentralized Knowledge Graph (DKG). + ## Table of Contents 1. [Overview & Architecture](#1-overview--architecture) -2. [Quick Start](#2-quick-start) -3. [EPCIS Event Types Explained](#3-epcis-event-types-explained) -4. [API Reference](#4-api-reference) -5. [Data Flow & DKG Publishing](#5-data-flow--dkg-publishing) -6. [Query Examples](#6-query-examples) -7. [Troubleshooting](#7-troubleshooting) +2. [Document Structure](#2-document-structure) +3. [JSON-LD Context](#3-json-ld-context) +4. [Event Types](#4-event-types) +5. [Event Fields Reference](#5-event-fields-reference) +6. [Business Step (bizStep)](#6-business-step-bizstep) +7. [Disposition](#7-disposition) +8. [Business Transaction Types](#8-business-transaction-types) +9. [GS1 URN Schemes](#9-gs1-urn-schemes) +10. [API Reference](#10-api-reference) +11. [MCP Tools Reference](#11-mcp-tools-reference) + - [Source Knowledge Assets](#source-knowledge-assets) + - [Event Result Structure](#event-result-structure) +12. [Query Examples](#12-query-examples) +13. [Data Flow & DKG Publishing](#13-data-flow--dkg-publishing) +14. [Sample EPCIS Documents](#14-sample-epcis-documents) +15. [Troubleshooting](#15-troubleshooting) --- @@ -24,318 +36,1082 @@ This integration bridges **GS1 EPCIS 2.0** (Electronic Product Code Information ### Why Use DKG for EPCIS? -| Traditional EPCIS | EPCIS + DKG | -|-------------------|-------------| -| Centralized database | Decentralized, permissionless network | -| Single point of failure | Replicated across multiple nodes | -| Trust the provider | Cryptographically verifiable | -| Siloed data | Interlinked Knowledge Graph | -| Company-controlled | Owned via blockchain (UAL) | +| Traditional EPCIS | EPCIS + DKG | +| ----------------------- | ------------------------------------- | +| Centralized database | Decentralized, permissionless network | +| Single point of failure | Replicated across multiple nodes | +| Trust the provider | Cryptographically verifiable | +| Siloed data | Interlinked Knowledge Graph | +| Company-controlled | Owned via blockchain (UAL) | ### Architecture Overview ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Your Application β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ HTTP POST /epcis/capture - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ EPCIS Plugin β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Validation │───▢│ JSON-LD Transform β”‚ β”‚ -β”‚ β”‚ (GS1 Schema) β”‚ β”‚ (EPCIS Context) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό +β”‚ Your Application / AI Agent (MCP) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ HTTP API β”‚ MCP Tools + β”‚ POST /epcis/capture β”‚ epcis-query + β”‚ GET /epcis/capture/:captureID β”‚ epcis-track-item + β”‚ GET /epcis/events β”‚ epcis-capture + β”‚ GET /epcis/events/track β”‚ epcis-capture-status + β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ DKG Publisher Plugin β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Asset Queue │───▢│ BullMQ │───▢│ DKG Network β”‚ β”‚ -β”‚ β”‚ (MySQL) β”‚ β”‚ Workers β”‚ β”‚ (via dkg.js) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό +β”‚ EPCIS Plugin β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Validation β”‚ β”‚ Query Serviceβ”‚ β”‚ Publisher Serviceβ”‚ β”‚ +β”‚ β”‚ (GS1 Schema) β”‚ β”‚ (SPARQL) β”‚ β”‚ (HTTP β†’ DKG) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β”‚ β”‚ SPARQL SELECT β”‚ HTTP POST + β”‚ β–Ό β–Ό + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ DKG Graph β”‚ β”‚ DKG Publisher β”‚ + β”‚ β”‚ (dkg.js) β”‚ β”‚ (/api/dkg/assets)β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β”‚ β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ OriginTrail Decentralized Knowledge Graph β”‚ -β”‚ β”‚ +β”‚ OriginTrail Decentralized Knowledge Graph β”‚ +β”‚ β”‚ β”‚ Knowledge Asset (UAL: did:dkg:otp/0x.../123456) β”‚ β”‚ β”œβ”€β”€ EPCIS Event Data (RDF/JSON-LD) β”‚ -β”‚ β”œβ”€β”€ Cryptographic Proof (Blockchain anchored) β”‚ -β”‚ └── Ownership (NFT) β”‚ +β”‚ β”œβ”€β”€ Cryptographic Proof (Blockchain anchored) β”‚ +β”‚ └── Ownership (NFT) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` --- -## 2. Quick Start +## 2. Document Structure -### Prerequisites +An EPCIS capture request consists of two main parts: + +```json +{ + "epcisDocument": { ... }, // The EPCIS document wrapper + "publishOptions": { ... } // DKG publishing configuration (optional) +} +``` -- DKG Node running (with EPCIS and Publisher plugins enabled) -- Access to the API endpoint (default: `http://localhost:9200`) +### epcisDocument Fields -### Step 1: Send Your First EPCIS Event +| Field | Example | Description | +| --------------- | ------------------------ | ----------------------------------------------- | +| `@context` | `{...}` | JSON-LD context for semantic interpretation | +| `type` | `"EPCISDocument"` | Document type identifier (must be exactly this) | +| `schemaVersion` | `"2.0"` | EPCIS schema version | +| `creationDate` | `"2024-03-01T08:00:00Z"` | When document was created (ISO 8601) | +| `epcisBody` | `{ eventList: [...] }` | Container for event data | -```bash -curl -X POST http://localhost:9200/epcis/capture \ - -H "Content-Type: application/json" \ - -d '{ +### publishOptions Fields (DKG-specific) + +| Field | Example | Default | Description | +| --------- | ----------- | ----------- | ------------------------------------------- | +| `privacy` | `"private"` | `"private"` | Asset visibility: `"private"` or `"public"` | +| `epochs` | `12` | `12` | How many epochs to keep asset published | + +> Both fields are optional. When omitted, the defaults above are used. + +--- + +## 3. JSON-LD Context + +The `@context` defines JSON-LD namespaces for semantic interpretation. It is **extensible** - you can add custom namespaces for domain-specific vocabularies. + +### Standard Context + +```json +"@context": { + "@vocab": "https://gs1.github.io/EPCIS/", + "epcis": "https://gs1.github.io/EPCIS/", + "cbv": "https://ref.gs1.org/cbv/", + "type": "@type", + "id": "@id" +} +``` + +| Key | Purpose | +| -------- | ---------------------------------------------------------- | +| `@vocab` | Default namespace for unmapped terms | +| `epcis` | EPCIS vocabulary namespace prefix | +| `cbv` | Core Business Vocabulary namespace (GS1 standard values) | +| `type` | JSON-LD alias for `@type` (required for DKG compatibility) | +| `id` | JSON-LD alias for `@id` | + +### Extended Context with Custom Namespaces + +```json +"@context": { + "@vocab": "https://gs1.github.io/EPCIS/", + "epcis": "https://gs1.github.io/EPCIS/", + "cbv": "https://ref.gs1.org/cbv/", + "type": "@type", + "id": "@id", + + "mycompany": "https://mycompany.com/ontology/", + "schema": "https://schema.org/", + "scor": "http://purl.org/ontology/scor#", + "gr": "http://purl.org/goodrelations/v1#" +} +``` + +### Common Extension Namespaces + +| Prefix | Namespace | Purpose | +| --------- | ----------------------------------- | --------------------------------- | +| `schema` | `https://schema.org/` | General-purpose vocabulary | +| `scor` | `http://purl.org/ontology/scor#` | Supply Chain Operations Reference | +| `gr` | `http://purl.org/goodrelations/v1#` | E-commerce and business | +| `foaf` | `http://xmlns.com/foaf/0.1/` | People and organizations | +| `dcterms` | `http://purl.org/dc/terms/` | Dublin Core metadata | + +> **Important:** Always include `"type": "@type"` in your context for DKG JSON-LD processing compatibility. + +--- + +## 4. Event Types + +EPCIS defines five event types, each serving a specific purpose in supply chain tracking: + +| Event Type | Purpose | Key Fields | Example Use Case | +| ----------------------- | ----------------------------- | --------------------------------- | ----------------------------------- | +| **ObjectEvent** | Track individual objects | `epcList`, `action` | Receiving goods, quality inspection | +| **AggregationEvent** | Parent-child relationships | `parentID`, `childEPCs`, `action` | Packing items onto a pallet | +| **TransactionEvent** | Link to business transactions | `bizTransactionList` | Purchase order fulfillment | +| **TransformationEvent** | Input/output transformations | `inputEPCList`, `outputEPCList` | Manufacturing, assembly | +| **AssociationEvent** | Link assets together | `parentID`, `childEPCs` | Sensor attached to container | + +### Event Type Decision Guide + +``` +Is the item being created from other items? +β”œβ”€β”€ YES β†’ TransformationEvent (inputs β†’ outputs) +└── NO + β”œβ”€β”€ Are items being grouped/ungrouped? + β”‚ └── YES β†’ AggregationEvent (parent-child) + └── NO + β”œβ”€β”€ Is this linked to a business document? + β”‚ └── YES β†’ TransactionEvent + └── NO β†’ ObjectEvent (most common) +``` + +--- + +## 5. Event Fields Reference + +### Core Event Identifiers + +| Field | Example | Description | +| --------------------- | ---------------------------- | ---------------------------------- | +| `type` | `"ObjectEvent"` | Event type identifier | +| `eventID` | `"urn:uuid:event:001"` | Unique event identifier (optional) | +| `eventTime` | `"2024-03-01T08:00:00.000Z"` | When event occurred (ISO 8601) | +| `eventTimeZoneOffset` | `"+00:00"` | Timezone offset from UTC | + +### What (Items Being Tracked) + +#### For ObjectEvent + +| Field | Example | Description | +| --------- | ------------------------------------------ | --------------------------- | +| `epcList` | `["urn:epc:id:sgtin:4012345.011111.1001"]` | List of EPCs being observed | +| `action` | `"ADD"` | Event action type | + +#### For AggregationEvent + +| Field | Example | Description | +| ----------- | ------------------------------------------ | ----------------------------------- | +| `parentID` | `"urn:epc:id:sscc:4012345.0000000001"` | Container/parent EPC | +| `childEPCs` | `["urn:epc:id:sgtin:4012345.099999.9001"]` | Items inside the container | +| `action` | `"ADD"` | ADD (packing) or DELETE (unpacking) | + +#### For TransformationEvent + +| Field | Example | Description | +| --------------- | -------------------------- | ------------------- | +| `inputEPCList` | `["urn:epc:id:sgtin:..."]` | Components consumed | +| `outputEPCList` | `["urn:epc:id:sgtin:..."]` | Products created | + +### Action Values + +| Action | Description | Use Case | +| --------- | ------------------------------------- | --------------------------------------- | +| `ADD` | Objects entering the supply chain | Commissioning, receiving, packing | +| `OBSERVE` | Objects observed without state change | Scanning, tracking, inspection | +| `DELETE` | Objects leaving the supply chain | Decommissioning, unpacking, destruction | + +### Where (Location Fields) + +| Field | Example | Description | +| ------------- | ------------------------------------------- | ---------------------------- | +| `readPoint` | `{"id": "urn:epc:id:sgln:4012345.00001.0"}` | Specific scan/read location | +| `bizLocation` | `{"id": "urn:epc:id:sgln:4012345.00001.0"}` | Business location (facility) | + +**Difference:** + +- `readPoint` = Where the scanner/reader is (specific station, dock door) +- `bizLocation` = Business context location (warehouse, production line, facility) + +### Why (Business Context) + +| Field | Example | Description | +| -------------------- | --------------------------------------------- | ------------------------- | +| `bizStep` | `"https://ref.gs1.org/cbv/BizStep-receiving"` | Business process step | +| `disposition` | `"https://ref.gs1.org/cbv/Disp-in_progress"` | Current state/condition | +| `bizTransactionList` | `[{type, bizTransaction}]` | Linked business documents | + +--- + +## 6. Business Step (bizStep) + +The `bizStep` field indicates what business process step is occurring. You can use either the full URI or shorthand (the API accepts both). + +### Commissioning & Decommissioning + +| BizStep | Description | +| ----------------- | ---------------------------------- | +| `commissioning` | Creating a new serialized instance | +| `decommissioning` | Removing from active use | + +### Manufacturing & Production + +| BizStep | Description | +| ------------- | ----------------------------------- | +| `assembling` | Combining components into a product | +| `disassembly` | Breaking down into components | +| `repairing` | Fixing a defective item | +| `repackaging` | Changing packaging | + +### Warehousing & Logistics + +| BizStep | Description | +| ------------------ | ---------------------------- | +| `receiving` | Goods arriving at a location | +| `shipping` | Goods departing a location | +| `storing` | Placing into storage | +| `picking` | Retrieving from storage | +| `packing` | Placing into containers | +| `unpacking` | Removing from containers | +| `loading` | Loading onto transport | +| `unloading` | Unloading from transport | +| `transporting` | In transit | +| `staging_outbound` | Staged for shipping | +| `arriving` | Arriving at destination | +| `departing` | Leaving a location | + +### Quality & Compliance + +| BizStep | Description | +| ------------ | -------------------------- | +| `inspecting` | Quality inspection | +| `accepting` | Accepting after inspection | +| `rejecting` | Rejecting after inspection | +| `holding` | Quarantine/hold status | +| `releasing` | Releasing from hold | + +### Retail & Commerce + +| BizStep | Description | +| ---------------- | ------------------ | +| `retail_selling` | Point of sale | +| `sampling` | Taking samples | +| `void_shipping` | Voiding a shipment | + +### Other + +| BizStep | Description | +| ------------------ | -------------------- | +| `cycle_counting` | Inventory count | +| `destroying` | Destruction of items | +| `encoding` | RFID encoding | +| `sensor_reporting` | Sensor data capture | + +**URI Format:** `https://ref.gs1.org/cbv/BizStep-{value}` + +**Shorthand:** The API accepts just the step name (e.g., `"assembling"`) and expands it automatically. + +--- + +## 7. Disposition + +The `disposition` field indicates the current state/condition of objects. + +### Process States + +| Disposition | Description | +| ------------- | ------------------------- | +| `in_progress` | Currently being processed | +| `in_transit` | Being transported | +| `active` | In active use | +| `inactive` | Not currently in use | + +### Container/Packaging States + +| Disposition | Description | +| ------------------ | ------------------- | +| `container_open` | Container is open | +| `container_closed` | Container is sealed | + +### Quality States + +| Disposition | Description | +| ------------------- | ----------------------- | +| `conformant` | Meets quality standards | +| `non_conformant` | Does not meet standards | +| `needs_replacement` | Requires replacement | +| `damaged` | Physical damage | +| `expired` | Past expiration date | + +### Inventory States + +| Disposition | Description | +| ------------------------- | ----------------------------- | +| `available` | Available for use/sale | +| `unavailable` | Not available | +| `reserved` | Reserved for specific purpose | +| `sellable_accessible` | Can be sold, accessible | +| `sellable_not_accessible` | Can be sold, not accessible | +| `non_sellable` | Cannot be sold | + +### Special States + +| Disposition | Description | +| ----------- | ------------------ | +| `recalled` | Subject to recall | +| `returned` | Returned item | +| `stolen` | Reported stolen | +| `destroyed` | Has been destroyed | +| `disposed` | Disposed of | +| `encoded` | RFID encoded | +| `unknown` | State unknown | + +**URI Format:** `https://ref.gs1.org/cbv/Disp-{value}` + +--- + +## 8. Business Transaction Types + +The `bizTransactionList` links events to business documents. + +### Standard Transaction Types (CBV 2.0) + +| Type Code | Description | Example Use | +| ----------- | -------------------------------- | --------------------------- | +| `po` | Purchase Order | Customer order | +| `prodorder` | Production Order | Manufacturing work order | +| `desadv` | Despatch Advice | Shipping notification (ASN) | +| `recadv` | Receiving Advice | Receipt confirmation | +| `inv` | Invoice | Billing document | +| `rma` | Return Merchandise Authorization | Return authorization | +| `pedigree` | Pedigree | Chain of custody | +| `cert` | Certificate | Quality certificate | + +**URI Format:** `https://ref.gs1.org/cbv/BTT-{type}` + +**Example:** + +```json +"bizTransactionList": [ + { + "type": "https://ref.gs1.org/cbv/BTT-po", + "bizTransaction": "urn:epc:id:gdti:4012345.00001.PO-2024-001" + } +] +``` + +--- + +## 9. GS1 URN Schemes + +GS1 URN (Uniform Resource Name) schemes provide globally unique identifiers for tracking items, locations, documents, and assets. + +### Overview + +| Scheme | Full Name | Used For | Granularity | +| --------- | ----------------------------------- | ------------------ | --------------- | +| **SGTIN** | Serialized Global Trade Item Number | Individual items | Unit level | +| **LGTIN** | Lot/Batch GTIN | Batch/lot tracking | Batch level | +| **SGLN** | Serialized Global Location Number | Locations | Location level | +| **SSCC** | Serial Shipping Container Code | Containers/pallets | Container level | +| **GRAI** | Global Returnable Asset ID | Reusable assets | Asset level | +| **GIAI** | Global Individual Asset ID | Fixed assets | Asset level | +| **GDTI** | Global Document Type ID | Documents | Document level | + +--- + +### SGTIN - Serialized Global Trade Item Number + +**Purpose:** Uniquely identify individual product instances (serialized items). + +**Format:** + +``` +urn:epc:id:sgtin:{CompanyPrefix}.{ItemReference}.{SerialNumber} +``` + +**Breakdown (Bicycle Manufacturing Example):** + +``` +urn:epc:id:sgtin:4012345.011111.1001 + β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”˜ + β”‚ β”‚ β”‚ + β”‚ β”‚ └── Serial Number (unique instance: 1001) + β”‚ └──────── Item Reference (product: carbon frame) + └─────────────── Company Prefix (Alpine Cycles: 4012345) +``` + +**Examples from Bicycle Manufacturing:** + +| Item | EPC | +| ---------------- | -------------------------------------- | +| Carbon Frame | `urn:epc:id:sgtin:4012345.011111.1001` | +| Front Wheel | `urn:epc:id:sgtin:4012345.022222.2001` | +| Rear Wheel | `urn:epc:id:sgtin:4012345.022222.2002` | +| Handlebar | `urn:epc:id:sgtin:4012345.033333.3001` | +| Finished Bicycle | `urn:epc:id:sgtin:4012345.099999.9001` | + +--- + +### SGLN - Serialized Global Location Number + +**Purpose:** Identify physical locations (facilities, zones, stations). + +**Format:** + +``` +urn:epc:id:sgln:{CompanyPrefix}.{LocationReference}.{Extension} +``` + +**Breakdown:** + +``` +urn:epc:id:sgln:4012345.00001.0 + β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”˜ β””β”˜ + β”‚ β”‚ β”‚ + β”‚ β”‚ └── Extension (specific point, 0 = general) + β”‚ └─────── Location Reference (area/zone) + └────────────── Company Prefix +``` + +**Examples from Bicycle Manufacturing:** + +| Location | EPC | +| -------------- | --------------------------------- | +| Receiving Dock | `urn:epc:id:sgln:4012345.00001.0` | +| Quality Lab | `urn:epc:id:sgln:4012345.00002.0` | +| Assembly Line | `urn:epc:id:sgln:4012345.00003.0` | +| Packing Area | `urn:epc:id:sgln:4012345.00004.0` | +| Shipping Dock | `urn:epc:id:sgln:4012345.00005.0` | + +--- + +### SSCC - Serial Shipping Container Code + +**Purpose:** Identify logistics units (pallets, containers, cases). + +**Format:** + +``` +urn:epc:id:sscc:{CompanyPrefix}.{SerialReference} +``` + +**Example:** + +``` +urn:epc:id:sscc:4012345.0000000001 + β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ └── Serial Reference (unique container ID) + └─────────── Company Prefix +``` + +**Use Cases:** +| Container Type | Example | +|----------------|---------| +| Shipping Pallet | `urn:epc:id:sscc:4012345.0000000001` | +| Cardboard Case | `urn:epc:id:sscc:4012345.CASE000123` | +| Shipping Container | `urn:epc:id:sscc:4012345.CONT456789` | + +--- + +### GDTI - Global Document Type Identifier + +**Purpose:** Identify business documents. + +**Format:** + +``` +urn:epc:id:gdti:{CompanyPrefix}.{DocumentType}.{SerialNumber} +``` + +**Examples:** + +| Document Type | Example | +| --------------- | -------------------------------------------- | +| Purchase Order | `urn:epc:id:gdti:4012345.00001.PO-2024-001` | +| Despatch Advice | `urn:epc:id:gdti:4012345.00001.ASN-2024-001` | +| Invoice | `urn:epc:id:gdti:4012345.00001.INV-12345` | + +--- + +## 10. API Reference + +### POST `/epcis/capture` + +Accept an EPCIS Document and queue it for publishing to DKG. + +**Request Body:** + +```json +{ + "epcisDocument": { "@context": { "@vocab": "https://gs1.github.io/EPCIS/", "epcis": "https://gs1.github.io/EPCIS/", "cbv": "https://ref.gs1.org/cbv/", "type": "@type", - "id": "@id", - "epcisBody": "epcis:epcisBody", - "eventList": "epcis:eventList" + "id": "@id" }, "type": "EPCISDocument", "schemaVersion": "2.0", - "creationDate": "2024-01-01T00:00:00Z", + "creationDate": "2024-03-01T08:00:00Z", "epcisBody": { - "eventList": [{ - "type": "ObjectEvent", - "eventTime": "2024-01-01T00:00:00.000Z", - "eventTimeZoneOffset": "+00:00", - "epcList": ["urn:epc:id:sgtin:0614141.107346.2017"], - "action": "OBSERVE", - "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving", - "disposition": "https://ref.gs1.org/cbv/Disp-in_progress", - "readPoint": {"id": "urn:epc:id:sgln:0614141.00001.0"}, - "bizLocation": {"id": "urn:epc:id:sgln:0614141.00001.0"} - }] + "eventList": [ + /* array of events */ + ] } - }' + }, + "publishOptions": { + "privacy": "private", + "epochs": 12 + } +} ``` -### Step 2: Check Status +**Responses:** -The response includes a `captureID`. Use it to check publishing status: +| Status | When | Description | +| ----------------------------- | ------------------------- | ----------------------------------------------------------------------- | +| **202 Accepted** | Document valid and queued | Capture forwarded to publisher; includes `captureID` for status polling | +| **400 Bad Request** | Validation failed | GS1 schema validation error or document contains no events | +| **500 Internal Server Error** | Publisher unreachable | Publisher service unavailable after retry attempts | -```bash -curl http://localhost:9200/epcis/capture/123 +**Example (HTTP 202 Accepted):** + +```json +{ + "status": "202", + "requestId": "epcis-1709280001123-a1b2c3", + "receivedAt": "2024-03-01T08:00:01.123Z", + "captureID": "456", + "eventCount": 1 +} ``` -Possible statuses: +> Poll `GET /epcis/capture/:captureID` with the returned `captureID` to track publishing progress and retrieve the UAL once published. -- `queued` - Waiting to be published -- `processing` - Currently being published to DKG -- `published` - Successfully published (includes UAL) -- `failed` - Publishing failed (includes error message) +**Example (HTTP 400 Bad Request - Validation):** -### Step 3: Query Events +```json +{ + "error": "Invalid EPCISDocument", + "details": [ + "/epcisBody/eventList/0/eventTime: must match format \"date-time\"" + ] +} +``` -Once published, query events from the DKG: +**Example (HTTP 400 Bad Request - Empty Events):** -```bash -# By EPC -curl "http://localhost:9200/epcis/events?epc=urn:epc:id:sgtin:0614141.107346.2017" +```json +{ + "error": "EPCISDocument contains no events", + "message": "The EPCISDocument contains no events to publish. Please check the document and try again." +} +``` -# By time range -curl "http://localhost:9200/epcis/events?from=2024-01-01T00:00:00Z&to=2024-12-31T23:59:59Z" +**Example (HTTP 500 Internal Server Error):** -# By business step -curl "http://localhost:9200/epcis/events?bizStep=inspecting" +```json +{ + "error": "Something went wrong with publishing the EPCIS document.", + "message": "Something went wrong with publishing the EPCIS document. Check if the publisher service is available." +} ``` -> πŸ’‘ **Interactive Documentation**: For detailed request/response schemas and to test the API live, visit the Swagger UI at `/swagger` - --- -## 3. EPCIS Event Types Explained +### GET `/epcis/capture/:captureID` + +Check the status of a previously submitted capture tracked by the publisher. -### What is EPCIS? +> **Note:** `captureID` must be a numeric string matching the pattern `^[0-9]{1,20}$`. +> Use the numeric `captureID` returned from `POST /epcis/capture`. -EPCIS (Electronic Product Code Information Services) is a GS1 standard for capturing and sharing supply chain events. It answers the "what, where, when, and why" of products moving through a supply chain. +**Responses:** -### The Five Event Types +| Status | When | Description | +| ----------------------------- | ------------------------ | --------------------------------------------------- | +| **200 OK** | Capture found | Returns current status, optional UAL and timestamps | +| **404 Not Found** | Unknown captureID | No capture with this ID exists in the publisher | +| **500 Internal Server Error** | Upstream publisher error | Unexpected publisher/status lookup failure | +| **504 Gateway Timeout** | Publisher timeout | Publisher service did not respond in time | -| Event Type | Purpose | Example Use Case | -|------------|---------|------------------| -| **ObjectEvent** | Track individual items | Product inspection, quality check | -| **AggregationEvent** | Items grouped/ungrouped | Packing items into a case | -| **TransactionEvent** | Business transactions | Purchase order, invoice | -| **TransformationEvent** | Inputβ†’Output conversion | Manufacturing, assembly | -| **AssociationEvent** | Link assets together | Sensor attached to container | +**Example (HTTP 200 OK):** -### Action Types +```json +{ + "status": "published", + "captureID": "456", + "UAL": "did:dkg:otp/0x1234.../789", + "publishedAt": "2024-03-01T08:01:23.456Z" +} +``` -- **ADD** - New item introduced (e.g., manufactured, received) -- **OBSERVE** - Item observed without state change (e.g., scanned at checkpoint) -- **DELETE** - Item removed from tracking (e.g., sold, destroyed) +**Example (Failed):** -### Business Steps (bizStep) +```json +{ + "status": "failed", + "captureID": "456", + "error": "Wallet balance insufficient" +} +``` -Common GS1 CBV (Core Business Vocabulary) business steps: +**Example (HTTP 500 Internal Server Error):** -| bizStep | Description | -|---------|-------------| -| `receiving` | Goods received at a location | -| `shipping` | Goods shipped from a location | -| `inspecting` | Quality inspection performed | -| `assembling` | Components assembled into product | -| `packing` | Items packed for shipment | -| `commissioning` | New serial assigned (e.g., manufacturing) | -| `decommissioning` | Serial number retired | +```json +{ + "error": "Failed to get capture status" +} +``` -> **Shorthand supported**: You can use just `"assembling"` instead of the full URI `"https://ref.gs1.org/cbv/BizStep-assembling"` +| Status | Description | +| ------------ | ------------------------------------------ | +| `pending` | Registered but not yet queued | +| `queued` | Waiting to be published | +| `assigned` | Assigned to a publishing wallet | +| `publishing` | Currently being published to DKG | +| `published` | Successfully published (includes UAL) | +| `failed` | Publishing failed (includes error message) | --- -## 4. API Reference +### GET `/epcis/events` + +Query EPCIS events from the DKG using SPARQL. + +**Validation Rules:** -### Understanding the JSON-LD Context +- At least one filter parameter is required (excluding `fullTrace`, `limit`, `offset`) +- When both `from` and `to` are provided, `to` must be >= `from` +- Empty string values are rejected for all filter parameters +- Date parameters must be valid ISO 8601 datetime strings -EPCIS documents use JSON-LD (Linked Data) format. The `@context` object maps terms to URIs for proper semantic interpretation: +**Query Parameters:** + +| Parameter | Type | Description | Example | +| ------------- | ----------------- | ------------------------------------------------------------------- | -------------------------------------- | +| `epc` | string | Filter by EPC identifier | `urn:epc:id:sgtin:4012345.011111.1001` | +| `from` | string (ISO 8601) | Start of time range | `2024-03-01T00:00:00Z` | +| `to` | string (ISO 8601) | End of time range | `2024-03-31T23:59:59Z` | +| `bizStep` | string | Filter by business step | `assembling` or full URI | +| `bizLocation` | string | Filter by location | `urn:epc:id:sgln:4012345.00002.0` | +| `fullTrace` | string enum | `"true"` or `"false"` - search all EPC fields for full traceability | `"true"` | +| `parentID` | string | Filter by parent EPC (AggregationEvent) | `urn:epc:id:sscc:...` | +| `childEPC` | string | Filter by child EPC (AggregationEvent) | `urn:epc:id:sgtin:...` | +| `inputEPC` | string | Filter by input EPC (TransformationEvent) | `urn:epc:id:sgtin:...` | +| `outputEPC` | string | Filter by output EPC (TransformationEvent) | `urn:epc:id:sgtin:...` | +| `limit` | integer | Results per page (default: 100, range: 1-1000) | `50` | +| `offset` | integer | Results to skip (pagination, min: 0) | `0` | + +**Responses:** + +| Status | When | Description | +| ----------------------------- | ----------------- | ---------------------------------------------- | +| **200 OK** | Query succeeded | Returns matching events with pagination | +| **400 Bad Request** | Validation failed | Missing filters, invalid date range, or params | +| **500 Internal Server Error** | DKG query failed | Failed to execute SPARQL query against DKG | + +**Example (HTTP 200 OK):** ```json { - "@context": { - "@vocab": "https://gs1.github.io/EPCIS/", - "epcis": "https://gs1.github.io/EPCIS/", - "cbv": "https://ref.gs1.org/cbv/", - "type": "@type", - "id": "@id", - "epcisBody": "epcis:epcisBody", - "eventList": "epcis:eventList" + "success": true, + "results": [ + { + "ual": "did:dkg:otp:2043/0x.../1/private", + "eventType": "https://gs1.github.io/EPCIS/ObjectEvent", + "eventTime": "2024-03-01T08:00:00.000Z", + "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving", + "bizLocation": "urn:epc:id:sgln:4012345.00001.0", + "disposition": "https://ref.gs1.org/cbv/Disp-in_progress", + "readPoint": "urn:epc:id:sgln:4012345.00001.0", + "action": "ADD", + "epcList": "urn:epc:id:sgtin:4012345.011111.1001" + } + ], + "count": 1, + "pagination": { + "limit": 100, + "offset": 0 } } ``` -| Key | Purpose | -|-----|---------| -| `@vocab` | Default namespace for unmapped terms | -| `epcis` | EPCIS vocabulary namespace | -| `cbv` | GS1 Core Business Vocabulary | -| `type` / `id` | Maps to JSON-LD keywords | -| `epcisBody`, `eventList` | Explicit term mappings | +> Each result row includes a `ual` field identifying which Knowledge Asset graph the event was found in. Array fields (`epcList`, `childEPCList`, `inputEPCs`, `outputEPCs`) are returned as comma-separated strings. + +**Example (HTTP 400 Bad Request):** + +```json +{ + "error": "At least one filter parameter is required." +} +``` + +**Example (HTTP 500 Internal Server Error):** -> **Note**: You can also use the shorthand `["https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld"]` but the explicit context above gives you more control and is properly tested. +```json +{ + "success": false, + "error": "Failed to query events" +} +``` --- -### POST `/epcis/capture` +### GET `/epcis/events/track` -Accept an EPCIS Document and queue it for publishing to DKG. +Track a single EPC through its full supply chain journey. This endpoint always performs a full-trace query across all EPC-relevant fields. + +**Query Parameters:** + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ----------------------------------------- | +| `epc` | string | **Yes** | EPC identifier to track across all events | -**Request Body**: EPCISDocument (JSON-LD) +**Responses:** + +| Status | When | Description | +| ----------------------------- | --------------------- | ---------------------------------- | +| **200 OK** | Query succeeded | Returns matching events for EPC | +| **400 Bad Request** | Missing/invalid `epc` | Query validation failed | +| **500 Internal Server Error** | DKG query failed | Failed to execute full-trace query | + +**Example (HTTP 200 OK):** ```json { - "@context": { - "@vocab": "https://gs1.github.io/EPCIS/", - "epcis": "https://gs1.github.io/EPCIS/", - "cbv": "https://ref.gs1.org/cbv/", - "type": "@type", - "id": "@id", - "epcisBody": "epcis:epcisBody", - "eventList": "epcis:eventList" - }, - "type": "EPCISDocument", - "schemaVersion": "2.0", - "creationDate": "2024-01-01T00:00:00Z", - "epcisBody": { - "eventList": [/* array of events */] + "success": true, + "results": [ + { + "ual": "did:dkg:otp:2043/0x.../6/private", + "eventType": "https://gs1.github.io/EPCIS/TransformationEvent", + "eventTime": "2024-03-01T14:00:00.000Z", + "bizStep": "https://ref.gs1.org/cbv/BizStep-assembling", + "bizLocation": "urn:epc:id:sgln:4012345.00003.0", + "inputEPCs": "urn:epc:id:sgtin:4012345.011111.1001, urn:epc:id:sgtin:4012345.022222.2001", + "outputEPCs": "urn:epc:id:sgtin:4012345.099999.9001" + } + ], + "count": 1 +} +``` + +**Example (HTTP 500 Internal Server Error):** + +```json +{ + "success": false, + "error": "Failed to query events" +} +``` + +--- + +## 11. MCP Tools Reference + +The EPCIS plugin exposes four MCP (Model Context Protocol) tools that AI agents can use to capture, query, and track EPCIS supply chain data. + +### `epcis-query` β€” Query EPCIS Events + +General-purpose query tool with the same filtering capabilities as `GET /epcis/events`. Returns matching events with pagination and source Knowledge Asset provenance. + +**Input Schema:** + +| Parameter | Type | Required | Description | +| ------------- | ----------------- | -------- | ------------------------------------------------------ | +| `epc` | string | No | EPC identifier to filter by | +| `from` | string (ISO 8601) | No | Start of time range | +| `to` | string (ISO 8601) | No | End of time range | +| `bizStep` | string | No | Business step (shorthand or full URI) | +| `bizLocation` | string | No | Business location URI | +| `fullTrace` | boolean | No | If `true`, search all EPC fields for full traceability | +| `parentID` | string | No | Parent ID for AggregationEvent queries | +| `childEPC` | string | No | Child EPC for AggregationEvent queries | +| `inputEPC` | string | No | Input EPC for TransformationEvent queries | +| `outputEPC` | string | No | Output EPC for TransformationEvent queries | +| `limit` | integer | No | Results per page (default: 100, max: 1000) | +| `offset` | integer | No | Results to skip for pagination (default: 0) | + +> **Note:** At least one filter parameter is required (excluding `fullTrace`, `limit`, `offset`). Unlike the HTTP API where `fullTrace` is a string (`"true"`/`"false"`), the MCP tool accepts a native boolean. + +**Response (first content block):** + +```json +{ + "summary": "Found 3 EPCIS event(s)", + "count": 3, + "events": [ + { + "ual": "did:dkg:otp:2043/0x.../1/private", + "eventType": "https://gs1.github.io/EPCIS/ObjectEvent", + "eventTime": "2024-03-01T08:00:00.000Z", + "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving", + "bizLocation": "urn:epc:id:sgln:4012345.00001.0", + "disposition": "https://ref.gs1.org/cbv/Disp-in_progress", + "readPoint": "urn:epc:id:sgln:4012345.00001.0", + "action": "ADD", + "epcList": "urn:epc:id:sgtin:4012345.011111.1001" + } + ], + "pagination": { + "limit": 100, + "offset": 0 } } ``` -**Response** (HTTP 202 Accepted): +When results contain events from DKG Knowledge Assets, a second content block is appended with **Source Knowledge Asset** provenance (see [Source Knowledge Assets](#source-knowledge-assets)). + +**Error cases:** + +- No filter parameters β†’ `{ "error": "At least one filter parameter is required." }` +- Invalid date range β†’ `{ "error": "Parameter 'to' must be greater than or equal to 'from'." }` +- DKG query failure β†’ `{ "error": "Query failed" }` + +--- + +### `epcis-track-item` β€” Track Item Journey + +Specialized tool for tracking a single item's complete journey through the supply chain. Automatically enables full traceability to find the item across all event types (observed, transformation input/output, aggregations). Returns events in chronological order. + +**Input Schema:** + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | --------------------------------------------------------------- | +| `epc` | string | **Yes** | The EPC to track (e.g., `urn:epc:id:sgtin:0614141.107346.2017`) | + +**Response (first content block):** + +```json +{ + "summary": "Tracking: urn:epc:id:sgtin:4012345.011111.1001\nFound 4 event(s) in the supply chain.\n\nJourney Timeline:\n1. [2024-03-01T08:00:00.000Z] receiving @ urn:epc:id:sgln:4012345.00001.0\n2. [2024-03-01T10:00:00.000Z] inspecting @ urn:epc:id:sgln:4012345.00002.0\n3. [2024-03-01T14:00:00.000Z] assembling @ urn:epc:id:sgln:4012345.00003.0\n4. [2024-03-01T16:00:00.000Z] packing @ urn:epc:id:sgln:4012345.00004.0\n", + "epc": "urn:epc:id:sgtin:4012345.011111.1001", + "eventCount": 4, + "events": [ + /* chronologically ordered event objects */ + ] +} +``` + +The `summary` field contains a human-readable timeline with numbered steps showing `[eventTime] bizStep @ location` for each event. When results are found, a second content block is appended with **Source Knowledge Asset** provenance (see [Source Knowledge Assets](#source-knowledge-assets)). + +**Error cases:** + +- DKG query failure β†’ `{ "error": "Tracking failed" }` + +--- + +### `epcis-capture` β€” Capture EPCIS Document + +Validates an EPCIS document and queues it for publishing via the DKG publisher service. + +**Input Schema:** + +| Parameter | Type | Required | Description | +| ---------------- | ------ | -------- | -------------------------------------------------- | +| `epcisDocument` | object | **Yes** | EPCIS 2.0 JSON-LD document | +| `publishOptions` | object | No | Optional publishing settings (`privacy`, `epochs`) | + +**Success Response:** ```json { - "status": "202", - "receivedAt": "2024-01-01T00:00:01.123Z", "captureID": "456", + "requestId": "epcis-1709280001123-a1b2c3", + "receivedAt": "2024-03-01T08:00:01.123Z", "eventCount": 1 } ``` +**Error cases:** + +- Validation error β†’ `{ "error": "Invalid EPCISDocument", "details": ["..."] }` +- Empty events β†’ `{ "error": "EPCISDocument contains no events", "message": "..." }` +- Publisher unavailable β†’ `{ "error": "Something went wrong with publishing the EPCIS document.", "message": "..." }` + --- -### GET `/epcis/capture/:captureID` +### `epcis-capture-status` β€” Get Capture Status + +Checks the publisher-tracked status for a capture request by numeric `captureID`. -Check the status of a previously submitted capture. +**Input Schema:** -**Response**: +| Parameter | Type | Required | Description | +| ----------- | ------ | -------- | ----------------------------------------------------------------- | +| `captureID` | string | **Yes** | Numeric capture ID (`^[0-9]{1,20}$`) returned by capture handlers | + +**Success Response:** ```json { "status": "published", "captureID": "456", "UAL": "did:dkg:otp/0x1234.../789", - "publishedAt": "2024-01-01T00:01:23.456Z" + "publishedAt": "2024-03-01T08:01:23.456Z" } ``` -| Field | Description | -|-------|-------------| -| `status` | `queued` / `processing` / `published` / `failed` | -| `UAL` | Uniform Asset Locator (only when published) | -| `error` | Error message (only when failed) | +> Fields `UAL`, `publishedAt`, and `error` are only present when applicable to the current status. + +**Error cases:** + +- Capture not found β†’ `{ "error": "Capture not found", "captureID": "456" }` +- Publisher timeout β†’ `{ "error": "Publisher timeout", "captureID": "456" }` +- Upstream failure β†’ `{ "error": "Failed to get capture status", "captureID": "456" }` --- -### GET `/epcis/events` +## Source Knowledge Assets -Query EPCIS events from the DKG. +MCP tool responses (`epcis-query` and `epcis-track-item`) include **Source Knowledge Asset provenance** when results are found. This is returned as a second MCP content block (markdown text) listing the unique Knowledge Assets that contained the matching events. -**Query Parameters**: +**Format:** -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `epc` | string | Filter by EPC identifier | `urn:epc:id:sgtin:0614141.107346.2017` | -| `from` | string (ISO 8601) | Start of time range | `2024-01-01T00:00:00Z` | -| `to` | string (ISO 8601) | End of time range | `2024-12-31T23:59:59Z` | -| `bizStep` | string | Filter by business step | `assembling` or full URI | -| `bizLocation` | string | Filter by location | `urn:epc:id:sgln:0614141.00001.0` | -| `ual` | string | Get specific event by UAL | `did:dkg:otp/...` | +``` +**Source Knowledge Assets:** +- **EPCIS ObjectEvent**: EPCIS Plugin + [did:dkg:otp:2043/0x.../1](https://dkg.origintrail.io/explore?ual=did:dkg:otp:2043/0x.../1) +- **EPCIS TransformationEvent**: EPCIS Plugin + [did:dkg:otp:2043/0x.../6](https://dkg.origintrail.io/explore?ual=did:dkg:otp:2043/0x.../6) +``` -**Response**: +Each entry includes: -```json -{ - "success": true, - "query": "SELECT ...", - "results": [/* array of matching events */], - "count": 5 -} +- **Title**: Derived from the event type (e.g., `EPCIS ObjectEvent`) +- **Issuer**: Always `"EPCIS Plugin"` +- **UAL**: The cleaned Knowledge Asset UAL (with `/private` or `/public` suffix removed), linked to the DKG Explorer + +### Event Result Structure + +SPARQL query results (from both the HTTP API and MCP tools) return events with the following fields: + +| Field | Description | Example | +| -------------- | ------------------------------------------------- | ------------------------------------------------------ | +| `ual` | Knowledge Asset graph containing this event | `did:dkg:otp:2043/0x.../1/private` | +| `eventType` | Full EPCIS event type URI | `https://gs1.github.io/EPCIS/ObjectEvent` | +| `eventTime` | When the event occurred (ISO 8601) | `2024-03-01T08:00:00.000Z` | +| `bizStep` | Business step URI | `https://ref.gs1.org/cbv/BizStep-receiving` | +| `bizLocation` | Business location identifier | `urn:epc:id:sgln:4012345.00001.0` | +| `disposition` | Current state/condition URI | `https://ref.gs1.org/cbv/Disp-in_progress` | +| `readPoint` | Scan/read location identifier | `urn:epc:id:sgln:4012345.00001.0` | +| `action` | Event action (`ADD`, `OBSERVE`, `DELETE`) | `ADD` | +| `parentID` | Parent EPC (AggregationEvent) | `urn:epc:id:sscc:4012345.0000000001` | +| `epcList` | Observed EPCs (comma-separated) | `urn:epc:id:sgtin:4012345.011111.1001` | +| `childEPCList` | Child EPCs (comma-separated, AggregationEvent) | `urn:epc:id:sgtin:4012345.099999.9001` | +| `inputEPCs` | Input EPCs (comma-separated, TransformationEvent) | `urn:epc:id:sgtin:4012345.011111.1001, ...` | +| `outputEPCs` | Output EPCs (comma-separated, TransformationEvent)| `urn:epc:id:sgtin:4012345.099999.9001` | + +> Array fields (`epcList`, `childEPCList`, `inputEPCs`, `outputEPCs`) are returned as comma-separated strings from the SPARQL `GROUP_CONCAT`. Fields that don't apply to a specific event type will be empty strings. + +--- + +## 12. Query Examples + +### Track a Single Item's Journey + +Use the dedicated track endpoint for full-trace item tracking: + +```bash +curl "http://localhost:9200/epcis/events/track?epc=urn:epc:id:sgtin:4012345.011111.1001" +``` + +### Track All Events for a Product + +Find all events where the carbon frame appears using the general query with full trace: + +```bash +curl "http://localhost:9200/epcis/events?epc=urn:epc:id:sgtin:4012345.011111.1001&fullTrace=true" +``` + +### Find All Receiving Events + +```bash +curl "http://localhost:9200/epcis/events?bizStep=receiving" +``` + +### Find Events at Quality Lab + +```bash +curl "http://localhost:9200/epcis/events?bizLocation=urn:epc:id:sgln:4012345.00002.0" +``` + +### Find Assembly Events in a Time Range + +```bash +curl "http://localhost:9200/epcis/events?bizStep=assembling&from=2024-03-01T00:00:00Z&to=2024-03-01T23:59:59Z" +``` + +### Find What Was Packed onto a Pallet + +```bash +curl "http://localhost:9200/epcis/events?parentID=urn:epc:id:sscc:4012345.0000000001" +``` + +### Find Transformation Events by Output + +```bash +curl "http://localhost:9200/epcis/events?outputEPC=urn:epc:id:sgtin:4012345.099999.9001" ``` --- -## 5. Data Flow & DKG Publishing +## 13. Data Flow & DKG Publishing ### Publishing Pipeline ``` 1. CAPTURE REQUEST - └─▢ Validate against GS1 EPCIS 2.0 JSON Schema - -2. QUEUE (Tier 1 - MySQL) - └─▢ Asset registered with status "queued" - └─▢ Assigned priority and metadata - -3. POLLING (every 2 seconds) - └─▢ QueuePoller checks for available wallets - └─▢ Moves jobs to BullMQ (Tier 2 - Redis) - -4. PROCESSING (BullMQ Workers) - └─▢ Worker acquires wallet lock - └─▢ Wraps content as JSON-LD Knowledge Asset + └─▢ EPCIS Plugin validates against GS1 EPCIS 2.0 JSON Schema + └─▢ Assigns internal requestId (epcis-{timestamp}-{random}) + +2. FORWARD TO PUBLISHER (HTTP POST) + └─▢ Sends JSON-LD content to DKG Publisher (/api/dkg/assets) + └─▢ Includes metadata (source: "EPCIS", sourceId: requestId) + └─▢ Includes publishOptions (privacy, epochs) + └─▢ Retries up to 3 times with exponential backoff on failure + +3. PUBLISHER QUEUING + └─▢ Publisher registers asset with status "pending" β†’ "queued" + └─▢ Returns numeric captureID for status tracking + +4. PUBLISHER PROCESSING + └─▢ Asset assigned to publishing wallet ("assigned") + └─▢ Wraps content as JSON-LD Knowledge Asset ("publishing") └─▢ Calls dkg.js asset.create() - + 5. DKG NETWORK └─▢ Content replicated to DKG nodes └─▢ Cryptographic proof anchored to blockchain └─▢ UAL (NFT) minted for ownership - + 6. COMPLETION └─▢ Asset status updated to "published" - └─▢ UAL stored for future queries + └─▢ UAL stored for future queries via GET /epcis/capture/:captureID ``` ### What is a UAL? @@ -361,252 +1137,315 @@ With a UAL, you can: --- -## 6. Query Examples +## 14. Sample EPCIS Documents -### Find All Events for a Product +### ObjectEvent - Receiving Goods -```bash -curl "http://localhost:9200/epcis/events?epc=urn:epc:id:sgtin:0614141.107346.2017" +Carbon fiber frame arrives from supplier: + +```json +{ + "epcisDocument": { + "@context": { + "@vocab": "https://gs1.github.io/EPCIS/", + "epcis": "https://gs1.github.io/EPCIS/", + "cbv": "https://ref.gs1.org/cbv/", + "type": "@type", + "id": "@id" + }, + "type": "EPCISDocument", + "schemaVersion": "2.0", + "creationDate": "2024-03-01T08:00:00Z", + "epcisBody": { + "eventList": [ + { + "type": "ObjectEvent", + "eventTime": "2024-03-01T08:00:00.000Z", + "eventTimeZoneOffset": "+00:00", + "epcList": ["urn:epc:id:sgtin:4012345.011111.1001"], + "action": "ADD", + "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving", + "disposition": "https://ref.gs1.org/cbv/Disp-in_progress", + "readPoint": { "id": "urn:epc:id:sgln:4012345.00001.0" }, + "bizLocation": { "id": "urn:epc:id:sgln:4012345.00001.0" }, + "bizTransactionList": [ + { + "type": "https://ref.gs1.org/cbv/BTT-po", + "bizTransaction": "urn:epc:id:gdti:4012345.00001.PO-2024-001" + } + ] + } + ] + } + }, + "publishOptions": { + "privacy": "private", + "epochs": 12 + } +} ``` -### Find Assembly Events at a Specific Location +### ObjectEvent - Quality Inspection -```bash -curl "http://localhost:9200/epcis/events?bizStep=assembling&bizLocation=urn:epc:id:sgln:0614141.00001.0" +Frame passes quality check: + +```json +{ + "epcisDocument": { + "@context": { + "@vocab": "https://gs1.github.io/EPCIS/", + "epcis": "https://gs1.github.io/EPCIS/", + "cbv": "https://ref.gs1.org/cbv/", + "type": "@type", + "id": "@id" + }, + "type": "EPCISDocument", + "schemaVersion": "2.0", + "creationDate": "2024-03-01T10:00:00Z", + "epcisBody": { + "eventList": [ + { + "type": "ObjectEvent", + "eventTime": "2024-03-01T10:00:00.000Z", + "eventTimeZoneOffset": "+00:00", + "epcList": ["urn:epc:id:sgtin:4012345.011111.1001"], + "action": "OBSERVE", + "bizStep": "https://ref.gs1.org/cbv/BizStep-inspecting", + "disposition": "https://ref.gs1.org/cbv/Disp-conformant", + "readPoint": { "id": "urn:epc:id:sgln:4012345.00002.0" }, + "bizLocation": { "id": "urn:epc:id:sgln:4012345.00002.0" } + } + ] + } + } +} ``` -### Get Full Event Details by UAL +### TransformationEvent - Assembly -```bash -curl "http://localhost:9200/epcis/events?ual=did:dkg:otp/0x1234.../789" +Components assembled into finished bicycle: + +```json +{ + "epcisDocument": { + "@context": { + "@vocab": "https://gs1.github.io/EPCIS/", + "epcis": "https://gs1.github.io/EPCIS/", + "cbv": "https://ref.gs1.org/cbv/", + "type": "@type", + "id": "@id" + }, + "type": "EPCISDocument", + "schemaVersion": "2.0", + "creationDate": "2024-03-01T14:00:00Z", + "epcisBody": { + "eventList": [ + { + "type": "TransformationEvent", + "eventTime": "2024-03-01T14:00:00.000Z", + "eventTimeZoneOffset": "+00:00", + "inputEPCList": [ + "urn:epc:id:sgtin:4012345.011111.1001", + "urn:epc:id:sgtin:4012345.022222.2001", + "urn:epc:id:sgtin:4012345.022222.2002", + "urn:epc:id:sgtin:4012345.033333.3001" + ], + "outputEPCList": ["urn:epc:id:sgtin:4012345.099999.9001"], + "bizStep": "https://ref.gs1.org/cbv/BizStep-assembling", + "disposition": "https://ref.gs1.org/cbv/Disp-active", + "readPoint": { "id": "urn:epc:id:sgln:4012345.00003.0" }, + "bizLocation": { "id": "urn:epc:id:sgln:4012345.00003.0" } + } + ] + } + } +} ``` -### Time Range Query +### AggregationEvent - Packing -```bash -curl "http://localhost:9200/epcis/events?from=2024-01-01T00:00:00Z&to=2024-01-31T23:59:59Z" -``` +Bicycle packed onto shipping pallet: -### SPARQL Direct Query +```json +{ + "epcisDocument": { + "@context": { + "@vocab": "https://gs1.github.io/EPCIS/", + "epcis": "https://gs1.github.io/EPCIS/", + "cbv": "https://ref.gs1.org/cbv/", + "type": "@type", + "id": "@id" + }, + "type": "EPCISDocument", + "schemaVersion": "2.0", + "creationDate": "2024-03-01T16:00:00Z", + "epcisBody": { + "eventList": [ + { + "type": "AggregationEvent", + "eventTime": "2024-03-01T16:00:00.000Z", + "eventTimeZoneOffset": "+00:00", + "parentID": "urn:epc:id:sscc:4012345.0000000001", + "childEPCs": ["urn:epc:id:sgtin:4012345.099999.9001"], + "action": "ADD", + "bizStep": "https://ref.gs1.org/cbv/BizStep-packing", + "disposition": "https://ref.gs1.org/cbv/Disp-in_transit", + "readPoint": { "id": "urn:epc:id:sgln:4012345.00004.0" }, + "bizLocation": { "id": "urn:epc:id:sgln:4012345.00004.0" } + } + ] + } + } +} +``` -Under the hood, queries are translated to SPARQL. Example generated query: +### ObjectEvent - Shipping -```sparql -PREFIX epcis: -PREFIX schema: +Pallet shipped to customer: -SELECT ?ual ?eventType ?eventTime ?epc ?bizStep ?disposition ?readPoint ?bizLocation -WHERE { - GRAPH ?ual { - ?event a ?eventType . - ?event epcis:epcList "urn:epc:id:sgtin:0614141.107346.2017" . - OPTIONAL { ?event epcis:bizStep ?bizStep . } - OPTIONAL { ?event epcis:eventTime ?eventTime . } +```json +{ + "epcisDocument": { + "@context": { + "@vocab": "https://gs1.github.io/EPCIS/", + "epcis": "https://gs1.github.io/EPCIS/", + "cbv": "https://ref.gs1.org/cbv/", + "type": "@type", + "id": "@id" + }, + "type": "EPCISDocument", + "schemaVersion": "2.0", + "creationDate": "2024-03-02T08:00:00Z", + "epcisBody": { + "eventList": [ + { + "type": "ObjectEvent", + "eventTime": "2024-03-02T08:00:00.000Z", + "eventTimeZoneOffset": "+00:00", + "epcList": ["urn:epc:id:sscc:4012345.0000000001"], + "action": "OBSERVE", + "bizStep": "https://ref.gs1.org/cbv/BizStep-shipping", + "disposition": "https://ref.gs1.org/cbv/Disp-in_transit", + "readPoint": { "id": "urn:epc:id:sgln:4012345.00005.0" }, + "bizLocation": { "id": "urn:epc:id:sgln:4012345.00005.0" }, + "bizTransactionList": [ + { + "type": "https://ref.gs1.org/cbv/BTT-desadv", + "bizTransaction": "urn:epc:id:gdti:4012345.00001.ASN-2024-001" + } + ] + } + ] + } } - FILTER(STRSTARTS(STR(?eventType), "https://gs1.github.io/EPCIS/")) } -ORDER BY DESC(?eventTime) -LIMIT 100 ``` --- -## 7. Troubleshooting +## Event Flow Visualization -### Common Errors +**Bicycle Manufacturing Supply Chain:** -| Error | Cause | Solution | -|-------|-------|----------| -| `Invalid EPCISDocument` | Schema validation failed | Check your JSON matches EPCIS 2.0 spec | -| `Invalid captureID format` | Non-numeric captureID | Use the numeric ID from capture response | -| `Capture not found` | Unknown captureID | Verify the ID; it may have been deleted | -| `Publishing failed` | DKG network error | Check wallet balance, node connectivity | -| `No available wallets` | All wallets are busy | Wait or add more wallets to the pool | +``` +Event 1: Receive Frame (receiving, in_progress) @ Receiving Dock + ↓ +Event 2: Receive Wheels (receiving, in_progress) @ Receiving Dock + ↓ +Event 3: Receive Handlebar (receiving, in_progress) @ Receiving Dock + ↓ +Event 4: Inspect Frame (inspecting, conformant) @ Quality Lab + ↓ +Event 5: Inspect Wheels (inspecting, conformant) @ Quality Lab + ↓ +Event 6: Assemble Bicycle (assembling, active) @ Assembly Line + [TRANSFORMATION: 4 inputs β†’ 1 output] + ↓ +Event 7: Final QC (inspecting, conformant) @ Quality Lab + ↓ +Event 8: Pack on Pallet (packing, in_transit) @ Packing Area + [AGGREGATION: bicycle β†’ pallet] + ↓ +Event 9: Ship (shipping, in_transit) @ Shipping Dock +``` -### Checking System Health +--- -**Publisher Dashboard**: Visit `/admin/queues` to see: +## 15. Troubleshooting -- Active jobs -- Waiting queue -- Failed jobs with error details -- Worker status +### Common Errors -**API Health**: The Swagger UI at `/swagger` shows all available endpoints and their status. +| Error | Cause | Solution | +| -------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `Invalid EPCISDocument` | GS1 schema validation failed | Check your JSON matches EPCIS 2.0 spec; `details` array shows specific issues | +| `EPCISDocument contains no events` | eventList is empty | Add at least one event to `epcisBody.eventList` | +| `Invalid captureID format` | captureID not numeric (must match `^[0-9]{1,20}$`) | Use the numeric ID from capture response | +| `Capture not found` (404) | Unknown captureID | Verify the ID exists in the publisher | +| `Publisher timeout` (504) | Publisher service did not respond | Publisher service may be overloaded; retry later | +| `Something went wrong with publishing` (500) | Publisher unreachable after 3 retries | Check that `EXPO_PUBLIC_MCP_URL` is set and the publisher is running | +| `At least one filter parameter is required` | Query with no filters | Provide at least one of: `epc`, `from`, `to`, `bizStep`, `bizLocation`, `parentID`, `childEPC`, `inputEPC`, `outputEPC` | +| `Parameter 'to' must be >= 'from'` | Invalid date range | Ensure `to` date is not before `from` date | +| `Parameter 'x' cannot be empty` | Empty string query parameter | Provide a value or omit the parameter entirely | ### Validation Errors The system validates against the official GS1 EPCIS 2.0 JSON Schema. Common issues: -1. **Missing `@context`** - Must include EPCIS context -2. **Invalid `eventTime`** - Must be ISO 8601 format +1. **Missing `@context`** - Must include EPCIS context with `type: @type` alias +2. **Invalid `eventTime`** - Must be ISO 8601 format (e.g., `2024-01-01T00:00:00Z`) 3. **Wrong `type`** - Must be exactly `"EPCISDocument"` (case-sensitive) 4. **Invalid `bizStep`** - Must be valid CBV URI or shorthand -### Getting Help +### Environment Variables -- **Swagger UI**: `http://your-server/swagger` - Interactive API docs -- **OpenAPI Spec**: `http://your-server/openapi` - Raw JSON spec -- **Logs**: Check server logs for detailed error messages +| Variable | Required | Description | +| --------------------- | -------- | --------------------------------------------------------------------- | +| `EXPO_PUBLIC_MCP_URL` | Yes | Base URL of the DKG publisher service (e.g., `http://localhost:9200`) | ---- +### Checking System Health -## Appendix: Sample EPCIS Documents +- **Swagger UI**: Visit `/swagger` for interactive API documentation +- **Publisher Dashboard**: Visit `/admin/queues` to monitor publishing jobs +- **Server Logs**: Check for detailed error messages -### Object Event (Receiving Goods) +--- -```json -{ - "@context": { - "@vocab": "https://gs1.github.io/EPCIS/", - "epcis": "https://gs1.github.io/EPCIS/", - "cbv": "https://ref.gs1.org/cbv/", - "type": "@type", - "id": "@id", - "epcisBody": "epcis:epcisBody", - "eventList": "epcis:eventList" - }, - "type": "EPCISDocument", - "schemaVersion": "2.0", - "creationDate": "2024-01-01T00:00:00Z", - "epcisBody": { - "eventList": [{ - "type": "ObjectEvent", - "eventTime": "2024-01-01T00:00:00.000Z", - "eventTimeZoneOffset": "+00:00", - "epcList": ["urn:epc:id:sgtin:0614141.107346.2017"], - "action": "OBSERVE", - "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving", - "disposition": "https://ref.gs1.org/cbv/Disp-in_progress", - "readPoint": {"id": "urn:epc:id:sgln:0614141.00001.0"}, - "bizLocation": {"id": "urn:epc:id:sgln:0614141.00001.0"}, - "bizTransactionList": [ - { - "type": "urn:epcglobal:cbv:btt:po", - "bizTransaction": "urn:epc:id:gdti:0614141.00001.1234" - } - ] - }] - } -} -``` +## Custom Extensions -### Transformation Event (Assembly) +You can add custom fields using your own namespace: ```json { "@context": { "@vocab": "https://gs1.github.io/EPCIS/", - "epcis": "https://gs1.github.io/EPCIS/", - "cbv": "https://ref.gs1.org/cbv/", "type": "@type", "id": "@id", - "epcisBody": "epcis:epcisBody", - "eventList": "epcis:eventList" + "mycompany": "https://mycompany.com/ontology/" }, - "type": "EPCISDocument", - "schemaVersion": "2.0", - "creationDate": "2024-01-01T00:00:00Z", - "epcisBody": { - "eventList": [{ - "type": "TransformationEvent", - "eventTime": "2024-01-01T12:00:00.000Z", - "eventTimeZoneOffset": "+00:00", - "inputEPCList": [ - "urn:epc:id:sgtin:0614141.107346.001", - "urn:epc:id:sgtin:0614141.107346.002" - ], - "outputEPCList": [ - "urn:epc:id:sgtin:0614141.107347.001" - ], - "bizStep": "https://ref.gs1.org/cbv/BizStep-assembling", - "bizLocation": {"id": "urn:epc:id:sgln:0614141.00002.0"} - }] - } + "type": "ObjectEvent", + "eventTime": "2024-03-01T10:00:00.000Z", + "epcList": ["urn:epc:id:sgtin:4012345.011111.1001"], + "action": "OBSERVE", + "bizStep": "https://ref.gs1.org/cbv/BizStep-inspecting", + + "mycompany:inspectorId": "EMP-12345", + "mycompany:testEquipment": "MACHINE-QC-03", + "mycompany:qualityScore": 98.5, + "mycompany:testDurationSeconds": 120 } ``` -### Aggregation Event (Packing) +--- -```json -{ - "@context": { - "@vocab": "https://gs1.github.io/EPCIS/", - "epcis": "https://gs1.github.io/EPCIS/", - "cbv": "https://ref.gs1.org/cbv/", - "type": "@type", - "id": "@id", - "epcisBody": "epcis:epcisBody", - "eventList": "epcis:eventList" - }, - "type": "EPCISDocument", - "schemaVersion": "2.0", - "creationDate": "2024-01-01T00:00:00Z", - "epcisBody": { - "eventList": [{ - "type": "AggregationEvent", - "eventTime": "2024-01-01T14:00:00.000Z", - "eventTimeZoneOffset": "+00:00", - "parentID": "urn:epc:id:sscc:0614141.0000000001", - "childEPCs": [ - "urn:epc:id:sgtin:0614141.107346.001", - "urn:epc:id:sgtin:0614141.107346.002", - "urn:epc:id:sgtin:0614141.107346.003" - ], - "action": "ADD", - "bizStep": "https://ref.gs1.org/cbv/BizStep-packing", - "bizLocation": {"id": "urn:epc:id:sgln:0614141.00001.0"} - }] - } -} -``` +## References -### Object Event with Sensor Data - -```json -{ - "@context": { - "@vocab": "https://gs1.github.io/EPCIS/", - "epcis": "https://gs1.github.io/EPCIS/", - "cbv": "https://ref.gs1.org/cbv/", - "type": "@type", - "id": "@id", - "epcisBody": "epcis:epcisBody", - "eventList": "epcis:eventList" - }, - "type": "EPCISDocument", - "schemaVersion": "2.0", - "creationDate": "2024-01-01T00:00:00Z", - "epcisBody": { - "eventList": [{ - "type": "ObjectEvent", - "eventTime": "2024-01-01T08:00:00.000Z", - "eventTimeZoneOffset": "+00:00", - "epcList": ["urn:epc:id:sgtin:0614141.107346.2017"], - "action": "OBSERVE", - "bizStep": "https://ref.gs1.org/cbv/BizStep-inspecting", - "disposition": "https://ref.gs1.org/cbv/Disp-conformant", - "readPoint": {"id": "urn:epc:id:sgln:0614141.00001.0"}, - "bizLocation": {"id": "urn:epc:id:sgln:0614141.00001.0"}, - "sensorElementList": [ - { - "sensorReport": [ - { - "type": "https://gs1.org/voc/MeasurementType-Temperature", - "time": "2024-01-01T08:00:00.000Z", - "value": 23.5, - "uom": "CEL" - } - ] - } - ] - }] - } -} -``` +- [EPCIS 2.0 Standard](https://ref.gs1.org/standards/epcis/) +- [Core Business Vocabulary (CBV) 2.0](https://ref.gs1.org/standards/cbv/) +- [GS1 Digital Link](https://www.gs1.org/standards/gs1-digital-link) +- [JSON-LD 1.1 Specification](https://www.w3.org/TR/json-ld11/) +- [OriginTrail DKG Documentation](https://docs.origintrail.io/) --- -*Last updated: January 2026* -*For API details, see the interactive [Swagger documentation](/swagger)* - +_Last updated: February 2026_ +_For API details, see the interactive [Swagger documentation](/swagger)_ diff --git a/packages/plugin-epcis/package.json b/packages/plugin-epcis/package.json index 8b9abc8b..c51fb009 100644 --- a/packages/plugin-epcis/package.json +++ b/packages/plugin-epcis/package.json @@ -10,7 +10,7 @@ "build": "tsup src/*.ts --format cjs,esm --dts", "check-types": "tsc --noEmit", "lint": "eslint . --max-warnings 0", - "test": "mocha --loader ../../node_modules/tsx/dist/loader.mjs 'tests/**/*.spec.ts'" + "test": "tsx ../../node_modules/mocha/bin/mocha.js 'tests/**/*.spec.ts'" }, "dependencies": { "@dkg/plugin-swagger": "^0.0.2", @@ -21,6 +21,7 @@ "devDependencies": { "@dkg/eslint-config": "*", "@dkg/typescript-config": "*", + "supertest": "^7.2.2", "tsup": "^8.5.0" } } diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 8c41c202..0ebff007 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -1,123 +1,341 @@ import { defineDkgPlugin } from "@dkg/plugins"; import { openAPIRoute, z } from "@dkg/plugin-swagger"; -import { EpcisValidationService } from "./services/EPCISValidationService"; -import { EpcisQueryService } from "./services/EPCISQueryService"; -import type { CaptureResponse } from "./model/types"; - -// Timeout for internal publisher requests (30s for POST, 5s for GET) -const PUBLISHER_POST_TIMEOUT_MS = 10000; -const PUBLISHER_GET_TIMEOUT_MS = 5000; - -// Helper function to send JSON-LD to publisher -async function sendToPublisher( - jsonLd: any, - metadata?: { source?: string; sourceId?: string }, - publishOptions?: { - privacy?: "private" | "public"; - epochs?: number; +import type { EpcisQueryParams, ValidationResult } from "./model/types"; +import { EpcisQueryService } from "./services/epcisQueryService"; +import { + fetchPublisherCaptureStatus, + isTimeoutError, + sendToPublisher, +} from "./services/epcisPublisherService"; +import { EpcisValidationService } from "./services/epcisValidationService"; +import { + hasAtLeastOneEpcisFilter, + hasValidEpcisDateRange, + optionalDateTimeQueryString, + optionalIntegerInputParam, + optionalIntegerQueryParam, + optionalNonEmptyQueryString, + requiredNonEmptyString, +} from "./utils/epcisQueryValidation"; +import { formatSourceKAs } from "./utils/sourceKa"; + +const QUERY_LIMIT = { + MIN: 1, + MAX: 1000, + DEFAULT: 100, +}; + +const QUERY_OFFSET = { + MIN: 0, + DEFAULT: 0, +}; + +const QUERY_LIMIT_ERROR = `Parameter 'limit' must be an integer between ${QUERY_LIMIT.MIN} and ${QUERY_LIMIT.MAX}`; +const QUERY_OFFSET_ERROR = `Parameter 'offset' must be an integer bigger than ${QUERY_OFFSET.MIN}`; +const CAPTURE_PUBLISH_ERROR = { + error: "Something went wrong with publishing the EPCIS document.", + message: + "Something went wrong with publishing the EPCIS document. Check if the publisher service is available.", +}; +const CAPTURE_ID_PATTERN = /^[0-9]{1,20}$/; + +type CaptureResponse = { + status: string; + requestId: string; + receivedAt: string; + captureID: string; + eventCount: number; + UAL?: string; +}; + +type CaptureStatusResponse = { + status: string; + captureID: string; + UAL?: string; + publishedAt?: string; + error?: string; +}; + +type PublishOptions = { + privacy?: "private" | "public"; + epochs?: number; +}; + +type PublisherCaptureStatusResponse = { + status: string; + ual?: string; + publishedAt?: string; + lastError?: string; +}; + +class CaptureValidationError extends Error { + constructor( + readonly payload: { + error: string; + details?: string[]; + message?: string; + }, + ) { + super(payload.error); + this.name = "CaptureValidationError"; } -): Promise<{ id: number; status: string; attemptCount: number }> { - const publisherUrl = process.env.PUBLISHER_URL || "http://localhost:9200"; - - try { - const response = await fetch(`${publisherUrl}/api/dkg/assets`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content: jsonLd, - metadata: metadata || { source: "EPCIS" }, - publishOptions: { - privacy: publishOptions?.privacy ?? "private", - epochs: publishOptions?.epochs ?? 12, - }, - }), - signal: AbortSignal.timeout(PUBLISHER_POST_TIMEOUT_MS), - }); +} - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Publisher request failed"); - } +class CapturePublishError extends Error { + constructor() { + super("Something went wrong with publishing the EPCIS document."); + this.name = "CapturePublishError"; + } +} - return response.json(); - } catch (error: any) { - if (error.name === "TimeoutError") { - throw new Error("Publisher request timed out"); - } - throw error; +type McpTextContent = { type: "text"; text: string }; + +function toMcpText(payload: unknown): McpTextContent { + return { type: "text", text: JSON.stringify(payload, null, 2) }; +} + +function mcpSuccess( + payload: unknown, + extraContent: McpTextContent[] = [], +): { content: McpTextContent[] } { + return { content: [toMcpText(payload), ...extraContent] }; +} + +function mcpError(payload: unknown): { + content: McpTextContent[]; + isError: true; +} { + return { + content: [toMcpText(payload)], + isError: true, + }; +} + +function generateRequestId(): string { + return `epcis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function buildTrackItemSummary(epc: string, events: any[]): string { + const eventCount = events.length; + let summary = `Tracking: ${epc}\n`; + summary += `Found ${eventCount} event(s) in the supply chain.\n\n`; + + if (eventCount === 0) { + return summary; } + + summary += "Journey Timeline:\n"; + events.forEach((event: any, idx: number) => { + const time = event.eventTime || "Unknown time"; + const step = + event.bizStep?.split("-").pop() || + event.eventType?.split("/").pop() || + "Unknown"; + const location = event.bizLocation || event.readPoint || "Unknown location"; + summary += `${idx + 1}. [${time}] ${step} @ ${location}\n`; + }); + + return summary; } -export default defineDkgPlugin((ctx, mcp, api) => { +function getCaptureValidationError( + validation: ValidationResult, +): { error: string; details?: string[]; message?: string } | null { + if (!validation.valid) { + return { + error: "Invalid EPCISDocument", + details: validation.errors, + }; + } + + if ((validation.eventCount ?? 0) < 1) { + return { + error: "EPCISDocument contains no events", + message: + "The EPCISDocument contains no events to publish. Please check the document and try again.", + }; + } + return null; +} + +export default defineDkgPlugin((ctx, mcp, api) => { const validationService = new EpcisValidationService(); const queryService = new EpcisQueryService(); - console.log("πŸš€ EPCIS Plugin loaded"); + async function executeEpcisEventsQuery(queryParams: EpcisQueryParams) { + const sparqlQuery = queryService.buildQuery(queryParams); + console.debug("[EPCIS] Executing SPARQL query:", sparqlQuery); + + const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); + const resultData = results?.data ?? []; + + return { + results, + resultData, + resultCount: resultData.length, + pagination: { + limit: Math.min( + queryParams.limit ?? QUERY_LIMIT.DEFAULT, + QUERY_LIMIT.MAX, + ), + offset: queryParams.offset ?? QUERY_OFFSET.DEFAULT, + }, + }; + } + + async function executeCapture( + epcisDocument: object, + publishOptions?: PublishOptions, + requestId: string = generateRequestId(), + ): Promise { + const validationResult = validationService.validate(epcisDocument); + const validationError = getCaptureValidationError(validationResult); + if (validationError) { + throw new CaptureValidationError(validationError); + } + + let publishResult: any; + try { + publishResult = await sendToPublisher( + epcisDocument, + { source: "EPCIS", sourceId: requestId }, + publishOptions, + ); + } catch { + throw new CapturePublishError(); + } + + return { + status: "202", + requestId, + receivedAt: new Date().toISOString(), + captureID: String(publishResult.id), + eventCount: validationResult.eventCount ?? 0, + }; + } + + async function parseCaptureStatus( + captureID: string, + ): Promise< + { notFound: true } | ({ notFound: false } & CaptureStatusResponse) + > { + const response = await fetchPublisherCaptureStatus(captureID); + if (!response.ok) { + if (response.status === 404) { + return { notFound: true }; + } + throw new Error(`Publisher returned ${response.status}`); + } + + const asset = (await response.json()) as PublisherCaptureStatusResponse; + return { + notFound: false, + status: asset.status, + captureID, + ...(asset.ual && { UAL: asset.ual }), + ...(asset.publishedAt && { publishedAt: asset.publishedAt }), + ...(asset.lastError && { error: asset.lastError }), + }; + } + + console.info("[EPCIS] Plugin loaded"); // MCP Tool: Query EPCIS events from DKG mcp.registerTool( "epcis-query", { title: "Query EPCIS Events", - description: + description: "Query EPCIS supply chain events from the OriginTrail DKG. " + "Can filter by EPC (product identifier), from date to date, business step, or location. " + "Use fullTrace=true to search across all event types (transformations, aggregations) for complete supply chain traceability.", inputSchema: { - epc: z.string().optional().describe("EPC identifier (e.g., urn:epc:id:sgtin:0614141.107346.2017)"), - from: z.string().optional().describe("Query events from this date onwards, requires it to follow ISO 8601 format (e.g., 2024-01-01T00:00:00Z)"), - to: z.string().optional().describe("Query events up to this date, requires it to follow ISO 8601 format (e.g., 2024-01-01T00:00:00Z)"), - bizStep: z.string().optional().describe("Business step (e.g., 'receiving', 'shipping', 'assembling')"), - bizLocation: z.string().optional().describe("Business location URI"), - fullTrace: z.boolean().optional().describe("If true, search all EPC fields for full traceability"), + epc: optionalNonEmptyQueryString("epc").describe( + "EPC identifier (e.g., urn:epc:id:sgtin:0614141.107346.2017)", + ), + from: optionalDateTimeQueryString("from").describe( + "Query events from this date onwards, requires it to follow ISO 8601 format (e.g., 2024-01-01T00:00:00Z)", + ), + to: optionalDateTimeQueryString("to").describe( + "Query events up to this date, requires it to follow ISO 8601 format (e.g., 2024-01-01T00:00:00Z)", + ), + bizStep: optionalNonEmptyQueryString("bizStep").describe( + "Business step (e.g., 'receiving', 'shipping', 'assembling')", + ), + bizLocation: optionalNonEmptyQueryString("bizLocation").describe( + "Business location URI", + ), + fullTrace: z + .boolean() + .optional() + .describe("If true, search all EPC fields for full traceability"), + parentID: optionalNonEmptyQueryString("parentID").describe( + "Parent ID for AggregationEvent queries", + ), + childEPC: optionalNonEmptyQueryString("childEPC").describe( + "Child EPC for AggregationEvent queries", + ), + inputEPC: optionalNonEmptyQueryString("inputEPC").describe( + "Input EPC for TransformationEvent queries", + ), + outputEPC: optionalNonEmptyQueryString("outputEPC").describe( + "Output EPC for TransformationEvent queries", + ), + limit: optionalIntegerInputParam({ + min: QUERY_LIMIT.MIN, + max: QUERY_LIMIT.MAX, + errorMessage: QUERY_LIMIT_ERROR, + }).describe( + `Number of results per page (default: ${QUERY_LIMIT.DEFAULT}, max: ${QUERY_LIMIT.MAX})`, + ), + offset: optionalIntegerInputParam({ + min: QUERY_OFFSET.MIN, + errorMessage: QUERY_OFFSET_ERROR, + }).describe( + `Number of results to skip for pagination (default: ${QUERY_OFFSET.DEFAULT})`, + ), }, }, async (input) => { try { - const sparqlQuery = queryService.buildQuery({ - epc: input.epc, - from: input.from, - to: input.to, - bizStep: input.bizStep, - bizLocation: input.bizLocation, - fullTrace: input.fullTrace, - }); + if (!hasAtLeastOneEpcisFilter(input)) { + return mcpError({ + error: "At least one filter parameter is required.", + }); + } - const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); + if (!hasValidEpcisDateRange(input)) { + return mcpError({ + error: "Parameter 'to' must be greater than or equal to 'from'.", + }); + } + + const { resultData, resultCount, pagination } = + await executeEpcisEventsQuery(input); - const summary = results?.length - ? `Found ${results.length} EPCIS event(s)` + const summary = resultCount + ? `Found ${resultCount} EPCIS event(s)` : "No events found matching the criteria"; - return { - content: [ - { - type: "text", - text: JSON.stringify({ - summary, - count: results?.data.length || 0, - events: results || [], - //query: sparqlQuery, - }, null, 2) - } - ], - }; + const sourceKAs = formatSourceKAs(resultData); + return mcpSuccess( + { + summary, + count: resultCount, + events: resultData || [], + pagination: { + limit: pagination.limit, + offset: pagination.offset, + }, + }, + sourceKAs ? [sourceKAs] : [], + ); } catch (error: any) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: "Query failed", - message: error.message, - }, null, 2) - } - ], - isError: true, - }; + console.error("[EPCIS] DKG query failed:", error); + return mcpError({ error: "Query failed" }); } - } + }, ); // MCP Tool: Track item journey (full traceability) @@ -125,65 +343,139 @@ export default defineDkgPlugin((ctx, mcp, api) => { "epcis-track-item", { title: "Track Item Journey", - description: + description: "Track a single item's complete journey through the supply chain. " + "Finds all events where this EPC appears - as observed item, transformation input/output, or in aggregations. " + "Returns events in chronological order showing the item's full lifecycle.", inputSchema: { - epc: z.string().describe("The EPC to track (e.g., urn:epc:id:sgtin:0614141.107346.2017)"), + epc: requiredNonEmptyString("epc").describe( + "The EPC to track (e.g., urn:epc:id:sgtin:0614141.107346.2017)", + ), }, }, async (input) => { try { - const sparqlQuery = queryService.buildQuery({ + const { resultData, resultCount } = await executeEpcisEventsQuery({ epc: input.epc, - fullTrace: true, // Always use full traceability for item tracking + fullTrace: true, // Always use full traceability for item tracking + }); + + const summary = buildTrackItemSummary(input.epc, resultData); + + const sourceKAs = formatSourceKAs(resultData); + return mcpSuccess( + { + summary, + epc: input.epc, + eventCount: resultCount, + events: resultData || [], + }, + sourceKAs ? [sourceKAs] : [], + ); + } catch (error: any) { + console.error(`[EPCIS] Item tracking failed, epc: ${input.epc}`, error); + return mcpError({ error: "Tracking failed" }); + } + }, + ); + + // MCP Tool: Capture EPCISDocument and queue for publishing + mcp.registerTool( + "epcis-capture", + { + title: "Capture EPCIS Document", + description: + "Validate an EPCISDocument and queue it for publishing to the DKG.", + inputSchema: { + epcisDocument: z.object({}).passthrough(), + publishOptions: z + .object({ + privacy: z.enum(["private", "public"]).optional(), + epochs: z.number().min(1).optional(), + }) + .optional(), + }, + }, + async (input) => { + try { + const response = await executeCapture( + input.epcisDocument, + input.publishOptions, + ); + return mcpSuccess({ + captureID: response.captureID, + requestId: response.requestId, + receivedAt: response.receivedAt, + eventCount: response.eventCount, }); + } catch (error: unknown) { + if (error instanceof CaptureValidationError) { + return mcpError(error.payload); + } - const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); + if (error instanceof CapturePublishError) { + return mcpError(CAPTURE_PUBLISH_ERROR); + } - const eventCount = results?.length || 0; - let summary = `Tracking: ${input.epc}\n`; - summary += `Found ${eventCount} event(s) in the supply chain.\n\n`; + console.error("[EPCIS] MCP capture failed:", error); + return mcpError({ error: "Capture failed" }); + } + }, + ); - if (eventCount > 0) { - summary += "Journey Timeline:\n"; - results.forEach((event: any, idx: number) => { - const time = event.eventTime || "Unknown time"; - const step = event.bizStep?.split("-").pop() || event.eventType?.split("/").pop() || "Unknown"; - const location = event.bizLocation || event.readPoint || "Unknown location"; - summary += `${idx + 1}. [${time}] ${step} @ ${location}\n`; + // MCP Tool: Check publisher-tracked status by capture ID + mcp.registerTool( + "epcis-capture-status", + { + title: "Get Capture Status", + description: "Check publisher-tracked status for a capture request.", + inputSchema: { + captureID: z + .string() + .regex(CAPTURE_ID_PATTERN, { message: "Invalid captureID format" }) + .describe( + "Numeric publisher capture ID returned from epcis-capture or POST /epcis/capture", + ), + }, + }, + async (input) => { + try { + const captureStatus = await parseCaptureStatus(input.captureID); + if (captureStatus.notFound) { + return mcpError({ + error: "Capture not found", + captureID: input.captureID, }); } - return { - content: [ - { - type: "text", - text: JSON.stringify({ - summary, - epc: input.epc, - eventCount, - events: results || [], - }, null, 2) - } - ], - }; - } catch (error: any) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: "Tracking failed", - message: error.message, - }, null, 2) - } - ], - isError: true, + const payload: CaptureStatusResponse = { + status: captureStatus.status, + captureID: captureStatus.captureID, + ...(captureStatus.UAL && { UAL: captureStatus.UAL }), + ...(captureStatus.publishedAt && { + publishedAt: captureStatus.publishedAt, + }), + ...(captureStatus.error && { error: captureStatus.error }), }; + return mcpSuccess(payload); + } catch (error: unknown) { + if (isTimeoutError(error)) { + return mcpError({ + error: "Publisher timeout", + captureID: input.captureID, + }); + } + + console.error( + `[EPCIS] MCP capture status failed, captureID: ${input.captureID}`, + error, + ); + return mcpError({ + error: "Failed to get capture status", + captureID: input.captureID, + }); } - } + }, ); // POST /epcis/capture - Accept EPCISDocument and queue for publishing @@ -193,26 +485,32 @@ export default defineDkgPlugin((ctx, mcp, api) => { { tag: "EPCIS", summary: "Capture EPCIS Document", - description: "Accept an EPCISDocument and queue it for publishing to DKG", + description: + "Accept an EPCISDocument and queue it for publishing to DKG", body: z.object({ epcisDocument: z.object({}).passthrough().openapi({ description: "The EPCISDocument (JSON-LD)", }), - publishOptions: z.object({ - privacy: z.enum(["private", "public"]).optional().openapi({ - description: "Asset visibility (default: private)", - }), - epochs: z.number().min(1).optional().openapi({ - description: "Number of epochs to publish for (default: 12)", + publishOptions: z + .object({ + privacy: z.enum(["private", "public"]).optional().openapi({ + description: "Asset visibility (default: private)", + }), + epochs: z.number().min(1).optional().openapi({ + description: "Number of epochs to publish for (default: 12)", + }), + }) + .optional() + .openapi({ + description: + "Publishing options (all optional with sensible defaults)", }), - }).optional().openapi({ - description: "Publishing options (all optional with sensible defaults)", - }), }), response: { - description: "Capture accepted", + description: "Capture accepted (202)", schema: z.object({ status: z.string(), + requestId: z.string(), receivedAt: z.string(), captureID: z.string(), eventCount: z.number(), @@ -220,47 +518,48 @@ export default defineDkgPlugin((ctx, mcp, api) => { }, }, async (req, res) => { + const requestId = generateRequestId(); + console.info( + `[EPCIS] Capture request received, requestId: ${requestId}`, + ); + try { const { epcisDocument, publishOptions } = req.body; + const response = await executeCapture( + epcisDocument, + publishOptions, + requestId, + ); + console.info( + `[EPCIS] Document queued via publisher, requestId: ${response.requestId}, eventCount: ${response.eventCount}, captureID: ${response.captureID}`, + ); - // Validate the EPCIS document - const validation = validationService.validate(epcisDocument); + return res.status(202).json(response); + } catch (error: unknown) { + if (error instanceof CaptureValidationError) { + console.warn( + `[EPCIS] Capture validation failed, requestId: ${requestId}`, + ); + return res.status(400).json(error.payload as any); + } - if (!validation.valid) { - return res.status(400).json({ - error: "Invalid EPCISDocument", - details: validation.errors, - } as any); + if (error instanceof CapturePublishError) { + console.error(`[EPCIS] Publishing failed, requestId: ${requestId}`); + return res.status(500).json(CAPTURE_PUBLISH_ERROR as any); } - // Send to publisher with user-provided options (or defaults) - const result = await sendToPublisher( - epcisDocument, - { - source: "EPCIS", - sourceId: `epcis-${Date.now()}`, - }, - publishOptions + console.error( + `[EPCIS] Unexpected error while processing capture request, requestId: ${requestId}:`, + error, ); - - // Return capture response - const response: CaptureResponse = { - status: "202", - receivedAt: new Date().toISOString(), - captureID: String(result.id), - eventCount: validation.eventCount || 0, - }; - - res.status(202).json(response); - } catch (error: any) { - console.error("[EPCIS Capture] Error:", error); - res.status(500).json({ - error: "Failed to process capture", - //message: error.message, + return res.status(500).json({ + error: "Something went wrong with processing the EPCIS document.", + message: + "An unexpected error occurred while processing the EPCIS document.", } as any); } - } - ) + }, + ), ); // GET /epcis/capture/:captureID - Check capture status @@ -270,12 +569,16 @@ export default defineDkgPlugin((ctx, mcp, api) => { { tag: "EPCIS", summary: "Get Capture Status", - description: "Check the status of an EPCIS capture by captureID", + description: "Check publisher-tracked status by numeric captureID.", params: z.object({ - captureID: z.string().openapi({ - description: "The capture ID returned from POST /epcis/capture", - example: "123", - }), + captureID: z + .string() + .regex(CAPTURE_ID_PATTERN, { message: "Invalid captureID format" }) + .openapi({ + description: + "Numeric publisher capture ID returned from POST /epcis/capture", + example: "123", + }), }), response: { description: "Capture status", @@ -289,63 +592,52 @@ export default defineDkgPlugin((ctx, mcp, api) => { }, }, async (req, res) => { + const { captureID } = req.params; + console.info( + `[EPCIS] Capture status request received, captureID: ${captureID}`, + ); + try { - const { captureID } = req.params; - const publisherUrl = process.env.PUBLISHER_URL || "http://localhost:9200"; + const captureStatus = await parseCaptureStatus(captureID); + if (captureStatus.notFound) { + return res + .status(404) + .json({ error: "Capture not found", captureID } as any); + } - const captureIdPattern = /^[0-9]{1,20}$/; - if (!captureIdPattern.test(captureID)) { - return res.status(400).json({ - error: "Invalid captureID format", + const payload: CaptureStatusResponse = { + status: captureStatus.status, + captureID: captureStatus.captureID, + ...(captureStatus.UAL && { UAL: captureStatus.UAL }), + ...(captureStatus.publishedAt && { + publishedAt: captureStatus.publishedAt, + }), + ...(captureStatus.error && { error: captureStatus.error }), + }; + return res.json(payload); + } catch (error: unknown) { + if (isTimeoutError(error)) { + return res.status(504).json({ + error: "Publisher timeout", captureID, } as any); } - // Query publisher for asset status - let response: Response; - try { - response = await fetch( - `${publisherUrl}/api/dkg/assets/status/${encodeURIComponent(captureID)}`, - { signal: AbortSignal.timeout(PUBLISHER_GET_TIMEOUT_MS) } - ); - } catch (error: any) { - if (error.name === "TimeoutError") { - return res.status(504).json({ - error: "Publisher timeout", - captureID, - } as any); - } - throw error; - } - - if (!response.ok) { - if (response.status === 404) { - return res.status(404).json({ error: "Capture not found", captureID } as any); - } - throw new Error("Failed to fetch capture status"); - } - - const asset = await response.json(); - // Map publisher status to EPCIS response - const result: any = { - status: asset.status, + const errorName = + error instanceof Error ? error.name : "UnknownError"; + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error("[EPCIS] Capture status request failed", { captureID, - }; - - if (asset.ual) result.UAL = asset.ual; - if (asset.publishedAt) result.publishedAt = asset.publishedAt; - if (asset.lastError) result.error = asset.lastError; - - res.json(result); - } catch (error: any) { - console.error("[EPCIS Status] Error:", error); - res.status(500).json({ + errorName, + errorMessage, + }); + return res.status(500).json({ error: "Failed to get capture status", - //message: error.message, } as any); } - } - ) + }, + ), ); // GET /epcis/events - Query EPCIS events from DKG @@ -356,81 +648,160 @@ export default defineDkgPlugin((ctx, mcp, api) => { tag: "EPCIS", summary: "Query EPCIS Events", description: "Query EPCIS events from DKG using various filters", - query: z.object({ - epc: z.string().optional().openapi({ - description: "Filter by EPC (product identifier)", - example: "urn:epc:id:sgtin:0614141.107346.2017", - }), - from: z.string().datetime({ message: "Must be ISO 8601 format (e.g., 2024-01-01T00:00:00Z)" }).optional().openapi({ - description: "Start of time range (ISO 8601)", - example: "2024-01-01T00:00:00Z", - }), - to: z.string().datetime({ message: "Must be ISO 8601 format (e.g., 2024-12-31T23:59:59Z)" }).optional().openapi({ - description: "End of time range (ISO 8601)", - example: "2024-12-31T23:59:59Z", - }), - bizStep: z.string().optional().openapi({ - description: "Filter by business step URI", - example: "https://ref.gs1.org/cbv/BizStep-assembling", + query: z + .object({ + epc: optionalNonEmptyQueryString("epc").openapi({ + description: "Filter by EPC (product identifier)", + example: "urn:epc:id:sgtin:0614141.107346.2017", + }), + from: optionalDateTimeQueryString("from").openapi({ + description: "Start of time range (ISO 8601)", + example: "2024-01-01T00:00:00Z", + }), + to: optionalDateTimeQueryString("to").openapi({ + description: "End of time range (ISO 8601)", + example: "2024-12-31T23:59:59Z", + }), + bizStep: optionalNonEmptyQueryString("bizStep").openapi({ + description: "Filter by business step URI", + example: "https://ref.gs1.org/cbv/BizStep-assembling", + }), + bizLocation: optionalNonEmptyQueryString("bizLocation").openapi({ + description: "Filter by business location", + example: "urn:epc:id:sgln:0614141.00001.0", + }), + fullTrace: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .openapi({ + description: + "If 'true', search all EPC fields for full supply chain traceability", + example: "true", + }), + parentID: optionalNonEmptyQueryString("parentID").openapi({ + description: "Filter by parent ID (AggregationEvent)", + example: "urn:epc:id:sscc:0614141.0000000001", + }), + childEPC: optionalNonEmptyQueryString("childEPC").openapi({ + description: "Filter by child EPC (AggregationEvent)", + example: "urn:epc:id:sgtin:0614141.107346.2017", + }), + inputEPC: optionalNonEmptyQueryString("inputEPC").openapi({ + description: "Filter by input EPC (TransformationEvent)", + example: "urn:epc:id:sgtin:0614141.107346.2017", + }), + outputEPC: optionalNonEmptyQueryString("outputEPC").openapi({ + description: "Filter by output EPC (TransformationEvent)", + example: "urn:epc:id:sgtin:0614141.099999.9001", + }), + limit: optionalIntegerQueryParam({ + min: 1, + max: 1000, + errorMessage: QUERY_LIMIT_ERROR, + }).openapi({ + description: `Number of results per page (default: ${QUERY_LIMIT.DEFAULT}, max: ${QUERY_LIMIT.MAX})`, + example: "50", + }), + offset: optionalIntegerQueryParam({ + min: 0, + errorMessage: QUERY_OFFSET_ERROR, + }).openapi({ + description: `Number of results to skip for pagination (default: ${QUERY_OFFSET.DEFAULT})`, + example: "0", + }), + }) + .refine(hasAtLeastOneEpcisFilter, { + message: "At least one filter parameter is required.", + }) + .refine(hasValidEpcisDateRange, { + path: ["to"], + message: "Parameter 'to' must be greater than or equal to 'from'.", }), - bizLocation: z.string().optional().openapi({ - description: "Filter by business location", - example: "urn:epc:id:sgln:0614141.00001.0", + response: { + description: "Query results", + schema: z.object({ + success: z.boolean(), + results: z.array(z.any()), + count: z.number(), + pagination: z.object({ + limit: z.number(), + offset: z.number(), + }), }), - /*ual: z.string().optional().openapi({ - description: "Get event by specific UAL", - }),*/ - fullTrace: z.string().optional().openapi({ - description: "If 'true', search all EPC fields (epcList, inputEPCList, outputEPCList, childEPCs, parentID) for full supply chain traceability", - example: "true", + }, + }, + async (req, res) => { + console.info("[EPCIS] Events query received"); + try { + const { resultData, resultCount, pagination } = + await executeEpcisEventsQuery(req.query); + + res.json({ + success: true, + results: resultData, + count: resultCount, + pagination: { + limit: pagination.limit, + offset: pagination.offset, + }, + }); + } catch (error: any) { + console.error("[EPCIS] Events query failed:", error); + res.status(500).json({ + success: false, + error: "Failed to query events", + } as any); + } + }, + ), + ); + + // GET /epcis/events/track - Track single EPC across full trace + api.get( + "/epcis/events/track", + openAPIRoute( + { + tag: "EPCIS", + summary: "Track Item Journey", + description: + "Track a single EPC across all event types using full traceability.", + query: z.object({ + epc: requiredNonEmptyString("epc").openapi({ + description: "EPC identifier to track", + example: "urn:epc:id:sgtin:0614141.107346.2017", }), }), response: { - description: "Query results", + description: "Tracking query results", schema: z.object({ success: z.boolean(), - query: z.string().optional(), results: z.array(z.any()), count: z.number(), }), }, }, async (req, res) => { + console.info("[EPCIS] Track item query received"); try { - const { epc, from, to, bizStep, bizLocation, /*ual,*/ fullTrace } = req.query; - - // Build the SPARQL query based on parameters - const sparqlQuery = queryService.buildQuery({ - epc: epc as string, - from: from as string, - to: to as string, - bizStep: bizStep as string, - bizLocation: bizLocation as string, - //ual: ual as string, - fullTrace: fullTrace === 'true', + const { resultData, resultCount } = await executeEpcisEventsQuery({ + epc: req.query.epc, + fullTrace: true, }); - console.log("[EPCIS Events] Executing SPARQL query:", sparqlQuery); - - // Execute query against DKG - const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); - res.json({ success: true, - //query: sparqlQuery, - results: results || [], - count: results?.length || 0, + results: resultData, + count: resultCount, }); } catch (error: any) { - console.error("[EPCIS Events] Query error:", error); + console.error("[EPCIS] Track query failed:", error); res.status(500).json({ success: false, error: "Failed to query events", - message: error.message, } as any); } - } - ) + }, + ), ); - -}); \ No newline at end of file +}); diff --git a/packages/plugin-epcis/src/model/types.ts b/packages/plugin-epcis/src/model/types.ts index ccf4ab87..fec13b3f 100644 --- a/packages/plugin-epcis/src/model/types.ts +++ b/packages/plugin-epcis/src/model/types.ts @@ -1,50 +1,56 @@ // EPCIS Document types based on GS1 EPCIS 2.0 export interface EPCISDocument { - "@context": string | string[] | Record; - type: "EPCISDocument"; - schemaVersion: string; - creationDate: string; - epcisBody?: { - eventList: EPCISEvent[]; - }; - eventList?: EPCISEvent[]; - [key: string]: any; - } - - export interface EPCISEvent { - type: string; - eventTime: string; - eventTimeZoneOffset?: string; - epcList?: string[]; - action?: string; - bizStep?: string; - disposition?: string; - readPoint?: { id: string }; - bizLocation?: { id: string }; - bizTransactionList?: Array<{ type: string; bizTransaction: string }>; - sensorElementList?: any[]; - [key: string]: any; - } - - // API Response types - export interface CaptureResponse { - status: string; - receivedAt: string; - captureID: string; - eventCount: number; - } - - export interface CaptureStatusResponse { - status: "pending" | "queued" | "assigned" | "publishing" | "published" | "failed"; - UAL?: string; - eventCount?: number; - error?: string; - publishedAt?: string | null; - } - - // Validation result type - export interface ValidationResult { - valid: boolean; - errors?: string[]; - eventCount?: number; - } \ No newline at end of file + "@context": string | string[] | Record; + type: "EPCISDocument"; + schemaVersion: string; + creationDate: string; + epcisBody?: { + eventList: EPCISEvent[]; + }; + eventList?: EPCISEvent[]; + [key: string]: any; +} + +export interface EPCISEvent { + type: string; + eventTime: string; + eventTimeZoneOffset?: string; + epcList?: string[]; + action?: string; + bizStep?: string; + disposition?: string; + readPoint?: { id: string }; + bizLocation?: { id: string }; + bizTransactionList?: Array<{ type: string; bizTransaction: string }>; + sensorElementList?: any[]; + [key: string]: any; +} + +// Validation result type +export interface ValidationResult { + valid: boolean; + errors?: string[]; + eventCount?: number; +} + +export interface EpcisQueryParams { + epc?: string; + from?: string; + to?: string; + bizStep?: string; + bizLocation?: string; + /** If true, searches all EPC fields (epcList, inputEPCList, outputEPCList, childEPCs, parentID) */ + fullTrace?: boolean; + /** Filter by parent ID (AggregationEvent) */ + parentID?: string; + /** Filter by child EPCs (AggregationEvent) */ + childEPC?: string; + /** Filter by input EPCs (TransformationEvent) */ + inputEPC?: string; + /** Filter by output EPCs (TransformationEvent) */ + outputEPC?: string; + /** Number of results per page (default: 100, max: 1000) */ + limit?: number; + /** Number of results to skip (for pagination) */ + offset?: number; +} diff --git a/packages/plugin-epcis/src/services/EPCISQueryService.ts b/packages/plugin-epcis/src/services/EPCISQueryService.ts deleted file mode 100644 index a867014d..00000000 --- a/packages/plugin-epcis/src/services/EPCISQueryService.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * EPCIS Query Service - * Supports composite filtering - combine multiple filters in one query - */ - -// Namespace prefixes for EPCIS queries -const PREFIXES = ` -PREFIX epcis: -PREFIX schema: -PREFIX xsd: -`; - -export interface EpcisQueryParams { - epc?: string; - from?: string; - to?: string; - bizStep?: string; - bizLocation?: string; - // ual?: string; // TODO: Re-enable when UAL query is implemented - /** If true, searches all EPC fields (epcList, inputEPCList, outputEPCList, childEPCs, parentID) */ - fullTrace?: boolean; -} - -/** - * Escape special characters in SPARQL string literals - */ -function escapeSparql(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -} - -/** - * Normalize bizStep to full GS1 CBV URI - * Accepts: "assembling" or "https://ref.gs1.org/cbv/BizStep-assembling" - */ -function normalizeBizStep(value: string): string { - - if (typeof value !== "string" || value.length === 0) { - throw new Error("Invalid bizStep value"); - } - - if (!value.includes('://')) { - return `https://ref.gs1.org/cbv/BizStep-${value}`; - } - return value; -} - -export class EpcisQueryService { - /** - * Build a composite SPARQL query supporting multiple filters - * All provided filters are combined with AND logic - */ - buildQuery(params: EpcisQueryParams): string { - // Special case: UAL lookup returns all triples for that graph - /*if (params.ual) { - return this.getEventByUal(params.ual); - }*/ - - const wherePatterns: string[] = []; - const filterClauses: string[] = []; - const optionalClauses: string[] = []; - - // Base pattern - always present - wherePatterns.push('?event a ?eventType .'); - - // Filter by event type (must be EPCIS event) - filterClauses.push('FILTER(STRSTARTS(STR(?eventType), "https://gs1.github.io/EPCIS/"))'); - - // EPC filter - with optional full traceability across all EPC fields - if (params.epc) { - const epcValue = escapeSparql(params.epc); - if (params.fullTrace) { - // Search across ALL EPC fields for full supply chain traceability - wherePatterns.push(`{ - { ?event epcis:epcList "${epcValue}" } - UNION { ?event epcis:inputEPCList "${epcValue}" } - UNION { ?event epcis:outputEPCList "${epcValue}" } - UNION { ?event epcis:childEPCs "${epcValue}" } - UNION { ?event epcis:parentID "${epcValue}" } - }`); - } else { - // Default: only search epcList - wherePatterns.push(`?event epcis:epcList "${epcValue}" .`); - } - } else { - optionalClauses.push('OPTIONAL { ?event epcis:epcList ?epc . }'); - } - - // BizStep filter (accepts shorthand like "assembling" or full URI) - if (params.bizStep) { - const bizStepUri = normalizeBizStep(params.bizStep); - wherePatterns.push('?event epcis:bizStep ?bizStep .'); - filterClauses.push(`FILTER(STR(?bizStep) = "${escapeSparql(bizStepUri)}")`); - } else { - optionalClauses.push('OPTIONAL { ?event epcis:bizStep ?bizStep . }'); - } - - // BizLocation filter - if (params.bizLocation) { - wherePatterns.push(`?event epcis:bizLocation "${escapeSparql(params.bizLocation)}" .`); - } else { - optionalClauses.push('OPTIONAL { ?event epcis:bizLocation ?bizLocation . }'); - } - - // Time range filter - use xsd:dateTime for proper date comparison - if (params.from || params.to) { - wherePatterns.push('?event epcis:eventTime ?eventTime .'); - if (params.from && params.to) { - filterClauses.push( - `FILTER(xsd:dateTime(?eventTime) >= xsd:dateTime("${escapeSparql(params.from)}") && xsd:dateTime(?eventTime) <= xsd:dateTime("${escapeSparql(params.to)}"))` - ); - } else if (params.from) { - filterClauses.push(`FILTER(xsd:dateTime(?eventTime) >= xsd:dateTime("${escapeSparql(params.from)}"))`); - } else if (params.to) { - filterClauses.push(`FILTER(xsd:dateTime(?eventTime) <= xsd:dateTime("${escapeSparql(params.to)}"))`); - } - } else { - optionalClauses.push('OPTIONAL { ?event epcis:eventTime ?eventTime . }'); - } - - // Always optional fields - optionalClauses.push('OPTIONAL { ?event epcis:disposition ?disposition . }'); - optionalClauses.push('OPTIONAL { ?event epcis:readPoint ?readPoint . }'); - - // Assemble the query - return `${PREFIXES} -SELECT ?ual ?eventType ?eventTime ?epc ?bizStep ?disposition ?readPoint ?bizLocation -WHERE { - GRAPH ?ual { - ${wherePatterns.join('\n ')} - ${optionalClauses.join('\n ')} - } - ${filterClauses.join('\n ')} -} -ORDER BY DESC(?eventTime) -LIMIT 100`; - } - - /** - * Query event by UAL (get full event details) - */ - /*private getEventByUal(ual: string): string { - // Basic UAL format validation - if (!ual.startsWith('did:')) { - throw new Error('Invalid UAL format'); - } - return `${PREFIXES} -SELECT ?predicate ?object -WHERE { - GRAPH <${escapeSparql(ual)}> { - ?subject ?predicate ?object . - } -}`; - }*/ -} diff --git a/packages/plugin-epcis/src/services/epcisPublisherService.ts b/packages/plugin-epcis/src/services/epcisPublisherService.ts new file mode 100644 index 00000000..437704f3 --- /dev/null +++ b/packages/plugin-epcis/src/services/epcisPublisherService.ts @@ -0,0 +1,105 @@ +const PUBLISHER_POST_TIMEOUT_MS = 10_000; +const PUBLISHER_GET_TIMEOUT_MS = 5_000; +const SEND_TO_PUBLISHER_MAX_RETRIES = 3; +const SEND_TO_PUBLISHER_RETRY_DELAY_MS = 1_000; + +type AssetInput = { + content: object | string; + metadata?: { + source?: string; + sourceId?: string; + [key: string]: any; + }; + publishOptions?: { + privacy?: "private" | "public"; + epochs?: number; + }; +}; + +type PublisherMetadata = { + source?: string; + sourceId?: string; +}; + +type PublishOptions = { + privacy?: "private" | "public"; + epochs?: number; +}; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function sendToPublisher( + jsonLd: object | string, + metadata?: PublisherMetadata, + publishOptions?: PublishOptions, +): Promise<{ id: number; status: string; attemptCount: number; ual?: string }> { + for (let attempt = 1; attempt <= SEND_TO_PUBLISHER_MAX_RETRIES; attempt++) { + try { + const publisherUrl = getPublisherEndpoint(); + const url = `${publisherUrl}/api/dkg/assets`; + const payload: AssetInput = { + content: jsonLd, + metadata: metadata || { source: "EPCIS" }, + publishOptions: { + privacy: publishOptions?.privacy ?? "private", + epochs: publishOptions?.epochs ?? 12, + }, + }; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(PUBLISHER_POST_TIMEOUT_MS), + }); + + if ( + !response.ok || + !response.headers.get("content-type")?.includes("application/json") + ) { + throw new Error("Publisher not available"); + } + + return await response.json(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[EPCIS] Publisher attempt ${attempt}/${SEND_TO_PUBLISHER_MAX_RETRIES} failed:`, + message, + ); + + if (attempt < SEND_TO_PUBLISHER_MAX_RETRIES) { + await delay( + SEND_TO_PUBLISHER_RETRY_DELAY_MS * Math.pow(2, attempt - 1), + ); + continue; + } + } + } + + throw new Error("Publisher not available"); +} + +export function isTimeoutError(error: unknown): boolean { + return error instanceof Error && error.name === "TimeoutError"; +} + +export async function fetchPublisherCaptureStatus( + captureID: string, +): Promise { + const publisherUrl = getPublisherEndpoint(); + const url = `${publisherUrl}/api/dkg/assets/status/${encodeURIComponent(captureID)}`; + return fetch(url, { signal: AbortSignal.timeout(PUBLISHER_GET_TIMEOUT_MS) }); +} + +function getPublisherEndpoint(): string { + const publisherUrl = process.env.EXPO_PUBLIC_MCP_URL; + if (!publisherUrl) { + throw new Error( + "Publisher endpoint not configured. Set EXPO_PUBLIC_MCP_URL in .env", + ); + } + return publisherUrl; +} diff --git a/packages/plugin-epcis/src/services/epcisQueryService.ts b/packages/plugin-epcis/src/services/epcisQueryService.ts new file mode 100644 index 00000000..5f2d473c --- /dev/null +++ b/packages/plugin-epcis/src/services/epcisQueryService.ts @@ -0,0 +1,181 @@ +import type { EpcisQueryParams } from "../model/types"; +/** + * EPCIS Query Service + * Supports composite filtering - combine multiple filters in one query + */ + +const PREFIXES = ` +PREFIX epcis: +PREFIX schema: +PREFIX xsd: +`; + +/** + * Escape special characters in SPARQL string literals + */ +function escapeSparql(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * Normalize bizStep to full GS1 CBV URI + * Accepts: "assembling" or "https://ref.gs1.org/cbv/BizStep-assembling" + */ +function normalizeBizStep(value: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error("Invalid bizStep value"); + } + + if (!value.includes("://")) { + return `https://ref.gs1.org/cbv/BizStep-${value}`; + } + return value; +} + +export class EpcisQueryService { + /** + * Build a composite SPARQL query supporting multiple filters + * All provided filters are combined with AND logic + */ + buildQuery(params: EpcisQueryParams): string { + const wherePatterns: string[] = []; + const filterClauses: string[] = []; + const optionalClauses: string[] = []; + + // Base pattern - always present + wherePatterns.push("?event a ?eventType ."); + + // Filter by event type (must be EPCIS event) + filterClauses.push( + 'FILTER(STRSTARTS(STR(?eventType), "https://gs1.github.io/EPCIS/"))', + ); + + // EPC filter - with optional full traceability across all EPC fields + if (params.epc) { + const epcValue = escapeSparql(params.epc); + if (params.fullTrace) { + // Search across ALL EPC fields for full supply chain traceability + wherePatterns.push(`{ + { ?event epcis:epcList "${epcValue}" } + UNION { ?event epcis:inputEPCList "${epcValue}" } + UNION { ?event epcis:outputEPCList "${epcValue}" } + UNION { ?event epcis:childEPCs "${epcValue}" } + UNION { ?event epcis:parentID "${epcValue}" } + }`); + } else { + // Default: only search epcList + wherePatterns.push(`?event epcis:epcList "${epcValue}" .`); + } + } + // Always bind ?epc for GROUP_CONCAT projection + optionalClauses.push("OPTIONAL { ?event epcis:epcList ?epc . }"); + + // Parent ID filter (AggregationEvent) + if (params.parentID) { + wherePatterns.push( + `?event epcis:parentID "${escapeSparql(params.parentID)}" .`, + ); + } + + // Child EPCs filter (AggregationEvent) + if (params.childEPC) { + wherePatterns.push( + `?event epcis:childEPCs "${escapeSparql(params.childEPC)}" .`, + ); + } + + // Input EPCs filter (TransformationEvent) + if (params.inputEPC) { + wherePatterns.push( + `?event epcis:inputEPCList "${escapeSparql(params.inputEPC)}" .`, + ); + } + + // Output EPCs filter (TransformationEvent) + if (params.outputEPC) { + wherePatterns.push( + `?event epcis:outputEPCList "${escapeSparql(params.outputEPC)}" .`, + ); + } + + // BizStep filter (accepts shorthand like "assembling" or full URI) + if (params.bizStep) { + const bizStepUri = normalizeBizStep(params.bizStep); + wherePatterns.push("?event epcis:bizStep ?bizStep ."); + filterClauses.push( + `FILTER(STR(?bizStep) = "${escapeSparql(bizStepUri)}")`, + ); + } else { + optionalClauses.push("OPTIONAL { ?event epcis:bizStep ?bizStep . }"); + } + + // BizLocation filter + if (params.bizLocation) { + wherePatterns.push( + `?event epcis:bizLocation "${escapeSparql(params.bizLocation)}" .`, + ); + } else { + optionalClauses.push( + "OPTIONAL { ?event epcis:bizLocation ?bizLocation . }", + ); + } + + // Time range filter - use xsd:dateTime for proper date comparison + if (params.from || params.to) { + wherePatterns.push("?event epcis:eventTime ?eventTime ."); + if (params.from && params.to) { + filterClauses.push( + `FILTER(xsd:dateTime(?eventTime) >= xsd:dateTime("${escapeSparql(params.from)}") && xsd:dateTime(?eventTime) <= xsd:dateTime("${escapeSparql(params.to)}"))`, + ); + } else if (params.from) { + filterClauses.push( + `FILTER(xsd:dateTime(?eventTime) >= xsd:dateTime("${escapeSparql(params.from)}"))`, + ); + } else if (params.to) { + filterClauses.push( + `FILTER(xsd:dateTime(?eventTime) <= xsd:dateTime("${escapeSparql(params.to)}"))`, + ); + } + } else { + optionalClauses.push("OPTIONAL { ?event epcis:eventTime ?eventTime . }"); + } + + // Always optional fields + optionalClauses.push( + "OPTIONAL { ?event epcis:disposition ?disposition . }", + ); + optionalClauses.push("OPTIONAL { ?event epcis:readPoint ?readPoint . }"); + optionalClauses.push("OPTIONAL { ?event epcis:action ?action . }"); + optionalClauses.push("OPTIONAL { ?event epcis:parentID ?parentID . }"); + optionalClauses.push("OPTIONAL { ?event epcis:childEPCs ?childEPCs . }"); + optionalClauses.push( + "OPTIONAL { ?event epcis:inputEPCList ?inputEPCList . }", + ); + optionalClauses.push( + "OPTIONAL { ?event epcis:outputEPCList ?outputEPCList . }", + ); + + // Pagination with defaults and max limits + const limit = Math.min(params.limit ?? 100, 1000); // Default 100, max 1000 + const offset = params.offset ?? 0; + + // Assemble the query with GROUP_CONCAT for array fields + return `${PREFIXES} +SELECT ?ual ?eventType ?eventTime ?bizStep ?bizLocation ?disposition ?readPoint ?action ?parentID + (GROUP_CONCAT(DISTINCT ?epc; SEPARATOR=", ") AS ?epcList) + (GROUP_CONCAT(DISTINCT ?childEPCs; SEPARATOR=", ") AS ?childEPCList) + (GROUP_CONCAT(DISTINCT ?inputEPCList; SEPARATOR=", ") AS ?inputEPCs) + (GROUP_CONCAT(DISTINCT ?outputEPCList; SEPARATOR=", ") AS ?outputEPCs) +WHERE { + GRAPH ?ual { + ${wherePatterns.join("\n ")} + ${optionalClauses.join("\n ")} + } + ${filterClauses.join("\n ")} +} +GROUP BY ?ual ?eventType ?eventTime ?bizStep ?bizLocation ?disposition ?readPoint ?action ?parentID +ORDER BY DESC(?eventTime) +LIMIT ${limit} +OFFSET ${offset}`; + } +} diff --git a/packages/plugin-epcis/src/services/EPCISValidationService.ts b/packages/plugin-epcis/src/services/epcisValidationService.ts similarity index 100% rename from packages/plugin-epcis/src/services/EPCISValidationService.ts rename to packages/plugin-epcis/src/services/epcisValidationService.ts diff --git a/packages/plugin-epcis/src/utils/epcisQueryValidation.ts b/packages/plugin-epcis/src/utils/epcisQueryValidation.ts new file mode 100644 index 00000000..6769b945 --- /dev/null +++ b/packages/plugin-epcis/src/utils/epcisQueryValidation.ts @@ -0,0 +1,82 @@ +import { z } from "@dkg/plugin-swagger"; + +type OptionalIntegerParamOptions = { + min: number; + max?: number; + errorMessage: string; +}; + +export function optionalNonEmptyQueryString(parameter: string) { + return z + .string() + .trim() + .min(1, { message: `Parameter '${parameter}' cannot be empty` }) + .optional(); +} + +export function requiredNonEmptyString(parameter: string) { + return z + .string() + .trim() + .min(1, { message: `Parameter '${parameter}' cannot be empty` }); +} + +export function optionalDateTimeQueryString(parameter: string) { + return z + .string() + .trim() + .min(1, { message: `Parameter '${parameter}' cannot be empty` }) + .datetime({ + message: "Must be ISO 8601 format (e.g., 2024-01-01T00:00:00Z)", + }) + .optional(); +} + +export function optionalIntegerQueryParam({ + min, + max, + errorMessage, +}: OptionalIntegerParamOptions) { + return z + .string() + .trim() + .regex(/^-?\d+$/, { message: errorMessage }) + .transform((value) => Number.parseInt(value, 10)) + .refine((value) => value >= min && (max === undefined || value <= max), { + message: errorMessage, + }) + .optional(); +} + +export function optionalIntegerInputParam({ + min, + max, + errorMessage, +}: OptionalIntegerParamOptions) { + return z + .number() + .int({ message: errorMessage }) + .refine((value) => value >= min && (max === undefined || value <= max), { + message: errorMessage, + }) + .optional(); +} + +export function hasAtLeastOneEpcisFilter( + query: Record, +): boolean { + return Object.entries(query) + .filter(([key]) => !["fullTrace", "limit", "offset"].includes(key)) + .some(([, value]) => value !== undefined); +} + +export function hasValidEpcisDateRange(query: { + from?: string; + to?: string; +}): boolean { + if (!query.from || !query.to) { + return true; + } + + return Date.parse(query.from) <= Date.parse(query.to); +} diff --git a/packages/plugin-epcis/src/utils/sourceKa.ts b/packages/plugin-epcis/src/utils/sourceKa.ts new file mode 100644 index 00000000..3833abae --- /dev/null +++ b/packages/plugin-epcis/src/utils/sourceKa.ts @@ -0,0 +1,41 @@ +// DKG Explorer URL for source KAs +const DKG_EXPLORER_BASE_URL = "https://dkg.origintrail.io/explore?ual="; + +export type SourceKA = { + title: string; + issuer: string; + ual: string; +}; + +/** + * Format source Knowledge Assets for MCP tool responses. + * Extracts unique UALs from query results and formats them as markdown + * that can be parsed by the chat UI to display KA chips. + */ +export function formatSourceKAs(results: any[]): { type: "text"; text: string } | null { + const seenUals = new Set(); + const kas: SourceKA[] = []; + + for (const row of results) { + if (row.ual && !seenUals.has(row.ual)) { + seenUals.add(row.ual); + const eventType = row.eventType?.split('/').pop() || 'Event'; + // Clean UAL by removing /private or /public suffix + const cleanUal = row.ual.replace(/\/(private|public)$/, ''); + kas.push({ + title: `EPCIS ${eventType}`, + issuer: "EPCIS Plugin", + ual: cleanUal, + }); + } + } + + if (kas.length === 0) return null; + + return { + type: "text", + text: "**Source Knowledge Assets:**\n" + + kas.map(k => `- **${k.title}**: ${k.issuer}\n [${k.ual}](${DKG_EXPLORER_BASE_URL}${k.ual})`).join("\n"), + }; +} + diff --git a/packages/plugin-epcis/tests/fixtures/bicycleStoryFixtures.ts b/packages/plugin-epcis/tests/fixtures/bicycleStoryFixtures.ts new file mode 100644 index 00000000..e8f8e56f --- /dev/null +++ b/packages/plugin-epcis/tests/fixtures/bicycleStoryFixtures.ts @@ -0,0 +1,201 @@ +type QueryRow = { + ual: string; + eventType: string; + eventTime: string; + bizStep: string; + bizLocation: string; + disposition: string; + readPoint: string; + action: string; + parentID: string; + epcList: string; + childEPCList: string; + inputEPCs: string; + outputEPCs: string; +}; + +const UAL_BASE = "did:dkg:otp:2043"; +const EPCIS_OBJECT_EVENT = "https://gs1.github.io/EPCIS/ObjectEvent"; +const EPCIS_TRANSFORMATION_EVENT = "https://gs1.github.io/EPCIS/TransformationEvent"; +const EPCIS_AGGREGATION_EVENT = "https://gs1.github.io/EPCIS/AggregationEvent"; + +const FRAME_EPC = "urn:epc:id:sgtin:4012345.011111.1001"; +const FRONT_WHEEL_EPC = "urn:epc:id:sgtin:4012345.022222.2001"; +const REAR_WHEEL_EPC = "urn:epc:id:sgtin:4012345.022222.2002"; +const HANDLEBAR_EPC = "urn:epc:id:sgtin:4012345.033333.3001"; +const BICYCLE_EPC = "urn:epc:id:sgtin:4012345.099999.9001"; +const PALLET_EPC = "urn:epc:id:sscc:4012345.0000000001"; + +const RECEIVING_DOCK = "urn:epc:id:sgln:4012345.00001.0"; +const QUALITY_LAB = "urn:epc:id:sgln:4012345.00002.0"; +const ASSEMBLY_LINE = "urn:epc:id:sgln:4012345.00003.0"; +const PACKING_AREA = "urn:epc:id:sgln:4012345.00004.0"; + +export function jsonResponse(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export function publisherQueuedResponse(id: number): Response { + return jsonResponse({ id, status: "queued", attemptCount: 1 }); +} + +export function publisherStatusResponse( + status: string, + ual?: string, + publishedAt?: string, +): Response { + return jsonResponse({ + status, + ...(ual && { ual }), + ...(publishedAt && { publishedAt }), + }); +} + +export function makeDkgQueryResult(rows: QueryRow[]): { data: QueryRow[] } { + return { data: rows }; +} + +export const RECEIVING_EVENTS: QueryRow[] = [ + { + ual: `${UAL_BASE}/1/private`, + eventType: EPCIS_OBJECT_EVENT, + eventTime: "2024-03-01T08:00:00.000Z", + bizStep: "https://ref.gs1.org/cbv/BizStep-receiving", + bizLocation: RECEIVING_DOCK, + disposition: "https://ref.gs1.org/cbv/Disp-in_progress", + readPoint: RECEIVING_DOCK, + action: "ADD", + parentID: "", + epcList: FRAME_EPC, + childEPCList: "", + inputEPCs: "", + outputEPCs: "", + }, + { + ual: `${UAL_BASE}/2/private`, + eventType: EPCIS_OBJECT_EVENT, + eventTime: "2024-03-01T08:30:00.000Z", + bizStep: "https://ref.gs1.org/cbv/BizStep-receiving", + bizLocation: RECEIVING_DOCK, + disposition: "https://ref.gs1.org/cbv/Disp-in_progress", + readPoint: RECEIVING_DOCK, + action: "ADD", + parentID: "", + epcList: `${FRONT_WHEEL_EPC}, ${REAR_WHEEL_EPC}`, + childEPCList: "", + inputEPCs: "", + outputEPCs: "", + }, + { + ual: `${UAL_BASE}/3/private`, + eventType: EPCIS_OBJECT_EVENT, + eventTime: "2024-03-01T09:00:00.000Z", + bizStep: "https://ref.gs1.org/cbv/BizStep-receiving", + bizLocation: RECEIVING_DOCK, + disposition: "https://ref.gs1.org/cbv/Disp-in_progress", + readPoint: RECEIVING_DOCK, + action: "ADD", + parentID: "", + epcList: HANDLEBAR_EPC, + childEPCList: "", + inputEPCs: "", + outputEPCs: "", + }, +]; + +export const QUALITY_LAB_EVENTS: QueryRow[] = [ + { + ual: `${UAL_BASE}/4/private`, + eventType: EPCIS_OBJECT_EVENT, + eventTime: "2024-03-01T10:00:00.000Z", + bizStep: "https://ref.gs1.org/cbv/BizStep-inspecting", + bizLocation: QUALITY_LAB, + disposition: "https://ref.gs1.org/cbv/Disp-conformant", + readPoint: QUALITY_LAB, + action: "OBSERVE", + parentID: "", + epcList: FRAME_EPC, + childEPCList: "", + inputEPCs: "", + outputEPCs: "", + }, + { + ual: `${UAL_BASE}/5/private`, + eventType: EPCIS_OBJECT_EVENT, + eventTime: "2024-03-01T10:30:00.000Z", + bizStep: "https://ref.gs1.org/cbv/BizStep-inspecting", + bizLocation: QUALITY_LAB, + disposition: "https://ref.gs1.org/cbv/Disp-conformant", + readPoint: QUALITY_LAB, + action: "OBSERVE", + parentID: "", + epcList: `${FRONT_WHEEL_EPC}, ${REAR_WHEEL_EPC}`, + childEPCList: "", + inputEPCs: "", + outputEPCs: "", + }, + { + ual: `${UAL_BASE}/7/private`, + eventType: EPCIS_OBJECT_EVENT, + eventTime: "2024-03-01T15:00:00.000Z", + bizStep: "https://ref.gs1.org/cbv/BizStep-inspecting", + bizLocation: QUALITY_LAB, + disposition: "https://ref.gs1.org/cbv/Disp-conformant", + readPoint: QUALITY_LAB, + action: "OBSERVE", + parentID: "", + epcList: BICYCLE_EPC, + childEPCList: "", + inputEPCs: "", + outputEPCs: "", + }, +]; + +const ASSEMBLY_EVENT: QueryRow = { + ual: `${UAL_BASE}/6/private`, + eventType: EPCIS_TRANSFORMATION_EVENT, + eventTime: "2024-03-01T14:00:00.000Z", + bizStep: "https://ref.gs1.org/cbv/BizStep-assembling", + bizLocation: ASSEMBLY_LINE, + disposition: "https://ref.gs1.org/cbv/Disp-active", + readPoint: ASSEMBLY_LINE, + action: "", + parentID: "", + epcList: "", + childEPCList: "", + inputEPCs: `${FRAME_EPC}, ${FRONT_WHEEL_EPC}, ${REAR_WHEEL_EPC}, ${HANDLEBAR_EPC}`, + outputEPCs: BICYCLE_EPC, +}; + +const PACKING_EVENT: QueryRow = { + ual: `${UAL_BASE}/8/private`, + eventType: EPCIS_AGGREGATION_EVENT, + eventTime: "2024-03-01T16:00:00.000Z", + bizStep: "https://ref.gs1.org/cbv/BizStep-packing", + bizLocation: PACKING_AREA, + disposition: "https://ref.gs1.org/cbv/Disp-in_transit", + readPoint: PACKING_AREA, + action: "ADD", + parentID: PALLET_EPC, + epcList: "", + childEPCList: BICYCLE_EPC, + inputEPCs: "", + outputEPCs: "", +}; + +export const FRAME_TRACE_EVENTS: QueryRow[] = [ + RECEIVING_EVENTS[0], + QUALITY_LAB_EVENTS[0], + ASSEMBLY_EVENT, +]; + +export const BICYCLE_TRACE_EVENTS: QueryRow[] = [ + ASSEMBLY_EVENT, + QUALITY_LAB_EVENTS[2], + PACKING_EVENT, +]; + +export const ASSEMBLY_EVENTS: QueryRow[] = [ASSEMBLY_EVENT]; diff --git a/packages/plugin-epcis/tests/plugin-epcis.spec.ts b/packages/plugin-epcis/tests/plugin-epcis.spec.ts deleted file mode 100644 index f1e5e674..00000000 --- a/packages/plugin-epcis/tests/plugin-epcis.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, beforeEach, afterEach } from "mocha"; -import { expect } from "chai"; -import sinon from "sinon"; -import pluginEpcisPlugin from "../dist/index.js"; -import { - createExpressApp, - createInMemoryBlobStorage, - createMcpServerClientPair, - createMockDkgClient, -} from "@dkg/plugins/testing"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import express from "express"; -// import request from "supertest"; - -// Mock DKG context -const mockDkgContext = { - dkg: createMockDkgClient(), - blob: createInMemoryBlobStorage(), -}; - -describe("@dkg/plugin-epcis checks", function () { - let mockMcpServer: McpServer; - let mockMcpClient: Client; - let apiRouter: express.Router; - let app: express.Application; - - this.timeout(5000); - - beforeEach(async () => { - const { server, client, connect } = await createMcpServerClientPair(); - mockMcpServer = server; - mockMcpClient = client; - apiRouter = express.Router(); - app = createExpressApp(); - - // Initialize plugin - pluginEpcisPlugin(mockDkgContext, mockMcpServer, apiRouter); - await connect(); - app.use("/", apiRouter); - }); - - afterEach(() => { - sinon.restore(); - }); - - describe("Plugin Configuration", () => { - it("should create plugin without errors", () => { - expect(pluginEpcisPlugin).to.be.a("function"); - }); - }); - - describe("Core Functionality", () => { - it("should register tools or endpoints", async () => { - // TODO: Replace this placeholder with your actual tests! - // Example for MCP tools: - // const tools = await mockMcpClient.listTools().then((r) => r.tools); - // expect(tools.some((t) => t.name === "your-tool-name")).to.equal(true); - - // Example for API endpoints: - // request(app).get("/your-endpoint").expect(200); - - throw new Error( - "TODO: Replace placeholder test with your actual plugin functionality tests", - ); - }); - }); - - describe("Error Handling", () => { - it("should handle invalid parameters", async () => { - // TODO: Replace this placeholder with your actual error handling tests! - // Example: - // await request(app).get("/invalid-endpoint").expect(400); - - throw new Error( - "TODO: Replace placeholder test with your actual error handling tests", - ); - }); - }); -}); diff --git a/packages/plugin-epcis/tests/pluginEpcis.spec.ts b/packages/plugin-epcis/tests/pluginEpcis.spec.ts new file mode 100644 index 00000000..f79fe8e1 --- /dev/null +++ b/packages/plugin-epcis/tests/pluginEpcis.spec.ts @@ -0,0 +1,572 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable turbo/no-undeclared-env-vars */ + +import { describe, it, beforeEach, afterEach } from "mocha"; +import { expect } from "chai"; +import sinon from "sinon"; +import request from "supertest"; +import pluginEpcisPlugin from "../dist/index.js"; +import bicycleStory from "../test-data/bicycle-manufacturing-story.json"; +import { + ASSEMBLY_EVENTS, + BICYCLE_TRACE_EVENTS, + FRAME_TRACE_EVENTS, + QUALITY_LAB_EVENTS, + RECEIVING_EVENTS, + jsonResponse, + makeDkgQueryResult, + publisherQueuedResponse, + publisherStatusResponse, +} from "./fixtures/bicycleStoryFixtures"; +import { + createExpressApp, + createInMemoryBlobStorage, + createMcpServerClientPair, + createMockDkgClient, +} from "@dkg/plugins/testing"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import express from "express"; + +const story = bicycleStory as any; +const event1 = story.events[0].request; +const event2 = story.events[1].request; +const event6 = story.events[5].request; +const event8 = story.events[7].request; +const frameEpc = story.characters.components.frame as string; +const bicycleEpc = story.characters.finishedProduct.bicycle as string; +const qualityLab = story.locations.qualityLab as string; + +function parseToolResult(result: any): Record { + return JSON.parse((result.content as any[])[0].text); +} + +function expectResponseErrorMessage( + responseBody: Record, + messageFragment: string, +): void { + expect(responseBody.error).to.be.a("string"); + expect(responseBody.error).to.include(messageFragment); +} + +describe("@dkg/plugin-epcis checks", function () { + let mockMcpServer: McpServer; + let mockMcpClient: Client; + let apiRouter: express.Router; + let app: express.Application; + let dkgContext: any; + let dkgQueryStub: sinon.SinonStub; + let fetchStub: sinon.SinonStub; + let originalMcpUrl: string | undefined; + + this.timeout(5000); + + beforeEach(async () => { + originalMcpUrl = process.env.EXPO_PUBLIC_MCP_URL; + process.env.EXPO_PUBLIC_MCP_URL = "http://test-publisher:9999"; + fetchStub = sinon.stub(global, "fetch"); + + dkgContext = { + dkg: createMockDkgClient(), + blob: createInMemoryBlobStorage(), + }; + dkgQueryStub = sinon.stub(dkgContext.dkg.graph, "query"); + + const { server, client, connect } = await createMcpServerClientPair(); + mockMcpServer = server; + mockMcpClient = client; + apiRouter = express.Router(); + app = createExpressApp(); + + pluginEpcisPlugin(dkgContext, mockMcpServer, apiRouter); + await connect(); + app.use("/", apiRouter); + }); + + afterEach(() => { + sinon.restore(); + if (originalMcpUrl !== undefined) { + process.env.EXPO_PUBLIC_MCP_URL = originalMcpUrl; + } else { + delete process.env.EXPO_PUBLIC_MCP_URL; + } + }); + + describe("Plugin Registration", () => { + it("registers epcis MCP tools with expected schema fields", async () => { + const tools = await mockMcpClient.listTools().then((t) => t.tools); + const queryTool = tools.find((tool) => tool.name === "epcis-query"); + const trackTool = tools.find((tool) => tool.name === "epcis-track-item"); + const captureTool = tools.find((tool) => tool.name === "epcis-capture"); + const captureStatusTool = tools.find( + (tool) => tool.name === "epcis-capture-status", + ); + + expect(queryTool).to.not.equal(undefined); + expect(trackTool).to.not.equal(undefined); + expect(captureTool).to.not.equal(undefined); + expect(captureStatusTool).to.not.equal(undefined); + expect((queryTool as any).inputSchema.properties).to.include.keys( + "epc", + "bizStep", + "bizLocation", + "limit", + "offset", + ); + expect((trackTool as any).inputSchema.properties).to.include.keys("epc"); + expect((captureTool as any).inputSchema.properties).to.include.keys( + "epcisDocument", + "publishOptions", + ); + expect((captureStatusTool as any).inputSchema.properties).to.include.keys( + "captureID", + ); + }); + }); + + describe("Capture - POST /epcis/capture", () => { + it("captures a valid ObjectEvent and returns 202", async () => { + fetchStub.resolves(publisherQueuedResponse(101)); + const response = await request(app) + .post("/epcis/capture") + .send(event1) + .expect(202); + + expect(response.body.captureID).to.equal("101"); + expect(response.body.requestId).to.match(/^epcis-/); + expect(response.body.receivedAt).to.be.a("string"); + expect(response.body.eventCount).to.equal(1); + }); + + it("captures a multi-EPC ObjectEvent and keeps eventCount as 1", async () => { + fetchStub.resolves(publisherQueuedResponse(102)); + const response = await request(app) + .post("/epcis/capture") + .send(event2) + .expect(202); + + expect(response.body.captureID).to.equal("102"); + expect(response.body.eventCount).to.equal(1); + }); + + it("captures a valid TransformationEvent", async () => { + fetchStub.resolves(publisherQueuedResponse(106)); + await request(app).post("/epcis/capture").send(event6).expect(202); + }); + + it("captures a valid AggregationEvent", async () => { + fetchStub.resolves(publisherQueuedResponse(108)); + await request(app).post("/epcis/capture").send(event8).expect(202); + }); + }); + + describe("Capture Status - GET /epcis/capture/:captureID", () => { + it("returns completed status with UAL", async () => { + fetchStub.resolves( + publisherStatusResponse( + "completed", + "did:dkg:otp:2043/0xabc/123", + "2024-03-01T16:30:00.000Z", + ), + ); + const response = await request(app).get("/epcis/capture/123").expect(200); + + expect(response.body.status).to.equal("completed"); + expect(response.body.captureID).to.equal("123"); + expect(response.body.UAL).to.equal("did:dkg:otp:2043/0xabc/123"); + }); + + it("returns 404 when publisher returns 404", async () => { + fetchStub.resolves(jsonResponse({ error: "not found" }, 404)); + const response = await request(app).get("/epcis/capture/404").expect(404); + + expect(response.body.error).to.equal("Capture not found"); + expect(response.body.captureID).to.equal("404"); + }); + + it("returns 504 on publisher timeout", async () => { + fetchStub.rejects(new DOMException("Timed out", "TimeoutError")); + const response = await request(app).get("/epcis/capture/888").expect(504); + + expect(response.body.error).to.equal("Publisher timeout"); + expect(response.body.captureID).to.equal("888"); + }); + }); + + describe("Events Query - GET /epcis/events", () => { + it("queries by bizStep=receiving and returns 3 events", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(RECEIVING_EVENTS)); + const response = await request(app) + .get("/epcis/events") + .query({ bizStep: "receiving" }) + .expect(200); + + expect(response.body.count).to.equal(3); + expect(response.body.results).to.have.length(3); + expect(dkgQueryStub.firstCall.args[0]).to.include("BizStep-receiving"); + }); + + it("queries by quality lab bizLocation and returns 3 events", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(QUALITY_LAB_EVENTS)); + const response = await request(app) + .get("/epcis/events") + .query({ bizLocation: qualityLab }) + .expect(200); + + expect(response.body.count).to.equal(3); + expect(response.body.results).to.have.length(3); + expect(dkgQueryStub.calledOnce).to.equal(true); + const sparql = dkgQueryStub.firstCall.args[0] as string; + expect(sparql).to.include(`epcis:bizLocation "${qualityLab}"`); + }); + + it("queries full trace for frame EPC and returns receiving/inspecting/assembling", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(FRAME_TRACE_EVENTS)); + const response = await request(app) + .get("/epcis/events") + .query({ epc: frameEpc, fullTrace: "true" }) + .expect(200); + + const steps = response.body.results.map((row: any) => row.bizStep); + expect(response.body.count).to.equal(3); + expect( + steps.some((step: string) => step.endsWith("BizStep-receiving")), + ).to.equal(true); + expect( + steps.some((step: string) => step.endsWith("BizStep-inspecting")), + ).to.equal(true); + expect( + steps.some((step: string) => step.endsWith("BizStep-assembling")), + ).to.equal(true); + }); + + it("queries full trace for bicycle EPC and returns 3 events", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(BICYCLE_TRACE_EVENTS)); + const response = await request(app) + .get("/epcis/events") + .query({ epc: bicycleEpc, fullTrace: "true" }) + .expect(200); + + expect(response.body.count).to.equal(3); + expect(response.body.results).to.have.length(3); + expect(dkgQueryStub.calledOnce).to.equal(true); + const sparql = dkgQueryStub.firstCall.args[0] as string; + expect(sparql).to.include("UNION"); + expect(sparql).to.include(`?event epcis:outputEPCList "${bicycleEpc}"`); + expect(sparql).to.include(`?event epcis:childEPCs "${bicycleEpc}"`); + }); + + it("includes date range filters in generated SPARQL", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(RECEIVING_EVENTS)); + await request(app) + .get("/epcis/events") + .query({ + from: "2024-03-01T00:00:00Z", + to: "2024-03-01T23:59:59Z", + }) + .expect(200); + + const sparql = dkgQueryStub.firstCall.args[0] as string; + expect(sparql).to.include('xsd:dateTime("2024-03-01T00:00:00Z")'); + expect(sparql).to.include('xsd:dateTime("2024-03-01T23:59:59Z")'); + }); + + it("returns pagination based on limit and offset query params", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(RECEIVING_EVENTS)); + const response = await request(app) + .get("/epcis/events") + .query({ bizStep: "receiving", limit: "5", offset: "10" }) + .expect(200); + + expect(response.body.pagination).to.deep.equal({ limit: 5, offset: 10 }); + }); + }); + + describe("Events Track - GET /epcis/events/track", () => { + it("tracks item events with full trace and returns results", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(BICYCLE_TRACE_EVENTS)); + const response = await request(app) + .get("/epcis/events/track") + .query({ epc: bicycleEpc }) + .expect(200); + + expect(response.body.success).to.equal(true); + expect(response.body.count).to.equal(3); + expect(response.body.results).to.have.length(3); + expect(dkgQueryStub.calledOnce).to.equal(true); + const sparql = dkgQueryStub.firstCall.args[0] as string; + expect(sparql).to.include("UNION"); + expect(sparql).to.include(`?event epcis:outputEPCList "${bicycleEpc}"`); + expect(sparql).to.include(`?event epcis:childEPCs "${bicycleEpc}"`); + }); + + it("returns 400 for missing epc", async () => { + const response = await request(app) + .get("/epcis/events/track") + .expect(400); + expect(response.body.error).to.be.a("string"); + }); + }); + + describe("MCP Tools", () => { + it("epcis-query returns assembly event results", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(ASSEMBLY_EVENTS)); + const result = await mockMcpClient.callTool({ + name: "epcis-query", + arguments: { bizStep: "assembling" }, + }); + + const payload = parseToolResult(result); + expect(result.isError).to.not.equal(true); + expect(payload.count).to.equal(1); + expect(payload.summary).to.include("Found 1 EPCIS event"); + expect(payload.events[0].bizStep).to.include("BizStep-assembling"); + }); + + it("epcis-track-item returns journey timeline with numbered steps", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(BICYCLE_TRACE_EVENTS)); + const result = await mockMcpClient.callTool({ + name: "epcis-track-item", + arguments: { epc: bicycleEpc }, + }); + + const payload = parseToolResult(result); + expect(payload.eventCount).to.equal(3); + expect(payload.summary).to.include("Journey Timeline"); + expect(payload.summary).to.include("1."); + expect(payload.summary).to.include("assembling"); + expect(payload.summary).to.include("inspecting"); + expect(payload.summary).to.include("packing"); + expect(dkgQueryStub.calledOnce).to.equal(true); + const sparql = dkgQueryStub.firstCall.args[0] as string; + expect(sparql).to.include("UNION"); + expect(sparql).to.include(`?event epcis:inputEPCList "${bicycleEpc}"`); + }); + + it("epcis-capture captures valid document and returns capture details", async () => { + fetchStub.resolves(publisherQueuedResponse(501)); + const result = await mockMcpClient.callTool({ + name: "epcis-capture", + arguments: event1, + }); + + const payload = parseToolResult(result); + expect(result.isError).to.not.equal(true); + expect(payload.captureID).to.equal("501"); + expect(payload.requestId).to.match(/^epcis-/); + expect(payload.receivedAt).to.be.a("string"); + expect(payload.eventCount).to.equal(1); + }); + + it("epcis-capture returns validation error for invalid document", async () => { + const result = await mockMcpClient.callTool({ + name: "epcis-capture", + arguments: { epcisDocument: { type: "NotAnEPCIS" } }, + }); + + const payload = parseToolResult(result); + expect(result.isError).to.equal(true); + expect(payload.error).to.equal("Invalid EPCISDocument"); + }); + + it("epcis-capture returns publisher error when publisher is unavailable", async function () { + this.timeout(15000); + fetchStub.rejects(new Error("publisher down")); + const result = await mockMcpClient.callTool({ + name: "epcis-capture", + arguments: event1, + }); + + const payload = parseToolResult(result); + expect(result.isError).to.equal(true); + expect(payload.error).to.include("Something went wrong"); + expect(fetchStub.callCount).to.equal(3); + }); + + it("epcis-capture-status returns completed status with UAL", async () => { + fetchStub.resolves( + publisherStatusResponse( + "completed", + "did:dkg:otp:2043/0xabc/123", + "2024-03-01T16:30:00.000Z", + ), + ); + const result = await mockMcpClient.callTool({ + name: "epcis-capture-status", + arguments: { captureID: "123" }, + }); + + const payload = parseToolResult(result); + expect(result.isError).to.not.equal(true); + expect(payload.status).to.equal("completed"); + expect(payload.captureID).to.equal("123"); + expect(payload.UAL).to.equal("did:dkg:otp:2043/0xabc/123"); + }); + + it("epcis-capture-status returns error when capture is not found", async () => { + fetchStub.resolves(jsonResponse({ error: "not found" }, 404)); + const result = await mockMcpClient.callTool({ + name: "epcis-capture-status", + arguments: { captureID: "404" }, + }); + + const payload = parseToolResult(result); + expect(result.isError).to.equal(true); + expect(payload.error).to.equal("Capture not found"); + expect(payload.captureID).to.equal("404"); + }); + + it("epcis-capture-status returns timeout error on publisher timeout", async () => { + fetchStub.rejects(new DOMException("Timed out", "TimeoutError")); + const result = await mockMcpClient.callTool({ + name: "epcis-capture-status", + arguments: { captureID: "888" }, + }); + + const payload = parseToolResult(result); + expect(result.isError).to.equal(true); + expect(payload.error).to.equal("Publisher timeout"); + expect(payload.captureID).to.equal("888"); + }); + }); + + describe("MCP/API Parity", () => { + it("returns the same query event data for epcis-query and GET /epcis/events", async () => { + dkgQueryStub.resolves(makeDkgQueryResult(ASSEMBLY_EVENTS)); + + const mcpResult = await mockMcpClient.callTool({ + name: "epcis-query", + arguments: { bizStep: "assembling" }, + }); + const mcpPayload = parseToolResult(mcpResult); + + const apiResponse = await request(app) + .get("/epcis/events") + .query({ bizStep: "assembling" }) + .expect(200); + + expect(mcpPayload.events).to.deep.equal(apiResponse.body.results); + expect(mcpPayload.count).to.equal(apiResponse.body.count); + }); + + it("returns the same captureID and eventCount for epcis-capture and POST /epcis/capture", async () => { + fetchStub.onFirstCall().resolves(publisherQueuedResponse(777)); + fetchStub.onSecondCall().resolves(publisherQueuedResponse(777)); + + const mcpResult = await mockMcpClient.callTool({ + name: "epcis-capture", + arguments: event1, + }); + const mcpPayload = parseToolResult(mcpResult); + + const apiResponse = await request(app) + .post("/epcis/capture") + .send(event1) + .expect(202); + + expect(mcpPayload.captureID).to.equal(apiResponse.body.captureID); + expect(mcpPayload.eventCount).to.equal(apiResponse.body.eventCount); + }); + }); + + describe("Error Handling", () => { + it("returns 400 when EPCISDocument fails schema validation", async () => { + const response = await request(app) + .post("/epcis/capture") + .send({ epcisDocument: { type: "NotAnEPCIS" } }) + .expect(400); + + expect(response.body.error).to.equal("Invalid EPCISDocument"); + }); + + it("returns 400 when EPCISDocument has no events", async () => { + const emptyEventDoc = structuredClone(event1.epcisDocument); + emptyEventDoc.epcisBody.eventList = []; + + const response = await request(app) + .post("/epcis/capture") + .send({ + epcisDocument: emptyEventDoc, + publishOptions: event1.publishOptions, + }) + .expect(400); + + expect(response.body.error).to.equal("EPCISDocument contains no events"); + }); + + it("returns 500 when publisher is unavailable for capture", async function () { + this.timeout(15000); + fetchStub.rejects(new Error("publisher down")); + + const response = await request(app) + .post("/epcis/capture") + .send(event1) + .expect(500); + + expect(response.body.error).to.include("Something went wrong"); + expect(fetchStub.callCount).to.equal(3); + }); + + it("returns 400 when events query has no filters", async () => { + const response = await request(app).get("/epcis/events").expect(400); + expectResponseErrorMessage( + response.body, + "At least one filter parameter is required.", + ); + }); + + it("returns 400 when 'to' is before 'from'", async () => { + const response = await request(app) + .get("/epcis/events") + .query({ + from: "2024-03-02T00:00:00Z", + to: "2024-03-01T00:00:00Z", + }) + .expect(400); + expectResponseErrorMessage( + response.body, + "Parameter 'to' must be greater than or equal to 'from'.", + ); + }); + + it("returns 400 for non-numeric captureID", async () => { + const response = await request(app).get("/epcis/capture/abc").expect(400); + expectResponseErrorMessage(response.body, "Invalid captureID format"); + }); + + it("returns MCP error when epcis-query has no filters", async () => { + const result = await mockMcpClient.callTool({ + name: "epcis-query", + arguments: {}, + }); + const payload = parseToolResult(result); + + expect(result.isError).to.equal(true); + expect(payload.error).to.include( + "At least one filter parameter is required.", + ); + }); + + it("returns MCP error when DKG query fails", async () => { + dkgQueryStub.rejects(new Error("query exploded")); + const result = await mockMcpClient.callTool({ + name: "epcis-query", + arguments: { bizStep: "receiving" }, + }); + const payload = parseToolResult(result); + + expect(result.isError).to.equal(true); + expect(payload.error).to.equal("Query failed"); + }); + + it("returns 500 when /epcis/events DKG query fails", async () => { + dkgQueryStub.rejects(new Error("query exploded")); + const response = await request(app) + .get("/epcis/events") + .query({ bizStep: "receiving" }) + .expect(500); + + expect(response.body.error).to.equal("Failed to query events"); + }); + }); +});