Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(CB2-10573): Implement AWS AppConfig Feature Flagging #14

Merged
merged 3 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading