Skip to content

Commit 8ed0161

Browse files
committed
Add event schemas for supplier-api domain
1 parent 4bca3e4 commit 8ed0161

35 files changed

+5178
-19
lines changed

.editorconfig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,6 @@ trim_trailing_whitespace = unset
6767
indent_style = unset
6868
indent_size = unset
6969
generated_code = true
70+
71+
[/internal/events/**/*.schema.json]
72+
insert_final_newline = unset

.github/workflows/stage-1-commit.yaml

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,95 @@ jobs:
199199
idp_aws_report_upload_region: "${{ secrets.IDP_AWS_REPORT_UPLOAD_REGION }}"
200200
idp_aws_report_upload_role_name: "${{ secrets.IDP_AWS_REPORT_UPLOAD_ROLE_NAME }}"
201201
idp_aws_report_upload_bucket_endpoint: "${{ secrets.IDP_AWS_REPORT_UPLOAD_BUCKET_ENDPOINT }}"
202+
203+
detect-event-schema-package-changes:
204+
name: "Check for changes to event schema package compared to main branch"
205+
runs-on: ubuntu-latest
206+
permissions:
207+
contents: read
208+
outputs:
209+
changed: ${{ steps.check.outputs.changed }}
210+
main_version: ${{ steps.check.outputs.main_version }}
211+
212+
steps:
213+
- name: "Checkout code"
214+
uses: actions/checkout@v4
215+
with:
216+
fetch-depth: 0
217+
218+
- name: Detect package changes and current version
219+
id: check
220+
run: |
221+
git fetch origin main
222+
223+
if git diff --quiet origin/main...HEAD -- internal/events; then
224+
echo "No changes in event schemas package"
225+
echo "changed=false" >> $GITHUB_OUTPUT
226+
else
227+
echo "Changes detected in event schemas"
228+
echo "changed=true" >> $GITHUB_OUTPUT
229+
fi
230+
231+
if content=$(git show origin/main:internal/events/schemas/package.json 2>/dev/null); then
232+
version=$(jq -r .version <<< $content);
233+
else
234+
version=null;
235+
fi
236+
237+
echo "Detected package version $version in main branch"
238+
echo "main_version=$version" >> $GITHUB_OUTPUT
239+
240+
check-schemas-generated:
241+
name: Check event schemas have been regenerated
242+
needs: detect-event-schema-package-changes
243+
if: needs.detect-event-schema-package-changes.outputs.changed == 'true'
244+
runs-on: ubuntu-latest
245+
permissions:
246+
contents: read
247+
steps:
248+
- name: "Checkout code"
249+
uses: actions/checkout@v4
250+
251+
# Simplified caching - template management has more complex caching of installed modules from another build step
252+
- name: "Cache node_modules"
253+
uses: actions/cache@v4
254+
with:
255+
path: |
256+
**/node_modules
257+
key: ${{ runner.os }}-node-${{ inputs.nodejs_version }}-${{ hashFiles('**/package-lock.json') }}
258+
restore-keys: |
259+
${{ runner.os }}-node-${{ inputs.nodejs_version }}-
260+
261+
- name: "Re-generate schemas"
262+
run: |
263+
npm ci --workspace internal/events
264+
npm --workspace internal/events run gen:jsonschema
265+
266+
- name: Check for schema changes
267+
run: git diff --quiet internal/events/schemas
268+
269+
check-schema-version-change:
270+
name: Check event schema version has been updated
271+
needs: detect-event-schema-package-changes
272+
if: needs.detect-event-schema-package-changes.outputs.changed == 'true'
273+
runs-on: ubuntu-latest
274+
permissions:
275+
contents: read
276+
steps:
277+
- name: Checkout code
278+
uses: actions/checkout@v4
279+
280+
- name: Check schema versions
281+
run: |
282+
source scripts/is_valid_increment.sh
283+
284+
main_version="${{ needs.detect-event-schema-package-changes.outputs.main_version }}"
285+
echo "Main version: ${{ needs.detect-event-schema-package-changes.outputs.main_version }}"
286+
287+
local_version=$(jq -r '.version' internal/events/package.json)
288+
echo "Local version: $local_version"
289+
290+
if ! is_valid_increment "$main_version" "$local_version" ; then
291+
echo "Error: Event Schema package has changed, but new version ($local_version) is not a valid increment from latest version on main branch ($main_version)."
292+
exit 1
293+
fi

