server/apiGatewayDiscovery.ts (267 lines of code) (raw):

import type { StackResourceSummaries, StackResourceSummary, } from 'aws-sdk/clients/cloudformation'; import type express from 'express'; import { MDA_TEST_USER_HEADER } from '../shared/productResponse'; import type { AdditionalHeaderGenerator, Headers } from './apiProxy'; import { proxyApiHandler, straightThroughBodyHandler } from './apiProxy'; import { APIGateway, AWS_REGION, CloudFormation, generateAwsSignatureHeaders, } from './awsIntegration'; import { conf } from './config'; import { log } from './log'; type ApiName = | 'cancellation-sf-cases-api' | 'delivery-records-api' | 'holiday-stop-api' | 'invoicing-api' | 'contact-us-api' | 'product-move-api' | 'discount-api' | 'product-switch-api' | 'update-supporter-plus-amount'; const isProd = conf.STAGE.toUpperCase() === 'PROD'; const normalUserApiStage = isProd ? 'PROD' : 'CODE'; const testUserApiStage = 'CODE'; const byResourceType = (resourceTypeFilter: string) => (resource: StackResourceSummary) => resource.ResourceType === resourceTypeFilter; const toDefinedPhysicalResourceId = (stackName: string) => (resource: StackResourceSummary) => { if (resource.PhysicalResourceId) { return resource.PhysicalResourceId; } throw new Error( `PhysicalResourceId missing for '${resource.ResourceType}' of ${stackName}`, ); }; const getHost = ( stackName: string, stackResourceSummaries?: StackResourceSummaries, ) => { const hosts = stackResourceSummaries ?.filter(byResourceType('AWS::ApiGateway::RestApi')) .map(toDefinedPhysicalResourceId(stackName)) .map( (apiGatewayId) => `${apiGatewayId}.execute-api.${AWS_REGION}.amazonaws.com`, ); if (hosts && hosts.length === 1) { return hosts[0]; } log.error(`${(hosts || []).length} hosts for ${stackName}, expected 1`); }; const lookupApiKey = async (apiKey: string) => ( await APIGateway.getApiKey({ apiKey, includeValue: true, }).promise() ).value; const getApiKeyPromise = ( stackName: string, stackResourceSummaries?: StackResourceSummaries, ) => { const apiKeyPromises = stackResourceSummaries ?.filter(byResourceType('AWS::ApiGateway::ApiKey')) .map(toDefinedPhysicalResourceId(stackName)) .map(lookupApiKey); if (apiKeyPromises && apiKeyPromises.length === 1) { return apiKeyPromises[0]; } log.error( `${ (apiKeyPromises || []).length } API keys for ${stackName}, expected 1`, ); }; interface HostAndApiKey { host?: string; apiKey?: string; } type StackPrefix = 'membership' | 'support'; function getHostAndApiKeyForStack( stackPrefix: StackPrefix, apiName: ApiName, stage: string, ): Promise<HostAndApiKey> { const stackName = `${stackPrefix}-${stage}-${apiName}`; log.info(`loading host and api key for ${stackName}`); return CloudFormation.listStackResources({ StackName: stackName, // no resources in question have anywhere near enough resources per-stack to require pagination with 'NextToken' // https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ListStackResources.html }) .promise() .then(async (result) => ({ host: getHost(stackName, result.StackResourceSummaries), apiKey: await getApiKeyPromise( stackName, result.StackResourceSummaries, ), })) .catch((err) => { log.error(`ERROR loading host and api key for ${stackName}. `, err); return {}; }); } const getApiGateway = ( stackPrefix: StackPrefix, apiName: ApiName, additionalHeaderGenerator?: AdditionalHeaderGenerator, ) => { const normalModeConfigPromise = getHostAndApiKeyForStack( stackPrefix, apiName, normalUserApiStage, ); const testModeConfigPromise = getHostAndApiKeyForStack( stackPrefix, apiName, testUserApiStage, ); return { configPromises: [testModeConfigPromise, normalModeConfigPromise], authorisedExpressCallback: ( path: string, loggingCode: string, urlParamNamesToReplace: string[] = [], headers: Headers = {}, shouldNotLogBody?: boolean, ) => async (req: express.Request, res: express.Response) => { const testUserHeader = req.header(MDA_TEST_USER_HEADER); if ( testUserHeader === undefined || !['true', 'false'].includes(testUserHeader) ) { log.error( `${path} request will not work for test users - missing ${MDA_TEST_USER_HEADER} header: '${testUserHeader}'`, ); } const isTestUser = testUserHeader === 'true'; const { host, apiKey } = await (isTestUser ? testModeConfigPromise : normalModeConfigPromise); const stage = isTestUser ? testUserApiStage : normalUserApiStage; if (!apiKey) { log.error(`Missing API Key for ${stage} ${apiName}`); res.status(500).send(); } else if (!host) { log.error(`Missing host for ${stage} ${apiName}`); res.status(500).send(); } else if (!res.locals.identity?.userId) { log.error(`Missing identity ID on the request object`); res.status(500).send(); } else { const shouldForwardQueryArgs = true; return proxyApiHandler( host, { 'x-api-key': apiKey, 'x-identity-id': res.locals.identity && res.locals.identity.userId, ...headers, }, additionalHeaderGenerator, )(straightThroughBodyHandler)( `${stage}/${path}`, loggingCode, urlParamNamesToReplace, shouldForwardQueryArgs, shouldNotLogBody, )(req, res); } }, }; }; const discountAPIGateway = getApiGateway('support', 'discount-api'); export const discountAPI = discountAPIGateway.authorisedExpressCallback; const cancellationSfCasesAPIGateway = getApiGateway( 'membership', 'cancellation-sf-cases-api', ); export const cancellationSfCasesAPI = cancellationSfCasesAPIGateway.authorisedExpressCallback; const holidayStopAPIGateway = getApiGateway('membership', 'holiday-stop-api'); export const holidayStopAPI = holidayStopAPIGateway.authorisedExpressCallback; const deliveryRecordsAPIGateway = getApiGateway( 'membership', 'delivery-records-api', ); export const deliveryRecordsAPI = deliveryRecordsAPIGateway.authorisedExpressCallback; const productMoveAPIGateway = getApiGateway( 'membership', 'product-move-api', generateAwsSignatureHeaders, ); export const productMoveAPI = productMoveAPIGateway.authorisedExpressCallback; const productSwitchAPIGateway = getApiGateway( 'support', 'product-switch-api', generateAwsSignatureHeaders, ); export const productSwitchAPI = productSwitchAPIGateway.authorisedExpressCallback; const updateSupporterPlusAmountAPIGateway = getApiGateway( 'support', 'update-supporter-plus-amount', generateAwsSignatureHeaders, ); export const updateSupporterPlusAmountAPI = updateSupporterPlusAmountAPIGateway.authorisedExpressCallback; const invoicingAPIGateway = getApiGateway( 'support', 'invoicing-api', generateAwsSignatureHeaders, ); export const invoicingAPI = invoicingAPIGateway.authorisedExpressCallback; // not sure why this doesn't follow the pattern above export const getContactUsAPIHostAndKey = async () => { const stage = conf.STAGE.toUpperCase() === 'PROD' ? 'PROD' : 'CODE'; const { host, apiKey } = await getHostAndApiKeyForStack( 'membership', 'contact-us-api', stage, ); if (!apiKey) { log.error(`Missing API Key for ${stage} contact-us-api`); return undefined; } else if (!host) { log.error(`Missing host for ${stage} contact-us-api`); return undefined; } return { host: `https://${host}/${stage}/`, apiKey }; }; const apiCredentialsArray = [ cancellationSfCasesAPIGateway, holidayStopAPIGateway, deliveryRecordsAPIGateway, invoicingAPIGateway, ]; export const middlewareFailIfAnyAPIGatewayCredsAreMissing = ( errorMessage: string, ) => { const allConfigPromises = apiCredentialsArray.flatMap( (_) => _.configPromises, ); return async ( _: express.Request, res: express.Response, next: express.NextFunction, ) => { const allConfig = await Promise.all(allConfigPromises); if (allConfig.every(({ host, apiKey }) => host && apiKey)) { next(); } else { log.error(errorMessage); res.status(500).send(errorMessage); } }; };