server/awsIntegration.ts (125 lines of code) (raw):
import * as Sentry from '@sentry/node';
import AWS from 'aws-sdk';
import type { GetObjectRequest } from 'aws-sdk/clients/s3';
import { RequestSigner } from 'aws4';
import { conf } from './config';
import { log } from './log';
export const AWS_REGION = 'eu-west-1';
const PROFILE = 'membership';
const CREDENTIAL_PROVIDER = new AWS.CredentialProviderChain([
() => new AWS.SharedIniFileCredentials({ profile: PROFILE }),
...AWS.CredentialProviderChain.defaultProviders,
]);
const standardAwsConfig = {
region: AWS_REGION,
credentialProvider: CREDENTIAL_PROVIDER,
};
const S3 = new AWS.S3(standardAwsConfig);
// Returns AWS signature version 4 headers to be used for AWS_IAM authorization in API Gateway
export const generateAwsSignatureHeaders = async (
method: string,
host: string, // foo.execute-api.eu-west-1.amazonaws.com
path: string, // DEV/bar
body: string, // '{"foo": "bar"}'
) => {
const creds: AWS.Credentials = await CREDENTIAL_PROVIDER.resolvePromise();
const opts = {
region: AWS_REGION,
service: 'execute-api',
method,
host,
path,
body,
};
return new RequestSigner(opts, creds).sign().headers;
};
export const APIGateway = new AWS.APIGateway(standardAwsConfig);
export const CloudFormation = new AWS.CloudFormation(standardAwsConfig);
const CloudWatch = new AWS.CloudWatch(standardAwsConfig);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- assume we don't know the range of possible types for the detail argument?
export const handleAwsRelatedError = (message: string, detail?: any) => {
log.error(message, detail);
Sentry.captureMessage(message);
};
export const s3ConfigPromise =
<ConfigInterface>(...fieldNamesToValidate: string[]) =>
(configPathPart: string) =>
s3FilePromise<ConfigInterface>(
'gu-reader-revenue-private',
`manage-frontend/${conf.STAGE}/${configPathPart}-${conf.STAGE}.json`,
...fieldNamesToValidate,
);
export const s3FilePromise = <ConfigInterface>(
bucket: string,
fileKey: string,
...fieldNamesToValidate: string[]
) =>
(async () => {
const configPath: GetObjectRequest = {
Bucket: bucket,
Key: fileKey,
};
const s3PromiseResult = await S3.getObject(configPath).promise();
if (s3PromiseResult.Body) {
try {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- any errors in the returned Body attribute will be handled by the .catch block
const parsed = JSON.parse(s3PromiseResult.Body.toString());
const missingProperties = fieldNamesToValidate.filter(
(field) => !parsed.hasOwnProperty(field),
);
if (missingProperties.length === 0) {
return parsed as ConfigInterface;
}
handleAwsRelatedError(
`${fileKey} missing ${missingProperties.map(
(field) => `'${field}'`,
)} properties in '${bucket}'`,
);
} catch (err) {
handleAwsRelatedError(
`could not parse ${fileKey} in '${bucket}'`,
err,
);
}
}
handleAwsRelatedError(
`S3 error fetching ${fileKey} in '${bucket}'`,
s3PromiseResult,
);
})();
export const s3TextFilePromise = (
bucket: string,
fileKey: string,
): Promise<string | undefined> =>
(async () => {
const filePath: GetObjectRequest = {
Bucket: bucket,
Key: fileKey,
};
const s3PromiseResult = await S3.getObject(filePath).promise();
if (
s3PromiseResult.Body &&
s3PromiseResult.ContentType === 'application/json'
) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- we rely on the S3 object to honour its contract
return s3PromiseResult.Body.toString();
}
handleAwsRelatedError(
`S3 error fetching ${fileKey} in '${bucket}'`,
s3PromiseResult,
);
})();
export const putMetricDataPromise = (
metricName: string,
dimensions: Record<string, string>,
) =>
CloudWatch.putMetricData({
Namespace: 'manage-frontend',
MetricData: [
{
MetricName: metricName,
Dimensions: Object.entries(dimensions).map(([Name, Value]) => ({
Name,
Value,
})),
Value: 1,
Unit: 'Count',
},
],
}).promise();