diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index 97653bcac..e74fe5301 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -64,6 +64,7 @@ No requirements.
| [get\_letter](#module\_get\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
+| [get\_mi](#module\_get\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [get\_status](#module\_get\_status) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-kms.zip | n/a |
| [lambda\_alarms](#module\_lambda\_alarms) | ../../modules/alarms-lambda | n/a |
diff --git a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf
index 8acebc8b8..9691a4dc5 100644
--- a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf
+++ b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf
@@ -52,6 +52,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" {
module.get_letter.function_arn,
module.get_letter_data.function_arn,
module.get_letters.function_arn,
+ module.get_mi.function_arn,
module.patch_letter.function_arn,
module.post_letters.function_arn,
module.get_status.function_arn,
diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf
index 111925095..f4f02ad8f 100644
--- a/infrastructure/terraform/components/api/locals.tf
+++ b/infrastructure/terraform/components/api/locals.tf
@@ -17,6 +17,7 @@ locals {
PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn
POST_LETTERS_LAMBDA_ARN = module.post_letters.function_arn
POST_MI_LAMBDA_ARN = module.post_mi.function_arn
+ GET_MI_LAMBDA_ARN = module.get_mi.function_arn
})
destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs"
diff --git a/infrastructure/terraform/components/api/locals_alarms.tf b/infrastructure/terraform/components/api/locals_alarms.tf
index 873c6b38a..1123d0ff9 100644
--- a/infrastructure/terraform/components/api/locals_alarms.tf
+++ b/infrastructure/terraform/components/api/locals_alarms.tf
@@ -11,6 +11,7 @@ locals {
get_letter = module.get_letter.function_name
get_letters = module.get_letters.function_name
get_letter_data = module.get_letter_data.function_name
+ get_mi = module.get_mi.function_name
get_status = module.get_status.function_name
patch_letter = module.patch_letter.function_name
post_letters = module.post_letters.function_name
diff --git a/infrastructure/terraform/components/api/module_lambda_get_mi.tf b/infrastructure/terraform/components/api/module_lambda_get_mi.tf
new file mode 100644
index 000000000..456bd588f
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_get_mi.tf
@@ -0,0 +1,68 @@
+module "get_mi" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
+
+ function_name = "get_mi"
+ description = "Retrieve management information"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ group = var.group
+
+ log_retention_in_days = var.log_retention_in_days
+ kms_key_arn = module.kms.key_arn
+
+ iam_policy_document = {
+ body = data.aws_iam_policy_document.get_mi_lambda.json
+ }
+
+ function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ function_code_base_path = local.aws_lambda_functions_dir_path
+ function_code_dir = "api-handler/dist"
+ function_include_common = true
+ handler_function_name = "getMI"
+ runtime = "nodejs22.x"
+ memory = 512
+ timeout = 29
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ log_destination_arn = local.destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = merge(local.common_lambda_env_vars, {})
+}
+
+data "aws_iam_policy_document" "get_mi_lambda" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn, ## Requires shared kms module
+ ]
+ }
+
+ statement {
+ sid = "AllowDynamoDBAccess"
+ effect = "Allow"
+
+ actions = [
+ "dynamodb:GetItem",
+ "dynamodb:Query"
+ ]
+
+ resources = [
+ aws_dynamodb_table.mi.arn,
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json
index 5efb7ac0c..8d6430127 100644
--- a/infrastructure/terraform/components/api/resources/spec.tmpl.json
+++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json
@@ -306,6 +306,56 @@
"uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${POST_MI_LAMBDA_ARN}/invocations"
}
}
+ },
+ "/mi/{id}": {
+ "get": {
+ "description": "Returns 200 OK with management information.",
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad request, invalid input data"
+ },
+ "404": {
+ "description": "Resource not found"
+ },
+ "500": {
+ "description": "Server error"
+ }
+ },
+ "security": [
+ {
+ "LambdaAuthorizer": []
+ }
+ ],
+ "summary": "Get MI",
+ "x-amazon-apigateway-integration": {
+ "contentHandling": "CONVERT_TO_TEXT",
+ "credentials": "${APIG_EXECUTION_ROLE_ARN}",
+ "httpMethod": "POST",
+ "passthroughBehavior": "WHEN_NO_TEMPLATES",
+ "responses": {
+ ".*": {
+ "statusCode": "200"
+ }
+ },
+ "timeoutInMillis": 29000,
+ "type": "AWS_PROXY",
+ "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_MI_LAMBDA_ARN}/invocations"
+ }
+ },
+ "parameters": [
+ {
+ "description": "Unique identifier of this resource",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
}
},
"x-amazon-apigateway-endpoint-access-mode": "${ENDPOINT_ACCESS_MODE}",
diff --git a/internal/datastore/src/__test__/mi-repository.test.ts b/internal/datastore/src/__test__/mi-repository.test.ts
index dd34e5d15..e44bee98a 100644
--- a/internal/datastore/src/__test__/mi-repository.test.ts
+++ b/internal/datastore/src/__test__/mi-repository.test.ts
@@ -64,4 +64,54 @@ describe("MiRepository", () => {
);
});
});
+
+ describe("getMi", () => {
+ it("throws an error when fetching MI information that does not exist", async () => {
+ await expect(miRepository.getMI("XXX", "supplier1")).rejects.toThrow(
+ "Management information not found: supplierId=supplier1, miId=XXX",
+ );
+ });
+
+ it("creates MI with id and timestamps", async () => {
+ jest.useFakeTimers();
+ // Month is zero-indexed in JS Date
+ jest.setSystemTime(new Date(2020, 1, 1));
+ const mi = {
+ specificationId: "spec1",
+ supplierId: "supplier1",
+ groupId: "group1",
+ lineItem: "item1",
+ quantity: 12,
+ timestamp: new Date().toISOString(),
+ stockRemaining: 0,
+ };
+
+ const persistedMi = await miRepository.putMI(mi);
+
+ expect(persistedMi).toEqual(
+ expect.objectContaining({
+ id: expect.any(String),
+ createdAt: "2020-02-01T00:00:00.000Z",
+ updatedAt: "2020-02-01T00:00:00.000Z",
+ ttl: 1_580_518_800, // 2020-02-01T00:01:00.000Z, seconds since epoch
+ ...mi,
+ }),
+ );
+
+ const fetchedMi = await miRepository.getMI(
+ persistedMi.id,
+ persistedMi.supplierId,
+ );
+
+ expect(fetchedMi).toEqual(
+ expect.objectContaining({
+ id: expect.any(String),
+ createdAt: "2020-02-01T00:00:00.000Z",
+ updatedAt: "2020-02-01T00:00:00.000Z",
+ ttl: 1_580_518_800, // 2020-02-01T00:01:00.000Z, seconds since epoch
+ ...mi,
+ }),
+ );
+ });
+ });
});
diff --git a/internal/datastore/src/errors/mi-not-found-error.ts b/internal/datastore/src/errors/mi-not-found-error.ts
new file mode 100644
index 000000000..f41386267
--- /dev/null
+++ b/internal/datastore/src/errors/mi-not-found-error.ts
@@ -0,0 +1,14 @@
+/**
+ * Error thrown when management information is not found in the database.
+ */
+export default class MiNotFoundError extends Error {
+ constructor(
+ public readonly supplierId: string,
+ public readonly miId: string,
+ ) {
+ super(
+ `Management information not found: supplierId=${supplierId}, miId=${miId}`,
+ );
+ this.name = "MiNotFoundError";
+ }
+}
diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts
index 9b656d9ee..4368823cc 100644
--- a/internal/datastore/src/index.ts
+++ b/internal/datastore/src/index.ts
@@ -7,3 +7,4 @@ export { default as LetterQueueRepository } from "./letter-queue-repository";
export { default as DBHealthcheck } from "./healthcheck";
export { default as LetterAlreadyExistsError } from "./errors/letter-already-exists-error";
export { default as LetterNotFoundError } from "./errors/letter-not-found-error";
+export { default as MiNotFoundError } from "./errors/mi-not-found-error";
diff --git a/internal/datastore/src/mi-repository.ts b/internal/datastore/src/mi-repository.ts
index 1a92fe715..5eec79f51 100644
--- a/internal/datastore/src/mi-repository.ts
+++ b/internal/datastore/src/mi-repository.ts
@@ -1,7 +1,12 @@
-import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
+import {
+ DynamoDBDocumentClient,
+ GetCommand,
+ PutCommand,
+} from "@aws-sdk/lib-dynamodb";
import { Logger } from "pino";
import { randomUUID } from "node:crypto";
import { MI, MISchema } from "./types";
+import MiNotFoundError from "./errors/mi-not-found-error";
export type MIRepositoryConfig = {
miTableName: string;
@@ -36,4 +41,22 @@ export class MIRepository {
return MISchema.parse(miDb);
}
+
+ async getMI(miId: string, supplierId: string): Promise {
+ const result = await this.ddbClient.send(
+ new GetCommand({
+ TableName: this.config.miTableName,
+ Key: {
+ id: miId,
+ supplierId,
+ },
+ }),
+ );
+
+ if (!result.Item) {
+ throw new MiNotFoundError(supplierId, miId);
+ }
+
+ return MISchema.parse(result.Item);
+ }
}
diff --git a/lambdas/api-handler/src/contracts/errors.ts b/lambdas/api-handler/src/contracts/errors.ts
index ceb9ce51f..ceca38814 100644
--- a/lambdas/api-handler/src/contracts/errors.ts
+++ b/lambdas/api-handler/src/contracts/errors.ts
@@ -12,7 +12,7 @@ export interface ApiError {
export enum ApiErrorCode {
InternalServerError = "NOTIFY_INTERNAL_SERVER_ERROR",
InvalidRequest = "NOTIFY_INVALID_REQUEST",
- NotFound = "NOTIFY_LETTER_NOT_FOUND",
+ NotFound = "NOTIFY_NOT_FOUND",
}
export enum ApiErrorTitle {
@@ -28,10 +28,11 @@ export enum ApiErrorStatus {
}
export enum ApiErrorDetail {
- NotFoundLetterId = "No resource found with that ID",
+ NotFoundId = "No resource found with that ID",
InvalidRequestMissingBody = "The request is missing the body",
InvalidRequestMissingLetterIdPathParameter = "The request is missing the letter id path parameter",
InvalidRequestLetterIdsMismatch = "The letter ID in the request body does not match the letter ID path parameter",
+ InvalidRequestMissingMiIdPathParameter = "The request is missing the mi id path parameter",
InvalidRequestBody = "The request body is invalid",
InvalidRequestLimitNotANumber = "The limit parameter is not a number",
InvalidRequestLimitNotInRange = "The limit parameter must be a positive number not greater than %s",
diff --git a/lambdas/api-handler/src/contracts/mi.ts b/lambdas/api-handler/src/contracts/mi.ts
index 81a5d166e..3dce6003f 100644
--- a/lambdas/api-handler/src/contracts/mi.ts
+++ b/lambdas/api-handler/src/contracts/mi.ts
@@ -37,3 +37,16 @@ export type PostMIResponse = z.infer;
export type IncomingMI = PostMIRequest["data"]["attributes"] & {
supplierId: string;
};
+
+export const GetMIResponseResourceSchema = z
+ .object({
+ id: z.string(),
+ ...PostMIRequestResourceSchema.shape,
+ })
+ .strict();
+
+export const GetMIResponseSchema = makeDocumentSchema(
+ GetMIResponseResourceSchema,
+);
+
+export type GetMIResponse = z.infer;
diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts
index 1d85a2192..2449edc4e 100644
--- a/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts
+++ b/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts
@@ -120,7 +120,7 @@ describe("API Lambda handler", () => {
it("returns 404 Not Found when letter matching id is not found", async () => {
const mockedGetLetterById = letterService.getLetterById as jest.Mock;
mockedGetLetterById.mockImplementation(() => {
- throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
+ throw new NotFoundError(ApiErrorDetail.NotFoundId);
});
const event = makeApiGwEvent({
diff --git a/lambdas/api-handler/src/handlers/__tests__/get-mi.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-mi.test.ts
new file mode 100644
index 000000000..8f4455578
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/__tests__/get-mi.test.ts
@@ -0,0 +1,165 @@
+import { Context } from "aws-lambda";
+import { mockDeep } from "jest-mock-extended";
+import pino from "pino";
+import { MIRepository } from "@internal/datastore/src";
+import { getMI as getMiOperation } from "../../services/mi-operations";
+import { makeApiGwEvent } from "./utils/test-utils";
+import { ApiErrorDetail } from "../../contracts/errors";
+import NotFoundError from "../../errors/not-found-error";
+import { Deps } from "../../config/deps";
+import { EnvVars } from "../../config/env";
+import createGetMIHandler from "../get-mi";
+
+jest.mock("../../services/mi-operations");
+
+describe("API Lambda handler", () => {
+ const mockedDeps: jest.Mocked = {
+ miRepo: {} as unknown as MIRepository,
+ logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
+ env: {
+ SUPPLIER_ID_HEADER: "nhsd-supplier-id",
+ APIM_CORRELATION_HEADER: "nhsd-correlation-id",
+ DOWNLOAD_URL_TTL_SECONDS: 1,
+ } as unknown as EnvVars,
+ } as Deps;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.resetModules();
+ });
+
+ it("returns 200 OK and the MI information", async () => {
+ const mockedGetMiById = getMiOperation as jest.Mock;
+ mockedGetMiById.mockResolvedValue({
+ data: {
+ id: "id1",
+ type: "ManagementInformation",
+ attributes: {
+ lineItem: "envelope-business-standard",
+ timestamp: "2023-11-17T14:27:51.413Z",
+ quantity: 22,
+ specificationId: "spec1",
+ groupId: "group1",
+ stockRemaining: 20_000,
+ },
+ },
+ });
+
+ const event = makeApiGwEvent({
+ path: "/mi/id1",
+ headers: {
+ "nhsd-supplier-id": "supplier1",
+ "nhsd-correlation-id": "correlationId",
+ "x-request-id": "requestId",
+ },
+ pathParameters: { id: "id1" },
+ });
+
+ const getMi = createGetMIHandler(mockedDeps);
+ const result = await getMi(event, mockDeep(), jest.fn());
+
+ const expected = {
+ data: {
+ id: "id1",
+ type: "ManagementInformation",
+ attributes: {
+ lineItem: "envelope-business-standard",
+ timestamp: "2023-11-17T14:27:51.413Z",
+ quantity: 22,
+ specificationId: "spec1",
+ groupId: "group1",
+ stockRemaining: 20_000,
+ },
+ },
+ };
+
+ expect(result).toEqual({
+ statusCode: 200,
+ body: JSON.stringify(expected, null, 2),
+ });
+ });
+
+ it("returns 404 Not Found when MI matching id is not found", async () => {
+ const mockedGetMiById = getMiOperation as jest.Mock;
+
+ mockedGetMiById.mockImplementation(() => {
+ throw new NotFoundError(ApiErrorDetail.NotFoundId);
+ });
+
+ const event = makeApiGwEvent({
+ path: "/mi/id1",
+ headers: {
+ "nhsd-supplier-id": "supplier1",
+ "nhsd-correlation-id": "correlationId",
+ "x-request-id": "requestId",
+ },
+ pathParameters: { id: "id1" },
+ });
+
+ const getMi = createGetMIHandler(mockedDeps);
+ const result = await getMi(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ statusCode: 404,
+ }),
+ );
+ });
+
+ it("returns 500 when correlation id is missing from header", async () => {
+ const event = makeApiGwEvent({
+ path: "/mi/id1",
+ headers: { "nhsd-supplier-id": "supplier1", "x-request-id": "requestId" },
+ pathParameters: { id: "id1" },
+ });
+
+ const getMi = createGetMIHandler(mockedDeps);
+ const result = await getMi(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ statusCode: 500,
+ }),
+ );
+ });
+
+ it("returns 500 when supplier id is missing from header", async () => {
+ const event = makeApiGwEvent({
+ path: "/mi/id1",
+ headers: {
+ "nhsd-correlation-id": "correlationId",
+ "x-request-id": "requestId",
+ },
+ pathParameters: { id: "id1" },
+ });
+
+ const getMi = createGetMIHandler(mockedDeps);
+ const result = await getMi(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ statusCode: 500,
+ }),
+ );
+ });
+
+ it("returns 400 when letter id is missing from path", async () => {
+ const event = makeApiGwEvent({
+ path: "/mi/id1",
+ headers: {
+ "nhsd-supplier-id": "supplier1",
+ "nhsd-correlation-id": "correlationId",
+ "x-request-id": "requestId",
+ },
+ });
+
+ const getMi = createGetMIHandler(mockedDeps);
+ const result = await getMi(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ statusCode: 400,
+ }),
+ );
+ });
+});
diff --git a/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts b/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts
index c10908799..a5eee1812 100644
--- a/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts
+++ b/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts
@@ -4,7 +4,7 @@ import pino from "pino";
import { MIRepository } from "@internal/datastore/src";
import { makeApiGwEvent } from "./utils/test-utils";
import { PostMIRequest, PostMIResponse } from "../../contracts/mi";
-import postMiOperation from "../../services/mi-operations";
+import { postMI as postMiOperation } from "../../services/mi-operations";
import { Deps } from "../../config/deps";
import { EnvVars } from "../../config/env";
import createPostMIHandler from "../post-mi";
diff --git a/lambdas/api-handler/src/handlers/get-mi.ts b/lambdas/api-handler/src/handlers/get-mi.ts
new file mode 100644
index 000000000..5b0a17cc5
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/get-mi.ts
@@ -0,0 +1,85 @@
+import { APIGatewayProxyHandler } from "aws-lambda";
+import { Unit } from "aws-embedded-metrics";
+import pino from "pino";
+import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers";
+import { getMI as getMIOperation } from "../services/mi-operations";
+import { ApiErrorDetail } from "../contracts/errors";
+import ValidationError from "../errors/validation-error";
+import { processError } from "../mappers/error-mapper";
+import { assertNotEmpty } from "../utils/validation";
+import { extractCommonIds } from "../utils/common-ids";
+import { Deps } from "../config/deps";
+
+export default function createGetMIHandler(deps: Deps): APIGatewayProxyHandler {
+ return async (event) => {
+ const commonIds = extractCommonIds(
+ event.headers,
+ event.requestContext,
+ deps,
+ );
+
+ if (!commonIds.ok) {
+ return processError(
+ commonIds.error,
+ commonIds.correlationId,
+ deps.logger,
+ );
+ }
+
+ const { supplierId } = commonIds.value;
+ try {
+ const miId = assertNotEmpty(
+ event.pathParameters?.id,
+ new ValidationError(
+ ApiErrorDetail.InvalidRequestMissingMiIdPathParameter,
+ ),
+ );
+
+ const result = await getMIOperation(miId, supplierId, deps.miRepo);
+
+ deps.logger.info({
+ description: "Retrieved management information",
+ supplierId: commonIds.value.supplierId,
+ correlationId: commonIds.value.correlationId,
+ });
+
+ // metric with count 1 specifying the supplier
+ const dimensions: Record = { supplierId };
+ emitMetric("getMi", dimensions, deps.logger, MetricStatus.Success, 1);
+
+ // metric displaying the type/number of lineItems posted per supplier
+ dimensions.lineItem = result.data.attributes.lineItem;
+ emitMetric(
+ "getMi",
+ dimensions,
+ deps.logger,
+ "LineItem per supplier",
+ result.data.attributes.quantity,
+ );
+
+ return {
+ statusCode: 200,
+ body: JSON.stringify(result, null, 2),
+ };
+ } catch (error) {
+ emitMetric("getMi", { supplierId }, deps.logger, MetricStatus.Failure, 1);
+ return processError(error, commonIds.value.correlationId, deps.logger);
+ }
+ };
+}
+
+function emitMetric(
+ source: string,
+ dimensions: Record,
+ logger: pino.Logger,
+ key: string,
+ value: number,
+) {
+ const metric: MetricEntry = {
+ key,
+ value,
+ unit: Unit.Count,
+ };
+ const emf = buildEMFObject(source, dimensions, metric);
+ logger.info(emf);
+}
diff --git a/lambdas/api-handler/src/handlers/post-mi.ts b/lambdas/api-handler/src/handlers/post-mi.ts
index 74409cdc7..f39759183 100644
--- a/lambdas/api-handler/src/handlers/post-mi.ts
+++ b/lambdas/api-handler/src/handlers/post-mi.ts
@@ -2,7 +2,7 @@ import { APIGatewayProxyHandler } from "aws-lambda";
import { Unit } from "aws-embedded-metrics";
import pino from "pino";
import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers";
-import postMIOperation from "../services/mi-operations";
+import { postMI as postMIOperation } from "../services/mi-operations";
import { ApiErrorDetail } from "../contracts/errors";
import ValidationError from "../errors/validation-error";
import { processError } from "../mappers/error-mapper";
diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts
index 573be050d..72fcc7812 100644
--- a/lambdas/api-handler/src/index.ts
+++ b/lambdas/api-handler/src/index.ts
@@ -6,6 +6,7 @@ import createPatchLetterHandler from "./handlers/patch-letter";
import createPostLettersHandler from "./handlers/post-letters";
import createTransformAmendmentEventHandler from "./handlers/amendment-event-transformer";
import createPostMIHandler from "./handlers/post-mi";
+import createGetMIHandler from "./handlers/get-mi";
import createGetStatusHandler from "./handlers/get-status";
const container = createDependenciesContainer();
@@ -19,4 +20,5 @@ export const transformAmendmentEvent =
export const postLetters = createPostLettersHandler(container);
export const postMI = createPostMIHandler(container);
+export const getMI = createGetMIHandler(container);
export const getStatus = createGetStatusHandler(container);
diff --git a/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts
index 3a41f7d7a..862a9566d 100644
--- a/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts
+++ b/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts
@@ -35,7 +35,7 @@ describe("processError", () => {
});
it("should map NotFoundError to NotFound response", () => {
- const err = new NotFoundError(ApiErrorDetail.NotFoundLetterId);
+ const err = new NotFoundError(ApiErrorDetail.NotFoundId);
const res = processError(err, undefined, {
info: jest.fn(),
@@ -46,7 +46,7 @@ describe("processError", () => {
expect(JSON.parse(res.body)).toEqual({
errors: [
{
- code: "NOTIFY_LETTER_NOT_FOUND",
+ code: "NOTIFY_NOT_FOUND",
detail: "No resource found with that ID",
id: expect.any(String),
links: {
diff --git a/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts
index 10791ba59..2ac728e38 100644
--- a/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts
+++ b/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts
@@ -1,6 +1,6 @@
import { MIBase } from "@internal/datastore/src";
import { IncomingMI, PostMIRequest } from "../../contracts/mi";
-import { mapToMI, mapToPostMIResponse } from "../mi-mapper";
+import { mapToGetMIResponse, mapToMI, mapToPostMIResponse } from "../mi-mapper";
describe("mi-mapper", () => {
it("maps a PostMIRequest to an IncomingMI object", async () => {
@@ -59,4 +59,33 @@ describe("mi-mapper", () => {
},
});
});
+
+ it("maps an internal MIBase object to a GetMIResponse", async () => {
+ const mi: MIBase = {
+ id: "id1",
+ lineItem: "envelope-business-standard",
+ timestamp: "2023-11-17T14:27:51.413Z",
+ quantity: 22,
+ specificationId: "spec1",
+ groupId: "group1",
+ stockRemaining: 20_000,
+ };
+
+ const result = mapToGetMIResponse(mi);
+
+ expect(result).toEqual({
+ data: {
+ id: "id1",
+ type: "ManagementInformation",
+ attributes: {
+ lineItem: "envelope-business-standard",
+ timestamp: "2023-11-17T14:27:51.413Z",
+ quantity: 22,
+ specificationId: "spec1",
+ groupId: "group1",
+ stockRemaining: 20_000,
+ },
+ },
+ });
+ });
});
diff --git a/lambdas/api-handler/src/mappers/mi-mapper.ts b/lambdas/api-handler/src/mappers/mi-mapper.ts
index d20d19b69..88400062d 100644
--- a/lambdas/api-handler/src/mappers/mi-mapper.ts
+++ b/lambdas/api-handler/src/mappers/mi-mapper.ts
@@ -1,5 +1,7 @@
import { MIBase } from "@internal/datastore/src";
import {
+ GetMIResponse,
+ GetMIResponseSchema,
IncomingMI,
PostMIRequest,
PostMIResponse,
@@ -32,3 +34,20 @@ export function mapToPostMIResponse(mi: MIBase): PostMIResponse {
},
});
}
+
+export function mapToGetMIResponse(mi: MIBase): GetMIResponse {
+ return GetMIResponseSchema.parse({
+ data: {
+ id: mi.id,
+ type: "ManagementInformation",
+ attributes: {
+ lineItem: mi.lineItem,
+ timestamp: mi.timestamp,
+ quantity: mi.quantity,
+ specificationId: mi.specificationId,
+ groupId: mi.groupId,
+ stockRemaining: mi.stockRemaining,
+ },
+ },
+ });
+}
diff --git a/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts b/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts
index f831c4061..20ef858e6 100644
--- a/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts
+++ b/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts
@@ -1,5 +1,6 @@
+import MiNotFoundError from "@internal/datastore/src/errors/mi-not-found-error";
import { IncomingMI } from "../../contracts/mi";
-import postMI from "../mi-operations";
+import { getMI, postMI } from "../mi-operations";
describe("postMI function", () => {
const incomingMi: IncomingMI = {
@@ -37,3 +38,61 @@ describe("postMI function", () => {
});
});
});
+
+describe("getMI function", () => {
+ const incomingMi: IncomingMI = {
+ lineItem: "envelope-business-standard",
+ timestamp: "2023-11-17T14:27:51.413Z",
+ quantity: 22,
+ specificationId: "spec1",
+ groupId: "group1",
+ stockRemaining: 20_000,
+ supplierId: "supplier1",
+ };
+ it("retrieves the MI from the repository", async () => {
+ const persistedMi = { id: "id1", ...incomingMi };
+
+ const mockRepo = {
+ getMI: jest.fn().mockResolvedValue(persistedMi),
+ };
+
+ const result = await getMI("id1", "supplier1", mockRepo as any);
+
+ expect(result).toEqual({
+ data: {
+ id: "id1",
+ type: "ManagementInformation",
+ attributes: {
+ lineItem: "envelope-business-standard",
+ timestamp: "2023-11-17T14:27:51.413Z",
+ quantity: 22,
+ specificationId: "spec1",
+ groupId: "group1",
+ stockRemaining: 20_000,
+ },
+ },
+ });
+ });
+
+ it("should throw notFoundError when letter does not exist", async () => {
+ const mockRepo = {
+ getMI: jest
+ .fn()
+ .mockRejectedValue(new MiNotFoundError("supplier1", "miId1")),
+ };
+
+ await expect(getMI("miId1", "supplier1", mockRepo as any)).rejects.toThrow(
+ "No resource found with that ID",
+ );
+ });
+
+ it("should throw unexpected error", async () => {
+ const mockRepo = {
+ getMI: jest.fn().mockRejectedValue(new Error("unexpected error")),
+ };
+
+ await expect(getMI("miId1", "supplier1", mockRepo as any)).rejects.toThrow(
+ "unexpected error",
+ );
+ });
+});
diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts
index b96b3f8db..7ed0c4058 100644
--- a/lambdas/api-handler/src/services/letter-operations.ts
+++ b/lambdas/api-handler/src/services/letter-operations.ts
@@ -74,7 +74,7 @@ export const getLetterById = async (
letter = await letterRepo.getLetterById(supplierId, letterId);
} catch (error) {
if (error instanceof LetterNotFoundError) {
- throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
+ throw new NotFoundError(ApiErrorDetail.NotFoundId);
}
throw error;
}
@@ -98,7 +98,7 @@ export const getLetterDataUrl = async (
);
} catch (error) {
if (error instanceof LetterNotFoundError) {
- throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
+ throw new NotFoundError(ApiErrorDetail.NotFoundId);
}
throw error;
}
diff --git a/lambdas/api-handler/src/services/mi-operations.ts b/lambdas/api-handler/src/services/mi-operations.ts
index a6b791e44..6f3e53816 100644
--- a/lambdas/api-handler/src/services/mi-operations.ts
+++ b/lambdas/api-handler/src/services/mi-operations.ts
@@ -1,12 +1,31 @@
+import MiNotFoundError from "@internal/datastore/src/errors/mi-not-found-error";
import { MIRepository } from "@internal/datastore/src/mi-repository";
-import { IncomingMI, PostMIResponse } from "../contracts/mi";
-import { mapToPostMIResponse } from "../mappers/mi-mapper";
+import { GetMIResponse, IncomingMI, PostMIResponse } from "../contracts/mi";
+import { mapToGetMIResponse, mapToPostMIResponse } from "../mappers/mi-mapper";
+import { ApiErrorDetail } from "../contracts/errors";
+import NotFoundError from "../errors/not-found-error";
-const postMI = async (
+export const postMI = async (
incomingMi: IncomingMI,
miRepo: MIRepository,
): Promise => {
return mapToPostMIResponse(await miRepo.putMI(incomingMi));
};
-export default postMI;
+export const getMI = async (
+ miId: string,
+ supplierId: string,
+ miRepo: MIRepository,
+): Promise => {
+ let mi;
+
+ try {
+ mi = await miRepo.getMI(miId, supplierId);
+ } catch (error) {
+ if (error instanceof MiNotFoundError) {
+ throw new NotFoundError(ApiErrorDetail.NotFoundId);
+ }
+ throw error;
+ }
+ return mapToGetMIResponse(mi);
+};
diff --git a/postman/NHSNotifySupplierAPI.sandbox.postman_collection.json b/postman/NHSNotifySupplierAPI.sandbox.postman_collection.json
index a73203c45..b4cf5d77c 100644
--- a/postman/NHSNotifySupplierAPI.sandbox.postman_collection.json
+++ b/postman/NHSNotifySupplierAPI.sandbox.postman_collection.json
@@ -2044,6 +2044,157 @@
}
},
"response": []
+ },
+ {
+ "item": [
+ {
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code is 200\", () => {",
+ " pm.expect(pm.response.code).to.equal(200);",
+ "});",
+ "",
+ "const jsonData = pm.response.json();",
+ "pm.test(\"Response Body contains data field and status\", () => {",
+ " pm.expect(jsonData).to.have.property(\"data\");",
+ " pm.expect(jsonData.data.attributes).to.have.property(\"id\");",
+ "});"
+ ],
+ "packages": {},
+ "requests": {},
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "name": "200 Get Management Information",
+ "request": {
+ "auth": {
+ "type": "noauth"
+ },
+ "description": "Retrieve managaement information.",
+ "header": [
+ {
+ "disabled": true,
+ "key": "Accept",
+ "type": "default",
+ "value": "application/vnd.api+json"
+ },
+ {
+ "disabled": false,
+ "key": "Content-Type",
+ "type": "default",
+ "value": "application/vnd.api+json"
+ },
+ {
+ "disabled": false,
+ "key": "X-Correlation-Id",
+ "type": "default",
+ "value": "{{correlation_id}}"
+ },
+ {
+ "disabled": false,
+ "key": "X-Request-ID",
+ "type": "default",
+ "value": "{{x_request_id}}"
+ }
+ ],
+ "method": "GET",
+ "url": {
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "mi",
+ ":id"
+ ],
+ "protocol": "",
+ "query": [],
+ "raw": "{{baseUrl}}/mi/:id",
+ "variable": [
+ {
+ "description": "(Required) Unique identifier of this resource",
+ "key": "id",
+ "value": "2AL5eYSWGzCHlGmzNxuqVusPxDg"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code is 404\", () => {",
+ " pm.expect(pm.response.code).to.equal(404);",
+ "});",
+ "",
+ "const jsonData = pm.response.json();",
+ "pm.test(\"Response Body contains errors with correct code\", () => {",
+ " pm.expect(jsonData).to.have.property(\"errors\");",
+ " pm.expect(jsonData.errors[0].code).to.equal(\"NOTIFY_RESOURCE_NOT_FOUND\");",
+ "});"
+ ],
+ "packages": {},
+ "requests": {},
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "name": "404 Not Found",
+ "request": {
+ "auth": {
+ "type": "noauth"
+ },
+ "description": "Retrieve management information.",
+ "header": [
+ {
+ "disabled": false,
+ "key": "Content-Type",
+ "type": "default",
+ "value": "application/vnd.api+json"
+ },
+ {
+ "disabled": false,
+ "key": "X-Correlation-Id",
+ "type": "default",
+ "value": "{{correlation_id}}"
+ },
+ {
+ "disabled": false,
+ "key": "X-Request-ID",
+ "type": "default",
+ "value": "{{x_request_id}}"
+ }
+ ],
+ "method": "GET",
+ "url": {
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "mi",
+ ":id"
+ ],
+ "protocol": "",
+ "query": [],
+ "raw": "{{baseUrl}}/mi/:id",
+ "variable": [
+ {
+ "description": "(Required) Unique identifier of this resource",
+ "key": "id",
+ "value": "24L5eYSWGzCHlGmzNxuqVusP"
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "name": "Get Management Information"
}
],
"name": "MI"
diff --git a/sandbox/api/openapi.yaml b/sandbox/api/openapi.yaml
index 0f80ee80b..67d963a45 100644
--- a/sandbox/api/openapi.yaml
+++ b/sandbox/api/openapi.yaml
@@ -1455,7 +1455,7 @@ components:
enum:
- NOTIFY_INTERNAL_SERVER_ERROR
- NOTIFY_INVALID_REQUEST
- - NOTIFY_LETTER_NOT_FOUND
+ - NOTIFY_NOT_FOUND
type: string
links:
$ref: "#/components/schemas/listLetters_400_response_errors_inner_links"
diff --git a/sandbox/data/examples/getMI/responses/getMI_2WL5eYSWGzCHlGmzNxuqVusPxDg.json b/sandbox/data/examples/getMI/responses/getMI_2WL5eYSWGzCHlGmzNxuqVusPxDg.json
new file mode 100644
index 000000000..87ba61bfc
--- /dev/null
+++ b/sandbox/data/examples/getMI/responses/getMI_2WL5eYSWGzCHlGmzNxuqVusPxDg.json
@@ -0,0 +1,14 @@
+{
+ "data": {
+ "attributes": {
+ "groupId": "abc123",
+ "lineItem": "envelope-business-standard",
+ "quantity": 22,
+ "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg",
+ "stockRemaining": 2000,
+ "timestamp": "2023-11-17T14:27:51.413Z"
+ },
+ "id": "2WL5eYSWGzCHlGmzNxuqVusPxDg",
+ "type": "ManagementInformation"
+ }
+}
diff --git a/specification/api/components/documentation/getMI.md b/specification/api/components/documentation/getMI.md
new file mode 100644
index 000000000..386c850c4
--- /dev/null
+++ b/specification/api/components/documentation/getMI.md
@@ -0,0 +1,17 @@
+# Overview
+
+Use this endpoint to retrieve management or operational metrics relating to letter processing and print fulfilment.
+
+When you submit a get management information request, the endpoint will respond with a 200 (Success) response code along with the created data including a unique id for the record or an unsuccessful (4xx/5xx) response.
+
+Rate limiting applies. On excess requests, you may receive **429 Too Many Requests** (example error code(s): `NOTIFY_QUOTA`). Back off and retry later.
+
+## Sandbox test scenarios
+
+You can test the following scenarios in our sandbox environment.
+
+|Scenario|Request|Response|
+|--------|-------|--------|
+|Success|Request for successful MI record retrieval|200 (Success) with the retrieved management information in the response|
+|Invalid Request|Invalid Request for MI record retrieval|400 (Bad Request) with the error details in the body|
+|Unknown specification|Request for MI record retrieval for unknown spec|404 (Not Found) with the error details in the body|
diff --git a/specification/api/components/endpoints/getMI.yml b/specification/api/components/endpoints/getMI.yml
new file mode 100644
index 000000000..8cc5c0097
--- /dev/null
+++ b/specification/api/components/endpoints/getMI.yml
@@ -0,0 +1,17 @@
+summary: Fetch an existing MI record
+description:
+ $ref: '../documentation/getMI.md'
+operationId: getMI
+tags:
+ - mi
+responses:
+ "200":
+ $ref: "../responses/getMI200.yml"
+ "404":
+ $ref: "../responses/errors/resourceNotFound.yml"
+ "429":
+ $ref: "../responses/errors/tooManyRequests.yml"
+ "500":
+ $ref: "../responses/errors/serverError.yml"
+ "502":
+ $ref: "../responses/errors/badGateway.yml"
diff --git a/specification/api/components/responses/getMI200.yml b/specification/api/components/responses/getMI200.yml
new file mode 100644
index 000000000..d8ef96a68
--- /dev/null
+++ b/specification/api/components/responses/getMI200.yml
@@ -0,0 +1,13 @@
+description: "Management information returned successfully"
+headers:
+ X-Request-ID:
+ $ref: "../responseHeaders/xRequestId.yml"
+ X-Correlation-ID:
+ $ref: "../responseHeaders/xCorrelationId.yml"
+content:
+ application/vnd.api+json:
+ schema:
+ $ref: "../schemas/miResponse.yml"
+ examples:
+ get-mi-response:
+ $ref: "../examples/getMI/responses/getMI_2WL5eYSWGzCHlGmzNxuqVusPxDg.json"
diff --git a/specification/api/components/schemas/errorItem.yml b/specification/api/components/schemas/errorItem.yml
index 05b2ae4d4..1a1e9251e 100644
--- a/specification/api/components/schemas/errorItem.yml
+++ b/specification/api/components/schemas/errorItem.yml
@@ -7,7 +7,7 @@ properties:
enum:
- NOTIFY_INTERNAL_SERVER_ERROR
- NOTIFY_INVALID_REQUEST
- - NOTIFY_LETTER_NOT_FOUND
+ - NOTIFY_NOT_FOUND
links:
type: object
properties:
diff --git a/specification/api/notify-supplier-phase1.yml b/specification/api/notify-supplier-phase1.yml
index 2e26e8b76..f5fdc8704 100644
--- a/specification/api/notify-supplier-phase1.yml
+++ b/specification/api/notify-supplier-phase1.yml
@@ -43,6 +43,14 @@ paths:
- $ref: 'components/parameters/correlationId.yml'
post:
$ref: 'components/endpoints/createMI.yml'
+ '/mi/{id}':
+ parameters:
+ - $ref: 'components/parameters/authorization/authorization.yml'
+ - $ref: 'components/parameters/requestId.yml'
+ - $ref: 'components/parameters/correlationId.yml'
+ - $ref: 'components/parameters/resourceId.yml'
+ get:
+ $ref: 'components/endpoints/getMI.yml'
/_status:
get:
description: Returns 200 OK if the service is up.
diff --git a/tests/helpers/common-types.ts b/tests/helpers/common-types.ts
index c730159f6..2b3f23a8d 100644
--- a/tests/helpers/common-types.ts
+++ b/tests/helpers/common-types.ts
@@ -34,7 +34,7 @@ export function error404ResponseBody(): ErrorMessageBody {
errors: [
{
id: "12345",
- code: "NOTIFY_LETTER_NOT_FOUND",
+ code: "NOTIFY_NOT_FOUND",
links: {
about:
"https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier",