Skip to content

Commit

Permalink
Merge pull request #14 from dvsa/feature/CB2-10573
Browse files Browse the repository at this point in the history
feat(CB2-10573): Implement AWS AppConfig Feature Flagging
  • Loading branch information
me-matt authored Mar 26, 2024
2 parents 77507f3 + 7a85ce4 commit add1fc5
Show file tree
Hide file tree
Showing 15 changed files with 16,194 additions and 7,340 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/build_hash.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ on:
jobs:
Build:
uses: dvsa/cvs-github-actions/.github/workflows/build-node-hash.yaml@develop
with:
mono_repo: true
secrets:
CVS_MGMT_AWS_ROLE: ${{ secrets.CVS_MGMT_AWS_ROLE }}
DVSA_AWS_REGION: ${{ secrets.DVSA_AWS_REGION }}
5 changes: 5 additions & 0 deletions .snyk
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: v1.5.0
ignore:
'SNYK-JS-RAILROADDIAGRAMS-6282875':
- '* > railroad-diagrams':
reason: 'No fix available - file with vulnerability not used'
23,245 changes: 15,929 additions & 7,316 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@
}
],
"license": "MIT",
"dependencies": {
"@aws-sdk/client-ssm": "^3.478.0"
},
"devDependencies": {
"@aws-lambda-powertools/parameters": "^1.18.1",
"@aws-sdk/client-appconfigdata": "^3.515.0",
"@aws-sdk/client-ssm": "^3.478.0",
"@dvsa/eslint-config-ts": "^3.0.0",
"@smithy/util-stream": "^2.1.3",
"@types/aws-lambda": "^8.10.114",
"@types/jest": "^28.1.8",
"@types/node": "^16.18.23",
Expand All @@ -39,6 +40,8 @@
"@typescript-eslint/parser": "^5.57.1",
"archiver": "^5.3.1",
"aws-sam-webpack-plugin": "^0.13.0",
"aws-sdk-client-mock": "^3.0.1",
"aws-sdk-client-mock-jest": "^3.0.1",
"copy-webpack-plugin": "^11.0.0",
"current-git-branch": "^1.1.0",
"dotenv": "^16.0.3",
Expand All @@ -61,5 +64,8 @@
},
"engines": {
"node": "^18.15.0"
},
"dependencies": {
"@dvsa/cvs-microservice-common": "^0.9.5"
}
}
43 changes: 43 additions & 0 deletions src/feature-flags/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { FeatureFlagsClientName } from '@dvsa/cvs-microservice-common/feature-flags';
import 'dotenv/config';
import logger from '../util/logger';
import { Clients } from './util/clients';
import { headers } from '../util/headers';

const parseClientFromEvent = (value = ''): FeatureFlagsClientName => {
const clientValue = value.toUpperCase();
return FeatureFlagsClientName[clientValue as keyof typeof FeatureFlagsClientName];
};

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
logger.info('feature flag endpoint called');

const client = parseClientFromEvent(event?.pathParameters?.client);
if (client === undefined) {
return {
statusCode: 404,
body: JSON.stringify('Client not found'),
headers,
};
}

try {
const config = Clients.get(client);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const flags = await config!();

return {
statusCode: 200,
body: JSON.stringify(flags),
headers,
};
} catch (error) {
logger.error(error);
return {
statusCode: 500,
body: JSON.stringify('Error fetching feature flags'),
headers,
};
}
};
26 changes: 26 additions & 0 deletions src/feature-flags/util/clients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getVTXProfile, getVTAProfile, getVTMProfile } from '@dvsa/cvs-microservice-common/feature-flags/profiles';
import { FeatureFlagsClientName } from '@dvsa/cvs-microservice-common/feature-flags';

/*
* This mapping exists as a way to cache the client profiles outside the handler and in the context of the
* execution environment (lambda). This means these will only be hydrated for cold starts.
*
* We want to do this for the get handler because this endopint will be getting called frequently. In this
* scenario it means cold starts should be less frequent, so we can rely on the app config caching at the container.
*/
export const Clients = new Map(
[
[
FeatureFlagsClientName.VTX,
() => getVTXProfile(),
],
[
FeatureFlagsClientName.VTA,
() => getVTAProfile(),
],
[
FeatureFlagsClientName.VTM,
() => getVTMProfile(),
],
],
);
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm';
import type { APIGatewayProxyResult } from 'aws-lambda';
import 'dotenv/config';
import logger from '../util/logger';

