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",