Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/publish-packages.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: "Publish packages"

on:
push:
tags:
- "shared-utils-v*"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read
packages: write

jobs:
publish-shared-utils:
name: "Publish @nhsdigital/nhs-notify-shared-utils"
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: "Set CI/CD variables"
id: variables
run: |
echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT"
echo "pnpm_version=$(grep "^pnpm\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT"

- name: "Node install and setup"
uses: ./.github/actions/node-install
with:
node-version: ${{ steps.variables.outputs.nodejs_version }}
pnpm-version: ${{ steps.variables.outputs.pnpm_version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: "Verify tag version matches package version"
run: |
TAG_VERSION="${GITHUB_REF_NAME#shared-utils-v}"
PKG_VERSION="$(node -p "require('./packages/shared-utils/package.json').version")"
echo "Tag version: ${TAG_VERSION}"
echo "Package version: ${PKG_VERSION}"
if [ "${TAG_VERSION}" != "${PKG_VERSION}" ]; then
echo "::error::Tag version (${TAG_VERSION}) does not match package version (${PKG_VERSION})" >&2
exit 1
fi

- name: "Install dependencies"
run: pnpm install --frozen-lockfile

- name: "Lint"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils run lint

- name: "Typecheck"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils run typecheck

- name: "Unit tests (100% coverage)"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils run test:unit

- name: "Build"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils run build

- name: "Publish to GitHub Packages"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils publish --no-git-checks
3 changes: 2 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default defineConfig([
project: [
'src/lambdas/*/tsconfig.json',
'src/utils/tsconfig.json',
'packages/*/tsconfig.json',
],
}),
],
Expand Down Expand Up @@ -217,7 +218,7 @@ export default defineConfig([
},
},
{
files: ['src/utils/**', '**/jest.config.ts'],
files: ['src/utils/**', '**/jest.config.ts', 'packages/**'],
rules: {
'no-relative-import-paths/no-relative-import-paths': 0,
'import-x/no-relative-packages': 0,
Expand Down
30 changes: 30 additions & 0 deletions packages/shared-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# @nhsdigital/nhs-notify-shared-utils

This package contains **generic** technical helpers (logging, Lambda
helpers, integration test support) for use across bounded contexts.

## Exports

| Subpath | Purpose | Docs |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------ |
| `./logger` | Generic pino-backed `Logger` with redaction support | [logger](src/logger/README.md) |
| `./lambda-utils` | `requireEnv`, `MissingEnvironmentVariableError`, `formatZodIssues`, SQS attribute readers | [lambda-utils](src/lambda-utils/README.md) |
| `./test-support` | Low level Integration test-support utilities - deployment/naming, polling, event factories | [test-support](src/test-support/README.md) |
| `./test-support/{cloudwatch,sqs,s3,dynamodb,eventbridge}` | Per-service AWS client factories and helpers | [test-support](src/test-support/README.md) |
| `./s3-json` | S3 get-JSON-and-validate helper | [s3-json](src/s3-json/README.md) |

## Scripts

```sh
pnpm run build # rm -rf dist && tsc
pnpm run lint # eslint .
pnpm run typecheck # tsc --noEmit
pnpm run test:unit # jest (100% coverage)
pnpm run verify # lint && typecheck && test:unit
```

## Release

Publishing is tag-driven. Pushing a tag of the form `shared-utils-vX.Y.Z`
triggers the publish workflow, which requires an equivalent version bump to
`version` in `package.json`.
22 changes: 22 additions & 0 deletions packages/shared-utils/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Config } from 'jest';
import { baseJestConfig } from '../../jest.config.base';

const sharedUtilsJestConfig: Config = {
...baseJestConfig,

coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},

coveragePathIgnorePatterns: [
...(baseJestConfig.coveragePathIgnorePatterns ?? []),
'index.ts',
],
};

export default sharedUtilsJestConfig;
124 changes: 124 additions & 0 deletions packages/shared-utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
{
"name": "@nhsdigital/nhs-notify-shared-utils",
"version": "0.1.0",
"description": "Generic technical utilities (logging, lambda helpers, integration test support) shared across NHS Notify bounded contexts",
"license": "MIT",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"source": "./src/index.ts",
"default": "./dist/index.js"
},
"./lambda-utils": {
"types": "./dist/lambda-utils/index.d.ts",
"source": "./src/lambda-utils/index.ts",
"default": "./dist/lambda-utils/index.js"
},
"./logger": {
"types": "./dist/logger/index.d.ts",
"source": "./src/logger/index.ts",
"default": "./dist/logger/index.js"
},
"./test-support": {
"types": "./dist/test-support/index.d.ts",
"source": "./src/test-support/index.ts",
"default": "./dist/test-support/index.js"
},
"./test-support/cloudwatch": {
"types": "./dist/test-support/cloudwatch.d.ts",
"source": "./src/test-support/cloudwatch.ts",
"default": "./dist/test-support/cloudwatch.js"
},
"./test-support/dynamodb": {
"types": "./dist/test-support/dynamodb.d.ts",
"source": "./src/test-support/dynamodb.ts",
"default": "./dist/test-support/dynamodb.js"
},
"./test-support/eventbridge": {
"types": "./dist/test-support/eventbridge.d.ts",
"source": "./src/test-support/eventbridge.ts",
"default": "./dist/test-support/eventbridge.js"
},
"./test-support/s3": {
"types": "./dist/test-support/s3.d.ts",
"source": "./src/test-support/s3.ts",
"default": "./dist/test-support/s3.js"
},
"./test-support/sqs": {
"types": "./dist/test-support/sqs.d.ts",
"source": "./src/test-support/sqs.ts",
"default": "./dist/test-support/sqs.js"
},
"./s3-json": {
"types": "./dist/s3-json/index.d.ts",
"source": "./src/s3-json/index.ts",
"default": "./dist/s3-json/index.js"
}
},
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test:unit": "jest",
"typecheck": "tsc --noEmit",
"verify": "pnpm run lint && pnpm run typecheck && pnpm run test:unit"
},
"peerDependencies": {
"@aws-sdk/client-cloudwatch-logs": "catalog:aws",
"@aws-sdk/client-dynamodb": "catalog:aws",
"@aws-sdk/client-eventbridge": "catalog:aws",
"@aws-sdk/client-s3": "catalog:aws",
"@aws-sdk/client-sqs": "catalog:aws",
"@aws-sdk/lib-dynamodb": "catalog:aws",
"pino": "catalog:runtime"
},
"peerDependenciesMeta": {
"@aws-sdk/client-cloudwatch-logs": {
"optional": true
},
"@aws-sdk/client-dynamodb": {
"optional": true
},
"@aws-sdk/client-eventbridge": {
"optional": true
},
"@aws-sdk/client-s3": {
"optional": true
},
"@aws-sdk/client-sqs": {
"optional": true
},
"@aws-sdk/lib-dynamodb": {
"optional": true
}
},
"devDependencies": {
"@aws-sdk/client-cloudwatch-logs": "catalog:aws",
"@aws-sdk/client-dynamodb": "catalog:aws",
"@aws-sdk/client-eventbridge": "catalog:aws",
"@aws-sdk/client-s3": "catalog:aws",
"@aws-sdk/client-sqs": "catalog:aws",
"@aws-sdk/lib-dynamodb": "catalog:aws",
"@tsconfig/node22": "catalog:tools",
"@types/jest": "catalog:test",
"@types/node": "catalog:tools",
"eslint": "catalog:lint",
"jest": "catalog:test",
"pino": "catalog:runtime",
"ts-jest": "catalog:test",
"typescript": "catalog:tools"
},
"engines": {
"node": ">=22.15.1"
},
"private": false
}
2 changes: 2 additions & 0 deletions packages/shared-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './lambda-utils';
export * from './logger';
50 changes: 50 additions & 0 deletions packages/shared-utils/src/lambda-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# `@nhsdigital/nhs-notify-shared-utils/lambda-utils`

