From d359f763eb0517fcdecfa0357a98471674a6671b Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Wed, 11 Feb 2026 22:21:12 +0100 Subject: [PATCH 01/23] error.message cleanup + epcis retry and fallback --- packages/plugin-epcis/src/index.ts | 147 ++++++++++++++++------- packages/plugin-epcis/src/model/types.ts | 1 + 2 files changed, 104 insertions(+), 44 deletions(-) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 8c41c202..52ee893d 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -8,7 +8,16 @@ import type { CaptureResponse } from "./model/types"; const PUBLISHER_POST_TIMEOUT_MS = 10000; const PUBLISHER_GET_TIMEOUT_MS = 5000; -// Helper function to send JSON-LD to publisher +// Retry configuration +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 1000; + +// Helper for delay +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Helper function to send JSON-LD to publisher with retries async function sendToPublisher( jsonLd: any, metadata?: { source?: string; sourceId?: string }, @@ -19,33 +28,62 @@ async function sendToPublisher( ): 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), - }); + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + 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"); - } + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) { + throw new Error("Publisher not available"); + } - return response.json(); - } catch (error: any) { - if (error.name === "TimeoutError") { - throw new Error("Publisher request timed out"); + return await response.json(); + } catch (error: any) { + console.warn(`[EPCIS] Publisher attempt ${attempt}/${MAX_RETRIES} failed`); + + if (attempt < MAX_RETRIES) { + await delay(RETRY_DELAY_MS * Math.pow(2, attempt - 1)); + continue; + } } - throw error; } + + throw new Error("Publisher not available"); +} + +// Fallback: publish directly to DKG +async function publishDirectToDKG( + ctx: any, + jsonLd: any, + publishOptions?: { privacy?: "private" | "public"; epochs?: number } +): Promise<{ ual: string }> { + const privacy = publishOptions?.privacy ?? "private"; + const wrapped = { [privacy]: jsonLd }; + + console.log(`[EPCIS] Publishing directly to DKG (fallback)...`); + + const result = await ctx.dkg.asset.create(wrapped, { + epochsNum: publishOptions?.epochs ?? 12, + minimumNumberOfFinalizationConfirmations: 3, + minimumNumberOfNodeReplications: 1, + }); + + if (!result?.UAL) { + throw new Error("DKG publish failed - no UAL returned"); + } + + return { ual: result.UAL }; } export default defineDkgPlugin((ctx, mcp, api) => { @@ -98,7 +136,6 @@ export default defineDkgPlugin((ctx, mcp, api) => { summary, count: results?.data.length || 0, events: results || [], - //query: sparqlQuery, }, null, 2) } ], @@ -110,7 +147,6 @@ export default defineDkgPlugin((ctx, mcp, api) => { type: "text", text: JSON.stringify({ error: "Query failed", - message: error.message, }, null, 2) } ], @@ -176,7 +212,6 @@ export default defineDkgPlugin((ctx, mcp, api) => { type: "text", text: JSON.stringify({ error: "Tracking failed", - message: error.message, }, null, 2) } ], @@ -210,12 +245,13 @@ export default defineDkgPlugin((ctx, mcp, api) => { }), }), response: { - description: "Capture accepted", + description: "Capture accepted (202) or published directly (201)", schema: z.object({ status: z.string(), receivedAt: z.string(), captureID: z.string(), eventCount: z.number(), + UAL: z.string().optional(), }), }, }, @@ -233,30 +269,55 @@ export default defineDkgPlugin((ctx, mcp, api) => { } as any); } - // Send to publisher with user-provided options (or defaults) - const result = await sendToPublisher( - epcisDocument, - { - source: "EPCIS", - sourceId: `epcis-${Date.now()}`, - }, - publishOptions - ); + // Generate request ID for tracing + const requestId = `epcis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + console.log(`[EPCIS] [${requestId}] Capture request received, ${validation.eventCount} event(s)`); + + let result: any; + let usedFallback = false; + + // Try publisher first (with retries) + try { + result = await sendToPublisher( + epcisDocument, + { source: "EPCIS", sourceId: requestId }, + publishOptions + ); + console.log(`[EPCIS] [${requestId}] Queued via publisher, captureID: ${result.id}`); + } catch (publisherError: any) { + console.warn(`[EPCIS] [${requestId}] Publisher not available, trying direct DKG fallback`); + + // Fallback to direct DKG publish + try { + const directResult = await publishDirectToDKG(ctx, epcisDocument, publishOptions); + result = { id: `direct-${Date.now()}`, ual: directResult.ual }; + usedFallback = true; + console.log(`[EPCIS] [${requestId}] Published directly to DKG, UAL: ${result.ual}`); + } catch (fallbackError: any) { + console.error(`[EPCIS] [${requestId}] Both publisher and DKG fallback failed`); + return res.status(503).json({ + error: "Publishing unavailable", + message: "Both publisher service and direct DKG publishing failed", + requestId + } as any); + } + } // Return capture response const response: CaptureResponse = { - status: "202", + status: usedFallback ? "201" : "202", receivedAt: new Date().toISOString(), captureID: String(result.id), eventCount: validation.eventCount || 0, + ...(result.ual && { UAL: result.ual }), }; - res.status(202).json(response); + res.status(usedFallback ? 201 : 202).json(response); } catch (error: any) { - console.error("[EPCIS Capture] Error:", error); + console.error("[EPCIS Capture] Unexpected error:", error); res.status(500).json({ - error: "Failed to process capture", - //message: error.message, + error: "Internal server error", + message: "An unexpected error occurred while processing the capture", } as any); } } @@ -341,7 +402,6 @@ export default defineDkgPlugin((ctx, mcp, api) => { console.error("[EPCIS Status] Error:", error); res.status(500).json({ error: "Failed to get capture status", - //message: error.message, } as any); } } @@ -426,7 +486,6 @@ export default defineDkgPlugin((ctx, mcp, api) => { res.status(500).json({ success: false, error: "Failed to query events", - message: error.message, } as any); } } diff --git a/packages/plugin-epcis/src/model/types.ts b/packages/plugin-epcis/src/model/types.ts index ccf4ab87..443ab408 100644 --- a/packages/plugin-epcis/src/model/types.ts +++ b/packages/plugin-epcis/src/model/types.ts @@ -32,6 +32,7 @@ export interface EPCISDocument { receivedAt: string; captureID: string; eventCount: number; + UAL?: string; } export interface CaptureStatusResponse { From ad06c17f1de8fdb82f3bafcae93fa7457ca6840b Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Tue, 17 Feb 2026 15:15:13 +0100 Subject: [PATCH 02/23] Added GET API --- packages/plugin-epcis/src/index.ts | 93 ++++++++++++++----- .../src/services/EPCISQueryService.ts | 24 +---- 2 files changed, 73 insertions(+), 44 deletions(-) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 52ee893d..f687c228 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -51,7 +51,7 @@ async function sendToPublisher( return await response.json(); } catch (error: any) { console.warn(`[EPCIS] Publisher attempt ${attempt}/${MAX_RETRIES} failed`); - + if (attempt < MAX_RETRIES) { await delay(RETRY_DELAY_MS * Math.pow(2, attempt - 1)); continue; @@ -70,9 +70,9 @@ async function publishDirectToDKG( ): Promise<{ ual: string }> { const privacy = publishOptions?.privacy ?? "private"; const wrapped = { [privacy]: jsonLd }; - + console.log(`[EPCIS] Publishing directly to DKG (fallback)...`); - + const result = await ctx.dkg.asset.create(wrapped, { epochsNum: publishOptions?.epochs ?? 12, minimumNumberOfFinalizationConfirmations: 3, @@ -98,7 +98,7 @@ export default defineDkgPlugin((ctx, mcp, api) => { "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.", @@ -124,14 +124,14 @@ export default defineDkgPlugin((ctx, mcp, api) => { const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); - const summary = results?.length - ? `Found ${results.length} EPCIS event(s)` + const summary = results?.length + ? `Found ${results.length} EPCIS event(s)` : "No events found matching the criteria"; return { content: [ - { - type: "text", + { + type: "text", text: JSON.stringify({ summary, count: results?.data.length || 0, @@ -143,8 +143,8 @@ export default defineDkgPlugin((ctx, mcp, api) => { } catch (error: any) { return { content: [ - { - type: "text", + { + type: "text", text: JSON.stringify({ error: "Query failed", }, null, 2) @@ -161,7 +161,7 @@ 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.", @@ -194,8 +194,8 @@ export default defineDkgPlugin((ctx, mcp, api) => { return { content: [ - { - type: "text", + { + type: "text", text: JSON.stringify({ summary, epc: input.epc, @@ -208,8 +208,8 @@ export default defineDkgPlugin((ctx, mcp, api) => { } catch (error: any) { return { content: [ - { - type: "text", + { + type: "text", text: JSON.stringify({ error: "Tracking failed", }, null, 2) @@ -286,7 +286,7 @@ export default defineDkgPlugin((ctx, mcp, api) => { console.log(`[EPCIS] [${requestId}] Queued via publisher, captureID: ${result.id}`); } catch (publisherError: any) { console.warn(`[EPCIS] [${requestId}] Publisher not available, trying direct DKG fallback`); - + // Fallback to direct DKG publish try { const directResult = await publishDirectToDKG(ctx, epcisDocument, publishOptions); @@ -437,9 +437,6 @@ export default defineDkgPlugin((ctx, mcp, api) => { description: "Filter by business location", example: "urn:epc:id:sgln:0614141.00001.0", }), - /*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", @@ -466,7 +463,6 @@ export default defineDkgPlugin((ctx, mcp, api) => { to: to as string, bizStep: bizStep as string, bizLocation: bizLocation as string, - //ual: ual as string, fullTrace: fullTrace === 'true', }); @@ -491,5 +487,60 @@ export default defineDkgPlugin((ctx, mcp, api) => { } ) ); - + + // GET /epcis/asset/:ual - Retrieve EPCIS document by UAL + api.get( + "/epcis/asset/*ual", + openAPIRoute( + { + tag: "EPCIS", + summary: "Get EPCIS Document by UAL", + description: "Retrieve a complete EPCIS document from DKG by its UAL", + params: z.object({ + ual: z.union([z.string(), z.array(z.string())]).openapi({ + description: "The UAL of the published EPCIS document", + example: "did:dkg:otp:2043/0x1234.../123456", + }), + }), + response: { + description: "EPCIS document content", + schema: z.object({ + success: z.boolean(), + ual: z.string(), + data: z.any(), + }), + }, + }, + async (req, res) => { + try { + const ual = Array.isArray(req.params.ual) + ? req.params.ual.join('/') + : req.params.ual; + + if (!ual.startsWith("did:dkg:")) { + return res.status(400).json({ + success: false, + error: "Invalid UAL format", + } as any); + } + + const assetResult = await ctx.dkg.asset.get(ual, { + contentType: "all", + }); + + res.json({ + success: true, + ual, + data: assetResult, + }); + } catch (error: any) { + console.error("[EPCIS Asset] Get error:", error); + res.status(404).json({ + success: false, + error: "Asset not found", + } as any); + } + } + ) + ); }); \ No newline at end of file diff --git a/packages/plugin-epcis/src/services/EPCISQueryService.ts b/packages/plugin-epcis/src/services/EPCISQueryService.ts index a867014d..49c31c45 100644 --- a/packages/plugin-epcis/src/services/EPCISQueryService.ts +++ b/packages/plugin-epcis/src/services/EPCISQueryService.ts @@ -16,7 +16,6 @@ export interface EpcisQueryParams { 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; } @@ -37,7 +36,7 @@ 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}`; } @@ -50,10 +49,6 @@ export class EpcisQueryService { * 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[] = []; @@ -134,21 +129,4 @@ WHERE { 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 . - } -}`; - }*/ } From a763b2d61ae2052f98791be2509e958a9dedac89 Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Tue, 17 Feb 2026 16:06:32 +0100 Subject: [PATCH 03/23] pagination support --- packages/plugin-epcis/src/index.ts | 46 ++++++++++++++++--- .../src/services/EPCISQueryService.ts | 11 ++++- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index f687c228..ee2a1d17 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -109,6 +109,8 @@ export default defineDkgPlugin((ctx, mcp, api) => { 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"), + limit: z.number().optional().describe("Number of results per page (default: 100, max: 1000)"), + offset: z.number().optional().describe("Number of results to skip for pagination"), }, }, async (input) => { @@ -120,12 +122,18 @@ export default defineDkgPlugin((ctx, mcp, api) => { bizStep: input.bizStep, bizLocation: input.bizLocation, fullTrace: input.fullTrace, + limit: input.limit, + offset: input.offset, }); const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); - const summary = results?.length - ? `Found ${results.length} EPCIS event(s)` + const effectiveLimit = Math.min(input.limit ?? 100, 1000); + const effectiveOffset = input.offset ?? 0; + const resultCount = results?.data?.length || 0; + + const summary = resultCount + ? `Found ${resultCount} EPCIS event(s)` : "No events found matching the criteria"; return { @@ -134,8 +142,12 @@ export default defineDkgPlugin((ctx, mcp, api) => { type: "text", text: JSON.stringify({ summary, - count: results?.data.length || 0, + count: resultCount, events: results || [], + pagination: { + limit: effectiveLimit, + offset: effectiveOffset, + }, }, null, 2) } ], @@ -441,20 +453,31 @@ export default defineDkgPlugin((ctx, mcp, api) => { description: "If 'true', search all EPC fields (epcList, inputEPCList, outputEPCList, childEPCs, parentID) for full supply chain traceability", example: "true", }), + limit: z.string().optional().openapi({ + description: "Number of results per page (default: 100, max: 1000)", + example: "50", + }), + offset: z.string().optional().openapi({ + description: "Number of results to skip for pagination", + example: "0", + }), }), response: { description: "Query results", schema: z.object({ success: z.boolean(), - query: z.string().optional(), results: z.array(z.any()), count: z.number(), + pagination: z.object({ + limit: z.number(), + offset: z.number(), + }), }), }, }, async (req, res) => { try { - const { epc, from, to, bizStep, bizLocation, /*ual,*/ fullTrace } = req.query; + const { epc, from, to, bizStep, bizLocation, fullTrace, limit, offset } = req.query; // Build the SPARQL query based on parameters const sparqlQuery = queryService.buildQuery({ @@ -464,6 +487,8 @@ export default defineDkgPlugin((ctx, mcp, api) => { bizStep: bizStep as string, bizLocation: bizLocation as string, fullTrace: fullTrace === 'true', + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, }); console.log("[EPCIS Events] Executing SPARQL query:", sparqlQuery); @@ -471,11 +496,20 @@ export default defineDkgPlugin((ctx, mcp, api) => { // Execute query against DKG const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); + // Calculate pagination values + const effectiveLimit = Math.min(limit ? parseInt(limit as string, 10) : 100, 1000); + const effectiveOffset = offset ? parseInt(offset as string, 10) : 0; + const resultCount = results?.length || 0; + res.json({ success: true, //query: sparqlQuery, results: results || [], - count: results?.length || 0, + count: resultCount, + pagination: { + limit: effectiveLimit, + offset: effectiveOffset, + }, }); } catch (error: any) { console.error("[EPCIS Events] Query error:", error); diff --git a/packages/plugin-epcis/src/services/EPCISQueryService.ts b/packages/plugin-epcis/src/services/EPCISQueryService.ts index 49c31c45..562ae579 100644 --- a/packages/plugin-epcis/src/services/EPCISQueryService.ts +++ b/packages/plugin-epcis/src/services/EPCISQueryService.ts @@ -18,6 +18,10 @@ export interface EpcisQueryParams { bizLocation?: string; /** If true, searches all EPC fields (epcList, inputEPCList, outputEPCList, childEPCs, parentID) */ fullTrace?: boolean; + /** Number of results per page (default: 100, max: 1000) */ + limit?: number; + /** Number of results to skip (for pagination) */ + offset?: number; } /** @@ -116,6 +120,10 @@ export class EpcisQueryService { optionalClauses.push('OPTIONAL { ?event epcis:disposition ?disposition . }'); optionalClauses.push('OPTIONAL { ?event epcis:readPoint ?readPoint . }'); + // 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 return `${PREFIXES} SELECT ?ual ?eventType ?eventTime ?epc ?bizStep ?disposition ?readPoint ?bizLocation @@ -127,6 +135,7 @@ WHERE { ${filterClauses.join('\n ')} } ORDER BY DESC(?eventTime) -LIMIT 100`; +LIMIT ${limit} +OFFSET ${offset}`; } } From 628ec48ff88ee9d3ae9e24d4d7a7f4a59802b863 Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Tue, 17 Feb 2026 16:22:21 +0100 Subject: [PATCH 04/23] remove sparqlQuery --- packages/plugin-epcis/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index ee2a1d17..dc605c7e 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -503,7 +503,6 @@ export default defineDkgPlugin((ctx, mcp, api) => { res.json({ success: true, - //query: sparqlQuery, results: results || [], count: resultCount, pagination: { From abda326784c65b2f507cd22751aea474d26ddfb3 Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Tue, 17 Feb 2026 16:47:58 +0100 Subject: [PATCH 05/23] fixed eventCounts not returning properly --- packages/plugin-epcis/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index dc605c7e..1f797c43 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -190,13 +190,13 @@ export default defineDkgPlugin((ctx, mcp, api) => { const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); - const eventCount = results?.length || 0; + const eventCount = results?.data?.length || 0; let summary = `Tracking: ${input.epc}\n`; summary += `Found ${eventCount} event(s) in the supply chain.\n\n`; if (eventCount > 0) { summary += "Journey Timeline:\n"; - results.forEach((event: any, idx: number) => { + results.data.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"; From adc37bb68f2da22b0159ccd7ddcccaf096bf1b59 Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Tue, 17 Feb 2026 19:21:25 +0100 Subject: [PATCH 06/23] Extension of functionality with Aggregation and Transformation event querying --- packages/plugin-epcis/src/index.ts | 43 ++++++++++++++++++- .../src/services/EPCISQueryService.ts | 42 +++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 1f797c43..30c5e2e7 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -109,6 +109,10 @@ export default defineDkgPlugin((ctx, mcp, api) => { 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"), + parentID: z.string().optional().describe("Parent ID for AggregationEvent queries"), + childEPC: z.string().optional().describe("Child EPC for AggregationEvent queries"), + inputEPC: z.string().optional().describe("Input EPC for TransformationEvent queries"), + outputEPC: z.string().optional().describe("Output EPC for TransformationEvent queries"), limit: z.number().optional().describe("Number of results per page (default: 100, max: 1000)"), offset: z.number().optional().describe("Number of results to skip for pagination"), }, @@ -122,6 +126,10 @@ export default defineDkgPlugin((ctx, mcp, api) => { bizStep: input.bizStep, bizLocation: input.bizLocation, fullTrace: input.fullTrace, + parentID: input.parentID, + childEPC: input.childEPC, + inputEPC: input.inputEPC, + outputEPC: input.outputEPC, limit: input.limit, offset: input.offset, }); @@ -450,9 +458,25 @@ export default defineDkgPlugin((ctx, mcp, api) => { example: "urn:epc:id:sgln:0614141.00001.0", }), fullTrace: z.string().optional().openapi({ - description: "If 'true', search all EPC fields (epcList, inputEPCList, outputEPCList, childEPCs, parentID) for full supply chain traceability", + description: "If 'true', search all EPC fields for full supply chain traceability", example: "true", }), + parentID: z.string().optional().openapi({ + description: "Filter by parent ID (AggregationEvent)", + example: "urn:epc:id:sscc:0614141.0000000001", + }), + childEPC: z.string().optional().openapi({ + description: "Filter by child EPC (AggregationEvent)", + example: "urn:epc:id:sgtin:0614141.107346.2017", + }), + inputEPC: z.string().optional().openapi({ + description: "Filter by input EPC (TransformationEvent)", + example: "urn:epc:id:sgtin:0614141.107346.2017", + }), + outputEPC: z.string().optional().openapi({ + description: "Filter by output EPC (TransformationEvent)", + example: "urn:epc:id:sgtin:0614141.099999.9001", + }), limit: z.string().optional().openapi({ description: "Number of results per page (default: 100, max: 1000)", example: "50", @@ -477,7 +501,18 @@ export default defineDkgPlugin((ctx, mcp, api) => { }, async (req, res) => { try { - const { epc, from, to, bizStep, bizLocation, fullTrace, limit, offset } = req.query; + const { epc, from, to, bizStep, bizLocation, fullTrace, parentID, childEPC, inputEPC, outputEPC, limit, offset } = req.query; + + // Validate: reject empty string values for filter parameters + const filters = { epc, from, to, bizStep, bizLocation, parentID, childEPC, inputEPC, outputEPC }; + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value === '') { + return res.status(400).json({ + success: false, + error: `Parameter '${key}' cannot be empty`, + } as any); + } + } // Build the SPARQL query based on parameters const sparqlQuery = queryService.buildQuery({ @@ -487,6 +522,10 @@ export default defineDkgPlugin((ctx, mcp, api) => { bizStep: bizStep as string, bizLocation: bizLocation as string, fullTrace: fullTrace === 'true', + parentID: parentID as string, + childEPC: childEPC as string, + inputEPC: inputEPC as string, + outputEPC: outputEPC as string, limit: limit ? parseInt(limit as string, 10) : undefined, offset: offset ? parseInt(offset as string, 10) : undefined, }); diff --git a/packages/plugin-epcis/src/services/EPCISQueryService.ts b/packages/plugin-epcis/src/services/EPCISQueryService.ts index 562ae579..4be4bd46 100644 --- a/packages/plugin-epcis/src/services/EPCISQueryService.ts +++ b/packages/plugin-epcis/src/services/EPCISQueryService.ts @@ -18,6 +18,14 @@ export interface EpcisQueryParams { 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) */ @@ -84,6 +92,26 @@ export class EpcisQueryService { 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); @@ -119,14 +147,23 @@ export class EpcisQueryService { // 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 + // Assemble the query with GROUP_CONCAT for array fields return `${PREFIXES} -SELECT ?ual ?eventType ?eventTime ?epc ?bizStep ?disposition ?readPoint ?bizLocation +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 ')} @@ -134,6 +171,7 @@ WHERE { } ${filterClauses.join('\n ')} } +GROUP BY ?ual ?eventType ?eventTime ?bizStep ?bizLocation ?disposition ?readPoint ?action ?parentID ORDER BY DESC(?eventTime) LIMIT ${limit} OFFSET ${offset}`; From da67eeffd8b33a21eafe84307a4f5eec2e905db0 Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Tue, 17 Feb 2026 19:24:15 +0100 Subject: [PATCH 07/23] Generalized guide and doc --- .../docs/EPCIS-Integration-Guide.md | 1133 ++++++++++++----- 1 file changed, 789 insertions(+), 344 deletions(-) diff --git a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md index 63aef1c5..0a4e0bcc 100644 --- a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md +++ b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md @@ -1,14 +1,23 @@ # πŸ“˜ 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. [Query Examples](#11-query-examples) +12. [Data Flow & DKG Publishing](#12-data-flow--dkg-publishing) +13. [Sample EPCIS Documents](#13-sample-epcis-documents) +14. [Troubleshooting](#14-troubleshooting) --- @@ -36,12 +45,12 @@ This integration bridges **GS1 EPCIS 2.0** (Electronic Product Code Information ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Your Application β”‚ +β”‚ Your Application β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ HTTP POST /epcis/capture β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ EPCIS Plugin β”‚ +β”‚ EPCIS Plugin β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Validation │───▢│ JSON-LD Transform β”‚ β”‚ β”‚ β”‚ (GS1 Schema) β”‚ β”‚ (EPCIS Context) β”‚ β”‚ @@ -50,195 +59,516 @@ This integration bridges **GS1 EPCIS 2.0** (Electronic Product Code Information β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ DKG Publisher Plugin β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Asset Queue │───▢│ BullMQ │───▢│ DKG Network β”‚ β”‚ -β”‚ β”‚ (MySQL) β”‚ β”‚ Workers β”‚ β”‚ (via dkg.js) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό +β”‚ DKG Publisher Plugin β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Asset Queue │───▢│ BullMQ │───▢│ DKG Network β”‚ β”‚ +β”‚ β”‚ (MySQL) β”‚ β”‚ Workers β”‚ β”‚ (via dkg.js) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ OriginTrail Decentralized Knowledge Graph β”‚ -β”‚ β”‚ -β”‚ Knowledge Asset (UAL: did:dkg:otp/0x.../123456) β”‚ -β”‚ β”œβ”€β”€ EPCIS Event Data (RDF/JSON-LD) β”‚ -β”‚ β”œβ”€β”€ Cryptographic Proof (Blockchain anchored) β”‚ -β”‚ └── Ownership (NFT) β”‚ +β”‚ OriginTrail Decentralized Knowledge Graph β”‚ +β”‚ β”‚ +β”‚ Knowledge Asset (UAL: did:dkg:otp/0x.../123456) β”‚ +β”‚ β”œβ”€β”€ EPCIS Event Data (RDF/JSON-LD) β”‚ +β”‚ β”œβ”€β”€ Cryptographic Proof (Blockchain anchored) β”‚ +β”‚ └── Ownership (NFT) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` --- -## 2. Quick Start +## 2. Document Structure -### Prerequisites +An EPCIS capture request consists of two main parts: -- DKG Node running (with EPCIS and Publisher plugins enabled) -- Access to the API endpoint (default: `http://localhost:9200`) +```json +{ + "epcisDocument": { ... }, // The EPCIS document wrapper + "publishOptions": { ... } // DKG publishing configuration (optional) +} +``` -### Step 1: Send Your First EPCIS Event +### epcisDocument Fields -```bash -curl -X POST http://localhost:9200/epcis/capture \ - -H "Content-Type: application/json" \ - -d '{ - "@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"} - }] - } - }' +| 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 | + +### publishOptions Fields (DKG-specific) + +| Field | Example | Description | +|-------|---------|-------------| +| `privacy` | `"private"` | Asset visibility: `"private"` or `"public"` | +| `epochs` | `12` | How many epochs to keep asset published | + +--- + +## 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" +} ``` -### Step 2: Check Status +| 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` | -The response includes a `captureID`. Use it to check publishing status: +### Extended Context with Custom Namespaces -```bash -curl http://localhost:9200/epcis/capture/123 +```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#" +} ``` -Possible statuses: +### Common Extension Namespaces -- `queued` - Waiting to be published -- `processing` - Currently being published to DKG -- `published` - Successfully published (includes UAL) -- `failed` - Publishing failed (includes error message) +| 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 | -### Step 3: Query Events +> **Important:** Always include `"type": "@type"` in your context for DKG JSON-LD processing compatibility. -Once published, query events from the DKG: +--- -```bash -# By EPC -curl "http://localhost:9200/epcis/events?epc=urn:epc:id:sgtin:0614141.107346.2017" +## 4. Event Types + +EPCIS defines five event types, each serving a specific purpose in supply chain tracking: -# By time range -curl "http://localhost:9200/epcis/events?from=2024-01-01T00:00:00Z&to=2024-12-31T23:59:59Z" +| 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 | -# By business step -curl "http://localhost:9200/epcis/events?bizStep=inspecting" +### 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) ``` -> πŸ’‘ **Interactive Documentation**: For detailed request/response schemas and to test the API live, visit the Swagger UI at `/swagger` +--- + +## 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 | --- -## 3. EPCIS Event Types Explained +## 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). -### What is EPCIS? +### Commissioning & Decommissioning -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. +| BizStep | Description | +|---------|-------------| +| `commissioning` | Creating a new serialized instance | +| `decommissioning` | Removing from active use | -### The Five Event Types +### Manufacturing & Production + +| BizStep | Description | +|---------|-------------| +| `assembling` | Combining components into a product | +| `disassembly` | Breaking down into components | +| `repairing` | Fixing a defective item | +| `repackaging` | Changing packaging | -| 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 | +### Warehousing & Logistics -### Action Types +| 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 | -- **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) +### Retail & Commerce -### Business Steps (bizStep) +| BizStep | Description | +|---------|-------------| +| `retail_selling` | Point of sale | +| `sampling` | Taking samples | +| `void_shipping` | Voiding a shipment | -Common GS1 CBV (Core Business Vocabulary) business steps: +### Other -| bizStep | Description | +| 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 | +| `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 | -> **Shorthand supported**: You can use just `"assembling"` instead of the full URI `"https://ref.gs1.org/cbv/BizStep-assembling"` +### 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}` --- -## 4. API Reference +## 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 | -### Understanding the JSON-LD Context +**URI Format:** `https://ref.gs1.org/cbv/BTT-{type}` -EPCIS documents use JSON-LD (Linked Data) format. The `@context` object maps terms to URIs for proper semantic interpretation: +**Example:** ```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" +"bizTransactionList": [ + { + "type": "https://ref.gs1.org/cbv/BTT-po", + "bizTransaction": "urn:epc:id:gdti:4012345.00001.PO-2024-001" } -} +] ``` -| 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 | +--- + +## 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:** -> **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. +| 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**: EPCISDocument (JSON-LD) +**Request Body:** ```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" + "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": [/* array of events */] + } }, - "type": "EPCISDocument", - "schemaVersion": "2.0", - "creationDate": "2024-01-01T00:00:00Z", - "epcisBody": { - "eventList": [/* array of events */] + "publishOptions": { + "privacy": "private", + "epochs": 12 } } ``` @@ -248,7 +578,7 @@ Accept an EPCIS Document and queue it for publishing to DKG. ```json { "status": "202", - "receivedAt": "2024-01-01T00:00:01.123Z", + "receivedAt": "2024-03-01T08:00:01.123Z", "captureID": "456", "eventCount": 1 } @@ -260,22 +590,23 @@ Accept an EPCIS Document and queue it for publishing to DKG. Check the status of a previously submitted capture. -**Response**: +**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) | +| Status | Description | +|--------|-------------| +| `queued` | Waiting to be published | +| `processing` | Currently being published to DKG | +| `published` | Successfully published (includes UAL) | +| `failed` | Publishing failed (includes error message) | --- @@ -283,31 +614,103 @@ Check the status of a previously submitted capture. Query EPCIS events from the DKG. -**Query Parameters**: +**Query Parameters:** | 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` | +| `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:0614141.00001.0` | -| `ual` | string | Get specific event by UAL | `did:dkg:otp/...` | +| `bizLocation` | string | Filter by location | `urn:epc:id:sgln:4012345.00002.0` | +| `fullTrace` | string | Search all EPC fields | `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` | number | Results per page (default: 100, max: 1000) | `50` | +| `offset` | number | Results to skip (pagination) | `0` | -**Response**: +**Response:** ```json { "success": true, - "query": "SELECT ...", "results": [/* array of matching events */], - "count": 5 + "count": 5, + "pagination": { + "limit": 100, + "offset": 0 + } +} +``` + +--- + +### GET `/epcis/asset/*ual` + +Retrieve a complete EPCIS document from DKG by its UAL. + +**Example:** +``` +GET /epcis/asset/did:dkg:otp/0x1234.../789 +``` + +**Response:** + +```json +{ + "success": true, + "ual": "did:dkg:otp/0x1234.../789", + "data": { /* full EPCIS document */ } } ``` --- -## 5. Data Flow & DKG Publishing +## 11. Query Examples + +### Track All Events for a Product + +Find all events where the carbon frame appears: + +```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" +``` + +--- + +## 12. Data Flow & DKG Publishing ### Publishing Pipeline @@ -361,57 +764,229 @@ With a UAL, you can: --- -## 6. Query Examples +## 13. 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 + +**Bicycle Manufacturing Supply Chain:** + +``` +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 +``` + +--- + +## 14. Troubleshooting ### Common Errors @@ -421,192 +996,62 @@ LIMIT 100 | `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 | - -### Checking System Health - -**Publisher Dashboard**: Visit `/admin/queues` to see: - -- Active jobs -- Waiting queue -- Failed jobs with error details -- Worker status - -**API Health**: The Swagger UI at `/swagger` shows all available endpoints and their status. +| `Parameter 'x' cannot be empty` | Empty query parameter | Provide a value or omit the parameter | +| `Safe mode validation error` | Missing `type: @type` in context | Add `"type": "@type"` to your @context | ### 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 with timezone 3. **Wrong `type`** - Must be exactly `"EPCISDocument"` (case-sensitive) 4. **Invalid `bizStep`** - Must be valid CBV URI or shorthand -### Getting Help +### Checking System Health -- **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 +- **Swagger UI**: Visit `/swagger` for interactive API documentation +- **Publisher Dashboard**: Visit `/admin/queues` to monitor publishing jobs +- **Server Logs**: Check for detailed error messages --- -## Appendix: Sample EPCIS Documents - -### 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"} - }] - } -} -``` +--- -### Object Event with Sensor Data +## References -```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* +*Last updated: February 2026* *For API details, see the interactive [Swagger documentation](/swagger)* - From bcdab8b0a71c40a401c83f69dd2eb7126911b480 Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Tue, 17 Feb 2026 20:07:19 +0100 Subject: [PATCH 08/23] added sourceKAs to chat --- packages/plugin-epcis/src/index.ts | 81 +++++++++++++-------- packages/plugin-epcis/src/utils/sourceKA.ts | 41 +++++++++++ 2 files changed, 90 insertions(+), 32 deletions(-) create mode 100644 packages/plugin-epcis/src/utils/sourceKA.ts diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 30c5e2e7..78b845c4 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -2,6 +2,7 @@ import { defineDkgPlugin } from "@dkg/plugins"; import { openAPIRoute, z } from "@dkg/plugin-swagger"; import { EpcisValidationService } from "./services/EPCISValidationService"; import { EpcisQueryService } from "./services/EPCISQueryService"; +import { formatSourceKAs } from "./utils/sourceKA"; import type { CaptureResponse } from "./model/types"; // Timeout for internal publisher requests (30s for POST, 5s for GET) @@ -138,28 +139,36 @@ export default defineDkgPlugin((ctx, mcp, api) => { const effectiveLimit = Math.min(input.limit ?? 100, 1000); const effectiveOffset = input.offset ?? 0; - const resultCount = results?.data?.length || 0; + const resultData = results?.data || []; + const resultCount = resultData.length; const summary = resultCount ? `Found ${resultCount} EPCIS event(s)` : "No events found matching the criteria"; - return { - content: [ - { - type: "text", - text: JSON.stringify({ - summary, - count: resultCount, - events: results || [], - pagination: { - limit: effectiveLimit, - offset: effectiveOffset, - }, - }, null, 2) - } - ], - }; + // Build content array with optional source KAs + const content: { type: "text"; text: string }[] = [ + { + type: "text", + text: JSON.stringify({ + summary, + count: resultCount, + events: results || [], + pagination: { + limit: effectiveLimit, + offset: effectiveOffset, + }, + }, null, 2) + } + ]; + + // Append source Knowledge Assets if available + const sourceKAs = formatSourceKAs(resultData); + if (sourceKAs) { + content.push(sourceKAs); + } + + return { content }; } catch (error: any) { return { content: [ @@ -198,13 +207,14 @@ export default defineDkgPlugin((ctx, mcp, api) => { const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); - const eventCount = results?.data?.length || 0; + const resultData = results?.data || []; + const eventCount = resultData.length; let summary = `Tracking: ${input.epc}\n`; summary += `Found ${eventCount} event(s) in the supply chain.\n\n`; if (eventCount > 0) { summary += "Journey Timeline:\n"; - results.data.forEach((event: any, idx: number) => { + resultData.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"; @@ -212,19 +222,26 @@ export default defineDkgPlugin((ctx, mcp, api) => { }); } - return { - content: [ - { - type: "text", - text: JSON.stringify({ - summary, - epc: input.epc, - eventCount, - events: results || [], - }, null, 2) - } - ], - }; + // Build content array with optional source KAs + const content: { type: "text"; text: string }[] = [ + { + type: "text", + text: JSON.stringify({ + summary, + epc: input.epc, + eventCount, + events: results || [], + }, null, 2) + } + ]; + + // Append source Knowledge Assets if available + const sourceKAs = formatSourceKAs(resultData); + if (sourceKAs) { + content.push(sourceKAs); + } + + return { content }; } catch (error: any) { return { content: [ 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"), + }; +} + From 456c8c85f3d6676c7dddad784f56e6d71aac6cbd Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Wed, 18 Feb 2026 13:24:41 +0100 Subject: [PATCH 09/23] Codex fixes --- .../docs/EPCIS-Integration-Guide.md | 31 +++++++++-- packages/plugin-epcis/src/index.ts | 52 +++++++++++++++---- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md index 0a4e0bcc..24230395 100644 --- a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md +++ b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md @@ -573,7 +573,12 @@ Accept an EPCIS Document and queue it for publishing to DKG. } ``` -**Response** (HTTP 202 Accepted): +**Response modes:** + +- **HTTP 202 Accepted**: Capture queued in publisher (numeric `captureID`, status is queryable) +- **HTTP 201 Created**: Publisher unavailable, fallback published directly to DKG (`captureID` in `direct-*` format, includes `UAL`) + +**Example (HTTP 202 Accepted):** ```json { @@ -584,11 +589,27 @@ Accept an EPCIS Document and queue it for publishing to DKG. } ``` +**Example (HTTP 201 Created - direct fallback):** + +```json +{ + "status": "201", + "receivedAt": "2024-03-01T08:00:01.123Z", + "captureID": "direct-1709280001123", + "eventCount": 1, + "UAL": "did:dkg:otp/0x1234.../789" +} +``` + --- ### GET `/epcis/capture/:captureID` -Check the status of a previously submitted capture. +Check the status of a previously submitted capture tracked by the publisher. + +> **Note:** This endpoint accepts numeric publisher `captureID` values. +> Direct fallback IDs (`direct-*`) are not tracked by publisher status API. +> For fallback captures, use the returned `UAL` with `GET /epcis/asset/*ual`. **Response:** @@ -623,13 +644,13 @@ Query EPCIS events from the DKG. | `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 | Search all EPC fields | `true` | +| `fullTrace` | string enum | Must be `"true"` or `"false"` | `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` | number | Results per page (default: 100, max: 1000) | `50` | -| `offset` | number | Results to skip (pagination) | `0` | +| `limit` | integer | Results per page (default: 100, range: 1-1000) | `50` | +| `offset` | integer | Results to skip (pagination, min: 0) | `0` | **Response:** diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 78b845c4..256146c9 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -5,7 +5,7 @@ import { EpcisQueryService } from "./services/EPCISQueryService"; import { formatSourceKAs } from "./utils/sourceKA"; import type { CaptureResponse } from "./model/types"; -// Timeout for internal publisher requests (30s for POST, 5s for GET) +// Timeout for internal publisher requests (10s for POST, 5s for GET) const PUBLISHER_POST_TIMEOUT_MS = 10000; const PUBLISHER_GET_TIMEOUT_MS = 5000; @@ -368,10 +368,12 @@ 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. " + + "Direct fallback IDs (direct-*) are published directly to DKG and are not tracked by this endpoint.", params: z.object({ captureID: z.string().openapi({ - description: "The capture ID returned from POST /epcis/capture", + description: "Numeric publisher capture ID returned from POST /epcis/capture", example: "123", }), }), @@ -392,6 +394,16 @@ export default defineDkgPlugin((ctx, mcp, api) => { const publisherUrl = process.env.PUBLISHER_URL || "http://localhost:9200"; const captureIdPattern = /^[0-9]{1,20}$/; + const directCaptureIdPattern = /^direct-[0-9]+$/; + + if (directCaptureIdPattern.test(captureID)) { + return res.status(400).json({ + error: "Direct fallback capture IDs are not tracked by publisher status API", + message: "This capture was published directly to DKG. Use the returned UAL to retrieve the asset.", + captureID, + } as any); + } + if (!captureIdPattern.test(captureID)) { return res.status(400).json({ error: "Invalid captureID format", @@ -474,7 +486,7 @@ export default defineDkgPlugin((ctx, mcp, api) => { description: "Filter by business location", example: "urn:epc:id:sgln:0614141.00001.0", }), - fullTrace: z.string().optional().openapi({ + fullTrace: z.enum(["true", "false"]).optional().openapi({ description: "If 'true', search all EPC fields for full supply chain traceability", example: "true", }), @@ -531,6 +543,26 @@ export default defineDkgPlugin((ctx, mcp, api) => { } } + // Parse + validate pagination params + const parsedLimit = + typeof limit === "string" && limit.length > 0 ? Number.parseInt(limit, 10) : undefined; + const parsedOffset = + typeof offset === "string" && offset.length > 0 ? Number.parseInt(offset, 10) : undefined; + + if (parsedLimit !== undefined && (!Number.isInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 1000)) { + return res.status(400).json({ + success: false, + error: "Parameter 'limit' must be an integer between 1 and 1000", + } as any); + } + + if (parsedOffset !== undefined && (!Number.isInteger(parsedOffset) || parsedOffset < 0)) { + return res.status(400).json({ + success: false, + error: "Parameter 'offset' must be a non-negative integer", + } as any); + } + // Build the SPARQL query based on parameters const sparqlQuery = queryService.buildQuery({ epc: epc as string, @@ -543,8 +575,8 @@ export default defineDkgPlugin((ctx, mcp, api) => { childEPC: childEPC as string, inputEPC: inputEPC as string, outputEPC: outputEPC as string, - limit: limit ? parseInt(limit as string, 10) : undefined, - offset: offset ? parseInt(offset as string, 10) : undefined, + limit: parsedLimit, + offset: parsedOffset, }); console.log("[EPCIS Events] Executing SPARQL query:", sparqlQuery); @@ -553,13 +585,13 @@ export default defineDkgPlugin((ctx, mcp, api) => { const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); // Calculate pagination values - const effectiveLimit = Math.min(limit ? parseInt(limit as string, 10) : 100, 1000); - const effectiveOffset = offset ? parseInt(offset as string, 10) : 0; - const resultCount = results?.length || 0; + const effectiveLimit = parsedLimit ?? 100; + const effectiveOffset = parsedOffset ?? 0; + const resultCount = results?.data?.length || 0; res.json({ success: true, - results: results || [], + results: results?.data || [], count: resultCount, pagination: { limit: effectiveLimit, From 106327e5529799696c3955d03e20dcc61c262616 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Fri, 20 Feb 2026 15:26:52 +0100 Subject: [PATCH 10/23] rework epcis capture --- packages/plugin-epcis/src/index.ts | 122 ++++++++++------------- packages/plugin-epcis/src/model/types.ts | 1 + 2 files changed, 51 insertions(+), 72 deletions(-) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 256146c9..6258d5eb 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -18,6 +18,10 @@ function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +function generateRequestId(): string { + return `epcis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + // Helper function to send JSON-LD to publisher with retries async function sendToPublisher( jsonLd: any, @@ -27,7 +31,11 @@ async function sendToPublisher( epochs?: number; } ): Promise<{ id: number; status: string; attemptCount: number }> { - const publisherUrl = process.env.PUBLISHER_URL || "http://localhost:9200"; + const publisherUrl = process.env.PUBLISHER_URL; + + if (!publisherUrl) { + throw new Error("PUBLISHER_URL is not set"); + } for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { @@ -63,30 +71,6 @@ async function sendToPublisher( throw new Error("Publisher not available"); } -// Fallback: publish directly to DKG -async function publishDirectToDKG( - ctx: any, - jsonLd: any, - publishOptions?: { privacy?: "private" | "public"; epochs?: number } -): Promise<{ ual: string }> { - const privacy = publishOptions?.privacy ?? "private"; - const wrapped = { [privacy]: jsonLd }; - - console.log(`[EPCIS] Publishing directly to DKG (fallback)...`); - - const result = await ctx.dkg.asset.create(wrapped, { - epochsNum: publishOptions?.epochs ?? 12, - minimumNumberOfFinalizationConfirmations: 3, - minimumNumberOfNodeReplications: 1, - }); - - if (!result?.UAL) { - throw new Error("DKG publish failed - no UAL returned"); - } - - return { ual: result.UAL }; -} - export default defineDkgPlugin((ctx, mcp, api) => { const validationService = new EpcisValidationService(); @@ -282,7 +266,7 @@ export default defineDkgPlugin((ctx, mcp, api) => { }), }), response: { - description: "Capture accepted (202) or published directly (201)", + description: "Capture accepted (202)", schema: z.object({ status: z.string(), receivedAt: z.string(), @@ -293,7 +277,10 @@ export default defineDkgPlugin((ctx, mcp, api) => { }, }, async (req, res) => { - try { + const requestId = generateRequestId(); + console.info(`[EPCIS] Capture request received, requestId: ${requestId}`); + + try { const { epcisDocument, publishOptions } = req.body; // Validate the EPCIS document @@ -306,55 +293,45 @@ export default defineDkgPlugin((ctx, mcp, api) => { } as any); } - // Generate request ID for tracing - const requestId = `epcis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - console.log(`[EPCIS] [${requestId}] Capture request received, ${validation.eventCount} event(s)`); + if (!validation.eventCount) { + return res.status(400).json({ + error: "EPCISDocument contains no events", + message: "The EPCISDocument contains no events to publish. Please check the document and try again.", + } as any); + } let result: any; - let usedFallback = false; - - // Try publisher first (with retries) try { result = await sendToPublisher( epcisDocument, { source: "EPCIS", sourceId: requestId }, publishOptions ); - console.log(`[EPCIS] [${requestId}] Queued via publisher, captureID: ${result.id}`); - } catch (publisherError: any) { - console.warn(`[EPCIS] [${requestId}] Publisher not available, trying direct DKG fallback`); - - // Fallback to direct DKG publish - try { - const directResult = await publishDirectToDKG(ctx, epcisDocument, publishOptions); - result = { id: `direct-${Date.now()}`, ual: directResult.ual }; - usedFallback = true; - console.log(`[EPCIS] [${requestId}] Published directly to DKG, UAL: ${result.ual}`); - } catch (fallbackError: any) { - console.error(`[EPCIS] [${requestId}] Both publisher and DKG fallback failed`); - return res.status(503).json({ - error: "Publishing unavailable", - message: "Both publisher service and direct DKG publishing failed", - requestId - } as any); - } + console.info(`[EPCIS] Document queued via publisher, requestId: ${requestId}, eventCount: ${validation.eventCount}, captureID: ${result.id}`); + } catch (error: any) { + console.error(`[EPCIS] Publishing failed, requestId: ${requestId}, eventCount: ${validation.eventCount}, error:`, error); + return res.status(500).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.", + } as any); } // Return capture response const response: CaptureResponse = { - status: usedFallback ? "201" : "202", + status: "202", + requestId, receivedAt: new Date().toISOString(), captureID: String(result.id), eventCount: validation.eventCount || 0, ...(result.ual && { UAL: result.ual }), }; - res.status(usedFallback ? 201 : 202).json(response); + return res.status(202).json(response); } catch (error: any) { - console.error("[EPCIS Capture] Unexpected error:", error); - res.status(500).json({ - error: "Internal server error", - message: "An unexpected error occurred while processing the capture", + console.error(`[EPCIS] Unexpected error, requestId: ${requestId}, error:`, error); + 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); } } @@ -368,9 +345,7 @@ export default defineDkgPlugin((ctx, mcp, api) => { { tag: "EPCIS", summary: "Get Capture Status", - description: - "Check publisher-tracked status by numeric captureID. " + - "Direct fallback IDs (direct-*) are published directly to DKG and are not tracked by this endpoint.", + description: "Check publisher-tracked status by numeric captureID.", params: z.object({ captureID: z.string().openapi({ description: "Numeric publisher capture ID returned from POST /epcis/capture", @@ -391,25 +366,20 @@ export default defineDkgPlugin((ctx, mcp, api) => { async (req, res) => { try { const { captureID } = req.params; - const publisherUrl = process.env.PUBLISHER_URL || "http://localhost:9200"; - - const captureIdPattern = /^[0-9]{1,20}$/; - const directCaptureIdPattern = /^direct-[0-9]+$/; + const publisherUrl = process.env.PUBLISHER_URL; - if (directCaptureIdPattern.test(captureID)) { - return res.status(400).json({ - error: "Direct fallback capture IDs are not tracked by publisher status API", - message: "This capture was published directly to DKG. Use the returned UAL to retrieve the asset.", - captureID, - } as any); + if (!publisherUrl) { + throw new Error("PUBLISHER_URL is not set"); } + const captureIdPattern = /^[0-9]{1,20}$/; if (!captureIdPattern.test(captureID)) { return res.status(400).json({ error: "Invalid captureID format", captureID, } as any); } + // Query publisher for asset status let response: Response; try { @@ -418,13 +388,21 @@ export default defineDkgPlugin((ctx, mcp, api) => { { signal: AbortSignal.timeout(PUBLISHER_GET_TIMEOUT_MS) } ); } catch (error: any) { + const errorName = error?.name ?? "UnknownError"; + const errorMessage = error?.message ?? String(error); + console.error( + `[EPCIS] [Failed to get publisher status for captureID=${captureID}`, + { errorName, errorMessage } + ); + if (error.name === "TimeoutError") { return res.status(504).json({ error: "Publisher timeout", captureID, } as any); } - throw error; + + throw new Error(`Publisher status request failed: ${errorMessage}`); } if (!response.ok) { @@ -579,7 +557,7 @@ export default defineDkgPlugin((ctx, mcp, api) => { offset: parsedOffset, }); - console.log("[EPCIS Events] Executing SPARQL query:", sparqlQuery); + console.debug("[EPCIS Events] Executing SPARQL query:", sparqlQuery); // Execute query against DKG const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); diff --git a/packages/plugin-epcis/src/model/types.ts b/packages/plugin-epcis/src/model/types.ts index 443ab408..9fb81bcc 100644 --- a/packages/plugin-epcis/src/model/types.ts +++ b/packages/plugin-epcis/src/model/types.ts @@ -29,6 +29,7 @@ export interface EPCISDocument { // API Response types export interface CaptureResponse { status: string; + requestId: string; receivedAt: string; captureID: string; eventCount: number; From 38746fff61ec7f156e5adcca46888d43c073d624 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Fri, 20 Feb 2026 16:02:16 +0100 Subject: [PATCH 11/23] better logs in check capture status --- packages/plugin-epcis/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 6258d5eb..0bda25fa 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -364,10 +364,11 @@ 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; - if (!publisherUrl) { throw new Error("PUBLISHER_URL is not set"); } From 160fa206da03882c2dcade6c98faa3e77a6d7179 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Tue, 24 Feb 2026 14:58:56 +0100 Subject: [PATCH 12/23] reworked epcis v2 --- package-lock.json | 70 ++ .../docs/EPCIS-Integration-Guide.md | 859 ++++++++++-------- packages/plugin-epcis/package.json | 3 +- packages/plugin-epcis/src/index.ts | 804 ++++++++-------- packages/plugin-epcis/src/model/types.ts | 104 ++- .../src/services/EPCISQueryService.ts | 112 +-- .../src/services/epcisPublisherService.ts | 105 +++ .../src/utils/epcisQueryValidation.ts | 82 ++ .../tests/fixtures/bicycleStoryFixtures.ts | 201 ++++ .../plugin-epcis/tests/plugin-epcis.spec.ts | 80 -- .../plugin-epcis/tests/pluginEpcis.spec.ts | 436 +++++++++ 11 files changed, 1908 insertions(+), 948 deletions(-) create mode 100644 packages/plugin-epcis/src/services/epcisPublisherService.ts create mode 100644 packages/plugin-epcis/src/utils/epcisQueryValidation.ts create mode 100644 packages/plugin-epcis/tests/fixtures/bicycleStoryFixtures.ts delete mode 100644 packages/plugin-epcis/tests/plugin-epcis.spec.ts create mode 100644 packages/plugin-epcis/tests/pluginEpcis.spec.ts 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 24230395..9bf70873 100644 --- a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md +++ b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md @@ -14,10 +14,11 @@ This document explains all fields used in EPCIS 2.0 documents and provides compr 8. [Business Transaction Types](#8-business-transaction-types) 9. [GS1 URN Schemes](#9-gs1-urn-schemes) 10. [API Reference](#10-api-reference) -11. [Query Examples](#11-query-examples) -12. [Data Flow & DKG Publishing](#12-data-flow--dkg-publishing) -13. [Sample EPCIS Documents](#13-sample-epcis-documents) -14. [Troubleshooting](#14-troubleshooting) +11. [MCP Tools Reference](#11-mcp-tools-reference) +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) --- @@ -33,47 +34,47 @@ 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 - β–Ό +β”‚ Your Application / AI Agent (MCP) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ HTTP API β”‚ MCP Tools + β”‚ POST /epcis/capture β”‚ epcis-query + β”‚ GET /epcis/events β”‚ epcis-track-item + β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ EPCIS Plugin β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Validation │───▢│ JSON-LD Transform β”‚ β”‚ -β”‚ β”‚ (GS1 Schema) β”‚ β”‚ (EPCIS Context) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ DKG Publisher Plugin β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Asset Queue │───▢│ BullMQ │───▢│ DKG Network β”‚ β”‚ -β”‚ β”‚ (MySQL) β”‚ β”‚ Workers β”‚ β”‚ (via dkg.js) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ 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 β”‚ β”‚ β”‚ -β”‚ Knowledge Asset (UAL: did:dkg:otp/0x.../123456) β”‚ -β”‚ β”œβ”€β”€ EPCIS Event Data (RDF/JSON-LD) β”‚ -β”‚ β”œβ”€β”€ Cryptographic Proof (Blockchain anchored) β”‚ -β”‚ └── Ownership (NFT) β”‚ +β”‚ Knowledge Asset (UAL: did:dkg:otp/0x.../123456) β”‚ +β”‚ β”œβ”€β”€ EPCIS Event Data (RDF/JSON-LD) β”‚ +β”‚ β”œβ”€β”€ Cryptographic Proof (Blockchain anchored) β”‚ +β”‚ └── Ownership (NFT) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` @@ -92,20 +93,22 @@ An EPCIS capture request consists of two main parts: ### epcisDocument Fields -| 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 | +| 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 | ### publishOptions Fields (DKG-specific) -| Field | Example | Description | -|-------|---------|-------------| -| `privacy` | `"private"` | Asset visibility: `"private"` or `"public"` | -| `epochs` | `12` | How many epochs to keep asset published | +| 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. --- @@ -125,13 +128,13 @@ The `@context` defines JSON-LD namespaces for semantic interpretation. It is **e } ``` -| 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` | +| 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 @@ -142,7 +145,7 @@ The `@context` defines JSON-LD namespaces for semantic interpretation. It is **e "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#", @@ -152,13 +155,13 @@ The `@context` defines JSON-LD namespaces for semantic interpretation. It is **e ### 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 | +| 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. @@ -168,13 +171,13 @@ The `@context` defines JSON-LD namespaces for semantic interpretation. It is **e 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 | 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 @@ -196,63 +199,64 @@ Is the item being created from other items? ### 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 | +| 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 | -|-------|---------|-------------| +| Field | Example | Description | +| --------- | ------------------------------------------ | --------------------------- | | `epcList` | `["urn:epc:id:sgtin:4012345.011111.1001"]` | List of EPCs being observed | -| `action` | `"ADD"` | Event action type | +| `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) | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | --- @@ -262,63 +266,63 @@ The `bizStep` field indicates what business process step is occurring. You can u ### Commissioning & Decommissioning -| BizStep | Description | -|---------|-------------| -| `commissioning` | Creating a new serialized instance | -| `decommissioning` | Removing from active use | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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}` @@ -332,52 +336,52 @@ The `disposition` field indicates the current state/condition of objects. ### Process States -| Disposition | Description | -|-------------|-------------| +| Disposition | Description | +| ------------- | ------------------------- | | `in_progress` | Currently being processed | -| `in_transit` | Being transported | -| `active` | In active use | -| `inactive` | Not currently in use | +| `in_transit` | Being transported | +| `active` | In active use | +| `inactive` | Not currently in use | ### Container/Packaging States -| Disposition | Description | -|-------------|-------------| -| `container_open` | Container is open | +| 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 | +| 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 | +| 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 | +| 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 | +| `disposed` | Disposed of | +| `encoded` | RFID encoded | +| `unknown` | State unknown | **URI Format:** `https://ref.gs1.org/cbv/Disp-{value}` @@ -389,16 +393,16 @@ 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 | +| 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}` @@ -421,15 +425,15 @@ GS1 URN (Uniform Resource Name) schemes provide globally unique identifiers for ### 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 | +| 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 | --- @@ -438,11 +442,13 @@ GS1 URN (Uniform Resource Name) schemes provide globally unique identifiers for **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 β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”˜ @@ -454,12 +460,12 @@ urn:epc:id:sgtin:4012345.011111.1001 **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` | +| 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` | --- @@ -469,11 +475,13 @@ urn:epc:id:sgtin:4012345.011111.1001 **Purpose:** Identify physical locations (facilities, zones, stations). **Format:** + ``` urn:epc:id:sgln:{CompanyPrefix}.{LocationReference}.{Extension} ``` **Breakdown:** + ``` urn:epc:id:sgln:4012345.00001.0 β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”˜ β””β”˜ @@ -485,13 +493,13 @@ urn:epc:id:sgln:4012345.00001.0 **Examples from Bicycle Manufacturing:** -| Location | EPC | -|----------|-----| +| 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` | +| 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` | --- @@ -500,11 +508,13 @@ urn:epc:id:sgln:4012345.00001.0 **Purpose:** Identify logistics units (pallets, containers, cases). **Format:** + ``` urn:epc:id:sscc:{CompanyPrefix}.{SerialReference} ``` **Example:** + ``` urn:epc:id:sscc:4012345.0000000001 β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ @@ -527,17 +537,18 @@ urn:epc:id:sscc:4012345.0000000001 **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` | +| 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` | +| Invoice | `urn:epc:id:gdti:4012345.00001.INV-12345` | --- @@ -563,7 +574,9 @@ Accept an EPCIS Document and queue it for publishing to DKG. "schemaVersion": "2.0", "creationDate": "2024-03-01T08:00:00Z", "epcisBody": { - "eventList": [/* array of events */] + "eventList": [ + /* array of events */ + ] } }, "publishOptions": { @@ -573,31 +586,54 @@ Accept an EPCIS Document and queue it for publishing to DKG. } ``` -**Response modes:** +**Responses:** -- **HTTP 202 Accepted**: Capture queued in publisher (numeric `captureID`, status is queryable) -- **HTTP 201 Created**: Publisher unavailable, fallback published directly to DKG (`captureID` in `direct-*` format, includes `UAL`) +| 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 | **Example (HTTP 202 Accepted):** ```json { "status": "202", + "requestId": "epcis-1709280001123-a1b2c3", "receivedAt": "2024-03-01T08:00:01.123Z", "captureID": "456", "eventCount": 1 } ``` -**Example (HTTP 201 Created - direct fallback):** +> Poll `GET /epcis/capture/:captureID` with the returned `captureID` to track publishing progress and retrieve the UAL once published. + +**Example (HTTP 400 Bad Request - Validation):** ```json { - "status": "201", - "receivedAt": "2024-03-01T08:00:01.123Z", - "captureID": "direct-1709280001123", - "eventCount": 1, - "UAL": "did:dkg:otp/0x1234.../789" + "error": "Invalid EPCISDocument", + "details": [ + "/epcisBody/eventList/0/eventTime: must match format \"date-time\"" + ] +} +``` + +**Example (HTTP 400 Bad Request - Empty Events):** + +```json +{ + "error": "EPCISDocument contains no events", + "message": "The EPCISDocument contains no events to publish. Please check the document and try again." +} +``` + +**Example (HTTP 500 Internal Server Error):** + +```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." } ``` @@ -607,11 +643,19 @@ Accept an EPCIS Document and queue it for publishing to DKG. Check the status of a previously submitted capture tracked by the publisher. -> **Note:** This endpoint accepts numeric publisher `captureID` values. -> Direct fallback IDs (`direct-*`) are not tracked by publisher status API. -> For fallback captures, use the returned `UAL` with `GET /epcis/asset/*ual`. +> **Note:** `captureID` must be a numeric string matching the pattern `^[0-9]{1,20}$`. +> Use the numeric `captureID` returned from `POST /epcis/capture`. -**Response:** +**Responses:** + +| 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 | + +**Example (HTTP 200 OK):** ```json { @@ -622,42 +666,71 @@ Check the status of a previously submitted capture tracked by the publisher. } ``` -| Status | Description | -|--------|-------------| -| `queued` | Waiting to be published | -| `processing` | Currently being published to DKG | -| `published` | Successfully published (includes UAL) | -| `failed` | Publishing failed (includes error message) | +**Example (Failed):** + +```json +{ + "status": "failed", + "captureID": "456", + "error": "Wallet balance insufficient" +} +``` + +**Example (HTTP 500 Internal Server Error):** + +```json +{ + "error": "Failed to get capture status" +} +``` + +| 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) | --- ### GET `/epcis/events` -Query EPCIS events from the DKG. +Query EPCIS events from the DKG using SPARQL. + +**Validation Rules:** + +- 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 **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 | Must be `"true"` or `"false"` | `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` | +| 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` | **Response:** ```json { "success": true, - "results": [/* array of matching events */], + "results": [ + /* array of matching events */ + ], "count": 5, "pagination": { "limit": 100, @@ -668,28 +741,72 @@ Query EPCIS events from the DKG. --- -### GET `/epcis/asset/*ual` +## 11. MCP Tools Reference -Retrieve a complete EPCIS document from DKG by its UAL. +The EPCIS plugin exposes two MCP (Model Context Protocol) tools that AI agents can use to query supply chain data from the DKG. -**Example:** -``` -GET /epcis/asset/did:dkg:otp/0x1234.../789 -``` +### `epcis-query` β€” Query EPCIS Events -**Response:** +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 includes:** + +- Event data with count and pagination +- Source Knowledge Assets with UALs and DKG Explorer links for provenance + +--- + +### `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 includes:** + +- Human-readable timeline summary +- Full event data in chronological order +- Source Knowledge Assets with UALs and DKG Explorer links + +**Example timeline output:** -```json -{ - "success": true, - "ual": "did:dkg:otp/0x1234.../789", - "data": { /* full EPCIS document */ } -} +``` +Tracking: urn:epc:id:sgtin:4012345.011111.1001 +Found 4 event(s) in the supply chain. + +Journey Timeline: +1. [2024-03-01T08:00:00.000Z] receiving @ urn:epc:id:sgln:4012345.00001.0 +2. [2024-03-01T10:00:00.000Z] inspecting @ urn:epc:id:sgln:4012345.00002.0 +3. [2024-03-01T14:00:00.000Z] assembling @ urn:epc:id:sgln:4012345.00003.0 +4. [2024-03-01T16:00:00.000Z] packing @ urn:epc:id:sgln:4012345.00004.0 ``` --- -## 11. Query Examples +## 12. Query Examples ### Track All Events for a Product @@ -731,35 +848,38 @@ curl "http://localhost:9200/epcis/events?outputEPC=urn:epc:id:sgtin:4012345.0999 --- -## 12. 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? @@ -785,7 +905,7 @@ With a UAL, you can: --- -## 13. Sample EPCIS Documents +## 14. Sample EPCIS Documents ### ObjectEvent - Receiving Goods @@ -805,20 +925,25 @@ Carbon fiber frame arrives from supplier: "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"} - ] - }] + "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": { @@ -846,17 +971,19 @@ Frame passes quality check: "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"} - }] + "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" } + } + ] } } } @@ -880,24 +1007,24 @@ Components assembled into finished bicycle: "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"} - }] + "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" } + } + ] } } } @@ -921,20 +1048,20 @@ Bicycle packed onto shipping pallet: "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"} - }] + "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" } + } + ] } } } @@ -958,20 +1085,25 @@ Pallet shipped to customer: "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"} - ] - }] + "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" + } + ] + } + ] } } } @@ -1007,28 +1139,37 @@ Event 9: Ship (shipping, in_transit) @ Shipping Dock --- -## 14. Troubleshooting +## 15. Troubleshooting ### Common Errors -| 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 | -| `Parameter 'x' cannot be empty` | Empty query parameter | Provide a value or omit the parameter | -| `Safe mode validation error` | Missing `type: @type` in context | Add `"type": "@type"` to your @context | +| 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 with `type: @type` alias -2. **Invalid `eventTime`** - Must be ISO 8601 format with timezone +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 +### Environment Variables + +| Variable | Required | Description | +| -------------------- | -------- | --------------------------------------------------------------------- | +| `EXPO_PUBLIC_MCP_URL` | Yes | Base URL of the DKG publisher service (e.g., `http://localhost:9200`) | + ### Checking System Health - **Swagger UI**: Visit `/swagger` for interactive API documentation @@ -1054,7 +1195,7 @@ You can add custom fields using your own namespace: "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, @@ -1074,5 +1215,5 @@ You can add custom fields using your own namespace: --- -*Last updated: February 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 0bda25fa..dedafeb7 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -1,82 +1,129 @@ import { defineDkgPlugin } from "@dkg/plugins"; import { openAPIRoute, z } from "@dkg/plugin-swagger"; -import { EpcisValidationService } from "./services/EPCISValidationService"; -import { EpcisQueryService } from "./services/EPCISQueryService"; -import { formatSourceKAs } from "./utils/sourceKA"; -import type { CaptureResponse } from "./model/types"; - -// Timeout for internal publisher requests (10s for POST, 5s for GET) -const PUBLISHER_POST_TIMEOUT_MS = 10000; -const PUBLISHER_GET_TIMEOUT_MS = 5000; - -// Retry configuration -const MAX_RETRIES = 3; -const RETRY_DELAY_MS = 1000; - -// Helper for delay -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} +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_ID_PATTERN = /^[0-9]{1,20}$/; + +type CaptureResponse = { + status: string; + requestId: string; + receivedAt: string; + captureID: string; + eventCount: number; + UAL?: string; +}; + +type PublisherCaptureStatusResponse = { + status: string; + ual?: string; + publishedAt?: string; + lastError?: string; +}; function generateRequestId(): string { return `epcis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -// Helper function to send JSON-LD to publisher with retries -async function sendToPublisher( - jsonLd: any, - metadata?: { source?: string; sourceId?: string }, - publishOptions?: { - privacy?: "private" | "public"; - epochs?: number; - } -): Promise<{ id: number; status: string; attemptCount: number }> { - const publisherUrl = process.env.PUBLISHER_URL; +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 (!publisherUrl) { - throw new Error("PUBLISHER_URL is not set"); + if (eventCount === 0) { + return summary; } - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - 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 || !response.headers.get("content-type")?.includes("application/json")) { - throw new Error("Publisher not available"); - } + 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; +} - return await response.json(); - } catch (error: any) { - console.warn(`[EPCIS] Publisher attempt ${attempt}/${MAX_RETRIES} failed`); +function getCaptureValidationError( + validation: ValidationResult, +): { error: string; details?: string[]; message?: string } | null { + if (!validation.valid) { + return { + error: "Invalid EPCISDocument", + details: validation.errors, + }; + } - if (attempt < MAX_RETRIES) { - await delay(RETRY_DELAY_MS * Math.pow(2, attempt - 1)); - continue; - } - } + 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.", + }; } - throw new Error("Publisher not available"); + 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, + }, + }; + } + + console.info("[EPCIS] Plugin loaded"); // MCP Tool: Query EPCIS events from DKG mcp.registerTool( @@ -88,43 +135,91 @@ export default defineDkgPlugin((ctx, mcp, api) => { "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"), - parentID: z.string().optional().describe("Parent ID for AggregationEvent queries"), - childEPC: z.string().optional().describe("Child EPC for AggregationEvent queries"), - inputEPC: z.string().optional().describe("Input EPC for TransformationEvent queries"), - outputEPC: z.string().optional().describe("Output EPC for TransformationEvent queries"), - limit: z.number().optional().describe("Number of results per page (default: 100, max: 1000)"), - offset: z.number().optional().describe("Number of results to skip for pagination"), + 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, - parentID: input.parentID, - childEPC: input.childEPC, - inputEPC: input.inputEPC, - outputEPC: input.outputEPC, - limit: input.limit, - offset: input.offset, - }); + if (!hasAtLeastOneEpcisFilter(input)) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { error: "At least one filter parameter is required." }, + null, + 2, + ), + }, + ], + isError: true, + }; + } - const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); + if (!hasValidEpcisDateRange(input)) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + error: + "Parameter 'to' must be greater than or equal to 'from'.", + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } - const effectiveLimit = Math.min(input.limit ?? 100, 1000); - const effectiveOffset = input.offset ?? 0; - const resultData = results?.data || []; - const resultCount = resultData.length; + const { results, resultData, resultCount, pagination } = + await executeEpcisEventsQuery(input); const summary = resultCount ? `Found ${resultCount} EPCIS event(s)` @@ -134,16 +229,20 @@ export default defineDkgPlugin((ctx, mcp, api) => { const content: { type: "text"; text: string }[] = [ { type: "text", - text: JSON.stringify({ - summary, - count: resultCount, - events: results || [], - pagination: { - limit: effectiveLimit, - offset: effectiveOffset, + text: JSON.stringify( + { + summary, + count: resultCount, + events: results || [], + pagination: { + limit: pagination.limit, + offset: pagination.offset, + }, }, - }, null, 2) - } + null, + 2, + ), + }, ]; // Append source Knowledge Assets if available @@ -154,19 +253,24 @@ export default defineDkgPlugin((ctx, mcp, api) => { return { content }; } catch (error: any) { + console.error("[EPCIS] DKG query failed:", error); return { content: [ { type: "text", - text: JSON.stringify({ - error: "Query failed", - }, null, 2) - } + text: JSON.stringify( + { + error: "Query failed", + }, + null, + 2, + ), + }, ], isError: true, }; } - } + }, ); // MCP Tool: Track item journey (full traceability) @@ -179,44 +283,35 @@ export default defineDkgPlugin((ctx, mcp, api) => { "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 results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); - - const resultData = results?.data || []; - const eventCount = resultData.length; - let summary = `Tracking: ${input.epc}\n`; - summary += `Found ${eventCount} event(s) in the supply chain.\n\n`; - - if (eventCount > 0) { - summary += "Journey Timeline:\n"; - resultData.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`; - }); - } + const summary = buildTrackItemSummary(input.epc, resultData); // Build content array with optional source KAs const content: { type: "text"; text: string }[] = [ { type: "text", - text: JSON.stringify({ - summary, - epc: input.epc, - eventCount, - events: results || [], - }, null, 2) - } + text: JSON.stringify( + { + summary, + epc: input.epc, + eventCount: resultCount, + events: resultData || [], + }, + null, + 2, + ), + }, ]; // Append source Knowledge Assets if available @@ -227,19 +322,24 @@ export default defineDkgPlugin((ctx, mcp, api) => { return { content }; } catch (error: any) { + console.error(`[EPCIS] Item tracking failed, epc: ${input.epc}`, error); return { content: [ { type: "text", - text: JSON.stringify({ - error: "Tracking failed", - }, null, 2) - } + text: JSON.stringify( + { + error: "Tracking failed", + }, + null, + 2, + ), + }, ], isError: true, }; } - } + }, ); // POST /epcis/capture - Accept EPCISDocument and queue for publishing @@ -249,21 +349,26 @@ 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 (202)", @@ -272,47 +377,43 @@ export default defineDkgPlugin((ctx, mcp, api) => { receivedAt: z.string(), captureID: z.string(), eventCount: z.number(), - UAL: z.string().optional(), }), }, }, async (req, res) => { const requestId = generateRequestId(); - console.info(`[EPCIS] Capture request received, requestId: ${requestId}`); + console.info( + `[EPCIS] Capture request received, requestId: ${requestId}`, + ); - try { + try { const { epcisDocument, publishOptions } = req.body; - // Validate the EPCIS document - const validation = validationService.validate(epcisDocument); - - if (!validation.valid) { - return res.status(400).json({ - error: "Invalid EPCISDocument", - details: validation.errors, - } as any); + const validationResult = validationService.validate(epcisDocument); + const validationError = getCaptureValidationError(validationResult); + if (validationError) { + return res.status(400).json(validationError as any); } - if (!validation.eventCount) { - return res.status(400).json({ - error: "EPCISDocument contains no events", - message: "The EPCISDocument contains no events to publish. Please check the document and try again.", - } as any); - } - - let result: any; + let publishResult: any; try { - result = await sendToPublisher( + publishResult = await sendToPublisher( epcisDocument, { source: "EPCIS", sourceId: requestId }, - publishOptions + publishOptions, + ); + console.info( + `[EPCIS] Document queued via publisher, requestId: ${requestId}, eventCount: ${validationResult.eventCount}, captureID: ${publishResult.id}`, ); - console.info(`[EPCIS] Document queued via publisher, requestId: ${requestId}, eventCount: ${validation.eventCount}, captureID: ${result.id}`); } catch (error: any) { - console.error(`[EPCIS] Publishing failed, requestId: ${requestId}, eventCount: ${validation.eventCount}, error:`, error); + console.error( + `[EPCIS] Publishing failed, requestId: ${requestId}, eventCount: ${validationResult.eventCount}, error:`, + error, + ); return res.status(500).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.", + message: + "Something went wrong with publishing the EPCIS document. Check if the publisher service is available.", } as any); } @@ -321,21 +422,24 @@ export default defineDkgPlugin((ctx, mcp, api) => { status: "202", requestId, receivedAt: new Date().toISOString(), - captureID: String(result.id), - eventCount: validation.eventCount || 0, - ...(result.ual && { UAL: result.ual }), + captureID: String(publishResult.id), + eventCount: validationResult.eventCount ?? 0, }; return res.status(202).json(response); } catch (error: any) { - console.error(`[EPCIS] Unexpected error, requestId: ${requestId}, error:`, error); + console.error( + `[EPCIS] Unexpected error, requestId: ${requestId}, error:`, + error, + ); return res.status(500).json({ error: "Something went wrong with processing the EPCIS document.", - message: "An unexpected error occurred while processing the EPCIS document.", + message: + "An unexpected error occurred while processing the EPCIS document.", } as any); } - } - ) + }, + ), ); // GET /epcis/capture/:captureID - Check capture status @@ -347,10 +451,14 @@ export default defineDkgPlugin((ctx, mcp, api) => { summary: "Get Capture Status", description: "Check publisher-tracked status by numeric captureID.", params: z.object({ - captureID: z.string().openapi({ - description: "Numeric publisher 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", @@ -365,75 +473,56 @@ export default defineDkgPlugin((ctx, mcp, api) => { }, async (req, res) => { const { captureID } = req.params; - console.info(`[EPCIS] Capture status request received, captureID: ${captureID}`); + console.info( + `[EPCIS] Capture status request received, captureID: ${captureID}`, + ); try { - const publisherUrl = process.env.PUBLISHER_URL; - if (!publisherUrl) { - throw new Error("PUBLISHER_URL is not set"); - } - - const captureIdPattern = /^[0-9]{1,20}$/; - if (!captureIdPattern.test(captureID)) { - return res.status(400).json({ - error: "Invalid captureID format", - 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) { - const errorName = error?.name ?? "UnknownError"; - const errorMessage = error?.message ?? String(error); - console.error( - `[EPCIS] [Failed to get publisher status for captureID=${captureID}`, - { errorName, errorMessage } - ); - - if (error.name === "TimeoutError") { - return res.status(504).json({ - error: "Publisher timeout", - captureID, - } as any); - } - - throw new Error(`Publisher status request failed: ${errorMessage}`); - } + const response = await fetchPublisherCaptureStatus(captureID); if (!response.ok) { if (response.status === 404) { - return res.status(404).json({ error: "Capture not found", captureID } as any); + return res + .status(404) + .json({ error: "Capture not found", captureID } as any); } - throw new Error("Failed to fetch capture status"); + throw new Error( + `Publisher returned ${response.status} for captureID: ${captureID}`, + ); } - const asset = await response.json(); + const asset = + (await response.json()) as PublisherCaptureStatusResponse; - // Map publisher status to EPCIS response - const result: any = { + return res.json({ status: asset.status, captureID, - }; - - if (asset.ual) result.UAL = asset.ual; - if (asset.publishedAt) result.publishedAt = asset.publishedAt; - if (asset.lastError) result.error = asset.lastError; + ...(asset.ual && { UAL: asset.ual }), + ...(asset.publishedAt && { publishedAt: asset.publishedAt }), + ...(asset.lastError && { error: asset.lastError }), + }); + } catch (error: unknown) { + if (isTimeoutError(error)) { + return res.status(504).json({ + error: "Publisher timeout", + captureID, + } as any); + } - res.json(result); - } catch (error: any) { - console.error("[EPCIS Status] Error:", error); - res.status(500).json({ + 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: ${captureID}`, + { errorName, errorMessage }, + ); + return res.status(500).json({ error: "Failed to get capture status", } as any); } - } - ) + }, + ), ); // GET /epcis/events - Query EPCIS events from DKG @@ -444,56 +533,76 @@ 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", - }), - bizLocation: z.string().optional().openapi({ - description: "Filter by business location", - example: "urn:epc:id:sgln:0614141.00001.0", - }), - fullTrace: z.enum(["true", "false"]).optional().openapi({ - description: "If 'true', search all EPC fields for full supply chain traceability", - example: "true", - }), - parentID: z.string().optional().openapi({ - description: "Filter by parent ID (AggregationEvent)", - example: "urn:epc:id:sscc:0614141.0000000001", - }), - childEPC: z.string().optional().openapi({ - description: "Filter by child EPC (AggregationEvent)", - example: "urn:epc:id:sgtin:0614141.107346.2017", - }), - inputEPC: z.string().optional().openapi({ - description: "Filter by input EPC (TransformationEvent)", - example: "urn:epc:id:sgtin:0614141.107346.2017", - }), - outputEPC: z.string().optional().openapi({ - description: "Filter by output EPC (TransformationEvent)", - example: "urn:epc:id:sgtin:0614141.099999.9001", - }), - limit: z.string().optional().openapi({ - description: "Number of results per page (default: 100, max: 1000)", - example: "50", - }), - offset: z.string().optional().openapi({ - description: "Number of results to skip for pagination", - example: "0", + 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'.", }), - }), response: { description: "Query results", schema: z.object({ @@ -508,139 +617,28 @@ export default defineDkgPlugin((ctx, mcp, api) => { }, }, async (req, res) => { + console.info("[EPCIS] Events query received"); try { - const { epc, from, to, bizStep, bizLocation, fullTrace, parentID, childEPC, inputEPC, outputEPC, limit, offset } = req.query; - - // Validate: reject empty string values for filter parameters - const filters = { epc, from, to, bizStep, bizLocation, parentID, childEPC, inputEPC, outputEPC }; - for (const [key, value] of Object.entries(filters)) { - if (value !== undefined && value === '') { - return res.status(400).json({ - success: false, - error: `Parameter '${key}' cannot be empty`, - } as any); - } - } - - // Parse + validate pagination params - const parsedLimit = - typeof limit === "string" && limit.length > 0 ? Number.parseInt(limit, 10) : undefined; - const parsedOffset = - typeof offset === "string" && offset.length > 0 ? Number.parseInt(offset, 10) : undefined; - - if (parsedLimit !== undefined && (!Number.isInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 1000)) { - return res.status(400).json({ - success: false, - error: "Parameter 'limit' must be an integer between 1 and 1000", - } as any); - } - - if (parsedOffset !== undefined && (!Number.isInteger(parsedOffset) || parsedOffset < 0)) { - return res.status(400).json({ - success: false, - error: "Parameter 'offset' must be a non-negative integer", - } as any); - } - - // 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, - fullTrace: fullTrace === 'true', - parentID: parentID as string, - childEPC: childEPC as string, - inputEPC: inputEPC as string, - outputEPC: outputEPC as string, - limit: parsedLimit, - offset: parsedOffset, - }); - - console.debug("[EPCIS Events] Executing SPARQL query:", sparqlQuery); - - // Execute query against DKG - const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT"); - - // Calculate pagination values - const effectiveLimit = parsedLimit ?? 100; - const effectiveOffset = parsedOffset ?? 0; - const resultCount = results?.data?.length || 0; + const { resultData, resultCount, pagination } = + await executeEpcisEventsQuery(req.query); res.json({ success: true, - results: results?.data || [], + results: resultData, count: resultCount, pagination: { - limit: effectiveLimit, - offset: effectiveOffset, + limit: pagination.limit, + offset: pagination.offset, }, }); } catch (error: any) { - console.error("[EPCIS Events] Query error:", error); + console.error("[EPCIS] Events query failed:", error); res.status(500).json({ success: false, error: "Failed to query events", } as any); } - } - ) - ); - - // GET /epcis/asset/:ual - Retrieve EPCIS document by UAL - api.get( - "/epcis/asset/*ual", - openAPIRoute( - { - tag: "EPCIS", - summary: "Get EPCIS Document by UAL", - description: "Retrieve a complete EPCIS document from DKG by its UAL", - params: z.object({ - ual: z.union([z.string(), z.array(z.string())]).openapi({ - description: "The UAL of the published EPCIS document", - example: "did:dkg:otp:2043/0x1234.../123456", - }), - }), - response: { - description: "EPCIS document content", - schema: z.object({ - success: z.boolean(), - ual: z.string(), - data: z.any(), - }), - }, }, - async (req, res) => { - try { - const ual = Array.isArray(req.params.ual) - ? req.params.ual.join('/') - : req.params.ual; - - if (!ual.startsWith("did:dkg:")) { - return res.status(400).json({ - success: false, - error: "Invalid UAL format", - } as any); - } - - const assetResult = await ctx.dkg.asset.get(ual, { - contentType: "all", - }); - - res.json({ - success: true, - ual, - data: assetResult, - }); - } catch (error: any) { - console.error("[EPCIS Asset] Get error:", error); - res.status(404).json({ - success: false, - error: "Asset not found", - } 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 9fb81bcc..fec13b3f 100644 --- a/packages/plugin-epcis/src/model/types.ts +++ b/packages/plugin-epcis/src/model/types.ts @@ -1,52 +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; - requestId: string; - receivedAt: string; - captureID: string; - eventCount: number; - UAL?: string; - } - - 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 index 4be4bd46..234493d7 100644 --- a/packages/plugin-epcis/src/services/EPCISQueryService.ts +++ b/packages/plugin-epcis/src/services/EPCISQueryService.ts @@ -1,42 +1,20 @@ +import type { EpcisQueryParams } from "../model/types"; /** * 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; - /** 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; -} - /** * Escape special characters in SPARQL string literals */ function escapeSparql(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } /** @@ -44,12 +22,11 @@ function escapeSparql(value: string): string { * 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('://')) { + if (!value.includes("://")) { return `https://ref.gs1.org/cbv/BizStep-${value}`; } return value; @@ -61,16 +38,17 @@ export class EpcisQueryService { * 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 .'); + wherePatterns.push("?event a ?eventType ."); // Filter by event type (must be EPCIS event) - filterClauses.push('FILTER(STRSTARTS(STR(?eventType), "https://gs1.github.io/EPCIS/"))'); + filterClauses.push( + 'FILTER(STRSTARTS(STR(?eventType), "https://gs1.github.io/EPCIS/"))', + ); // EPC filter - with optional full traceability across all EPC fields if (params.epc) { @@ -89,72 +67,96 @@ export class EpcisQueryService { wherePatterns.push(`?event epcis:epcList "${epcValue}" .`); } } else { - optionalClauses.push('OPTIONAL { ?event epcis:epcList ?epc . }'); + optionalClauses.push("OPTIONAL { ?event epcis:epcList ?epc . }"); } // Parent ID filter (AggregationEvent) if (params.parentID) { - wherePatterns.push(`?event epcis:parentID "${escapeSparql(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)}" .`); + wherePatterns.push( + `?event epcis:childEPCs "${escapeSparql(params.childEPC)}" .`, + ); } // Input EPCs filter (TransformationEvent) if (params.inputEPC) { - wherePatterns.push(`?event epcis:inputEPCList "${escapeSparql(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)}" .`); + 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)}")`); + wherePatterns.push("?event epcis:bizStep ?bizStep ."); + filterClauses.push( + `FILTER(STR(?bizStep) = "${escapeSparql(bizStepUri)}")`, + ); } else { - optionalClauses.push('OPTIONAL { ?event epcis:bizStep ?bizStep . }'); + optionalClauses.push("OPTIONAL { ?event epcis:bizStep ?bizStep . }"); } // BizLocation filter if (params.bizLocation) { - wherePatterns.push(`?event epcis:bizLocation "${escapeSparql(params.bizLocation)}" .`); + wherePatterns.push( + `?event epcis:bizLocation "${escapeSparql(params.bizLocation)}" .`, + ); } else { - optionalClauses.push('OPTIONAL { ?event epcis:bizLocation ?bizLocation . }'); + 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 .'); + 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)}"))` + `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)}"))`); + 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)}"))`); + filterClauses.push( + `FILTER(xsd:dateTime(?eventTime) <= xsd:dateTime("${escapeSparql(params.to)}"))`, + ); } } else { - optionalClauses.push('OPTIONAL { ?event epcis:eventTime ?eventTime . }'); + 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 . }'); + 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 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 @@ -166,10 +168,10 @@ SELECT ?ual ?eventType ?eventTime ?bizStep ?bizLocation ?disposition ?readPoint (GROUP_CONCAT(DISTINCT ?outputEPCList; SEPARATOR=", ") AS ?outputEPCs) WHERE { GRAPH ?ual { - ${wherePatterns.join('\n ')} - ${optionalClauses.join('\n ')} + ${wherePatterns.join("\n ")} + ${optionalClauses.join("\n ")} } - ${filterClauses.join('\n ')} + ${filterClauses.join("\n ")} } GROUP BY ?ual ?eventType ?eventTime ?bizStep ?bizLocation ?disposition ?readPoint ?action ?parentID ORDER BY DESC(?eventTime) 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/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/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..e8fc3cb7 --- /dev/null +++ b/packages/plugin-epcis/tests/pluginEpcis.spec.ts @@ -0,0 +1,436 @@ +/* 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 { EpcisQueryService } from "../src/services/epcisQueryService"; +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"); + + expect(queryTool).to.not.equal(undefined); + expect(trackTool).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"); + }); + }); + + 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("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.data[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}"`); + }); + }); + + 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"); + }); + }); + + describe("EpcisQueryService", () => { + it("normalizes shorthand bizStep to full GS1 URI", () => { + const queryService = new EpcisQueryService(); + const query = queryService.buildQuery({ bizStep: "receiving" }); + + expect(query).to.include("https://ref.gs1.org/cbv/BizStep-receiving"); + }); + + it("adds UNION for fullTrace EPC queries", () => { + const queryService = new EpcisQueryService(); + const query = queryService.buildQuery({ epc: frameEpc, fullTrace: true }); + + expect(query).to.include("UNION"); + }); + + it("uses full URI when bizStep is provided as shorthand", () => { + const queryService = new EpcisQueryService(); + const query = queryService.buildQuery({ bizStep: "shipping" }); + + expect(query).to.include("https://ref.gs1.org/cbv/BizStep-shipping"); + }); + + it("applies explicit LIMIT and OFFSET", () => { + const queryService = new EpcisQueryService(); + const query = queryService.buildQuery({ + bizStep: "receiving", + limit: 5, + offset: 10, + }); + + expect(query).to.include("LIMIT 5"); + expect(query).to.include("OFFSET 10"); + }); + }); +}); From cb796218a867d3485036a4d3b949a90b6bdc8262 Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Tue, 24 Feb 2026 18:09:28 +0100 Subject: [PATCH 13/23] Linux case sensitive fix --- packages/plugin-epcis/src/index.ts | 10 +++++----- ...cisPublisherService.ts => EPCISPublisherService.ts} | 0 ...epcisQueryValidation.ts => EPCISQueryValidation.ts} | 0 3 files changed, 5 insertions(+), 5 deletions(-) rename packages/plugin-epcis/src/services/{epcisPublisherService.ts => EPCISPublisherService.ts} (100%) rename packages/plugin-epcis/src/utils/{epcisQueryValidation.ts => EPCISQueryValidation.ts} (100%) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index dedafeb7..1c6b12b8 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -1,13 +1,13 @@ import { defineDkgPlugin } from "@dkg/plugins"; import { openAPIRoute, z } from "@dkg/plugin-swagger"; import type { EpcisQueryParams, ValidationResult } from "./model/types"; -import { EpcisQueryService } from "./services/epcisQueryService"; +import { EpcisQueryService } from "./services/EPCISQueryService"; import { fetchPublisherCaptureStatus, isTimeoutError, sendToPublisher, -} from "./services/epcisPublisherService"; -import { EpcisValidationService } from "./services/epcisValidationService"; +} from "./services/EPCISPublisherService"; +import { EpcisValidationService } from "./services/EPCISValidationService"; import { hasAtLeastOneEpcisFilter, hasValidEpcisDateRange, @@ -16,8 +16,8 @@ import { optionalIntegerQueryParam, optionalNonEmptyQueryString, requiredNonEmptyString, -} from "./utils/epcisQueryValidation"; -import { formatSourceKAs } from "./utils/sourceKa"; +} from "./utils/EPCISQueryValidation"; +import { formatSourceKAs } from "./utils/sourceKA"; const QUERY_LIMIT = { MIN: 1, diff --git a/packages/plugin-epcis/src/services/epcisPublisherService.ts b/packages/plugin-epcis/src/services/EPCISPublisherService.ts similarity index 100% rename from packages/plugin-epcis/src/services/epcisPublisherService.ts rename to packages/plugin-epcis/src/services/EPCISPublisherService.ts diff --git a/packages/plugin-epcis/src/utils/epcisQueryValidation.ts b/packages/plugin-epcis/src/utils/EPCISQueryValidation.ts similarity index 100% rename from packages/plugin-epcis/src/utils/epcisQueryValidation.ts rename to packages/plugin-epcis/src/utils/EPCISQueryValidation.ts From ffc46d1e385f631560f03f3cd10ec5f1291d6933 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Tue, 24 Feb 2026 11:39:38 +0100 Subject: [PATCH 14/23] [feat] Add automated Codex PR review via GitHub Action Adds OpenAI Codex-powered code review that runs on every PR and posts inline comments like a human reviewer. Uses structured output schema for reliable parsing and GitHub Review API for native inline comments. - .codex/review-prompt.md: Two-pass review (blockers then maintainability) with comment gate, deduplication, and uncertainty guard - .codex/review-schema.json: Strict output schema (additionalProperties: false) - .github/workflows/codex-review.yml: SHA-pinned actions, fork PR guard, paginated file listing, null-patch handling, overflow comments in summary Co-Authored-By: Claude Opus 4.6 --- .codex/review-prompt.md | 156 +++++++++++++++++++++++++++++ .codex/review-schema.json | 35 +++++++ .github/workflows/codex-review.yml | 124 +++++++++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 .codex/review-prompt.md create mode 100644 .codex/review-schema.json create mode 100644 .github/workflows/codex-review.yml diff --git a/.codex/review-prompt.md b/.codex/review-prompt.md new file mode 100644 index 00000000..35d22f95 --- /dev/null +++ b/.codex/review-prompt.md @@ -0,0 +1,156 @@ +# PR Review Instructions + +You are a senior code reviewer for a Turborepo monorepo (DKG Node). Your job is to review a pull request diff and produce structured, actionable feedback as inline comments on specific changed lines. You review like a staff engineer who cares deeply about code quality, readability, and simplicity. + +## Context Files + +Read these files before reviewing: + +1. **`pr-diff.patch`** β€” The PR diff (generated at runtime). This is the primary input. +2. **`AGENTS.md`** β€” Project conventions, Definition of Done, plugin patterns, testing requirements, and code quality standards. This is the source of truth for how code in this project should look. + +You may read other files in the repository to understand surrounding context (e.g., how a modified function is called, what a referenced constant is). However, all review comments must target lines that appear in the diff. + +## Review Philosophy + +Most PR issues in this codebase are maintainability problems β€” bloat, poor naming, scattered validation, hardcoded values, pattern drift. These matter a lot. + +However, review priority is always **severity-first**: + +1. **Blockers first** β€” correctness, security, auth, data integrity, API compatibility. +2. **Then maintainability** β€” readability, simplicity, pattern conformance. + +When both exist, report blockers first. + +### Review Method + +Do two passes: + +1. **Blockers pass** β€” Scan for correctness bugs, security issues, API/schema contract breaks, missing migrations, data integrity risks, and missing tests for changed behavior. These are `πŸ”΄ Bug` comments. +2. **Maintainability pass** β€” Scan for code bloat, readability issues, naming problems, pattern violations, hardcoded values. These are `🟑 Issue`, `πŸ”΅ Nit`, or `πŸ’‘ Suggestion` comments. + +### Comment Gate + +Before posting any comment, verify all three conditions: + +1. **Introduced by this diff** β€” The issue is introduced or materially worsened by the changes in this PR, not pre-existing. +2. **Materially impactful** β€” The issue affects correctness, security, readability, or maintainability in a meaningful way. Not a theoretical concern. +3. **Concrete fix direction** β€” You can suggest a specific fix or clear direction. If you can only say "this seems off" without a concrete suggestion, do not comment. + +If any check fails, skip the comment. + +**Uncertainty guard:** If you are not certain an issue is real and cannot verify it from the diff and allowed context, do not label it `πŸ”΄ Bug`. Downgrade to `🟑 Issue` or `πŸ’‘ Suggestion`, or skip it entirely. + +**Deduplication:** One comment per root cause. If the same pattern repeats across multiple lines, comment on the first occurrence and note "same pattern at lines X, Y, Z." Aim for a maximum of ~10 comments, highest impact first. + +## What to Review + +### Pass 1: Blockers + +#### Correctness +- Logic errors, off-by-one, null/undefined handling, incorrect assumptions, race conditions. +- Boundary conditions β€” empty arrays, null inputs, zero values, maximum values. +- Error handling β€” swallowed errors, missing error propagation, unhelpful error messages. Do not flag missing error handling for internal code that cannot reasonably fail. + +#### Security +- Injection risks (SQL, command, XSS) when handling user input. +- Hardcoded secrets β€” API keys, passwords, tokens in code. +- Missing input validation at system boundaries (user input, external APIs). Not for internal function calls. +- Auth bypass, privilege escalation, or missing authorization checks. + +#### API Compatibility +- Breaking changes to API response schemas or status codes without migration path. +- Removed or renamed API endpoints, query parameters, or response fields that existing consumers depend on. +- Database schema changes that require migration or backfill. +- MCP tool signature changes (renamed tools, changed input schemas) that break existing clients. + +#### Tests for Changed Behavior +- New behavior must have corresponding tests covering core functionality and error handling. +- Bug fixes must include a regression test that would have caught the original bug. +- Changed behavior must have updated tests reflecting the new expectations. +- If tests are present but brittle (testing implementation details rather than behavior), flag it. + +Missing tests for changed behavior are blockers (`πŸ”΄ Bug`) only when the change affects user-facing behavior, API contracts, or data integrity. Missing tests for internal refactors or trivial changes are `🟑 Issue`. + +### Pass 2: Maintainability + +#### Code Bloat and Unnecessary Complexity +- **Excessive code** β€” More lines than necessary. Could this be done in fewer lines without sacrificing clarity? +- **Over-engineering** β€” Abstractions, helpers, or utilities for one-time operations. Premature generalization. Feature flags or config for things that could just be code. +- **Speculative generality** β€” Code handling hypothetical future requirements nobody asked for. +- **Dead code** β€” Unused variables, unreachable branches, commented-out code. +- **Duplicate code** β€” Same logic repeated instead of extracted. But do not suggest extraction for only 2-3 similar lines β€” that is premature abstraction. + +#### Readability and Naming +- **Confusing variable/function names** β€” Names that don't describe what the thing is or does. Generic names like `data`, `result`, `item`, `temp`, `val` when a specific name would be clearer. +- **Misleading names** β€” Names that suggest different behavior than what the code does. +- **Inconsistent naming** β€” Not following conventions in the rest of the codebase. +- **File naming** β€” Files not following the project's naming conventions. +- **Long functions** β€” Functions doing too many things. If you need a comment to explain a section, it should probably be its own function. +- **Deep nesting** β€” More than 2-3 levels. Suggest early returns, guard clauses, or extraction. +- **Unclear control flow** β€” Complex conditionals that could be simplified or decomposed. + +#### Architecture and Pattern Violations +- **Inline validation instead of Zod schemas** β€” Validation logic written in code (if/else checks, manual type coercion) instead of using Zod schemas in `openAPIRoute()`. All request validation belongs in the schema, not handler code. This applies to both API routes and MCP tool `inputSchema`. +- **Missing `openAPIRoute()` wrapper** β€” API endpoints defined without the OpenAPI wrapper. +- **Wrong import paths in tests** β€” Tests importing from `src/` instead of `dist/`. +- **Missing test categories** β€” Tests without "Core Functionality" and "Error Handling" describe blocks. +- **Mixing concerns** β€” Route handlers doing business logic, database queries in API handlers, etc. + +#### Hardcoded Values and Magic Constants +Flag only when the value is: +- **Reused 3+ times** in touched files or the diff β€” should be a named constant. +- **Domain-significant** β€” timeout values, retry counts, port numbers, API URLs, status messages. Even if used once, these belong in constants or environment variables. + +Do not flag one-off numeric literals that are self-explanatory in context (e.g., `array.slice(0, 2)`, `Math.round(x * 100) / 100`). + +#### Performance (Only Obvious Issues) +- N+1 queries β€” database queries inside loops. +- Blocking operations in async contexts β€” synchronous I/O in async code. +- Unnecessary work in hot paths β€” redundant allocations, repeated computations. + +## What NOT to Review + +- Formatting or style β€” Prettier handles this. +- Type annotations for code that already type-checks. +- Things that are clearly intentional design choices backed by existing patterns. +- Pre-existing issues in unchanged code outside the diff. +- Adding documentation unless a public API is clearly undocumented. + +## Comment Format + +Use severity prefixes: +- `πŸ”΄ Bug:` β€” Correctness error, security issue, API break, data integrity risk. Will cause incorrect behavior. +- `🟑 Issue:` β€” Code quality problem that should be fixed. Bloated code, bad naming, pattern violation, missing tests. +- `πŸ”΅ Nit:` β€” Minor improvement, optional. +- `πŸ’‘ Suggestion:` β€” Alternative approach worth considering. + +Be specific, be concise, explain why. One clear sentence with a concrete fix is better than a paragraph of theory. + +## Output Format + +Return raw JSON only. No markdown fences, no prose before or after the JSON object. Your output MUST be valid JSON matching the provided output schema. Example: + +```json +{ + "summary": "This PR adds the user settings API but has a potential auth bypass in the update endpoint and several instances of validation logic that should be in Zod schemas instead of handler code.", + "comments": [ + { + "path": "packages/plugin-settings/src/index.ts", + "line": 42, + "body": "πŸ”΄ Bug: The `authorized()` middleware is missing on this route. Any unauthenticated user can update settings. Add `authorized(['settings:write'])` middleware." + }, + { + "path": "packages/plugin-settings/src/index.ts", + "line": 58, + "body": "🟑 Issue: This manual `if (!req.query.id || typeof req.query.id !== 'string')` check should be in the Zod schema passed to `openAPIRoute()`. The schema handles validation automatically and returns 400 with a descriptive error." + } + ] +} +``` + +The `line` field must refer to the line number in the new version of the file (right side of the diff), and it must be a line that actually appears in the diff hunks. Do not comment on lines outside the diff. + +## Summary + +Write a brief (2–4 sentence) overall assessment in the `summary` field. Lead with blockers if any exist. Mention whether the PR is clean/minimal or has code quality issues. If the PR looks good, say so. diff --git a/.codex/review-schema.json b/.codex/review-schema.json new file mode 100644 index 00000000..0039cffb --- /dev/null +++ b/.codex/review-schema.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Brief overall assessment of the PR (2-4 sentences)" + }, + "comments": { + "type": "array", + "description": "Inline review comments on specific changed lines", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path relative to repository root" + }, + "line": { + "type": "integer", + "minimum": 1, + "description": "Line number in the new version of the file (must be within the diff)" + }, + "body": { + "type": "string", + "description": "Review comment with severity prefix" + } + }, + "required": ["path", "line", "body"], + "additionalProperties": false + } + } + }, + "required": ["summary", "comments"], + "additionalProperties": false +} diff --git a/.github/workflows/codex-review.yml b/.github/workflows/codex-review.yml new file mode 100644 index 00000000..b9d78f56 --- /dev/null +++ b/.github/workflows/codex-review.yml @@ -0,0 +1,124 @@ +name: Codex PR Review + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: codex-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + review: + name: Codex Review + runs-on: ubuntu-latest + timeout-minutes: 15 + # Skip fork PRs β€” they cannot access repository secrets + if: github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Checkout PR merge commit + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge + fetch-depth: 0 + + - name: Generate PR diff + run: git diff ${{ github.event.pull_request.base.sha }}...HEAD > pr-diff.patch + + - name: Run Codex review + id: codex + uses: openai/codex-action@f5c0ca71642badb34c1e66321d8d85685a0fa3dc # v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + prompt-file: .codex/review-prompt.md + output-schema-file: .codex/review-schema.json + effort: high + sandbox: read-only + + - name: Post PR review with inline comments + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + REVIEW_JSON: ${{ steps.codex.outputs.final-message }} + with: + script: | + const review = JSON.parse(process.env.REVIEW_JSON); + + // Fetch all changed files (paginated for large PRs) + const files = await github.paginate( + github.rest.pulls.listFiles, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + } + ); + + // Build set of valid (path:line) pairs from diff patches + const validLines = new Set(); + for (const file of files) { + // Skip binary/large/truncated files with no patch + if (!file.patch) continue; + + const lines = file.patch.split('\n'); + let currentLine = 0; + for (const line of lines) { + const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)/); + if (hunkMatch) { + currentLine = parseInt(hunkMatch[1], 10); + continue; + } + // Deleted lines don't exist in the new file + if (line.startsWith('-')) continue; + // Context lines and added lines are valid targets + if (!line.startsWith('\\')) { + validLines.add(`${file.filename}:${currentLine}`); + currentLine++; + } + } + } + + // Partition comments into valid (on diff lines) and overflow + const validComments = []; + const overflowComments = []; + + for (const comment of review.comments) { + const key = `${comment.path}:${comment.line}`; + if (validLines.has(key)) { + validComments.push({ + path: comment.path, + line: comment.line, + body: comment.body, + side: 'RIGHT', + }); + } else { + overflowComments.push(comment); + } + } + + // Build review body: summary + any overflow comments + let body = review.summary || 'Codex review complete.'; + if (overflowComments.length > 0) { + body += '\n\n### Additional comments\n'; + body += '_These comments target lines outside the diff and could not be posted inline._\n\n'; + for (const c of overflowComments) { + body += `- **\`${c.path}:${c.line}\`** β€” ${c.body}\n`; + } + } + + // Post the review + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + body, + event: 'COMMENT', + comments: validComments, + }); + + console.log(`Review posted: ${validComments.length} inline comments, ${overflowComments.length} overflow comments`); From 68f882c6c66a07fb29330ff6f7bb2f7946e47746 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Tue, 24 Feb 2026 11:45:54 +0100 Subject: [PATCH 15/23] [improvement] Add try/catch fallback for malformed Codex output If Codex returns empty or non-JSON output, the workflow now posts a warning comment with a link to the logs instead of silently failing. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/codex-review.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codex-review.yml b/.github/workflows/codex-review.yml index b9d78f56..58d37965 100644 --- a/.github/workflows/codex-review.yml +++ b/.github/workflows/codex-review.yml @@ -46,7 +46,23 @@ jobs: REVIEW_JSON: ${{ steps.codex.outputs.final-message }} with: script: | - const review = JSON.parse(process.env.REVIEW_JSON); + let review; + try { + review = JSON.parse(process.env.REVIEW_JSON); + } catch (e) { + console.error('Failed to parse Codex output:', e.message); + console.error('Raw output:', process.env.REVIEW_JSON?.slice(0, 500)); + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + body: '⚠️ Codex review failed to produce valid JSON output. Check the [workflow logs](' + + `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}) for details.`, + event: 'COMMENT', + comments: [], + }); + return; + } // Fetch all changed files (paginated for large PRs) const files = await github.paginate( From 89d0ec203def104a8992e59d10e554efb7a84230 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Tue, 24 Feb 2026 11:50:03 +0100 Subject: [PATCH 16/23] [improvement] Guard against missing or non-array comments in Codex output Co-Authored-By: Claude Opus 4.6 --- .github/workflows/codex-review.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codex-review.yml b/.github/workflows/codex-review.yml index 58d37965..eb8815b1 100644 --- a/.github/workflows/codex-review.yml +++ b/.github/workflows/codex-review.yml @@ -100,10 +100,11 @@ jobs: } // Partition comments into valid (on diff lines) and overflow + const comments = Array.isArray(review.comments) ? review.comments : []; const validComments = []; const overflowComments = []; - for (const comment of review.comments) { + for (const comment of comments) { const key = `${comment.path}:${comment.line}`; if (validLines.has(key)) { validComments.push({ From 3cfde8c4eb9a6004a102b2fe17f941132cf64175 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Wed, 25 Feb 2026 16:39:11 +0100 Subject: [PATCH 17/23] updates --- .../docs/EPCIS-Integration-Guide.md | 92 +++- packages/plugin-epcis/src/index.ts | 485 ++++++++++++------ .../plugin-epcis/tests/pluginEpcis.spec.ts | 192 ++++++- 3 files changed, 592 insertions(+), 177 deletions(-) diff --git a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md index 9bf70873..15f9e542 100644 --- a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md +++ b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md @@ -50,7 +50,9 @@ This integration bridges **GS1 EPCIS 2.0** (Electronic Product Code Information β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ HTTP API β”‚ MCP Tools β”‚ POST /epcis/capture β”‚ epcis-query - β”‚ GET /epcis/events β”‚ epcis-track-item + β”‚ GET /epcis/capture/:captureID β”‚ epcis-track-item + β”‚ GET /epcis/events β”‚ epcis-capture + β”‚ GET /epcis/events/track β”‚ epcis-capture-status β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ EPCIS Plugin β”‚ @@ -741,9 +743,40 @@ Query EPCIS events from the DKG using SPARQL. --- +### GET `/epcis/events/track` + +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 | + +**Response:** + +```json +{ + "success": true, + "results": [ + /* array of matching events for the EPC */ + ], + "count": 3 +} +``` + +**Error Responses:** + +| Status | When | Description | +| ----------------------------- | --------------------- | ---------------------------------- | +| **400 Bad Request** | Missing/invalid `epc` | Query validation failed | +| **500 Internal Server Error** | DKG query failed | Failed to execute full-trace query | + +--- + ## 11. MCP Tools Reference -The EPCIS plugin exposes two MCP (Model Context Protocol) tools that AI agents can use to query supply chain data from the DKG. +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 @@ -806,6 +839,55 @@ Journey Timeline: --- +### `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 includes:** + +- `captureID` (publisher tracking ID) +- `requestId` (plugin-generated request ID) +- `receivedAt` timestamp +- `eventCount` + +**Error cases:** + +- Validation error (`Invalid EPCISDocument`, empty events) +- Publisher unavailable error + +--- + +### `epcis-capture-status` β€” Get Capture Status + +Checks the publisher-tracked status for a capture request by numeric `captureID`. + +**Input Schema:** + +| Parameter | Type | Required | Description | +| ----------- | ------ | -------- | ----------------------------------------------------------------- | +| `captureID` | string | **Yes** | Numeric capture ID (`^[0-9]{1,20}$`) returned by capture handlers | + +**Success Response includes:** + +- `status` +- `captureID` +- Optional `UAL`, `publishedAt`, and `error` + +**Error cases:** + +- Capture not found +- Publisher timeout +- Upstream status lookup failure + +--- + ## 12. Query Examples ### Track All Events for a Product @@ -1150,7 +1232,7 @@ Event 9: Ship (shipping, in_transit) @ Shipping Dock | `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 | +| `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 | @@ -1166,8 +1248,8 @@ The system validates against the official GS1 EPCIS 2.0 JSON Schema. Common issu ### Environment Variables -| Variable | Required | Description | -| -------------------- | -------- | --------------------------------------------------------------------- | +| Variable | Required | Description | +| --------------------- | -------- | --------------------------------------------------------------------- | | `EXPO_PUBLIC_MCP_URL` | Yes | Base URL of the DKG publisher service (e.g., `http://localhost:9200`) | ### Checking System Health diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 1c6b12b8..95b43288 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -32,6 +32,11 @@ const QUERY_OFFSET = { 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 = { @@ -43,6 +48,19 @@ type CaptureResponse = { 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; @@ -50,6 +68,49 @@ type PublisherCaptureStatusResponse = { lastError?: string; }; +class CaptureValidationError extends Error { + constructor( + readonly payload: { + error: string; + details?: string[]; + message?: string; + }, + ) { + super(payload.error); + this.name = "CaptureValidationError"; + } +} + +class CapturePublishError extends Error { + constructor() { + super("Something went wrong with publishing the EPCIS document."); + this.name = "CapturePublishError"; + } +} + +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)}`; } @@ -123,6 +184,61 @@ export default defineDkgPlugin((ctx, mcp, api) => { }; } + 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 @@ -184,91 +300,40 @@ export default defineDkgPlugin((ctx, mcp, api) => { async (input) => { try { if (!hasAtLeastOneEpcisFilter(input)) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { error: "At least one filter parameter is required." }, - null, - 2, - ), - }, - ], - isError: true, - }; + return mcpError({ + error: "At least one filter parameter is required.", + }); } if (!hasValidEpcisDateRange(input)) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: - "Parameter 'to' must be greater than or equal to 'from'.", - }, - null, - 2, - ), - }, - ], - isError: true, - }; + return mcpError({ + error: "Parameter 'to' must be greater than or equal to 'from'.", + }); } - const { results, resultData, resultCount, pagination } = + const { resultData, resultCount, pagination } = await executeEpcisEventsQuery(input); const summary = resultCount ? `Found ${resultCount} EPCIS event(s)` : "No events found matching the criteria"; - // Build content array with optional source KAs - const content: { type: "text"; text: string }[] = [ + const sourceKAs = formatSourceKAs(resultData); + return mcpSuccess( { - type: "text", - text: JSON.stringify( - { - summary, - count: resultCount, - events: results || [], - pagination: { - limit: pagination.limit, - offset: pagination.offset, - }, - }, - null, - 2, - ), + summary, + count: resultCount, + events: resultData || [], + pagination: { + limit: pagination.limit, + offset: pagination.offset, + }, }, - ]; - - // Append source Knowledge Assets if available - const sourceKAs = formatSourceKAs(resultData); - if (sourceKAs) { - content.push(sourceKAs); - } - - return { content }; + sourceKAs ? [sourceKAs] : [], + ); } catch (error: any) { console.error("[EPCIS] DKG query failed:", error); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: "Query failed", - }, - null, - 2, - ), - }, - ], - isError: true, - }; + return mcpError({ error: "Query failed" }); } }, ); @@ -297,47 +362,118 @@ export default defineDkgPlugin((ctx, mcp, api) => { const summary = buildTrackItemSummary(input.epc, resultData); - // Build content array with optional source KAs - const content: { type: "text"; text: string }[] = [ + const sourceKAs = formatSourceKAs(resultData); + return mcpSuccess( { - type: "text", - text: JSON.stringify( - { - summary, - epc: input.epc, - eventCount: resultCount, - events: resultData || [], - }, - null, - 2, - ), + 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" }); + } + }, + ); - // Append source Knowledge Assets if available - const sourceKAs = formatSourceKAs(resultData); - if (sourceKAs) { - content.push(sourceKAs); + // 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); } - return { content }; - } catch (error: any) { - console.error(`[EPCIS] Item tracking failed, epc: ${input.epc}`, error); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: "Tracking failed", - }, - null, - 2, - ), - }, - ], - isError: true, + if (error instanceof CapturePublishError) { + return mcpError(CAPTURE_PUBLISH_ERROR); + } + + console.error("[EPCIS] MCP capture failed:", error); + return mcpError({ error: "Capture failed" }); + } + }, + ); + + // 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, + }); + } + + 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, + }); } }, ); @@ -388,48 +524,31 @@ export default defineDkgPlugin((ctx, mcp, api) => { 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}`, + ); - const validationResult = validationService.validate(epcisDocument); - const validationError = getCaptureValidationError(validationResult); - if (validationError) { - return res.status(400).json(validationError as any); - } - - let publishResult: any; - try { - publishResult = await sendToPublisher( - epcisDocument, - { source: "EPCIS", sourceId: requestId }, - publishOptions, - ); - console.info( - `[EPCIS] Document queued via publisher, requestId: ${requestId}, eventCount: ${validationResult.eventCount}, captureID: ${publishResult.id}`, - ); - } catch (error: any) { - console.error( - `[EPCIS] Publishing failed, requestId: ${requestId}, eventCount: ${validationResult.eventCount}, error:`, - error, + return res.status(202).json(response); + } catch (error: unknown) { + if (error instanceof CaptureValidationError) { + console.warn( + `[EPCIS] Capture validation failed, requestId: ${requestId}`, ); - return res.status(500).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.", - } as any); + return res.status(400).json(error.payload as any); } - // Return capture response - const response: CaptureResponse = { - status: "202", - requestId, - receivedAt: new Date().toISOString(), - captureID: String(publishResult.id), - eventCount: validationResult.eventCount ?? 0, - }; + if (error instanceof CapturePublishError) { + console.error(`[EPCIS] Publishing failed, requestId: ${requestId}`); + return res.status(500).json(CAPTURE_PUBLISH_ERROR as any); + } - return res.status(202).json(response); - } catch (error: any) { console.error( - `[EPCIS] Unexpected error, requestId: ${requestId}, error:`, + `[EPCIS] Unexpected error while processing capture request, requestId: ${requestId}:`, error, ); return res.status(500).json({ @@ -478,29 +597,23 @@ export default defineDkgPlugin((ctx, mcp, api) => { ); try { - const response = await fetchPublisherCaptureStatus(captureID); - - if (!response.ok) { - if (response.status === 404) { - return res - .status(404) - .json({ error: "Capture not found", captureID } as any); - } - throw new Error( - `Publisher returned ${response.status} for captureID: ${captureID}`, - ); + const captureStatus = await parseCaptureStatus(captureID); + if (captureStatus.notFound) { + return res + .status(404) + .json({ error: "Capture not found", captureID } as any); } - const asset = - (await response.json()) as PublisherCaptureStatusResponse; - - return res.json({ - status: asset.status, - captureID, - ...(asset.ual && { UAL: asset.ual }), - ...(asset.publishedAt && { publishedAt: asset.publishedAt }), - ...(asset.lastError && { error: asset.lastError }), - }); + 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({ @@ -641,4 +754,52 @@ export default defineDkgPlugin((ctx, mcp, api) => { }, ), ); + + // 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: "Tracking query results", + schema: z.object({ + success: z.boolean(), + results: z.array(z.any()), + count: z.number(), + }), + }, + }, + async (req, res) => { + console.info("[EPCIS] Track item query received"); + try { + const { resultData, resultCount } = await executeEpcisEventsQuery({ + epc: req.query.epc, + fullTrace: true, + }); + + res.json({ + success: true, + results: resultData, + count: resultCount, + }); + } catch (error: any) { + console.error("[EPCIS] Track query failed:", error); + res.status(500).json({ + success: false, + error: "Failed to query events", + } as any); + } + }, + ), + ); }); diff --git a/packages/plugin-epcis/tests/pluginEpcis.spec.ts b/packages/plugin-epcis/tests/pluginEpcis.spec.ts index e8fc3cb7..9177ea10 100644 --- a/packages/plugin-epcis/tests/pluginEpcis.spec.ts +++ b/packages/plugin-epcis/tests/pluginEpcis.spec.ts @@ -6,7 +6,7 @@ import { expect } from "chai"; import sinon from "sinon"; import request from "supertest"; import pluginEpcisPlugin from "../dist/index.js"; -import { EpcisQueryService } from "../src/services/epcisQueryService"; +import { EpcisQueryService } from "../src/services/epcisQueryService.js"; import bicycleStory from "../test-data/bicycle-manufacturing-story.json"; import { ASSEMBLY_EVENTS, @@ -98,9 +98,15 @@ describe("@dkg/plugin-epcis checks", function () { 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", @@ -109,6 +115,13 @@ describe("@dkg/plugin-epcis checks", function () { "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", + ); }); }); @@ -217,12 +230,15 @@ describe("@dkg/plugin-epcis checks", function () { 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); + 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 () => { @@ -267,6 +283,32 @@ describe("@dkg/plugin-epcis checks", function () { }); }); + 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)); @@ -279,7 +321,7 @@ describe("@dkg/plugin-epcis checks", function () { 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.data[0].bizStep).to.include("BizStep-assembling"); + expect(payload.events[0].bizStep).to.include("BizStep-assembling"); }); it("epcis-track-item returns journey timeline with numbered steps", async () => { @@ -301,6 +343,131 @@ describe("@dkg/plugin-epcis checks", function () { 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", () => { @@ -319,7 +486,10 @@ describe("@dkg/plugin-epcis checks", function () { const response = await request(app) .post("/epcis/capture") - .send({ epcisDocument: emptyEventDoc, publishOptions: event1.publishOptions }) + .send({ + epcisDocument: emptyEventDoc, + publishOptions: event1.publishOptions, + }) .expect(400); expect(response.body.error).to.equal("EPCISDocument contains no events"); @@ -373,7 +543,9 @@ describe("@dkg/plugin-epcis checks", function () { const payload = parseToolResult(result); expect(result.isError).to.equal(true); - expect(payload.error).to.include("At least one filter parameter is required."); + expect(payload.error).to.include( + "At least one filter parameter is required.", + ); }); it("returns MCP error when DKG query fails", async () => { From 772f67452c7d4fd6d0e47176db95c3892257b064 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Wed, 25 Feb 2026 16:46:19 +0100 Subject: [PATCH 18/23] docs --- .../docs/EPCIS-Integration-Guide.md | 238 ++++++++++++++---- 1 file changed, 194 insertions(+), 44 deletions(-) diff --git a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md index 15f9e542..dc52d042 100644 --- a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md +++ b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md @@ -15,6 +15,8 @@ This document explains all fields used in EPCIS 2.0 documents and provides compr 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) @@ -725,15 +727,33 @@ Query EPCIS events from the DKG using SPARQL. | `limit` | integer | Results per page (default: 100, range: 1-1000) | `50` | | `offset` | integer | Results to skip (pagination, min: 0) | `0` | -**Response:** +**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 { "success": true, "results": [ - /* array of matching 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" + } ], - "count": 5, + "count": 1, "pagination": { "limit": 100, "offset": 0 @@ -741,6 +761,25 @@ Query EPCIS events from the DKG using SPARQL. } ``` +> 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):** + +```json +{ + "success": false, + "error": "Failed to query events" +} +``` + --- ### GET `/epcis/events/track` @@ -753,24 +792,42 @@ Track a single EPC through its full supply chain journey. This endpoint always p | --------- | ------ | -------- | ----------------------------------------- | | `epc` | string | **Yes** | EPC identifier to track across all events | -**Response:** +**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 { "success": true, "results": [ - /* array of matching events for the EPC */ + { + "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": 3 + "count": 1 } ``` -**Error Responses:** +**Example (HTTP 500 Internal Server Error):** -| Status | When | Description | -| ----------------------------- | --------------------- | ---------------------------------- | -| **400 Bad Request** | Missing/invalid `epc` | Query validation failed | -| **500 Internal Server Error** | DKG query failed | Failed to execute full-trace query | +```json +{ + "success": false, + "error": "Failed to query events" +} +``` --- @@ -801,10 +858,39 @@ General-purpose query tool with the same filtering capabilities as `GET /epcis/e > **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 includes:** +**Response (first content block):** -- Event data with count and pagination -- Source Knowledge Assets with UALs and DKG Explorer links for provenance +```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 + } +} +``` + +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" }` --- @@ -818,24 +904,24 @@ Specialized tool for tracking a single item's complete journey through the suppl | --------- | ------ | -------- | --------------------------------------------------------------- | | `epc` | string | **Yes** | The EPC to track (e.g., `urn:epc:id:sgtin:0614141.107346.2017`) | -**Response includes:** +**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 */ + ] +} +``` -- Human-readable timeline summary -- Full event data in chronological order -- Source Knowledge Assets with UALs and DKG Explorer links +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)). -**Example timeline output:** +**Error cases:** -``` -Tracking: urn:epc:id:sgtin:4012345.011111.1001 -Found 4 event(s) in the supply chain. - -Journey Timeline: -1. [2024-03-01T08:00:00.000Z] receiving @ urn:epc:id:sgln:4012345.00001.0 -2. [2024-03-01T10:00:00.000Z] inspecting @ urn:epc:id:sgln:4012345.00002.0 -3. [2024-03-01T14:00:00.000Z] assembling @ urn:epc:id:sgln:4012345.00003.0 -4. [2024-03-01T16:00:00.000Z] packing @ urn:epc:id:sgln:4012345.00004.0 -``` +- DKG query failure β†’ `{ "error": "Tracking failed" }` --- @@ -850,17 +936,22 @@ Validates an EPCIS document and queues it for publishing via the DKG publisher s | `epcisDocument` | object | **Yes** | EPCIS 2.0 JSON-LD document | | `publishOptions` | object | No | Optional publishing settings (`privacy`, `epochs`) | -**Success Response includes:** +**Success Response:** -- `captureID` (publisher tracking ID) -- `requestId` (plugin-generated request ID) -- `receivedAt` timestamp -- `eventCount` +```json +{ + "captureID": "456", + "requestId": "epcis-1709280001123-a1b2c3", + "receivedAt": "2024-03-01T08:00:01.123Z", + "eventCount": 1 +} +``` **Error cases:** -- Validation error (`Invalid EPCISDocument`, empty events) -- Publisher unavailable error +- 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": "..." }` --- @@ -874,25 +965,84 @@ Checks the publisher-tracked status for a capture request by numeric `captureID` | ----------- | ------ | -------- | ----------------------------------------------------------------- | | `captureID` | string | **Yes** | Numeric capture ID (`^[0-9]{1,20}$`) returned by capture handlers | -**Success Response includes:** +**Success Response:** + +```json +{ + "status": "published", + "captureID": "456", + "UAL": "did:dkg:otp/0x1234.../789", + "publishedAt": "2024-03-01T08:01:23.456Z" +} +``` -- `status` -- `captureID` -- Optional `UAL`, `publishedAt`, and `error` +> Fields `UAL`, `publishedAt`, and `error` are only present when applicable to the current status. **Error cases:** -- Capture not found -- Publisher timeout -- Upstream status lookup failure +- 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" }` + +--- + +## Source Knowledge Assets + +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. + +**Format:** + +``` +**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) +``` + +Each entry includes: + +- **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: +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" From bb63e5ad88f85c137b35eedcd00a7309697d9ba6 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Wed, 25 Feb 2026 16:52:34 +0100 Subject: [PATCH 19/23] fix errors on github actions --- packages/plugin-epcis/src/index.ts | 10 +++++----- ...CISPublisherService.ts => epcisPublisherService.ts} | 0 .../{EPCISQueryService.ts => epcisQueryService.ts} | 0 ...SValidationService.ts => epcisValidationService.ts} | 0 ...EPCISQueryValidation.ts => epcisQueryValidation.ts} | 0 .../src/utils/{sourceKA.ts => sourceKa.ts} | 0 6 files changed, 5 insertions(+), 5 deletions(-) rename packages/plugin-epcis/src/services/{EPCISPublisherService.ts => epcisPublisherService.ts} (100%) rename packages/plugin-epcis/src/services/{EPCISQueryService.ts => epcisQueryService.ts} (100%) rename packages/plugin-epcis/src/services/{EPCISValidationService.ts => epcisValidationService.ts} (100%) rename packages/plugin-epcis/src/utils/{EPCISQueryValidation.ts => epcisQueryValidation.ts} (100%) rename packages/plugin-epcis/src/utils/{sourceKA.ts => sourceKa.ts} (100%) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 95b43288..3b619ea6 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -1,13 +1,13 @@ import { defineDkgPlugin } from "@dkg/plugins"; import { openAPIRoute, z } from "@dkg/plugin-swagger"; import type { EpcisQueryParams, ValidationResult } from "./model/types"; -import { EpcisQueryService } from "./services/EPCISQueryService"; +import { EpcisQueryService } from "./services/epcisQueryService"; import { fetchPublisherCaptureStatus, isTimeoutError, sendToPublisher, -} from "./services/EPCISPublisherService"; -import { EpcisValidationService } from "./services/EPCISValidationService"; +} from "./services/epcisPublisherService"; +import { EpcisValidationService } from "./services/epcisValidationService"; import { hasAtLeastOneEpcisFilter, hasValidEpcisDateRange, @@ -16,8 +16,8 @@ import { optionalIntegerQueryParam, optionalNonEmptyQueryString, requiredNonEmptyString, -} from "./utils/EPCISQueryValidation"; -import { formatSourceKAs } from "./utils/sourceKA"; +} from "./utils/epcisQueryValidation"; +import { formatSourceKAs } from "./utils/sourceKa"; const QUERY_LIMIT = { MIN: 1, diff --git a/packages/plugin-epcis/src/services/EPCISPublisherService.ts b/packages/plugin-epcis/src/services/epcisPublisherService.ts similarity index 100% rename from packages/plugin-epcis/src/services/EPCISPublisherService.ts rename to packages/plugin-epcis/src/services/epcisPublisherService.ts diff --git a/packages/plugin-epcis/src/services/EPCISQueryService.ts b/packages/plugin-epcis/src/services/epcisQueryService.ts similarity index 100% rename from packages/plugin-epcis/src/services/EPCISQueryService.ts rename to packages/plugin-epcis/src/services/epcisQueryService.ts 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 similarity index 100% rename from packages/plugin-epcis/src/utils/EPCISQueryValidation.ts rename to packages/plugin-epcis/src/utils/epcisQueryValidation.ts diff --git a/packages/plugin-epcis/src/utils/sourceKA.ts b/packages/plugin-epcis/src/utils/sourceKa.ts similarity index 100% rename from packages/plugin-epcis/src/utils/sourceKA.ts rename to packages/plugin-epcis/src/utils/sourceKa.ts From 4da02af3cd1e76830d11bc36ff53940887ecb609 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Thu, 26 Feb 2026 15:36:08 +0100 Subject: [PATCH 20/23] [bug] Align EPCIS capture schema and status error logging --- packages/plugin-epcis/src/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts index 3b619ea6..0ebff007 100644 --- a/packages/plugin-epcis/src/index.ts +++ b/packages/plugin-epcis/src/index.ts @@ -510,6 +510,7 @@ export default defineDkgPlugin((ctx, mcp, api) => { description: "Capture accepted (202)", schema: z.object({ status: z.string(), + requestId: z.string(), receivedAt: z.string(), captureID: z.string(), eventCount: z.number(), @@ -626,10 +627,11 @@ export default defineDkgPlugin((ctx, mcp, api) => { error instanceof Error ? error.name : "UnknownError"; const errorMessage = error instanceof Error ? error.message : String(error); - console.error( - `[EPCIS] Capture status request failed, captureID: ${captureID}`, - { errorName, errorMessage }, - ); + console.error("[EPCIS] Capture status request failed", { + captureID, + errorName, + errorMessage, + }); return res.status(500).json({ error: "Failed to get capture status", } as any); From 559796c67efc7d16a5c2e088a0666a3f8526fe1d Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Thu, 26 Feb 2026 15:45:09 +0100 Subject: [PATCH 21/23] [improvement] Align EPCIS tests with dist-only imports --- .../plugin-epcis/tests/pluginEpcis.spec.ts | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/packages/plugin-epcis/tests/pluginEpcis.spec.ts b/packages/plugin-epcis/tests/pluginEpcis.spec.ts index 9177ea10..f79fe8e1 100644 --- a/packages/plugin-epcis/tests/pluginEpcis.spec.ts +++ b/packages/plugin-epcis/tests/pluginEpcis.spec.ts @@ -6,7 +6,6 @@ import { expect } from "chai"; import sinon from "sinon"; import request from "supertest"; import pluginEpcisPlugin from "../dist/index.js"; -import { EpcisQueryService } from "../src/services/epcisQueryService.js"; import bicycleStory from "../test-data/bicycle-manufacturing-story.json"; import { ASSEMBLY_EVENTS, @@ -570,39 +569,4 @@ describe("@dkg/plugin-epcis checks", function () { expect(response.body.error).to.equal("Failed to query events"); }); }); - - describe("EpcisQueryService", () => { - it("normalizes shorthand bizStep to full GS1 URI", () => { - const queryService = new EpcisQueryService(); - const query = queryService.buildQuery({ bizStep: "receiving" }); - - expect(query).to.include("https://ref.gs1.org/cbv/BizStep-receiving"); - }); - - it("adds UNION for fullTrace EPC queries", () => { - const queryService = new EpcisQueryService(); - const query = queryService.buildQuery({ epc: frameEpc, fullTrace: true }); - - expect(query).to.include("UNION"); - }); - - it("uses full URI when bizStep is provided as shorthand", () => { - const queryService = new EpcisQueryService(); - const query = queryService.buildQuery({ bizStep: "shipping" }); - - expect(query).to.include("https://ref.gs1.org/cbv/BizStep-shipping"); - }); - - it("applies explicit LIMIT and OFFSET", () => { - const queryService = new EpcisQueryService(); - const query = queryService.buildQuery({ - bizStep: "receiving", - limit: 5, - offset: 10, - }); - - expect(query).to.include("LIMIT 5"); - expect(query).to.include("OFFSET 10"); - }); - }); }); From 565551d174d42b1021c798b2f37b6bb88442efba Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Thu, 26 Feb 2026 17:17:36 +0100 Subject: [PATCH 22/23] query epc variable fix --- packages/plugin-epcis/src/services/epcisQueryService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-epcis/src/services/epcisQueryService.ts b/packages/plugin-epcis/src/services/epcisQueryService.ts index 234493d7..5f2d473c 100644 --- a/packages/plugin-epcis/src/services/epcisQueryService.ts +++ b/packages/plugin-epcis/src/services/epcisQueryService.ts @@ -66,9 +66,9 @@ export class EpcisQueryService { // Default: only search epcList wherePatterns.push(`?event epcis:epcList "${epcValue}" .`); } - } else { - optionalClauses.push("OPTIONAL { ?event epcis:epcList ?epc . }"); } + // Always bind ?epc for GROUP_CONCAT projection + optionalClauses.push("OPTIONAL { ?event epcis:epcList ?epc . }"); // Parent ID filter (AggregationEvent) if (params.parentID) { From dcde78ba547dcb3483b1399260d63a822597c0fc Mon Sep 17 00:00:00 2001 From: Vujkovic Date: Thu, 26 Feb 2026 17:17:51 +0100 Subject: [PATCH 23/23] gitbook docs added --- docs/SUMMARY.md | 2 + .../plugins/README.md | 14 ++ .../plugins/epcis-plugin.md | 155 ++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 docs/build-a-dkg-node-ai-agent/plugins/README.md create mode 100644 docs/build-a-dkg-node-ai-agent/plugins/epcis-plugin.md 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` +