export const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
'Access-Control-Allow-Methods': 'GET,OPTIONS',
};
import { headers } from '../util/headers';

export const handler = async (): Promise<APIGatewayProxyResult> => {
try {
Expand Down
5 changes: 5 additions & 0 deletions src/util/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
'Access-Control-Allow-Methods': 'GET,OPTIONS',
};
19 changes: 16 additions & 3 deletions template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'

Resources:
GetLambdaFunction:
MinAppVersionLambdaFunction:
Type: 'AWS::Serverless::Function'
Properties:
CodeUri: src/handler/
CodeUri: src/minimum-application-version/
Handler: get.handler
Runtime: nodejs18.x
Events:
Expand All @@ -15,10 +15,23 @@ Resources:
Path: /minimum-version
Method: get

FeatureFlagsLambdaFunction:
Type: 'AWS::Serverless::Function'
Properties:
CodeUri: src/feature-flags/
Handler: get.handler
Runtime: nodejs18.x
Events:
GetLambdaApi:
Type: Api
Properties:
Path: /feature-flags/{client}
Method: get

Outputs:
GetLambdaApi:
Description: "API Gateway endpoint URL for GetLambdaFunction on dev stage"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Dev/"
GetLambdaFunction:
MinAppVersionLambdaFunction:
Description: "Get Lambda Function ARN"
Value: !GetAtt GetLambdaFunction.Arn
64 changes: 64 additions & 0 deletions tests/feature-flags/get.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/* eslint-disable import/first */
const mockGetVTXProfile = jest.fn();

import { APIGatewayProxyEvent } from 'aws-lambda';

import { handler } from '../../src/feature-flags/get';
import { headers } from '../../src/util/headers';

jest.mock('@dvsa/cvs-microservice-common/feature-flags/profiles', () => ({
getVTXProfile: mockGetVTXProfile,
}));

describe('feature flags endpoint', () => {
const validEvent: Partial<APIGatewayProxyEvent> = {
pathParameters: {
client: 'vtx',
},
body: '',
};

type CvsFeatureFlags = {
firstFlag: {
enabled: boolean
},
};

const featureFlags = {
firstFlag: {
enabled: true,
},
};

it('should return 404 when an invalid client is specified', async () => {
const invalidEvent: Partial<APIGatewayProxyEvent> = {
pathParameters: {
client: 'invalid client',
},
body: '',
};
const result = await handler(invalidEvent as APIGatewayProxyEvent);

expect(result).toEqual({ statusCode: 404, body: '"Client not found"', headers });
});

it('should return 500 when app config throws an error', async () => {
mockGetVTXProfile.mockRejectedValue('Error!');

const result = await handler(validEvent as APIGatewayProxyEvent);

expect(mockGetVTXProfile).toHaveBeenCalled();
expect(result).toEqual({ statusCode: 500, body: '"Error fetching feature flags"', headers });
});

it('should return feature flags successfully', async () => {
mockGetVTXProfile.mockReturnValue(Promise.resolve(featureFlags));

const result = await handler(validEvent as APIGatewayProxyEvent);
const flags = JSON.parse(result.body) as CvsFeatureFlags;

expect(mockGetVTXProfile).toHaveBeenCalled();
expect(result.statusCode).toBe(200);
expect(flags).toEqual(featureFlags);
});
});
80 changes: 80 additions & 0 deletions tests/feature-flags/util/clients.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/* eslint-disable import/first */
const mockGetVTXProfile = jest.fn();
const mockGetVTAProfile = jest.fn();
const mockGetVTMProfile = jest.fn();

import { FeatureFlagsClientName } from '@dvsa/cvs-microservice-common/feature-flags';
import { Clients } from '../../../src/feature-flags/util/clients';

jest.mock('@dvsa/cvs-microservice-common/feature-flags/profiles', () => ({
getVTXProfile: mockGetVTXProfile,
getVTAProfile: mockGetVTAProfile,
getVTMProfile: mockGetVTMProfile,
}));

describe('feature flag clients', () => {
type CvsFeatureFlags = {
firstFlag: Flag,
secondFlag: Flag,
};

type Flag = {
enabled: boolean
};

const validFlags = {
firstFlag: {
enabled: true,
},
secondFlag: {
enabled: false,
},
};

beforeEach(() => {
mockGetVTXProfile.mockReset();
mockGetVTAProfile.mockReset();
mockGetVTMProfile.mockReset();
});

test.each`
clientName | mock
${FeatureFlagsClientName.VTX} | ${mockGetVTXProfile}
${FeatureFlagsClientName.VTA} | ${mockGetVTAProfile}
${FeatureFlagsClientName.VTM} | ${mockGetVTMProfile}
`(
'should not fetch feature flags when just loading',
({ clientName, mock }: { clientName: FeatureFlagsClientName, mock: jest.Mock }) => {
mock.mockReturnValue(Promise.resolve(validFlags));

const profile = Clients.get(clientName);

expect(profile).not.toBeNull();
expect(mock).toHaveBeenCalledTimes(0);
},
);

test.each`
clientName | mock
${FeatureFlagsClientName.VTX} | ${mockGetVTXProfile}
${FeatureFlagsClientName.VTA} | ${mockGetVTAProfile}
${FeatureFlagsClientName.VTM} | ${mockGetVTMProfile}
`(
'should fetch feature flags each time theyre invoked',
async ({ clientName, mock }: { clientName: FeatureFlagsClientName, mock: jest.Mock }) => {
mock.mockReturnValue(Promise.resolve(validFlags));

const firstAppConfig = Clients.get(clientName);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const firstFlags = await firstAppConfig!() as CvsFeatureFlags;

const secondAppConfig = Clients.get(clientName);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const secondFlags = await secondAppConfig!() as CvsFeatureFlags;

expect(firstFlags).toEqual(validFlags);
expect(secondFlags).toEqual(validFlags);
expect(mock).toHaveBeenCalledTimes(2);
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ jest.mock('@aws-sdk/client-ssm', () => ({
GetParameterCommand: mockGetParameterCommand,
}));

import { handler, headers } from '../../src/handler/get';
import { handler } from '../../src/minimum-application-version/get';
import { headers } from '../../src/util/headers';

describe('get endpoint', () => {
it('should return for non-local endpoint with mocked functions', async () => {
Expand Down
5 changes: 3 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"compilerOptions": {
"target": "es2019",
"module": "commonjs",
"module": "Node16",
"allowJs": true,
"checkJs": true,
"sourceMap": true,
"esModuleInterop": true,
"strict": true
"strict": true,
"moduleResolution": "Node16"
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
6 changes: 3 additions & 3 deletions webpack/webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ module.exports = {
// the size of your deployment package. If you want to always include it then comment out this line. It has
// been included conditionally because the node10.x docker image used by SAM local doesn't include it.
// externals: process.env.NODE_ENV === 'development' ? [] : ['aws-sdk'],
externals: {
fsevents: 'require(\'fsevents\')',
},
externals: [
/aws-sdk/,
],

// Add the TypeScript loader
module: {
Expand Down
12 changes: 6 additions & 6 deletions webpack/webpack.production.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const common = require('./webpack.common.js');
const archiver = require('archiver');
const branchName = require('current-git-branch');

const LAMBDA_NAME = 'GetLambdaFunction';
const LAMBDA_NAMES = ['FeatureFlagsLambdaFunction', 'MinAppVersionLambdaFunction'];
const OUTPUT_FOLDER = './'
const REPO_NAME = 'cvs-svc-minimum-application-version';
const BRANCH_NAME = branchName().replace(/\//g, "-");
Expand Down Expand Up @@ -62,13 +62,13 @@ module.exports = env => {
mode: 'production',
plugins: [
new BundlePlugin({
archives: [
{
inputPath: `.aws-sam/build/${LAMBDA_NAME}`,
archives: LAMBDA_NAMES.map(ln => {
return {
inputPath: `.aws-sam/build/${ln}`,
outputPath: `${OUTPUT_FOLDER}`,
outputName: `${COMMIT_HASH}`,
outputName: `${COMMIT_HASH}-${ln}`
}
],
})
}),
],
});
Expand Down

0 comments on commit add1fc5

Please sign in to comment.