@bifravst/http-api-mock 3.0.4 → 3.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm/cdk/App.d.ts +11 -0
- package/npm/cdk/App.js +4 -7
- package/npm/cdk/Stack.d.ts +20 -0
- package/npm/cdk/Stack.js +11 -16
- package/npm/cdk/http-api-mock.d.ts +1 -0
- package/npm/cdk/http-api-mock.js +20 -15
- package/npm/cdk/resources/HttpApiMock.d.ts +14 -0
- package/npm/cdk/resources/HttpApiMock.js +17 -19
- package/npm/cdk/resources/checkMatchingQueryParams.d.ts +5 -0
- package/npm/cdk/resources/checkMatchingQueryParams.js +18 -16
- package/npm/cdk/resources/checkMatchingQueryParams.spec.d.ts +1 -0
- package/npm/cdk/resources/checkMatchingQueryParams.spec.js +57 -0
- package/npm/cdk/resources/http-api-mock-lambda.d.ts +2 -0
- package/npm/cdk/resources/http-api-mock-lambda.js +41 -33
- package/npm/cdk/resources/splitMockResponse.d.ts +4 -0
- package/npm/cdk/resources/splitMockResponse.js +16 -11
- package/npm/cdk/resources/splitMockResponse.spec.d.ts +1 -0
- package/npm/cdk/resources/splitMockResponse.spec.js +13 -0
- package/npm/e2e.spec.d.ts +1 -0
- package/npm/e2e.spec.js +51 -0
- package/npm/package.json +105 -0
- package/npm/src/mock.d.ts +15 -0
- package/npm/src/mock.js +28 -24
- package/npm/src/mock.spec.d.ts +1 -0
- package/npm/src/mock.spec.js +36 -0
- package/npm/src/parseMockRequest.d.ts +7 -0
- package/npm/src/parseMockRequest.js +10 -8
- package/npm/src/parseMockRequest.spec.d.ts +1 -0
- package/npm/src/parseMockRequest.spec.js +23 -0
- package/npm/src/parseMockResponse.d.ts +6 -0
- package/npm/src/parseMockResponse.js +10 -8
- package/npm/src/parseMockResponse.spec.d.ts +1 -0
- package/npm/src/parseMockResponse.spec.js +20 -0
- package/npm/src/randomString.d.ts +1 -0
- package/npm/src/randomString.js +4 -1
- package/npm/src/requests.d.ts +13 -0
- package/npm/src/requests.js +9 -9
- package/npm/src/responses.d.ts +16 -0
- package/npm/src/responses.js +6 -6
- package/npm/src/sortQueryString.d.ts +3 -0
- package/npm/src/sortQueryString.js +10 -11
- package/npm/src/sortQueryString.spec.d.ts +1 -0
- package/npm/src/sortQueryString.spec.js +18 -0
- package/package.json +9 -9
package/npm/cdk/App.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PackedLambda } from '@bifravst/aws-cdk-lambda-helpers';
|
|
2
|
+
import type { PackedLayer } from '@bifravst/aws-cdk-lambda-helpers/layer';
|
|
3
|
+
import { App } from 'aws-cdk-lib';
|
|
4
|
+
export declare class HTTPAPIMockApp extends App {
|
|
5
|
+
constructor(stackName: string, { lambdaSources, layer, }: {
|
|
6
|
+
lambdaSources: {
|
|
7
|
+
httpApiMock: PackedLambda;
|
|
8
|
+
};
|
|
9
|
+
layer: PackedLayer;
|
|
10
|
+
});
|
|
11
|
+
}
|
package/npm/cdk/App.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import { App } from 'aws-cdk-lib';
|
|
2
2
|
import { HTTPAPIMockStack } from './Stack.js';
|
|
3
3
|
export class HTTPAPIMockApp extends App {
|
|
4
|
-
constructor(stackName, { lambdaSources, layer }){
|
|
4
|
+
constructor(stackName, { lambdaSources, layer, }) {
|
|
5
5
|
super({
|
|
6
6
|
context: {
|
|
7
|
-
isTest: true
|
|
8
|
-
}
|
|
9
|
-
});
|
|
10
|
-
new HTTPAPIMockStack(this, stackName, {
|
|
11
|
-
lambdaSources,
|
|
12
|
-
layer
|
|
7
|
+
isTest: true,
|
|
8
|
+
},
|
|
13
9
|
});
|
|
10
|
+
new HTTPAPIMockStack(this, stackName, { lambdaSources, layer });
|
|
14
11
|
}
|
|
15
12
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PackedLambda } from '@bifravst/aws-cdk-lambda-helpers';
|
|
2
|
+
import type { PackedLayer } from '@bifravst/aws-cdk-lambda-helpers/layer';
|
|
3
|
+
import type { App } from 'aws-cdk-lib';
|
|
4
|
+
import { Stack } from 'aws-cdk-lib';
|
|
5
|
+
/**
|
|
6
|
+
* This is CloudFormation stack sets up a dummy HTTP API which stores all requests in SQS for inspection
|
|
7
|
+
*/
|
|
8
|
+
export declare class HTTPAPIMockStack extends Stack {
|
|
9
|
+
constructor(parent: App, stackName: string, { lambdaSources, layer, }: {
|
|
10
|
+
lambdaSources: {
|
|
11
|
+
httpApiMock: PackedLambda;
|
|
12
|
+
};
|
|
13
|
+
layer: PackedLayer;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export type StackOutputs = {
|
|
17
|
+
apiURL: string;
|
|
18
|
+
requestsTableName: string;
|
|
19
|
+
responsesTableName: string;
|
|
20
|
+
};
|
package/npm/cdk/Stack.js
CHANGED
|
@@ -3,43 +3,38 @@ import { CfnOutput, aws_lambda as Lambda, Stack } from 'aws-cdk-lib';
|
|
|
3
3
|
import { HttpApiMock } from './resources/HttpApiMock.js';
|
|
4
4
|
/**
|
|
5
5
|
* This is CloudFormation stack sets up a dummy HTTP API which stores all requests in SQS for inspection
|
|
6
|
-
*/
|
|
7
|
-
|
|
6
|
+
*/
|
|
7
|
+
export class HTTPAPIMockStack extends Stack {
|
|
8
|
+
constructor(parent, stackName, { lambdaSources, layer, }) {
|
|
8
9
|
super(parent, stackName, {
|
|
9
|
-
description: 'Provides a mock HTTP API for testing third-party API integrations.'
|
|
10
|
+
description: 'Provides a mock HTTP API for testing third-party API integrations.',
|
|
10
11
|
});
|
|
11
12
|
const baseLayer = new Lambda.LayerVersion(this, 'baseLayer', {
|
|
12
13
|
layerVersionName: `${Stack.of(this).stackName}-baseLayer`,
|
|
13
14
|
code: new LambdaSource(this, {
|
|
14
15
|
id: 'baseLayer',
|
|
15
16
|
zipFilePath: layer.layerZipFilePath,
|
|
16
|
-
hash: layer.hash
|
|
17
|
+
hash: layer.hash,
|
|
17
18
|
}).code,
|
|
18
|
-
compatibleArchitectures: [
|
|
19
|
-
|
|
20
|
-
],
|
|
21
|
-
compatibleRuntimes: [
|
|
22
|
-
Lambda.Runtime.NODEJS_24_X
|
|
23
|
-
]
|
|
19
|
+
compatibleArchitectures: [Lambda.Architecture.ARM_64],
|
|
20
|
+
compatibleRuntimes: [Lambda.Runtime.NODEJS_24_X],
|
|
24
21
|
});
|
|
25
22
|
const httpMockApi = new HttpApiMock(this, {
|
|
26
23
|
lambdaSources,
|
|
27
|
-
layers: [
|
|
28
|
-
baseLayer
|
|
29
|
-
]
|
|
24
|
+
layers: [baseLayer],
|
|
30
25
|
});
|
|
31
26
|
// Export these so the test runner can use them
|
|
32
27
|
new CfnOutput(this, 'apiURL', {
|
|
33
28
|
value: httpMockApi.api.url,
|
|
34
|
-
exportName: `${this.stackName}:apiURL
|
|
29
|
+
exportName: `${this.stackName}:apiURL`,
|
|
35
30
|
});
|
|
36
31
|
new CfnOutput(this, 'responsesTableName', {
|
|
37
32
|
value: httpMockApi.responsesTable.tableName,
|
|
38
|
-
exportName: `${this.stackName}:responsesTableName
|
|
33
|
+
exportName: `${this.stackName}:responsesTableName`,
|
|
39
34
|
});
|
|
40
35
|
new CfnOutput(this, 'requestsTableName', {
|
|
41
36
|
value: httpMockApi.requestsTable.tableName,
|
|
42
|
-
exportName: `${this.stackName}:requestsTableName
|
|
37
|
+
exportName: `${this.stackName}:requestsTableName`,
|
|
43
38
|
});
|
|
44
39
|
}
|
|
45
40
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/npm/cdk/http-api-mock.js
CHANGED
|
@@ -9,33 +9,37 @@ import fs from 'node:fs/promises';
|
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import pJSON from '../package.json' with { type: 'json' };
|
|
12
13
|
import { randomString } from '../src/randomString.js';
|
|
13
14
|
import { HTTPAPIMockApp } from './App.js';
|
|
14
15
|
const options = commandLineArgs([
|
|
15
16
|
{
|
|
16
17
|
name: 'config',
|
|
17
18
|
type: Boolean,
|
|
18
|
-
defaultValue: false
|
|
19
|
+
defaultValue: false,
|
|
19
20
|
},
|
|
20
21
|
{
|
|
21
22
|
name: 'destroy',
|
|
22
23
|
type: Boolean,
|
|
23
|
-
defaultValue: false
|
|
24
|
-
}
|
|
24
|
+
defaultValue: false,
|
|
25
|
+
},
|
|
25
26
|
]);
|
|
26
|
-
const loadConfig = async ()=>{
|
|
27
|
+
const loadConfig = async () => {
|
|
27
28
|
try {
|
|
28
29
|
const config = JSON.parse(await fs.readFile(path.join(process.cwd(), 'http-api-mock.json'), 'utf-8'));
|
|
29
30
|
return config;
|
|
30
|
-
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
31
33
|
return {};
|
|
32
34
|
}
|
|
33
35
|
};
|
|
34
|
-
const stackName = process.env.HTTP_API_MOCK_STACK_NAME ??
|
|
35
|
-
|
|
36
|
+
const stackName = process.env.HTTP_API_MOCK_STACK_NAME ??
|
|
37
|
+
(await loadConfig())?.stackName ??
|
|
38
|
+
`http-api-mock-${randomString()}`;
|
|
39
|
+
const saveConfig = async () => {
|
|
36
40
|
writeFileSync(path.join(process.cwd(), 'http-api-mock.json'), JSON.stringify({
|
|
37
41
|
stackName,
|
|
38
|
-
...await stackOutput(new CloudFormationClient({}))(stackName)
|
|
42
|
+
...(await stackOutput(new CloudFormationClient({}))(stackName)),
|
|
39
43
|
}, null, 2));
|
|
40
44
|
};
|
|
41
45
|
if (options.config === true) {
|
|
@@ -49,7 +53,7 @@ await fs.mkdir(lambdasDir);
|
|
|
49
53
|
const layersDir = path.join(distDir, 'layers');
|
|
50
54
|
await fs.mkdir(layersDir);
|
|
51
55
|
const dependencies = [
|
|
52
|
-
'@bifravst/from-env'
|
|
56
|
+
'@bifravst/from-env',
|
|
53
57
|
];
|
|
54
58
|
const app = new HTTPAPIMockApp(stackName, {
|
|
55
59
|
lambdaSources: {
|
|
@@ -57,21 +61,22 @@ const app = new HTTPAPIMockApp(stackName, {
|
|
|
57
61
|
id: 'httpApiMock',
|
|
58
62
|
sourceFilePath: 'cdk/resources/http-api-mock-lambda.ts',
|
|
59
63
|
baseDir,
|
|
60
|
-
distDir: lambdasDir
|
|
61
|
-
})
|
|
64
|
+
distDir: lambdasDir,
|
|
65
|
+
}),
|
|
62
66
|
},
|
|
63
67
|
layer: await packLayer({
|
|
64
68
|
id: 'testResources',
|
|
65
69
|
dependencies,
|
|
66
70
|
baseDir,
|
|
67
|
-
distDir: layersDir
|
|
68
|
-
})
|
|
71
|
+
distDir: layersDir,
|
|
72
|
+
}),
|
|
69
73
|
});
|
|
70
74
|
const cdk = new Toolkit();
|
|
71
|
-
const cx = await cdk.fromAssemblyBuilder(async ()=>app.synth());
|
|
75
|
+
const cx = await cdk.fromAssemblyBuilder(async () => app.synth());
|
|
72
76
|
if (options.destroy === true) {
|
|
73
77
|
await cdk.destroy(cx);
|
|
74
|
-
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
75
80
|
await cdk.deploy(cx);
|
|
76
81
|
await saveConfig();
|
|
77
82
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PackedLambda } from '@bifravst/aws-cdk-lambda-helpers';
|
|
2
|
+
import { aws_apigateway as ApiGateway, aws_dynamodb as DynamoDB, aws_lambda as Lambda, Resource } from 'aws-cdk-lib';
|
|
3
|
+
import type { Construct } from 'constructs';
|
|
4
|
+
export declare class HttpApiMock extends Resource {
|
|
5
|
+
readonly api: ApiGateway.RestApi;
|
|
6
|
+
readonly requestsTable: DynamoDB.Table;
|
|
7
|
+
readonly responsesTable: DynamoDB.Table;
|
|
8
|
+
constructor(parent: Construct, { lambdaSources, layers, }: {
|
|
9
|
+
lambdaSources: {
|
|
10
|
+
httpApiMock: PackedLambda;
|
|
11
|
+
};
|
|
12
|
+
layers: Lambda.ILayerVersion[];
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
import { LambdaLogGroup, LambdaSource } from '@bifravst/aws-cdk-lambda-helpers/cdk';
|
|
2
|
-
import { aws_apigateway as ApiGateway, Duration, aws_dynamodb as DynamoDB, aws_iam as IAM, aws_lambda as Lambda, aws_logs as Logs, RemovalPolicy, Resource } from 'aws-cdk-lib';
|
|
1
|
+
import { LambdaLogGroup, LambdaSource, } from '@bifravst/aws-cdk-lambda-helpers/cdk';
|
|
2
|
+
import { aws_apigateway as ApiGateway, Duration, aws_dynamodb as DynamoDB, aws_iam as IAM, aws_lambda as Lambda, aws_logs as Logs, RemovalPolicy, Resource, } from 'aws-cdk-lib';
|
|
3
3
|
export class HttpApiMock extends Resource {
|
|
4
4
|
api;
|
|
5
5
|
requestsTable;
|
|
6
6
|
responsesTable;
|
|
7
|
-
constructor(parent, { lambdaSources, layers }){
|
|
7
|
+
constructor(parent, { lambdaSources, layers, }) {
|
|
8
8
|
super(parent, 'http-api-mock');
|
|
9
9
|
// This table will store all the requests made to the API Gateway
|
|
10
10
|
this.requestsTable = new DynamoDB.Table(this, 'requests', {
|
|
11
11
|
billingMode: DynamoDB.BillingMode.PAY_PER_REQUEST,
|
|
12
12
|
partitionKey: {
|
|
13
13
|
name: 'requestId',
|
|
14
|
-
type: DynamoDB.AttributeType.STRING
|
|
14
|
+
type: DynamoDB.AttributeType.STRING,
|
|
15
15
|
},
|
|
16
16
|
sortKey: {
|
|
17
17
|
name: 'timestamp',
|
|
18
|
-
type: DynamoDB.AttributeType.STRING
|
|
18
|
+
type: DynamoDB.AttributeType.STRING,
|
|
19
19
|
},
|
|
20
|
-
removalPolicy: RemovalPolicy.DESTROY
|
|
20
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
21
21
|
});
|
|
22
22
|
this.requestsTable.addGlobalSecondaryIndex({
|
|
23
23
|
indexName: 'methodPathQuery',
|
|
24
24
|
partitionKey: {
|
|
25
25
|
name: 'methodPathQuery',
|
|
26
|
-
type: DynamoDB.AttributeType.STRING
|
|
26
|
+
type: DynamoDB.AttributeType.STRING,
|
|
27
27
|
},
|
|
28
|
-
projectionType: DynamoDB.ProjectionType.ALL
|
|
28
|
+
projectionType: DynamoDB.ProjectionType.ALL,
|
|
29
29
|
});
|
|
30
30
|
// This table will store optional responses to be sent
|
|
31
31
|
this.responsesTable = new DynamoDB.Table(this, 'responses', {
|
|
32
32
|
billingMode: DynamoDB.BillingMode.PAY_PER_REQUEST,
|
|
33
33
|
partitionKey: {
|
|
34
34
|
name: 'responseId',
|
|
35
|
-
type: DynamoDB.AttributeType.STRING
|
|
35
|
+
type: DynamoDB.AttributeType.STRING,
|
|
36
36
|
},
|
|
37
37
|
sortKey: {
|
|
38
38
|
name: 'timestamp',
|
|
39
|
-
type: DynamoDB.AttributeType.STRING
|
|
39
|
+
type: DynamoDB.AttributeType.STRING,
|
|
40
40
|
},
|
|
41
41
|
removalPolicy: RemovalPolicy.DESTROY,
|
|
42
|
-
timeToLiveAttribute: 'ttl'
|
|
42
|
+
timeToLiveAttribute: 'ttl',
|
|
43
43
|
});
|
|
44
44
|
this.responsesTable.addGlobalSecondaryIndex({
|
|
45
45
|
indexName: 'methodPathQuery',
|
|
46
46
|
partitionKey: {
|
|
47
47
|
name: 'methodPathQuery',
|
|
48
|
-
type: DynamoDB.AttributeType.STRING
|
|
48
|
+
type: DynamoDB.AttributeType.STRING,
|
|
49
49
|
},
|
|
50
|
-
projectionType: DynamoDB.ProjectionType.ALL
|
|
50
|
+
projectionType: DynamoDB.ProjectionType.ALL,
|
|
51
51
|
});
|
|
52
52
|
// This lambda will publish all requests made to the API Gateway in the queue
|
|
53
53
|
const lambda = new Lambda.Function(this, 'Lambda', {
|
|
@@ -62,9 +62,9 @@ export class HttpApiMock extends Resource {
|
|
|
62
62
|
REQUESTS_TABLE_NAME: this.requestsTable.tableName,
|
|
63
63
|
RESPONSES_TABLE_NAME: this.responsesTable.tableName,
|
|
64
64
|
LOG_LEVEL: this.node.tryGetContext('logLevel'),
|
|
65
|
-
NODE_NO_WARNINGS: '1'
|
|
65
|
+
NODE_NO_WARNINGS: '1',
|
|
66
66
|
},
|
|
67
|
-
...new LambdaLogGroup(this, 'LambdaLogs', Logs.RetentionDays.ONE_DAY)
|
|
67
|
+
...new LambdaLogGroup(this, 'LambdaLogs', Logs.RetentionDays.ONE_DAY),
|
|
68
68
|
});
|
|
69
69
|
this.responsesTable.grantReadWriteData(lambda);
|
|
70
70
|
this.requestsTable.grantReadWriteData(lambda);
|
|
@@ -72,16 +72,14 @@ export class HttpApiMock extends Resource {
|
|
|
72
72
|
this.api = new ApiGateway.RestApi(this, 'api', {
|
|
73
73
|
restApiName: `HTTP Mock API for testing`,
|
|
74
74
|
description: 'API Gateway to test outgoing requests',
|
|
75
|
-
binaryMediaTypes: [
|
|
76
|
-
'application/octet-stream'
|
|
77
|
-
]
|
|
75
|
+
binaryMediaTypes: ['application/octet-stream'],
|
|
78
76
|
});
|
|
79
77
|
const proxyResource = this.api.root.addResource('{proxy+}');
|
|
80
78
|
proxyResource.addMethod('ANY', new ApiGateway.LambdaIntegration(lambda));
|
|
81
79
|
// API Gateway needs to be able to call the lambda
|
|
82
80
|
lambda.addPermission('InvokeByApiGateway', {
|
|
83
81
|
principal: new IAM.ServicePrincipal('apigateway.amazonaws.com'),
|
|
84
|
-
sourceArn: this.api.arnForExecuteApi()
|
|
82
|
+
sourceArn: this.api.arnForExecuteApi(),
|
|
85
83
|
});
|
|
86
84
|
}
|
|
87
85
|
}
|
|
@@ -1,30 +1,32 @@
|
|
|
1
1
|
const matchRegex = /^\/(?<re>.+)\/(?<option>[gi])?$/;
|
|
2
|
-
export const checkMatchingQueryParams = (actual, expected, log)=>{
|
|
3
|
-
log?.debug('checkMatchingQueryParams', {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
});
|
|
7
|
-
if (actual === null) return false;
|
|
2
|
+
export const checkMatchingQueryParams = (actual, expected, log) => {
|
|
3
|
+
log?.debug('checkMatchingQueryParams', { actual, expected });
|
|
4
|
+
if (actual === null)
|
|
5
|
+
return false;
|
|
8
6
|
// Check whether expected query parameters is subset of actual query parameters
|
|
9
|
-
for(const prop in expected){
|
|
7
|
+
for (const prop in expected) {
|
|
10
8
|
const expectedValue = expected[prop];
|
|
11
9
|
const actualValue = actual?.[prop];
|
|
12
|
-
if (actualValue === undefined)
|
|
10
|
+
if (actualValue === undefined)
|
|
11
|
+
return false;
|
|
13
12
|
if (typeof expectedValue === 'string') {
|
|
14
13
|
const match = matchRegex.exec(expectedValue);
|
|
15
14
|
if (match !== null) {
|
|
16
|
-
log?.debug('Compare using regex', {
|
|
17
|
-
expectedValue
|
|
18
|
-
});
|
|
15
|
+
log?.debug('Compare using regex', { expectedValue });
|
|
19
16
|
// Expect is regex
|
|
20
17
|
const check = new RegExp(match?.groups?.re ?? '', match?.groups?.option).test(String(actualValue));
|
|
21
|
-
if (check === false)
|
|
22
|
-
|
|
23
|
-
if (actualValue !== expectedValue) return false;
|
|
18
|
+
if (check === false)
|
|
19
|
+
return false;
|
|
24
20
|
}
|
|
25
|
-
|
|
21
|
+
else {
|
|
22
|
+
if (actualValue !== expectedValue)
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
26
27
|
// All query parameters are string
|
|
27
|
-
if (actualValue !== String(expectedValue))
|
|
28
|
+
if (actualValue !== String(expectedValue))
|
|
29
|
+
return false;
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
return true;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { checkMatchingQueryParams } from './checkMatchingQueryParams.js';
|
|
4
|
+
void describe('checkMatchingQueryParams', () => {
|
|
5
|
+
void it('should return true when expected is subset of actual parameters', () => {
|
|
6
|
+
const actual = {
|
|
7
|
+
param1: 'value1',
|
|
8
|
+
param2: 'value2',
|
|
9
|
+
};
|
|
10
|
+
const expected = {
|
|
11
|
+
param1: 'value1',
|
|
12
|
+
};
|
|
13
|
+
const result = checkMatchingQueryParams(actual, expected);
|
|
14
|
+
assert.equal(result, true);
|
|
15
|
+
});
|
|
16
|
+
void it('should return true when expected contains regular expression and it matches', () => {
|
|
17
|
+
const actual = {
|
|
18
|
+
param1: 'value1,value2,value3',
|
|
19
|
+
};
|
|
20
|
+
const expected = {
|
|
21
|
+
param1: '/\\bvalue2\\b/',
|
|
22
|
+
};
|
|
23
|
+
const result = checkMatchingQueryParams(actual, expected);
|
|
24
|
+
assert.equal(result, true);
|
|
25
|
+
});
|
|
26
|
+
void it('should return false when expected does not match actual parameters', () => {
|
|
27
|
+
const actual = {
|
|
28
|
+
param1: 'value1',
|
|
29
|
+
param2: 'value2',
|
|
30
|
+
};
|
|
31
|
+
const expected = {
|
|
32
|
+
param1: 'value2',
|
|
33
|
+
};
|
|
34
|
+
const result = checkMatchingQueryParams(actual, expected);
|
|
35
|
+
assert.equal(result, false);
|
|
36
|
+
});
|
|
37
|
+
void it('should return false when actual is null', () => {
|
|
38
|
+
const actual = null;
|
|
39
|
+
const expected = {
|
|
40
|
+
param1: 'value1',
|
|
41
|
+
};
|
|
42
|
+
const result = checkMatchingQueryParams(actual, expected);
|
|
43
|
+
assert.equal(result, false);
|
|
44
|
+
});
|
|
45
|
+
void it('should return true when expected parameters having number or boolean', () => {
|
|
46
|
+
const actual = {
|
|
47
|
+
param1: 'true',
|
|
48
|
+
param2: '1',
|
|
49
|
+
};
|
|
50
|
+
const expected = {
|
|
51
|
+
param1: true,
|
|
52
|
+
param2: 1,
|
|
53
|
+
};
|
|
54
|
+
const result = checkMatchingQueryParams(actual, expected);
|
|
55
|
+
assert.equal(result, true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { DeleteItemCommand, DynamoDBClient, PutItemCommand, ScanCommand } from '@aws-sdk/client-dynamodb';
|
|
1
|
+
import { DeleteItemCommand, DynamoDBClient, PutItemCommand, ScanCommand, } from '@aws-sdk/client-dynamodb';
|
|
2
2
|
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
|
|
3
3
|
import { URLSearchParams } from 'url';
|
|
4
4
|
import { sortQueryString } from '../../src/sortQueryString.js';
|
|
5
5
|
import { checkMatchingQueryParams } from './checkMatchingQueryParams.js';
|
|
6
6
|
import { splitMockResponse } from './splitMockResponse.js';
|
|
7
7
|
const db = new DynamoDBClient({});
|
|
8
|
-
export const handler = async (event, context)=>{
|
|
9
|
-
console.log(JSON.stringify({
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
export const handler = async (event, context) => {
|
|
9
|
+
console.log(JSON.stringify({ event }));
|
|
10
|
+
const query = event.queryStringParameters !== null &&
|
|
11
|
+
event.queryStringParameters !== undefined
|
|
12
|
+
? new URLSearchParams(event.queryStringParameters)
|
|
13
|
+
: undefined;
|
|
13
14
|
const path = event.path.replace(/^\//, '');
|
|
14
15
|
const pathWithQuery = sortQueryString(`${path}${query !== undefined ? `?${query.toString()}` : ''}`);
|
|
15
16
|
const request = {
|
|
@@ -20,7 +21,7 @@ export const handler = async (event, context)=>{
|
|
|
20
21
|
path,
|
|
21
22
|
query: query === undefined ? null : Object.fromEntries(query),
|
|
22
23
|
body: event.body ?? '{}',
|
|
23
|
-
headers: JSON.stringify(event.headers)
|
|
24
|
+
headers: JSON.stringify(event.headers),
|
|
24
25
|
};
|
|
25
26
|
// Check if response exists
|
|
26
27
|
console.debug(`Checking if response exists for ${event.httpMethod} ${pathWithQuery}...`);
|
|
@@ -30,31 +31,36 @@ export const handler = async (event, context)=>{
|
|
|
30
31
|
FilterExpression: 'begins_with(methodPathQuery, :methodPath)',
|
|
31
32
|
ExpressionAttributeValues: {
|
|
32
33
|
[':methodPath']: {
|
|
33
|
-
S: `${event.httpMethod} ${path}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
34
|
+
S: `${event.httpMethod} ${path}`,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
36
37
|
}));
|
|
37
38
|
console.debug(`Found response items beginning with same path: ${Items?.length}`);
|
|
38
39
|
// use newest response first
|
|
39
|
-
const itemsByTimestampDesc = (Items ?? [])
|
|
40
|
+
const itemsByTimestampDesc = (Items ?? [])
|
|
41
|
+
.map((Item) => unmarshall(Item))
|
|
42
|
+
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
40
43
|
let res = {
|
|
41
44
|
statusCode: 404,
|
|
42
|
-
body: 'No responses found'
|
|
45
|
+
body: 'No responses found',
|
|
43
46
|
};
|
|
44
|
-
for (const objItem of itemsByTimestampDesc){
|
|
47
|
+
for (const objItem of itemsByTimestampDesc) {
|
|
45
48
|
const hasExpectedQueryParams = 'queryParams' in objItem || query !== undefined;
|
|
46
|
-
const matchedQueryParams = hasExpectedQueryParams
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
const matchedQueryParams = hasExpectedQueryParams
|
|
50
|
+
? checkMatchingQueryParams(event.queryStringParameters, objItem.queryParams)
|
|
51
|
+
: true;
|
|
52
|
+
if (matchedQueryParams === false)
|
|
53
|
+
continue;
|
|
54
|
+
console.debug(`Matched response`, JSON.stringify({ response: objItem }));
|
|
55
|
+
if (objItem?.requestId !== undefined &&
|
|
56
|
+
objItem?.timestamp !== undefined &&
|
|
57
|
+
objItem?.keep !== true) {
|
|
52
58
|
await db.send(new DeleteItemCommand({
|
|
53
59
|
TableName: process.env.RESPONSES_TABLE_NAME,
|
|
54
60
|
Key: marshall({
|
|
55
61
|
requestId: objItem.requestId,
|
|
56
|
-
timestamp: objItem.timestamp
|
|
57
|
-
})
|
|
62
|
+
timestamp: objItem.timestamp,
|
|
63
|
+
}),
|
|
58
64
|
}));
|
|
59
65
|
}
|
|
60
66
|
const { body, headers } = splitMockResponse(objItem.body ?? '');
|
|
@@ -62,18 +68,20 @@ export const handler = async (event, context)=>{
|
|
|
62
68
|
const isBinary = /^[0-9a-f]+$/.test(body);
|
|
63
69
|
res = {
|
|
64
70
|
statusCode: objItem.statusCode ?? 200,
|
|
65
|
-
headers: isBinary
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
headers: isBinary
|
|
72
|
+
? {
|
|
73
|
+
...headers,
|
|
74
|
+
'Content-Type': 'application/octet-stream',
|
|
75
|
+
}
|
|
76
|
+
: headers,
|
|
77
|
+
body: isBinary
|
|
78
|
+
? /* body is HEX encoded */ Buffer.from(body, 'hex').toString('base64')
|
|
79
|
+
: body,
|
|
80
|
+
isBase64Encoded: isBinary,
|
|
71
81
|
};
|
|
72
82
|
break;
|
|
73
83
|
}
|
|
74
|
-
console.debug(`Return response`, JSON.stringify({
|
|
75
|
-
response: res
|
|
76
|
-
}));
|
|
84
|
+
console.debug(`Return response`, JSON.stringify({ response: res }));
|
|
77
85
|
await db.send(new PutItemCommand({
|
|
78
86
|
TableName: process.env.REQUESTS_TABLE_NAME,
|
|
79
87
|
Item: marshall({
|
|
@@ -81,10 +89,10 @@ export const handler = async (event, context)=>{
|
|
|
81
89
|
responseStatusCode: res.statusCode,
|
|
82
90
|
responseHeaders: res.headers,
|
|
83
91
|
responseBody: res.body,
|
|
84
|
-
responseIsBase64Encoded: res.isBase64Encoded
|
|
92
|
+
responseIsBase64Encoded: res.isBase64Encoded,
|
|
85
93
|
}, {
|
|
86
|
-
removeUndefinedValues: true
|
|
87
|
-
})
|
|
94
|
+
removeUndefinedValues: true,
|
|
95
|
+
}),
|
|
88
96
|
}));
|
|
89
97
|
return res;
|
|
90
98
|
};
|
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
export const splitMockResponse = (r)=>{
|
|
2
|
-
const trimmedLines = r
|
|
1
|
+
export const splitMockResponse = (r) => {
|
|
2
|
+
const trimmedLines = r
|
|
3
|
+
.split('\n')
|
|
4
|
+
.map((s) => s.trim())
|
|
5
|
+
.join('\n');
|
|
3
6
|
const blankLineLocation = trimmedLines.indexOf('\n\n');
|
|
4
|
-
if (blankLineLocation === -1)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
if (blankLineLocation === -1)
|
|
8
|
+
return {
|
|
9
|
+
headers: {},
|
|
10
|
+
body: trimmedLines,
|
|
11
|
+
};
|
|
8
12
|
return {
|
|
9
|
-
headers: trimmedLines
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
headers: trimmedLines
|
|
14
|
+
.slice(0, blankLineLocation)
|
|
15
|
+
.split('\n')
|
|
16
|
+
.map((s) => s.split(':', 2))
|
|
17
|
+
.reduce((headers, [k, v]) => ({ ...headers, [k]: v?.trim() }), {}),
|
|
18
|
+
body: trimmedLines.slice(blankLineLocation + 2),
|
|
14
19
|
};
|
|
15
20
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { splitMockResponse } from './splitMockResponse.js';
|
|
4
|
+
void describe('split mock response', () => {
|
|
5
|
+
void it('should parse headers and body', () => assert.deepEqual(splitMockResponse(`Content-Type: application/octet-stream
|
|
6
|
+
|
|
7
|
+
(binary A-GNSS data) other types`), {
|
|
8
|
+
headers: {
|
|
9
|
+
'Content-Type': 'application/octet-stream',
|
|
10
|
+
},
|
|
11
|
+
body: '(binary A-GNSS data) other types',
|
|
12
|
+
}));
|
|
13
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/npm/e2e.spec.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import { listRequests } from './src/requests.js';
|
|
7
|
+
import { registerResponse } from './src/responses.js';
|
|
8
|
+
const { responsesTableName, apiURL, requestsTableName } = JSON.parse(await readFile(path.join(process.cwd(), 'http-api-mock.json'), 'utf-8'));
|
|
9
|
+
const db = new DynamoDBClient({});
|
|
10
|
+
void describe('end-to-end tests', () => {
|
|
11
|
+
void it('should respond with 404 if no response is configured', async () => {
|
|
12
|
+
const res = await fetch(new URL('/prod/foo', apiURL), {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
body: JSON.stringify({ some: 'data' }),
|
|
15
|
+
headers: {
|
|
16
|
+
'content-type': 'application/json; charset=utf-8',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
assert.equal(res.ok, false);
|
|
20
|
+
assert.equal(res.status, 404);
|
|
21
|
+
});
|
|
22
|
+
void it('should store all requests', async () => {
|
|
23
|
+
const pathSegment = crypto.randomUUID();
|
|
24
|
+
await fetch(new URL(`/prod/${pathSegment}`, apiURL));
|
|
25
|
+
const request = (await listRequests(db, requestsTableName)).find(({ path }) => path === pathSegment);
|
|
26
|
+
assert.notEqual(request, undefined);
|
|
27
|
+
});
|
|
28
|
+
void it('should return a configured response', async () => {
|
|
29
|
+
const pathSegment = crypto.randomUUID();
|
|
30
|
+
await registerResponse(db, responsesTableName, {
|
|
31
|
+
method: 'PUT',
|
|
32
|
+
path: pathSegment,
|
|
33
|
+
queryParams: new URLSearchParams({
|
|
34
|
+
foo: 'bar',
|
|
35
|
+
}),
|
|
36
|
+
body: [
|
|
37
|
+
`Content-Type: application/json`,
|
|
38
|
+
'',
|
|
39
|
+
JSON.stringify({ success: true }),
|
|
40
|
+
].join('\n'),
|
|
41
|
+
statusCode: 201,
|
|
42
|
+
});
|
|
43
|
+
const res = await fetch(new URL(`/prod/${pathSegment}?${new URLSearchParams({ foo: 'bar' }).toString()}`, apiURL), {
|
|
44
|
+
method: 'PUT',
|
|
45
|
+
});
|
|
46
|
+
assert.equal(res.ok, true);
|
|
47
|
+
assert.equal(res.status, 201);
|
|
48
|
+
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
|
49
|
+
assert.deepEqual(await res.json(), { success: true });
|
|
50
|
+
});
|
|
51
|
+
});
|
package/npm/package.json
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bifravst/http-api-mock",
|
|
3
|
+
"version": "3.0.6",
|
|
4
|
+
"description": "Helper functions for AWS lambdas written in TypeScript.",
|
|
5
|
+
"exports": {
|
|
6
|
+
"./*": {
|
|
7
|
+
"import": {
|
|
8
|
+
"default": "./npm/src/*.js",
|
|
9
|
+
"types": "./npm/src/*.d.ts"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"prepare": "husky && case \"$npm_command\" in install|ci) check-node-version --package ;; esac",
|
|
16
|
+
"prepublishOnly": "npx tsc --noEmit false --outDir ./npm -d && cp -rv cdk/* npm/cdk && cp package.json npm/package.json",
|
|
17
|
+
"test": "node --no-warnings --experimental-transform-types --test \"!(node_modules|e2e-tests|integration-tests)/**/*.spec.ts\""
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/bifravst/http-api-mock.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/bifravst/http-api-mock/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/bifravst/http-api-mock",
|
|
27
|
+
"keywords": [
|
|
28
|
+
"aws",
|
|
29
|
+
"lambda",
|
|
30
|
+
"typescript"
|
|
31
|
+
],
|
|
32
|
+
"author": "Nordic Semiconductor ASA | nordicsemi.no",
|
|
33
|
+
"license": "BSD-3-Clause",
|
|
34
|
+
"lint-staged": {
|
|
35
|
+
"*.ts": [
|
|
36
|
+
"prettier --write",
|
|
37
|
+
"eslint"
|
|
38
|
+
],
|
|
39
|
+
"*.{md,json,yaml,yml}": [
|
|
40
|
+
"prettier --write"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=24.19.0 <25",
|
|
45
|
+
"npm": ">=12.0.2 <13"
|
|
46
|
+
},
|
|
47
|
+
"release": {
|
|
48
|
+
"branches": [
|
|
49
|
+
"saga"
|
|
50
|
+
],
|
|
51
|
+
"remoteTags": true,
|
|
52
|
+
"plugins": [
|
|
53
|
+
"@semantic-release/commit-analyzer",
|
|
54
|
+
"@semantic-release/release-notes-generator",
|
|
55
|
+
"@semantic-release/npm",
|
|
56
|
+
[
|
|
57
|
+
"@semantic-release/github",
|
|
58
|
+
{
|
|
59
|
+
"successCommentCondition": false,
|
|
60
|
+
"failTitle": false
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
},
|
|
68
|
+
"files": [
|
|
69
|
+
"npm",
|
|
70
|
+
"cdk",
|
|
71
|
+
"src",
|
|
72
|
+
"LICENSE",
|
|
73
|
+
"README.md"
|
|
74
|
+
],
|
|
75
|
+
"prettier": "@bifravst/prettier-config",
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"@aws-sdk/client-cloudformation": "3.1111.0",
|
|
78
|
+
"@aws-sdk/client-dynamodb": "3.1111.0",
|
|
79
|
+
"@aws-sdk/client-sts": "3.1111.0",
|
|
80
|
+
"@aws-cdk/toolkit-lib": "1.38.2",
|
|
81
|
+
"@aws-sdk/util-dynamodb": "3.996.9",
|
|
82
|
+
"@bifravst/aws-cdk-lambda-helpers": "5.0.4",
|
|
83
|
+
"@bifravst/cloudformation-helpers": "10.0.1",
|
|
84
|
+
"@bifravst/from-env": "4.0.0",
|
|
85
|
+
"@bifravst/run": "2.0.0",
|
|
86
|
+
"aws-cdk-lib": "2.265.0",
|
|
87
|
+
"cdk": "2.1136.0",
|
|
88
|
+
"chalk": "6.0.0",
|
|
89
|
+
"command-line-args": "6.0.2"
|
|
90
|
+
},
|
|
91
|
+
"devDependencies": {
|
|
92
|
+
"@bifravst/eslint-config-typescript": "8.0.0",
|
|
93
|
+
"@bifravst/prettier-config": "2.0.0",
|
|
94
|
+
"@commitlint/config-conventional": "21.2.2",
|
|
95
|
+
"@types/aws-lambda": "8.10.162",
|
|
96
|
+
"@types/command-line-args": "5.2.3",
|
|
97
|
+
"@types/node": "26.2.0",
|
|
98
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
99
|
+
"check-node-version": "4.2.1",
|
|
100
|
+
"commitlint": "21.2.2",
|
|
101
|
+
"husky": "9.1.7",
|
|
102
|
+
"lint-staged": "17.3.0",
|
|
103
|
+
"typescript": "npm:@typescript/typescript6@6.0.2"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
2
|
+
type MockResponseFn = (methodPathQuery: string, response: Partial<{
|
|
3
|
+
headers: Headers;
|
|
4
|
+
status: number;
|
|
5
|
+
body: string;
|
|
6
|
+
}>, keep?: boolean) => Promise<void>;
|
|
7
|
+
export declare const mockResponse: (db: DynamoDBClient, responsesTable: string) => MockResponseFn;
|
|
8
|
+
export type HttpAPIMock = {
|
|
9
|
+
response: MockResponseFn;
|
|
10
|
+
};
|
|
11
|
+
export declare const mock: ({ db, responsesTable, }: {
|
|
12
|
+
db: DynamoDBClient;
|
|
13
|
+
responsesTable: string;
|
|
14
|
+
}) => HttpAPIMock;
|
|
15
|
+
export {};
|
package/npm/src/mock.js
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
import { registerResponse } from './responses.js';
|
|
2
|
-
export const mockResponse = (db, responsesTable)=>async (methodPathQuery, response, keep)=>{
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
2
|
+
export const mockResponse = (db, responsesTable) => async (methodPathQuery, response, keep) => {
|
|
3
|
+
const [method, pathWithQuery] = methodPathQuery.split(' ', 2);
|
|
4
|
+
if (!/^[A-Z]+$/.test(method ?? ''))
|
|
5
|
+
throw new Error(`Invalid method ${method} in ${methodPathQuery}!`);
|
|
6
|
+
if (pathWithQuery === undefined)
|
|
7
|
+
throw new Error(`Missing path in ${methodPathQuery}!`);
|
|
8
|
+
const [path, query] = pathWithQuery.split('?', 2);
|
|
9
|
+
if (path.startsWith('/'))
|
|
10
|
+
throw new Error(`Path ${path} must not start with /!`);
|
|
11
|
+
const bodyParts = [];
|
|
12
|
+
if (response.headers !== undefined) {
|
|
13
|
+
for (const [k, v] of response.headers.entries()) {
|
|
14
|
+
bodyParts.push(`${k}: ${v}`);
|
|
14
15
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
response: mockResponse(db, responsesTable)
|
|
16
|
+
bodyParts.push('');
|
|
17
|
+
}
|
|
18
|
+
if (response.body !== undefined)
|
|
19
|
+
bodyParts.push(response.body);
|
|
20
|
+
await registerResponse(db, responsesTable, {
|
|
21
|
+
path,
|
|
22
|
+
method: method ?? 'GET',
|
|
23
|
+
queryParams: new URLSearchParams(query),
|
|
24
|
+
body: bodyParts.length > 0 ? bodyParts.join('\n') : undefined,
|
|
25
|
+
statusCode: response.status,
|
|
26
|
+
keep,
|
|
27
27
|
});
|
|
28
|
+
};
|
|
29
|
+
export const mock = ({ db, responsesTable, }) => ({
|
|
30
|
+
response: mockResponse(db, responsesTable),
|
|
31
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { unmarshall } from '@aws-sdk/util-dynamodb';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { describe, it, mock as testMock } from 'node:test';
|
|
4
|
+
import { mock } from './mock.js';
|
|
5
|
+
void describe('mock()', () => {
|
|
6
|
+
void it('should register a response', async () => {
|
|
7
|
+
const db = {
|
|
8
|
+
send: testMock.fn(async () => Promise.resolve(undefined)),
|
|
9
|
+
};
|
|
10
|
+
const httpApiMock = mock({
|
|
11
|
+
db: db,
|
|
12
|
+
responsesTable: 'response-table',
|
|
13
|
+
});
|
|
14
|
+
await httpApiMock.response(`GET foo/bar?k=v`, {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: new Headers({
|
|
17
|
+
'content-type': 'application/json; charset=utf-8',
|
|
18
|
+
}),
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
result: 'some-value',
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
assert.equal(db.send.mock.callCount(), 1);
|
|
24
|
+
const [{ input: args }] = db.send.mock.calls[0]?.arguments;
|
|
25
|
+
assert.equal(args.TableName, 'response-table');
|
|
26
|
+
const { methodPathQuery, statusCode, body, queryParams } = unmarshall(args.Item);
|
|
27
|
+
assert.equal(statusCode, 200);
|
|
28
|
+
assert.equal(methodPathQuery, 'GET foo/bar?k=v');
|
|
29
|
+
assert.equal(body, [
|
|
30
|
+
`content-type: application/json; charset=utf-8`,
|
|
31
|
+
``,
|
|
32
|
+
JSON.stringify({ result: 'some-value' }),
|
|
33
|
+
].join('\n'));
|
|
34
|
+
assert.deepEqual(queryParams, { k: 'v' });
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
export const parseMockRequest = (r)=>{
|
|
1
|
+
export const parseMockRequest = (r) => {
|
|
2
2
|
const lines = r.split('\n');
|
|
3
3
|
const methodResourceProtol = lines.shift();
|
|
4
4
|
const blankLineLocation = lines.indexOf('');
|
|
5
5
|
const headerLines = blankLineLocation === -1 ? lines : lines.slice(0, blankLineLocation);
|
|
6
|
-
const body = blankLineLocation === -1
|
|
6
|
+
const body = blankLineLocation === -1
|
|
7
|
+
? ''
|
|
8
|
+
: lines.slice(blankLineLocation + 1).join('\n');
|
|
7
9
|
const requestInfo = /^(?<method>[A-Z]+) (?<resource>[^ ]+) (?<protocol>HTTP\/[0-9.]+)/.exec(methodResourceProtol ?? '')?.groups;
|
|
8
|
-
if (requestInfo === null)
|
|
10
|
+
if (requestInfo === null)
|
|
11
|
+
throw new Error(`Invalid request info: ${methodResourceProtol}`);
|
|
9
12
|
return {
|
|
10
13
|
...requestInfo,
|
|
11
|
-
headers: headerLines
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
body
|
|
14
|
+
headers: headerLines
|
|
15
|
+
.map((s) => s.split(':', 2))
|
|
16
|
+
.reduce((headers, [k, v]) => ({ ...headers, [k ?? '']: v?.trim() }), {}),
|
|
17
|
+
body,
|
|
16
18
|
};
|
|
17
19
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { parseMockRequest } from './parseMockRequest.js';
|
|
4
|
+
void describe('parseMockRequest()', () => {
|
|
5
|
+
void it('should parse method, resource, protocol, headers and body', () => assert.deepEqual(parseMockRequest([
|
|
6
|
+
`PATCH /v1/devices/foo/state HTTP/1.1`,
|
|
7
|
+
`Content-Length: 36`,
|
|
8
|
+
`Content-Type: application/json`,
|
|
9
|
+
`If-Match: 8835`,
|
|
10
|
+
``,
|
|
11
|
+
`{"desired":{"config":{"nod":null}}}`,
|
|
12
|
+
].join('\n')), {
|
|
13
|
+
method: 'PATCH',
|
|
14
|
+
resource: '/v1/devices/foo/state',
|
|
15
|
+
protocol: 'HTTP/1.1',
|
|
16
|
+
headers: {
|
|
17
|
+
'Content-Length': '36',
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
'If-Match': '8835',
|
|
20
|
+
},
|
|
21
|
+
body: '{"desired":{"config":{"nod":null}}}',
|
|
22
|
+
}));
|
|
23
|
+
});
|
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
export const parseMockResponse = (r)=>{
|
|
1
|
+
export const parseMockResponse = (r) => {
|
|
2
2
|
const lines = r.split('\n');
|
|
3
3
|
const protocolStatusCode = lines.shift();
|
|
4
4
|
const blankLineLocation = lines.indexOf('');
|
|
5
5
|
const headerLines = blankLineLocation === -1 ? lines : lines.slice(0, blankLineLocation);
|
|
6
|
-
const body = blankLineLocation === -1
|
|
6
|
+
const body = blankLineLocation === -1
|
|
7
|
+
? ''
|
|
8
|
+
: lines.slice(blankLineLocation + 1).join('\n');
|
|
7
9
|
const responseInfo = /^(?<protocol>HTTP\/[0-9.]+) (?<statusCode>[0-9]+) /.exec(protocolStatusCode ?? '')?.groups;
|
|
8
|
-
if (responseInfo === null)
|
|
10
|
+
if (responseInfo === null)
|
|
11
|
+
throw new Error(`Invalid request info: ${protocolStatusCode}`);
|
|
9
12
|
return {
|
|
10
13
|
statusCode: parseInt(responseInfo.statusCode, 10),
|
|
11
14
|
protocol: responseInfo.protocol,
|
|
12
|
-
headers: headerLines
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
body
|
|
15
|
+
headers: headerLines
|
|
16
|
+
.map((s) => s.split(':', 2))
|
|
17
|
+
.reduce((headers, [k, v]) => ({ ...headers, [k ?? '']: v?.trim() }), {}),
|
|
18
|
+
body,
|
|
17
19
|
};
|
|
18
20
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { parseMockResponse } from './parseMockResponse.js';
|
|
4
|
+
void describe('parseMockResponse()', () => {
|
|
5
|
+
void it('should parse protocol, statusCode, headers and body', () => assert.deepEqual(parseMockResponse([
|
|
6
|
+
`HTTP/1.1 202 Accepted`,
|
|
7
|
+
`Content-Length: 36`,
|
|
8
|
+
`Content-Type: application/json`,
|
|
9
|
+
``,
|
|
10
|
+
`{"desired":{"config":{"nod":null}}}`,
|
|
11
|
+
].join('\n')), {
|
|
12
|
+
statusCode: 202,
|
|
13
|
+
protocol: 'HTTP/1.1',
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Length': '36',
|
|
16
|
+
'Content-Type': 'application/json',
|
|
17
|
+
},
|
|
18
|
+
body: '{"desired":{"config":{"nod":null}}}',
|
|
19
|
+
}));
|
|
20
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const randomString: () => string;
|
package/npm/src/randomString.js
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
2
|
+
export type Request = {
|
|
3
|
+
path: string;
|
|
4
|
+
query: null;
|
|
5
|
+
timestamp: string;
|
|
6
|
+
ttl: string;
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
method: string;
|
|
9
|
+
requestId: string;
|
|
10
|
+
body: string;
|
|
11
|
+
methodPathQuery: string;
|
|
12
|
+
};
|
|
13
|
+
export declare const listRequests: (db: DynamoDBClient, requestsTable: string) => Promise<Array<Request>>;
|
package/npm/src/requests.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { ScanCommand } from '@aws-sdk/client-dynamodb';
|
|
2
2
|
import { unmarshall } from '@aws-sdk/util-dynamodb';
|
|
3
|
-
export const listRequests = async (db, requestsTable)=>((await db.send(new ScanCommand({
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
export const listRequests = async (db, requestsTable) => ((await db.send(new ScanCommand({ TableName: requestsTable }))).Items ?? [])
|
|
4
|
+
.map((item) => {
|
|
5
|
+
const i = unmarshall(item);
|
|
6
|
+
return {
|
|
7
|
+
...i,
|
|
8
|
+
headers: JSON.parse(i.headers),
|
|
9
|
+
};
|
|
10
|
+
})
|
|
11
|
+
.sort((i1, i2) => i1.timestamp.localeCompare(i2.timestamp));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
2
|
+
export type Response = {
|
|
3
|
+
method: string;
|
|
4
|
+
path: string;
|
|
5
|
+
queryParams?: URLSearchParams;
|
|
6
|
+
statusCode?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Header + Body
|
|
9
|
+
*
|
|
10
|
+
* @see splitMockResponse
|
|
11
|
+
*/
|
|
12
|
+
body?: string;
|
|
13
|
+
ttl?: number;
|
|
14
|
+
keep?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare const registerResponse: (db: DynamoDBClient, responsesTable: string, response: Response) => Promise<void>;
|
package/npm/src/responses.js
CHANGED
|
@@ -2,7 +2,7 @@ import { PutItemCommand } from '@aws-sdk/client-dynamodb';
|
|
|
2
2
|
import { marshall } from '@aws-sdk/util-dynamodb';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { sortQuery } from './sortQueryString.js';
|
|
5
|
-
export const registerResponse = async (db, responsesTable, response)=>{
|
|
5
|
+
export const registerResponse = async (db, responsesTable, response) => {
|
|
6
6
|
await db.send(new PutItemCommand({
|
|
7
7
|
TableName: responsesTable,
|
|
8
8
|
Item: marshall({
|
|
@@ -11,11 +11,11 @@ export const registerResponse = async (db, responsesTable, response)=>{
|
|
|
11
11
|
timestamp: new Date().toISOString(),
|
|
12
12
|
statusCode: response.statusCode,
|
|
13
13
|
body: response.body,
|
|
14
|
-
queryParams: response.queryParams !== undefined
|
|
14
|
+
queryParams: response.queryParams !== undefined
|
|
15
|
+
? Object.fromEntries(response.queryParams)
|
|
16
|
+
: undefined,
|
|
15
17
|
ttl: response.ttl,
|
|
16
|
-
keep: response.keep
|
|
17
|
-
}, {
|
|
18
|
-
removeUndefinedValues: true
|
|
19
|
-
})
|
|
18
|
+
keep: response.keep,
|
|
19
|
+
}, { removeUndefinedValues: true }),
|
|
20
20
|
}));
|
|
21
21
|
};
|
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
import { URLSearchParams } from 'node:url';
|
|
2
|
-
export const sortQueryString = (mockUrl)=>{
|
|
2
|
+
export const sortQueryString = (mockUrl) => {
|
|
3
3
|
const [host, query] = mockUrl.split('?', 2);
|
|
4
|
-
if (query === undefined || (query?.length ?? 0) === 0)
|
|
4
|
+
if (query === undefined || (query?.length ?? 0) === 0)
|
|
5
|
+
return host;
|
|
5
6
|
return `${host}?${sortQuery(new URLSearchParams(query))}`;
|
|
6
7
|
};
|
|
7
|
-
export const sortQuery = (query)=>{
|
|
8
|
+
export const sortQuery = (query) => {
|
|
8
9
|
const params = [];
|
|
9
10
|
if (query instanceof URLSearchParams) {
|
|
10
|
-
query.forEach((v, k)=>{
|
|
11
|
-
params.push([
|
|
12
|
-
k,
|
|
13
|
-
v
|
|
14
|
-
]);
|
|
11
|
+
query.forEach((v, k) => {
|
|
12
|
+
params.push([k, v]);
|
|
15
13
|
});
|
|
16
|
-
}
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
17
16
|
params.push(...Object.entries(query));
|
|
18
17
|
}
|
|
19
|
-
params.sort(([k1], [k2])=>(k1 ?? '').localeCompare(k2 ?? ''));
|
|
18
|
+
params.sort(([k1], [k2]) => (k1 ?? '').localeCompare(k2 ?? ''));
|
|
20
19
|
const sortedParams = new URLSearchParams();
|
|
21
|
-
for (const [k, v] of params){
|
|
20
|
+
for (const [k, v] of params) {
|
|
22
21
|
sortedParams.append(k, v);
|
|
23
22
|
}
|
|
24
23
|
return sortedParams.toString();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, test as it } from 'node:test';
|
|
3
|
+
import { URLSearchParams } from 'node:url';
|
|
4
|
+
import { sortQuery, sortQueryString } from './sortQueryString.js';
|
|
5
|
+
void describe('sortQueryString', () => {
|
|
6
|
+
void it('should sort the query part of a mock URL', () => assert.deepStrictEqual(sortQueryString('api.nrfcloud.com/v1/location/agps?eci=73393515&tac=132&requestType=custom&mcc=397&mnc=73&customTypes=2'), 'api.nrfcloud.com/v1/location/agps?customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132'));
|
|
7
|
+
});
|
|
8
|
+
void describe('sortQuery', () => {
|
|
9
|
+
void it('should sort URLSearchParams', () => assert.equal(sortQuery(new URLSearchParams('eci=73393515&tac=132&requestType=custom&mcc=397&mnc=73&customTypes=2')), 'customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132'));
|
|
10
|
+
void it('should sort a Record', () => assert.equal(sortQuery({
|
|
11
|
+
eci: '73393515',
|
|
12
|
+
tac: '132',
|
|
13
|
+
requestType: 'custom',
|
|
14
|
+
mcc: '397',
|
|
15
|
+
mnc: '73',
|
|
16
|
+
customTypes: '2',
|
|
17
|
+
}), 'customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132'));
|
|
18
|
+
});
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bifravst/http-api-mock",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.6",
|
|
4
4
|
"description": "Helper functions for AWS lambdas written in TypeScript.",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./*": {
|
|
7
7
|
"import": {
|
|
8
8
|
"default": "./npm/src/*.js",
|
|
9
|
-
"types": "./src/*.ts"
|
|
9
|
+
"types": "./npm/src/*.d.ts"
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
14
|
"scripts": {
|
|
15
15
|
"prepare": "husky && case \"$npm_command\" in install|ci) check-node-version --package ;; esac",
|
|
16
|
-
"prepublishOnly": "
|
|
16
|
+
"prepublishOnly": "npx tsc --noEmit false --outDir ./npm -d && cp -rv cdk/* npm/cdk && cp package.json npm/package.json",
|
|
17
17
|
"test": "node --no-warnings --experimental-transform-types --test \"!(node_modules|e2e-tests|integration-tests)/**/*.spec.ts\""
|
|
18
18
|
},
|
|
19
19
|
"repository": {
|
|
@@ -79,18 +79,18 @@
|
|
|
79
79
|
"@aws-sdk/client-sts": "3.1111.0",
|
|
80
80
|
"@aws-cdk/toolkit-lib": "1.38.2",
|
|
81
81
|
"@aws-sdk/util-dynamodb": "3.996.9",
|
|
82
|
-
"@bifravst/aws-cdk-lambda-helpers": "
|
|
83
|
-
"@bifravst/cloudformation-helpers": "
|
|
84
|
-
"@bifravst/from-env": "
|
|
85
|
-
"@bifravst/run": "
|
|
82
|
+
"@bifravst/aws-cdk-lambda-helpers": "5.0.4",
|
|
83
|
+
"@bifravst/cloudformation-helpers": "10.0.1",
|
|
84
|
+
"@bifravst/from-env": "4.0.0",
|
|
85
|
+
"@bifravst/run": "2.0.0",
|
|
86
86
|
"aws-cdk-lib": "2.265.0",
|
|
87
87
|
"cdk": "2.1136.0",
|
|
88
88
|
"chalk": "6.0.0",
|
|
89
89
|
"command-line-args": "6.0.2"
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
|
-
"@bifravst/eslint-config-typescript": "
|
|
93
|
-
"@bifravst/prettier-config": "
|
|
92
|
+
"@bifravst/eslint-config-typescript": "8.0.0",
|
|
93
|
+
"@bifravst/prettier-config": "2.0.0",
|
|
94
94
|
"@commitlint/config-conventional": "21.2.2",
|
|
95
95
|
"@types/aws-lambda": "8.10.162",
|
|
96
96
|
"@types/command-line-args": "5.2.3",
|