From 1cf21d5ff793058a6bf2906e7925dc776d9fc790 Mon Sep 17 00:00:00 2001 From: paologaleotti <45665769+paologaleotti@users.noreply.github.com> Date: Thu, 12 Sep 2024 17:19:12 +0200 Subject: [PATCH] IMN-693 IMN-694 IMN-695 IMN-696 - Agreement routes pt.4 (#950) --- packages/api-clients/template-bff.hbs | 6 + packages/bff/.env | 3 + packages/bff/src/app.ts | 2 +- packages/bff/src/config/config.ts | 4 + packages/bff/src/model/domain/errors.ts | 31 ++++ packages/bff/src/routers/agreementRouter.ts | 108 +++++++++++++- packages/bff/src/services/agreementService.ts | 132 +++++++++++++++++- packages/bff/src/services/purposeService.ts | 14 +- packages/bff/src/utilities/errorMappers.ts | 15 ++ packages/bff/src/utilities/mimeTypes.ts | 13 ++ 10 files changed, 305 insertions(+), 23 deletions(-) create mode 100644 packages/bff/src/utilities/mimeTypes.ts diff --git a/packages/api-clients/template-bff.hbs b/packages/api-clients/template-bff.hbs index 26c5f2740d..61f8a1436d 100644 --- a/packages/api-clients/template-bff.hbs +++ b/packages/api-clients/template-bff.hbs @@ -63,6 +63,12 @@ export const {{@key}}Endpoints = makeApi([ response: z.instanceof(Buffer), {{else if (and (eq method "get") (eq path "/eservices/:eServiceId/consumers"))}} response: z.instanceof(Buffer), + {{else if (and (eq method "post") (eq path "/agreements/:agreementId/consumer-documents"))}} + response: z.instanceof(Buffer), + {{else if (and (eq method "get") (eq path "/agreements/:agreementId/consumer-documents"))}} + response: z.instanceof(Buffer), + {{else if (and (eq method "get") (eq path "/agreements/:agreementId/contract"))}} + response: z.instanceof(Buffer), {{else if (and (eq method "get") (eq path "/eservices/:eServiceId/descriptors/:descriptorId/documents/:documentId"))}} response: z.instanceof(Buffer), {{else}} diff --git a/packages/bff/.env b/packages/bff/.env index 697ff10b18..b5f1b029b3 100644 --- a/packages/bff/.env +++ b/packages/bff/.env @@ -44,6 +44,9 @@ RISK_ANALYSIS_DOCUMENTS_PATH="risk-analysis/docs" ESERVICE_DOCUMENTS_PATH="interop-eservice-documents" +CONSUMER_DOCUMENTS_PATH="interop-consumer-documents" +CONSUMER_DOCUMENTS_CONTAINER="interop-local-bucket" + AWS_CONFIG_FILE=aws.config.local SELFCARE_V2_URL=localhost diff --git a/packages/bff/src/app.ts b/packages/bff/src/app.ts index bc30202180..3e49b9c1ca 100644 --- a/packages/bff/src/app.ts +++ b/packages/bff/src/app.ts @@ -65,7 +65,7 @@ app.use(rateLimiterMiddleware(redisRateLimiter)); app.use(catalogRouter(zodiosCtx, clients, fileManager)); app.use(attributeRouter(zodiosCtx, clients)); app.use(purposeRouter(zodiosCtx, clients)); -app.use(agreementRouter(zodiosCtx, clients)); +app.use(agreementRouter(zodiosCtx, clients, fileManager)); app.use(selfcareRouter(zodiosCtx)); app.use(supportRouter(zodiosCtx, clients, redisRateLimiter)); app.use(toolRouter(zodiosCtx)); diff --git a/packages/bff/src/config/config.ts b/packages/bff/src/config/config.ts index 8da98b40b3..c6c2fe0125 100644 --- a/packages/bff/src/config/config.ts +++ b/packages/bff/src/config/config.ts @@ -24,9 +24,13 @@ export type TenantProcessServerConfig = z.infer< export const AgreementProcessServerConfig = z .object({ AGREEMENT_PROCESS_URL: APIEndpoint, + CONSUMER_DOCUMENTS_PATH: z.string(), + CONSUMER_DOCUMENTS_CONTAINER: z.string(), }) .transform((c) => ({ agreementProcessUrl: c.AGREEMENT_PROCESS_URL, + consumerDocumentsPath: c.CONSUMER_DOCUMENTS_PATH, + consumerDocumentsContainer: c.CONSUMER_DOCUMENTS_CONTAINER, })); export type AgreementProcessServerConfig = z.infer< typeof AgreementProcessServerConfig diff --git a/packages/bff/src/model/domain/errors.ts b/packages/bff/src/model/domain/errors.ts index 8f35d0ef63..68076325b3 100644 --- a/packages/bff/src/model/domain/errors.ts +++ b/packages/bff/src/model/domain/errors.ts @@ -34,6 +34,9 @@ export const errorCodes = { invalidJwtClaim: "0026", samlNotValid: "0027", missingSelfcareId: "0028", + invalidContentType: "0029", + contractNotFound: "0030", + contractException: "0031", }; export type ErrorCodes = keyof typeof errorCodes; @@ -283,3 +286,31 @@ export function interfaceExtractingInfoError(): ApiError { title: "Error extracting info from interface file", }); } + +export function contractNotFound(agreementId: string): ApiError { + return new ApiError({ + detail: `Contract not found for agreement ${agreementId}`, + code: "contractNotFound", + title: "Contract not found", + }); +} + +export function contractException(agreementId: string): ApiError { + return new ApiError({ + detail: `Contract exception for agreement ${agreementId}`, + code: "contractException", + title: "Contract exception", + }); +} + +export function invalidContentType( + contentType: string, + agreementId: string, + documentId: string +): ApiError { + return new ApiError({ + detail: `Invalid contentType ${contentType} for document ${documentId} from agreement ${agreementId}`, + code: "invalidContentType", + title: "Invalid content type", + }); +} diff --git a/packages/bff/src/routers/agreementRouter.ts b/packages/bff/src/routers/agreementRouter.ts index 9e5c4dc233..cabccf5f94 100644 --- a/packages/bff/src/routers/agreementRouter.ts +++ b/packages/bff/src/routers/agreementRouter.ts @@ -5,6 +5,7 @@ import { ZodiosContext, ExpressContext, zodiosValidationErrorToApiProblem, + FileManager, } from "pagopa-interop-commons"; import { makeApiProblem } from "../model/domain/errors.js"; import { PagoPAInteropBeClients } from "../providers/clientProvider.js"; @@ -13,19 +14,22 @@ import { activateAgreementErrorMapper, emptyErrorMapper, getAgreementByIdErrorMapper, + getAgreementConsumerDocumentErrorMapper, + getAgreementContractErrorMapper, getAgreementsErrorMapper, } from "../utilities/errorMappers.js"; import { agreementServiceBuilder } from "../services/agreementService.js"; const agreementRouter = ( ctx: ZodiosContext, - clients: PagoPAInteropBeClients + clients: PagoPAInteropBeClients, + fileManager: FileManager ): ZodiosRouter => { const agreementRouter = ctx.router(bffApi.agreementsApi.api, { validationErrorHandler: zodiosValidationErrorToApiProblem, }); - const agreementService = agreementServiceBuilder(clients); + const agreementService = agreementServiceBuilder(clients, fileManager); agreementRouter .get("/agreements", async (req, res) => { @@ -132,6 +136,35 @@ const agreementRouter = ( } }) + .post("/agreements/:agreementId/activate", async (_req, res) => + res.status(501).send() + ) + .post("/agreements/:agreementId/clone", async (_req, res) => + res.status(501).send() + ) + + .post("/agreements/:agreementId/consumer-documents", async (req, res) => { + const ctx = fromBffAppContext(req.ctx, req.headers); + + try { + const result = await agreementService.addAgreementConsumerDocument( + req.params.agreementId, + req.body, + ctx + ); + + return res.status(200).send(result).end(); + } catch (error) { + const errorRes = makeApiProblem( + error, + emptyErrorMapper, + ctx.logger, + `Error adding consumer document to agreement ${req.params.agreementId}` + ); + return res.status(errorRes.status).json(errorRes).end(); + } + }) + .post("/agreements/:agreementId/activate", async (req, res) => { const ctx = fromBffAppContext(req.ctx, req.headers); @@ -175,17 +208,77 @@ const agreementRouter = ( .post("/agreements/:agreementId/consumer-documents", async (_req, res) => res.status(501).send() ) + .get( "/agreements/:agreementId/consumer-documents/:documentId", - async (_req, res) => res.status(501).send() + async (req, res) => { + const ctx = fromBffAppContext(req.ctx, req.headers); + + try { + const result = await agreementService.getAgreementConsumerDocument( + req.params.agreementId, + req.params.documentId, + ctx + ); + + return res.status(200).send(result).end(); + } catch (error) { + const errorRes = makeApiProblem( + error, + getAgreementConsumerDocumentErrorMapper, + ctx.logger, + `Error downloading contract for agreement ${req.params.agreementId}` + ); + return res.status(errorRes.status).json(errorRes).end(); + } + } ) + .delete( "/agreements/:agreementId/consumer-documents/:documentId", - async (_req, res) => res.status(501).send() - ) - .get("/agreements/:agreementId/contract", async (_req, res) => - res.status(501).send() + async (req, res) => { + const ctx = fromBffAppContext(req.ctx, req.headers); + + try { + await agreementService.removeConsumerDocument( + req.params.agreementId, + req.params.documentId, + ctx + ); + + return res.status(204).end(); + } catch (error) { + const errorRes = makeApiProblem( + error, + emptyErrorMapper, + ctx.logger, + `Error deleting consumer document ${req.params.documentId} for agreement ${req.params.agreementId}` + ); + return res.status(errorRes.status).json(errorRes).end(); + } + } ) + .get("/agreements/:agreementId/contract", async (req, res) => { + const ctx = fromBffAppContext(req.ctx, req.headers); + + try { + const result = await agreementService.getAgreementContract( + req.params.agreementId, + ctx + ); + + return res.status(200).send(result).end(); + } catch (error) { + const errorRes = makeApiProblem( + error, + getAgreementContractErrorMapper, + ctx.logger, + `Error downloading contract for agreement ${req.params.agreementId}` + ); + return res.status(errorRes.status).json(errorRes).end(); + } + }) + .post("/agreements/:agreementId/submit", async (req, res) => { const ctx = fromBffAppContext(req.ctx, req.headers); @@ -206,6 +299,7 @@ const agreementRouter = ( return res.status(errorRes.status).json(errorRes).end(); } }) + .post("/agreements/:agreementId/suspend", async (req, res) => { const ctx = fromBffAppContext(req.ctx, req.headers); diff --git a/packages/bff/src/services/agreementService.ts b/packages/bff/src/services/agreementService.ts index 43df11f279..a90262a534 100644 --- a/packages/bff/src/services/agreementService.ts +++ b/packages/bff/src/services/agreementService.ts @@ -1,7 +1,9 @@ /* eslint-disable functional/immutable-data */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { randomUUID } from "crypto"; import { + FileManager, getAllFromPaginated, removeDuplicates, WithLogger, @@ -18,7 +20,12 @@ import { PagoPAInteropBeClients, } from "../providers/clientProvider.js"; import { BffAppContext, Headers } from "../utilities/context.js"; -import { agreementDescriptorNotFound } from "../model/domain/errors.js"; +import { + agreementDescriptorNotFound, + contractException, + contractNotFound, + invalidContentType, +} from "../model/domain/errors.js"; import { toCompactEserviceLight, toCompactOrganization, @@ -27,11 +34,16 @@ import { toCompactEservice, toCompactDescriptor, } from "../model/api/apiConverter.js"; +import { config } from "../config/config.js"; import { isAgreementUpgradable } from "../model/validators.js"; +import { contentTypes } from "../utilities/mimeTypes.js"; import { getBulkAttributes } from "./attributeService.js"; import { enhanceTenantAttributes } from "./tenantService.js"; -export function agreementServiceBuilder(clients: PagoPAInteropBeClients) { +export function agreementServiceBuilder( + clients: PagoPAInteropBeClients, + fileManager: FileManager +) { const { agreementProcessClient } = clients; return { async createAgreement( @@ -108,6 +120,113 @@ export function agreementServiceBuilder(clients: PagoPAInteropBeClients) { return enrichAgreement(agreement, clients, ctx); }, + async addAgreementConsumerDocument( + agreementId: string, + doc: bffApi.addAgreementConsumerDocument_Body, + { headers, logger }: WithLogger + ): Promise { + logger.info(`Adding consumer document to agreement ${agreementId}`); + + const documentPath = `${config.consumerDocumentsPath}/${agreementId}`; + const documentContent = Buffer.from(await doc.doc.arrayBuffer()); + const documentId = randomUUID(); + + await fileManager.storeBytes( + config.consumerDocumentsContainer, + documentPath, + documentId, + doc.doc.name, + documentContent, + logger + ); + + const seed: agreementApi.DocumentSeed = { + id: documentId, + prettyName: doc.prettyName, + name: doc.doc.name, + contentType: doc.doc.type, + path: documentPath, + }; + + await agreementProcessClient.addAgreementConsumerDocument(seed, { + params: { agreementId }, + headers, + }); + + return documentContent; + }, + + async getAgreementConsumerDocument( + agreementId: string, + documentId: string, + { headers, logger }: WithLogger + ): Promise { + logger.info( + `Retrieving consumer document ${documentId} from agreement ${agreementId}` + ); + + const document = + await agreementProcessClient.getAgreementConsumerDocument({ + params: { agreementId, documentId }, + headers, + }); + + assertContentMediaType(document.contentType, agreementId, documentId); + + const documentBytes = await fileManager.get( + config.consumerDocumentsContainer, + document.path, + logger + ); + + return Buffer.from(documentBytes); + }, + + async getAgreementContract( + agreementId: string, + { headers, logger }: WithLogger + ): Promise { + logger.info(`Retrieving contract for agreement ${agreementId}`); + + const agreement = await agreementProcessClient.getAgreementById({ + params: { agreementId }, + headers, + }); + + if (!agreement.contract) { + if ( + agreement.state === agreementApi.AgreementState.Values.ACTIVE || + agreement.state === agreementApi.AgreementState.Values.SUSPENDED || + agreement.state === agreementApi.AgreementState.Values.ARCHIVED + ) { + throw contractException(agreementId); + } + throw contractNotFound(agreementId); + } + + const documentBytes = await fileManager.get( + config.consumerDocumentsContainer, + agreement.contract.path, + logger + ); + + return Buffer.from(documentBytes); + }, + + async removeConsumerDocument( + agreementId: string, + documentId: string, + { headers, logger }: WithLogger + ): Promise { + logger.info( + `Removing consumer document with id ${documentId} from agreement ${agreementId}` + ); + + await agreementProcessClient.removeAgreementConsumerDocument(undefined, { + params: { agreementId, documentId }, + headers, + }); + }, async submitAgreement( agreementId: string, payload: bffApi.AgreementSubmissionPayload, @@ -633,6 +752,15 @@ export function getCurrentDescriptor( return descriptor; } +function assertContentMediaType( + contentType: string, + agreementId: string, + documentId: string +): void { + if (!contentTypes.includes(contentType)) { + throw invalidContentType(contentType, agreementId, documentId); + } +} const emptyPagination = (offset: number, limit: number) => ({ pagination: { limit, diff --git a/packages/bff/src/services/purposeService.ts b/packages/bff/src/services/purposeService.ts index 46ac4e577c..4eac3ccd34 100644 --- a/packages/bff/src/services/purposeService.ts +++ b/packages/bff/src/services/purposeService.ts @@ -37,6 +37,7 @@ import { BffAppContext, Headers } from "../utilities/context.js"; import { toBffApiCompactClient } from "../model/domain/apiConverter.js"; import { isAgreementUpgradable } from "../model/validators.js"; import { config } from "../config/config.js"; +import { contentTypes } from "../utilities/mimeTypes.js"; import { getLatestAgreement } from "./agreementService.js"; import { getAllClients } from "./clientService.js"; @@ -418,19 +419,6 @@ export function purposeServiceBuilder( headers, }); - // from https://doc.akka.io/api/akka-http/current/akka/http/scaladsl/model/ContentTypes$.html - const contentTypes = [ - "NoContentType", - "application/grpc+proto", - "application/json", - "application/octet-stream", - "application/x-www-form-urlencoded", - "text/csv(UTF-8)", - "text/html(UTF-8)", - "text/plain(UTF-8)", - "text/xml(UTF-8)", - ]; - if (!contentTypes.includes(document.contentType)) { throw invalidRiskAnalysisContentType( document.contentType, diff --git a/packages/bff/src/utilities/errorMappers.ts b/packages/bff/src/utilities/errorMappers.ts index e1bb20d7af..37af70c851 100644 --- a/packages/bff/src/utilities/errorMappers.ts +++ b/packages/bff/src/utilities/errorMappers.ts @@ -99,6 +99,21 @@ export const getAgreementByIdErrorMapper = ( ) .otherwise(() => HTTP_STATUS_INTERNAL_SERVER_ERROR); +export const getAgreementContractErrorMapper = ( + error: ApiError +): number => + match(error.code) + .with("contractException", () => HTTP_STATUS_INTERNAL_SERVER_ERROR) + .with("contractNotFound", () => HTTP_STATUS_NOT_FOUND) + .otherwise(() => HTTP_STATUS_INTERNAL_SERVER_ERROR); + +export const getAgreementConsumerDocumentErrorMapper = ( + error: ApiError +): number => + match(error.code) + .with("invalidContentType", () => HTTP_STATUS_INTERNAL_SERVER_ERROR) + .otherwise(() => HTTP_STATUS_INTERNAL_SERVER_ERROR); + export const activateAgreementErrorMapper = ( error: ApiError ): number => diff --git a/packages/bff/src/utilities/mimeTypes.ts b/packages/bff/src/utilities/mimeTypes.ts new file mode 100644 index 0000000000..b8e78e37e8 --- /dev/null +++ b/packages/bff/src/utilities/mimeTypes.ts @@ -0,0 +1,13 @@ +// Content types from: https://doc.akka.io/api/akka-http/current/akka/http/scaladsl/model/ContentTypes + +export const contentTypes = [ + "NoContentType", + "application/grpc+proto", + "application/json", + "application/octet-stream", + "application/x-www-form-urlencoded", + "text/csv(UTF-8)", + "text/html(UTF-8)", + "text/plain(UTF-8)", + "text/xml(UTF-8)", +];