Small, dependency-light helpers for Lambda handlers.

## Import

```ts
import {
CORRELATION_ID_ATTRIBUTE,
formatZodIssues,
MissingEnvironmentVariableError,
readSqsStringAttribute,
requireEnv,
} from '@nhsdigital/nhs-notify-shared-utils/lambda-utils';
```

## `requireEnv`

Reads a required environment variable. Throws
`MissingEnvironmentVariableError` when the variable is unset or empty.

```ts
const tableName = requireEnv('TABLE_NAME');
```

## `readSqsStringAttribute`

Reads a `String` message attribute from an SQS record, or `undefined` when the
attribute is absent or not a string. `CORRELATION_ID_ATTRIBUTE` is the shared
`correlationId` attribute name.

```ts
const correlationId = readSqsStringAttribute(record, CORRELATION_ID_ATTRIBUTE);
```

The record only needs a `messageAttributes` map (see `SqsRecordLike`), so the
helper stays decoupled from the `aws-lambda` types.

## `formatZodIssues`

Turns an array of Zod issues into a single readable string. It is pure: it does
not log or throw. Any `ZodError.issues` array is accepted.

```ts
const result = schema.safeParse(input);
if (!result.success) {
throw new Error(`Invalid input — ${formatZodIssues(result.error.issues)}`);
}
// "items.0.id: Required; name: Expected string"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { formatZodIssues } from '../format-zod-issues';

describe('formatZodIssues', () => {
it('returns an empty string for an empty issue list', () => {
expect(formatZodIssues([])).toBe('');
});

it('formats a single issue with a dotted path', () => {
expect(
formatZodIssues([{ path: ['channelId'], message: 'Required' }]),
).toBe('channelId: Required');
});

it('joins a nested path with dots and coerces numeric segments', () => {
expect(
formatZodIssues([
{ path: ['items', 0, 'id'], message: 'Expected string' },
]),
).toBe('items.0.id: Expected string');
});

it('omits the path prefix when the issue path is empty', () => {
expect(formatZodIssues([{ path: [], message: 'Invalid input' }])).toBe(
'Invalid input',
);
});

it('joins multiple issues with a semicolon separator', () => {
expect(
formatZodIssues([
{ path: ['a'], message: 'first' },
{ path: [], message: 'second' },
]),
).toBe('a: first; second');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { MissingEnvironmentVariableError, requireEnv } from '../require-env';

describe('requireEnv', () => {
const ORIGINAL_ENV = { ...process.env };

afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});

it('returns the value when the variable is set', () => {
process.env.TEST_VAR = 'hello';
expect(requireEnv('TEST_VAR')).toBe('hello');
});

it('throws MissingEnvironmentVariableError when the variable is undefined', () => {
delete process.env.MISSING_VAR;
expect(() => requireEnv('MISSING_VAR')).toThrow(
MissingEnvironmentVariableError,
);
expect(() => requireEnv('MISSING_VAR')).toThrow('MISSING_VAR is required');
});

it('throws MissingEnvironmentVariableError when the variable is empty string', () => {
process.env.EMPTY_VAR = '';
expect(() => requireEnv('EMPTY_VAR')).toThrow(
MissingEnvironmentVariableError,
);
});
});
Loading
Loading