internal/events/.spectral.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
extends: ["spectral:oas", "spectral:asyncapi", "spectral:arazzo"]

internal/events/README.md

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# NHS Notify Supplier API Event Schemas
2+
3+
This internal package defines CloudEvents-compatible schemas (with Zod) for the Supplier API domain – currently focusing on Letter Status Change events. It provides:
4+
5+
* A reusable CloudEvents envelope profile (`$EnvelopeProfile`)
6+
* Domain model schemas for letter status transitions (`$LetterStatus`, `$LetterStatusChange`)
7+
* Concrete per-status event schemas with strict `type`, `dataschema` URI and semantic version validation
8+
* Utilities to programmatically access all status change event schemas (`statusChangeEvents`)
9+
10+
> NOTE: This package is private and published only to the internal GitHub Packages registry. Do not reference it externally; consume it within this mono‑repo or via internal pipelines.
11+
12+
---
13+
14+
## Directory Structure
15+
16+
```text
17+
src/
18+
domain/
19+
letter-status-change.ts # Domain model and status enum
20+
events/
21+
envelope-profile.ts # CloudEvents base envelope extensions & constraints
22+
letter-status-change-events.ts # Per status event schema generation
23+
cli/ # CLI scripts for bundling / codegen
24+
index.ts # (re-)exports (not shown above if generated later)
25+
```
26+
27+
---
28+
29+
## Concepts
30+
31+
### 1. Envelope Profile (`$EnvelopeProfile`)
32+
33+
> NB: this will be replaced with a common schema in future published in the nhs-notify-standards repo
34+
35+
Defines the constrained CloudEvents 1.0 envelope used across Notify. It enforces:
36+
37+
* `specversion` fixed to `1.0`
38+
* Reverse‑DNS `type` pattern starting `uk.nhs.notify.` with prohibited ambiguous verbs
39+
* Structured `source` and `subject` path formats (with additional subject shape rules for `/data-plane` sources)
40+
* Trace context (`traceparent`, optional `tracestate`)
41+
* Optional classification / regulation tags
42+
* Consistency rules (e.g. severity text ↔ number mapping)
43+
44+
### 2. Letter Status Domain
45+
46+
`letter-status-change.ts` introduces:
47+
48+
* `$LetterStatus` enumeration covering lifecycle states:
49+
`PENDING | ACCEPTED | REJECTED | PRINTED | ENCLOSED | CANCELLED | DISPATCHED | FAILED | RETURNED | DESTROYED | FORWARDED | DELIVERED`
50+
* `$LetterStatusChange` domain object, extending a `DomainBase('LetterStatusChange')` (see helpers package) with:
51+
* `domainId` (branded identifier)
52+
* `sourceSubject` – original resource subject
53+
* `status` – one of `$LetterStatus`
54+
* Optional `reasonCode`, `reasonText`
55+
56+
### 3. Per‑Status Event Schemas
57+
58+
`letter-status-change-events.ts` programmatically creates a schema per status by extending `$EnvelopeProfile` and replacing `data` with the domain payload. Each schema enforces:
59+
60+
* `type = uk.nhs.notify.supplier-api.letter-status.<STATUS>.v1`
61+
* `dataschema` matches: `https://notify.nhs.uk/events/supplier-api/letter-status/<STATUS>/1.<minor>.<patch>.json`
62+
* `dataschemaversion` uses semantic version with major fixed to `1` (`1.x.y`)
63+
* `data.status` literal‑locked to the matching status
64+
65+
The export `statusChangeEvents` is a dictionary keyed by `letter-status.<STATUS>`.
66+
67+
---
68+
69+
## Installation (Internal)
70+
71+
Inside this mono‑repo other internal packages should depend on it by name:
72+
73+
```jsonc
74+
// package.json
75+
"dependencies": {
76+
"@nhsdigital/nhs-notify-event-schemas-supplier-api": "*"
77+
}
78+
```
79+
80+
External `npm install` instructions are intentionally omitted (private package).
81+
82+
---
83+
84+
## Usage Examples
85+
86+
### Validating a Raw Event
87+
88+
```typescript
89+
import { statusChangeEvents } from '@nhsdigital/nhs-notify-event-schemas-supplier-api';
90+
91+
const schema = statusChangeEvents['letter-status.PRINTED'];
92+
const parsed = schema.safeParse(incomingEventJson);
93+
if (!parsed.success) {
94+
// handle validation failure (log / DLQ)
95+
console.error(parsed.error.format());
96+
} else {
97+
const evt = parsed.data; // strongly typed
98+
console.log(`Letter ${evt.data.domainId} moved to ${evt.data.status}`);
99+
}
100+
```
101+
102+
### Validating a generic letter-status.* event
103+
104+
```typescript
105+
```typescript
106+
import { $LetterStatusChangeEvent } from '@nhsdigital/nhs-notify-event-schemas-supplier-api';
107+
108+
function validateLetterStatusEvent(e: unknown) {
109+
const result = $LetterStatusChangeEvent.safeParse(e);
110+
if (!result.success) {
111+
// handle validation failure (log / DLQ)
112+
return { ok: false as const, error: result.error };
113+
}
114+
// event is strongly typed
115+
return { ok: true as const, event: result.data };
116+
}
117+
```
118+
119+
### Creating a New Letter Status Event Instance
120+
121+
```typescript
122+
import { statusChangeEvents } from '@nhsdigital/nhs-notify-event-schemas-supplier-api';
123+
import { randomUUID } from 'crypto';
124+
125+
const status = 'ACCEPTED' as const;
126+
const schema = statusChangeEvents[`letter-status.${status}`];
127+
128+
const event = {
129+
specversion: '1.0',
130+
id: randomUUID(),
131+
source: '/data-plane/supplier-api',
132+
subject: 'customer/1b20f918-bb05-4c78-a4aa-5f6a3b8e0c91/letter/4a5a9cb5-1440-4a12-bd72-baa7cfecd111',
133+
type: 'uk.nhs.notify.supplier-api.letter-status.ACCEPTED.v1',
134+
time: new Date().toISOString(),
135+
dataschema: 'https://notify.nhs.uk/events/supplier-api/letter-status/ACCEPTED/1.0.0.json',
136+
dataschemaversion: '1.0.0',
137+
data: {
138+
domainId: 'abc123',
139+
sourceSubject: 'customer/.../letter/...',
140+
status: 'ACCEPTED',
141+
},
142+
traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
143+
recordedtime: new Date().toISOString(),
144+
severitytext: 'INFO',
145+
severitynumber: 2,
146+
};
147+
148+
schema.parse(event); // throws if invalid
149+
```
150+
151+
---
152+
153+
## Versioning & Dataschema
154+
155+
* Major version locked at `1` for current lineage.
156+
* Minor + patch increments should reflect additive / backwards compatible changes to the data payload.
157+
* `dataschema` URIs must be updated in lockstep with `dataschemaversion` when publishing new schema variants.
158+
159+
Automated generation tasks (below) assist bundling and JSON Schema emission.
160+
161+
---
162+
163+
## Scripts
164+
165+
| Script | Purpose |
166+
|--------|---------|
167+
| `npm run build` | TypeScript compile to `dist/` |
168+
| `npm test` / `npm run test:unit` | Run Jest unit tests |
169+
| `npm run gen:asyncapi` | Bundle AsyncAPI sources into `dist/asyncapi` |
170+
| `npm run gen:jsonschema` | Emit JSON Schemas derived from Zod definitions |
171+
| `npm run lint` | Lint code + schema (Spectral) |
172+
| `npm run lint:fix` | Auto-fix lint issues |
173+
174+
Execution order helpers:
175+
176+
* `prebuild` ensures a clean `dist` and generates asyncapi bundle
177+
* `prelint:schema` generates JSON prior to Spectral validation
178+
179+
---
180+
181+
## Adding New Event Types (Future)
182+
183+
1. Extend the domain model under `src/domain/`
184+
2. Add a generator similar to `letter-status-change-events.ts`
185+
3. Ensure `type` naming: `uk.nhs.notify.supplier-api.<area>.<action>.v1`
186+
4. Provide deterministic `dataschema` pattern with semantic versioning
187+
5. Export via `src/index.ts`
188+
6. Add unit tests & update documentation
189+
190+
---
191+
192+
## Validation Philosophy
193+
194+
Rules aim for early rejection of:
195+
196+
* Ambiguous or post-hoc semantic verbs ("completed", "updated", etc.) in event `type`
197+
* Poorly structured routing metadata (`source`, `subject`)
198+
* Inconsistent severity pairings
199+
* Non-conformant trace context
200+
201+
This reduces downstream consumer ambiguity and improves observability correlation.
202+
203+
---
204+
205+
## License
206+
207+
MIT (internal usage only)
208+
209+
---
210+
211+
## Support
212+
213+
Raise questions via the repository discussions or internal channel referencing the `events` package.

internal/events/jest.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default {
2+
preset: "ts-jest",
3+
testEnvironment: "node",
4+
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
5+
testPathIgnorePatterns: ["<rootDir>/dist/"],
6+
moduleFileExtensions: ["ts", "js", "json", "node"],
7+
transform: {
8+
"^.+\\.ts$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.jest.json" }],
9+
},
10+
};

internal/events/package.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"dependencies": {
3+
"@asyncapi/bundler": "^0.6.4",
4+
"@internal/helpers": "*",
5+
"zod": "^4.1.11"
6+
},
7+
"description": "Schemas for NHS Notify Supplier API events",
8+
"devDependencies": {
9+
"@stoplight/spectral-cli": "^6.15.0",
10+
"@stylistic/eslint-plugin": "^3.1.0",
11+
"@tsconfig/node22": "^22.0.2",
12+
"@types/jest": "^29.5.14",
13+
"@typescript-eslint/eslint-plugin": "^8.27.0",
14+
"@typescript-eslint/parser": "^8.27.0",
15+
"eslint": "^9.27.0",
16+
"eslint-plugin-jest": "^29.0.1",
17+
"jest": "^29.7.0",
18+
"ts-jest": "^29.4.0",
19+
"ts-node": "^10.9.2",
20+
"typescript": "^5.8.3"
21+
},
22+
"license": "MIT",
23+
"main": "dist/index.js",
24+
"name": "@nhsdigital/nhs-notify-event-schemas-supplier-api",
25+
"private": true,
26+
"publishConfig": {
27+
"registry": "https://npm.pkg.github.com"
28+
},
29+
"repository": "[email protected]:NHSDigital/nhs-notify-supplier-api.git",
30+
"scripts": {
31+
"build": "tsc",
32+
"dev": "ts-node src/index.ts",
33+
"gen:asyncapi": "mkdir -p ./dist/asyncapi && ts-node src/cli/bundle-asyncapi.ts",
34+
"gen:jsonschema": "ts-node src/cli/generate-json.ts",
35+
"lint": "npm run lint:node && npm run lint:schema",
36+
"lint:fix": "eslint . --fix",
37+
"lint:node": "eslint .",
38+
"lint:schema": "spectral lint client-config/client-config.yaml",
39+
"prebuild": "rm -rf dist && npm run gen:asyncapi",
40+
"pregen:asyncapi": "npm run gen:jsonschema",
41+
"pregen:jsonschema": "rm -rf ./client-config/json",
42+
"prelint:schema": "npm run gen:jsonschema",
43+
"start": "node dist/index.js",
44+
"test": "jest",
45+
"test:unit": "jest"
46+
},
47+
"types": "dist/index.d.ts",
48+
"version": "1.0.0"
49+
}

0 commit comments

Comments
 (0)