@bifravst/http-api-mock 2.1.465 → 2.2.0-dev.2

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.
Files changed (39) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +16 -0
  3. package/npm/cdk/App.js +15 -0
  4. package/npm/cdk/Stack.js +45 -0
  5. package/npm/cdk/http-api-mock.js +77 -0
  6. package/npm/cdk/resources/HttpApiMock.js +87 -0
  7. package/npm/cdk/resources/checkMatchingQueryParams.js +31 -0
  8. package/npm/cdk/resources/http-api-mock-lambda.js +90 -0
  9. package/npm/cdk/resources/splitMockResponse.js +15 -0
  10. package/package.json +25 -22
  11. package/src/mock.spec.ts +52 -0
  12. package/src/mock.ts +61 -0
  13. package/src/parseMockRequest.spec.ts +30 -0
  14. package/src/parseMockRequest.ts +35 -0
  15. package/src/parseMockResponse.spec.ts +27 -0
  16. package/src/parseMockResponse.ts +35 -0
  17. package/src/randomString.ts +7 -0
  18. package/src/requests.ts +28 -0
  19. package/src/responses.ts +50 -0
  20. package/src/sortQueryString.spec.ts +38 -0
  21. package/src/sortQueryString.ts +26 -0
  22. package/npm/mock.d.ts +0 -15
  23. package/npm/mock.spec.d.ts +0 -1
  24. package/npm/parseMockRequest.d.ts +0 -7
  25. package/npm/parseMockRequest.spec.d.ts +0 -1
  26. package/npm/parseMockResponse.d.ts +0 -6
  27. package/npm/parseMockResponse.spec.d.ts +0 -1
  28. package/npm/randomString.d.ts +0 -1
  29. package/npm/requests.d.ts +0 -13
  30. package/npm/responses.d.ts +0 -16
  31. package/npm/sortQueryString.d.ts +0 -3
  32. package/npm/sortQueryString.spec.d.ts +0 -1
  33. /package/npm/{mock.js → src/mock.js} +0 -0
  34. /package/npm/{parseMockRequest.js → src/parseMockRequest.js} +0 -0
  35. /package/npm/{parseMockResponse.js → src/parseMockResponse.js} +0 -0
  36. /package/npm/{randomString.js → src/randomString.js} +0 -0
  37. /package/npm/{requests.js → src/requests.js} +0 -0
  38. /package/npm/{responses.js → src/responses.js} +0 -0
  39. /package/npm/{sortQueryString.js → src/sortQueryString.js} +0 -0
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  BSD 3-Clause License
2
2
 
3
- Copyright (c) 2024-2025, Nordic Semiconductor ASA | nordicsemi.no
3
+ Copyright (c) 2024-2026, Nordic Semiconductor ASA | nordicsemi.no
4
4
  All rights reserved.
5
5
 
6
6
  Redistribution and use in source and binary forms, with or without
package/README.md CHANGED
@@ -37,3 +37,19 @@ npx @bifravst/http-api-mock describe <stackName>
37
37
  ```bash
38
38
  npx @bifravst/http-api-mock destroy <stackName>
39
39
  ```
40
+
41
+ ## Node & NPM
42
+
43
+ This project requires Node.js `>=24.19.0 <25` and npm `>=12.0.2 <13` (enforced
44
+ via `check-node-version` on `npm install` and `npm ci`).
45
+
46
+ The check is skipped during `npm publish` and `npm pack`, because
47
+ `semantic-release` bundles its own npm (`@semantic-release/npm` depends on
48
+ `npm@^11.6.2`) and runs the publish with that version rather than the one
49
+ installed in CI.
50
+
51
+ ## TypeScript 6 and 7
52
+
53
+ This repo
54
+ [runs TypeScript 6 and 7 side by side](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0),
55
+ [so that eslint works](https://github.com/typescript-eslint/typescript-eslint/issues/10940#issuecomment-4922812181).
package/npm/cdk/App.js ADDED
@@ -0,0 +1,15 @@
1
+ import { App } from 'aws-cdk-lib';
2
+ import { HTTPAPIMockStack } from './Stack.js';
3
+ export class HTTPAPIMockApp extends App {
4
+ constructor(stackName, { lambdaSources, layer }){
5
+ super({
6
+ context: {
7
+ isTest: true
8
+ }
9
+ });
10
+ new HTTPAPIMockStack(this, stackName, {
11
+ lambdaSources,
12
+ layer
13
+ });
14
+ }
15
+ }
@@ -0,0 +1,45 @@
1
+ import { LambdaSource } from '@bifravst/aws-cdk-lambda-helpers/cdk';
2
+ import { CfnOutput, aws_lambda as Lambda, Stack } from 'aws-cdk-lib';
3
+ import { HttpApiMock } from './resources/HttpApiMock.js';
4
+ /**
5
+ * This is CloudFormation stack sets up a dummy HTTP API which stores all requests in SQS for inspection
6
+ */ export class HTTPAPIMockStack extends Stack {
7
+ constructor(parent, stackName, { lambdaSources, layer }){
8
+ super(parent, stackName, {
9
+ description: 'Provides a mock HTTP API for testing third-party API integrations.'
10
+ });
11
+ const baseLayer = new Lambda.LayerVersion(this, 'baseLayer', {
12
+ layerVersionName: `${Stack.of(this).stackName}-baseLayer`,
13
+ code: new LambdaSource(this, {
14
+ id: 'baseLayer',
15
+ zipFilePath: layer.layerZipFilePath,
16
+ hash: layer.hash
17
+ }).code,
18
+ compatibleArchitectures: [
19
+ Lambda.Architecture.ARM_64
20
+ ],
21
+ compatibleRuntimes: [
22
+ Lambda.Runtime.NODEJS_24_X
23
+ ]
24
+ });
25
+ const httpMockApi = new HttpApiMock(this, {
26
+ lambdaSources,
27
+ layers: [
28
+ baseLayer
29
+ ]
30
+ });
31
+ // Export these so the test runner can use them
32
+ new CfnOutput(this, 'apiURL', {
33
+ value: httpMockApi.api.url,
34
+ exportName: `${this.stackName}:apiURL`
35
+ });
36
+ new CfnOutput(this, 'responsesTableName', {
37
+ value: httpMockApi.responsesTable.tableName,
38
+ exportName: `${this.stackName}:responsesTableName`
39
+ });
40
+ new CfnOutput(this, 'requestsTableName', {
41
+ value: httpMockApi.requestsTable.tableName,
42
+ exportName: `${this.stackName}:requestsTableName`
43
+ });
44
+ }
45
+ }
@@ -0,0 +1,77 @@
1
+ import { Toolkit } from '@aws-cdk/toolkit-lib';
2
+ import { CloudFormationClient } from '@aws-sdk/client-cloudformation';
3
+ import { packLambdaFromPath } from '@bifravst/aws-cdk-lambda-helpers';
4
+ import { packLayer } from '@bifravst/aws-cdk-lambda-helpers/layer';
5
+ import { stackOutput } from '@bifravst/cloudformation-helpers';
6
+ import commandLineArgs from 'command-line-args';
7
+ import { writeFileSync } from 'node:fs';
8
+ import fs from 'node:fs/promises';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { randomString } from '../src/randomString.js';
13
+ import { HTTPAPIMockApp } from './App.js';
14
+ const options = commandLineArgs([
15
+ {
16
+ name: 'config',
17
+ type: Boolean,
18
+ defaultValue: false
19
+ },
20
+ {
21
+ name: 'destroy',
22
+ type: Boolean,
23
+ defaultValue: false
24
+ }
25
+ ]);
26
+ const loadConfig = async ()=>{
27
+ try {
28
+ const config = JSON.parse(await fs.readFile(path.join(process.cwd(), 'http-api-mock.json'), 'utf-8'));
29
+ return config;
30
+ } catch {
31
+ return {};
32
+ }
33
+ };
34
+ const stackName = process.env.HTTP_API_MOCK_STACK_NAME ?? (await loadConfig())?.stackName ?? `http-api-mock-${randomString()}`;
35
+ const saveConfig = async ()=>{
36
+ writeFileSync(path.join(process.cwd(), 'http-api-mock.json'), JSON.stringify({
37
+ stackName,
38
+ ...await stackOutput(new CloudFormationClient({}))(stackName)
39
+ }, null, 2));
40
+ };
41
+ if (options.config === true) {
42
+ await saveConfig();
43
+ process.exit(0);
44
+ }
45
+ const baseDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
46
+ const distDir = await fs.mkdtemp(path.join(os.tmpdir(), 'temp-'));
47
+ const lambdasDir = path.join(distDir, 'lambdas');
48
+ await fs.mkdir(lambdasDir);
49
+ const layersDir = path.join(distDir, 'layers');
50
+ await fs.mkdir(layersDir);
51
+ const dependencies = [
52
+ '@bifravst/from-env'
53
+ ];
54
+ const app = new HTTPAPIMockApp(stackName, {
55
+ lambdaSources: {
56
+ httpApiMock: await packLambdaFromPath({
57
+ id: 'httpApiMock',
58
+ sourceFilePath: 'cdk/resources/http-api-mock-lambda.ts',
59
+ baseDir,
60
+ distDir: lambdasDir
61
+ })
62
+ },
63
+ layer: await packLayer({
64
+ id: 'testResources',
65
+ dependencies,
66
+ baseDir,
67
+ distDir: layersDir
68
+ })
69
+ });
70
+ const cdk = new Toolkit();
71
+ const cx = await cdk.fromAssemblyBuilder(async ()=>app.synth());
72
+ if (options.destroy === true) {
73
+ await cdk.destroy(cx);
74
+ } else {
75
+ await cdk.deploy(cx);
76
+ await saveConfig();
77
+ }
@@ -0,0 +1,87 @@
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
+ export class HttpApiMock extends Resource {
4
+ api;
5
+ requestsTable;
6
+ responsesTable;
7
+ constructor(parent, { lambdaSources, layers }){
8
+ super(parent, 'http-api-mock');
9
+ // This table will store all the requests made to the API Gateway
10
+ this.requestsTable = new DynamoDB.Table(this, 'requests', {
11
+ billingMode: DynamoDB.BillingMode.PAY_PER_REQUEST,
12
+ partitionKey: {
13
+ name: 'requestId',
14
+ type: DynamoDB.AttributeType.STRING
15
+ },
16
+ sortKey: {
17
+ name: 'timestamp',
18
+ type: DynamoDB.AttributeType.STRING
19
+ },
20
+ removalPolicy: RemovalPolicy.DESTROY
21
+ });
22
+ this.requestsTable.addGlobalSecondaryIndex({
23
+ indexName: 'methodPathQuery',
24
+ partitionKey: {
25
+ name: 'methodPathQuery',
26
+ type: DynamoDB.AttributeType.STRING
27
+ },
28
+ projectionType: DynamoDB.ProjectionType.ALL
29
+ });
30
+ // This table will store optional responses to be sent
31
+ this.responsesTable = new DynamoDB.Table(this, 'responses', {
32
+ billingMode: DynamoDB.BillingMode.PAY_PER_REQUEST,
33
+ partitionKey: {
34
+ name: 'responseId',
35
+ type: DynamoDB.AttributeType.STRING
36
+ },
37
+ sortKey: {
38
+ name: 'timestamp',
39
+ type: DynamoDB.AttributeType.STRING
40
+ },
41
+ removalPolicy: RemovalPolicy.DESTROY,
42
+ timeToLiveAttribute: 'ttl'
43
+ });
44
+ this.responsesTable.addGlobalSecondaryIndex({
45
+ indexName: 'methodPathQuery',
46
+ partitionKey: {
47
+ name: 'methodPathQuery',
48
+ type: DynamoDB.AttributeType.STRING
49
+ },
50
+ projectionType: DynamoDB.ProjectionType.ALL
51
+ });
52
+ // This lambda will publish all requests made to the API Gateway in the queue
53
+ const lambda = new Lambda.Function(this, 'Lambda', {
54
+ description: 'Mocks a HTTP API and stores all requests in SQS for inspection, and optionally replies with enqued responses',
55
+ code: new LambdaSource(this, lambdaSources.httpApiMock).code,
56
+ layers,
57
+ handler: lambdaSources.httpApiMock.handler,
58
+ architecture: Lambda.Architecture.ARM_64,
59
+ runtime: Lambda.Runtime.NODEJS_24_X,
60
+ timeout: Duration.seconds(5),
61
+ environment: {
62
+ REQUESTS_TABLE_NAME: this.requestsTable.tableName,
63
+ RESPONSES_TABLE_NAME: this.responsesTable.tableName,
64
+ LOG_LEVEL: this.node.tryGetContext('logLevel'),
65
+ NODE_NO_WARNINGS: '1'
66
+ },
67
+ ...new LambdaLogGroup(this, 'LambdaLogs', Logs.RetentionDays.ONE_DAY)
68
+ });
69
+ this.responsesTable.grantReadWriteData(lambda);
70
+ this.requestsTable.grantReadWriteData(lambda);
71
+ // This is the API Gateway, AWS CDK automatically creates a prod stage and deployment
72
+ this.api = new ApiGateway.RestApi(this, 'api', {
73
+ restApiName: `HTTP Mock API for testing`,
74
+ description: 'API Gateway to test outgoing requests',
75
+ binaryMediaTypes: [
76
+ 'application/octet-stream'
77
+ ]
78
+ });
79
+ const proxyResource = this.api.root.addResource('{proxy+}');
80
+ proxyResource.addMethod('ANY', new ApiGateway.LambdaIntegration(lambda));
81
+ // API Gateway needs to be able to call the lambda
82
+ lambda.addPermission('InvokeByApiGateway', {
83
+ principal: new IAM.ServicePrincipal('apigateway.amazonaws.com'),
84
+ sourceArn: this.api.arnForExecuteApi()
85
+ });
86
+ }
87
+ }
@@ -0,0 +1,31 @@
1
+ const matchRegex = /^\/(?<re>.+)\/(?<option>[gi])?$/;
2
+ export const checkMatchingQueryParams = (actual, expected, log)=>{
3
+ log?.debug('checkMatchingQueryParams', {
4
+ actual,
5
+ expected
6
+ });
7
+ if (actual === null) return false;
8
+ // Check whether expected query parameters is subset of actual query parameters
9
+ for(const prop in expected){
10
+ const expectedValue = expected[prop];
11
+ const actualValue = actual?.[prop];
12
+ if (actualValue === undefined) return false;
13
+ if (typeof expectedValue === 'string') {
14
+ const match = matchRegex.exec(expectedValue);
15
+ if (match !== null) {
16
+ log?.debug('Compare using regex', {
17
+ expectedValue
18
+ });
19
+ // Expect is regex
20
+ const check = new RegExp(match?.groups?.re ?? '', match?.groups?.option).test(String(actualValue));
21
+ if (check === false) return false;
22
+ } else {
23
+ if (actualValue !== expectedValue) return false;
24
+ }
25
+ } else {
26
+ // All query parameters are string
27
+ if (actualValue !== String(expectedValue)) return false;
28
+ }
29
+ }
30
+ return true;
31
+ };
@@ -0,0 +1,90 @@
1
+ import { DeleteItemCommand, DynamoDBClient, PutItemCommand, ScanCommand } from '@aws-sdk/client-dynamodb';
2
+ import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
3
+ import { URLSearchParams } from 'url';
4
+ import { sortQueryString } from '../../src/sortQueryString.js';
5
+ import { checkMatchingQueryParams } from './checkMatchingQueryParams.js';
6
+ import { splitMockResponse } from './splitMockResponse.js';
7
+ const db = new DynamoDBClient({});
8
+ export const handler = async (event, context)=>{
9
+ console.log(JSON.stringify({
10
+ event
11
+ }));
12
+ const query = event.queryStringParameters !== null && event.queryStringParameters !== undefined ? new URLSearchParams(event.queryStringParameters) : undefined;
13
+ const path = event.path.replace(/^\//, '');
14
+ const pathWithQuery = sortQueryString(`${path}${query !== undefined ? `?${query.toString()}` : ''}`);
15
+ const request = {
16
+ methodPathQuery: `${event.httpMethod} ${pathWithQuery}`,
17
+ timestamp: new Date().toISOString(),
18
+ requestId: context.awsRequestId,
19
+ method: event.httpMethod,
20
+ path,
21
+ query: query === undefined ? null : Object.fromEntries(query),
22
+ body: event.body ?? '{}',
23
+ headers: JSON.stringify(event.headers)
24
+ };
25
+ // Check if response exists
26
+ console.debug(`Checking if response exists for ${event.httpMethod} ${pathWithQuery}...`);
27
+ // Scan using httpMethod and path only so query strings can be partially matched
28
+ const { Items } = await db.send(new ScanCommand({
29
+ TableName: process.env.RESPONSES_TABLE_NAME,
30
+ FilterExpression: 'begins_with(methodPathQuery, :methodPath)',
31
+ ExpressionAttributeValues: {
32
+ [':methodPath']: {
33
+ S: `${event.httpMethod} ${path}`
34
+ }
35
+ }
36
+ }));
37
+ console.debug(`Found response items beginning with same path: ${Items?.length}`);
38
+ // use newest response first
39
+ const itemsByTimestampDesc = (Items ?? []).map((Item)=>unmarshall(Item)).sort((a, b)=>b.timestamp.localeCompare(a.timestamp));
40
+ let res = {
41
+ statusCode: 404,
42
+ body: 'No responses found'
43
+ };
44
+ for (const objItem of itemsByTimestampDesc){
45
+ const hasExpectedQueryParams = 'queryParams' in objItem || query !== undefined;
46
+ const matchedQueryParams = hasExpectedQueryParams ? checkMatchingQueryParams(event.queryStringParameters, objItem.queryParams) : true;
47
+ if (matchedQueryParams === false) continue;
48
+ console.debug(`Matched response`, JSON.stringify({
49
+ response: objItem
50
+ }));
51
+ if (objItem?.requestId !== undefined && objItem?.timestamp !== undefined && objItem?.keep !== true) {
52
+ await db.send(new DeleteItemCommand({
53
+ TableName: process.env.RESPONSES_TABLE_NAME,
54
+ Key: marshall({
55
+ requestId: objItem.requestId,
56
+ timestamp: objItem.timestamp
57
+ })
58
+ }));
59
+ }
60
+ const { body, headers } = splitMockResponse(objItem.body ?? '');
61
+ // Send as binary, if mock response is HEX encoded. See https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings.html
62
+ const isBinary = /^[0-9a-f]+$/.test(body);
63
+ res = {
64
+ statusCode: objItem.statusCode ?? 200,
65
+ headers: isBinary ? {
66
+ ...headers,
67
+ 'Content-Type': 'application/octet-stream'
68
+ } : headers,
69
+ body: isBinary ? /* body is HEX encoded */ Buffer.from(body, 'hex').toString('base64') : body,
70
+ isBase64Encoded: isBinary
71
+ };
72
+ break;
73
+ }
74
+ console.debug(`Return response`, JSON.stringify({
75
+ response: res
76
+ }));
77
+ await db.send(new PutItemCommand({
78
+ TableName: process.env.REQUESTS_TABLE_NAME,
79
+ Item: marshall({
80
+ ...request,
81
+ responseStatusCode: res.statusCode,
82
+ responseHeaders: res.headers,
83
+ responseBody: res.body,
84
+ responseIsBase64Encoded: res.isBase64Encoded
85
+ }, {
86
+ removeUndefinedValues: true
87
+ })
88
+ }));
89
+ return res;
90
+ };
@@ -0,0 +1,15 @@
1
+ export const splitMockResponse = (r)=>{
2
+ const trimmedLines = r.split('\n').map((s)=>s.trim()).join('\n');
3
+ const blankLineLocation = trimmedLines.indexOf('\n\n');
4
+ if (blankLineLocation === -1) return {
5
+ headers: {},
6
+ body: trimmedLines
7
+ };
8
+ return {
9
+ headers: trimmedLines.slice(0, blankLineLocation).split('\n').map((s)=>s.split(':', 2)).reduce((headers, [k, v])=>({
10
+ ...headers,
11
+ [k]: v?.trim()
12
+ }), {}),
13
+ body: trimmedLines.slice(blankLineLocation + 2)
14
+ };
15
+ };
package/package.json CHANGED
@@ -1,19 +1,20 @@
1
1
  {
2
2
  "name": "@bifravst/http-api-mock",
3
- "version": "2.1.465",
3
+ "version": "2.2.0-dev.2",
4
4
  "description": "Helper functions for AWS lambdas written in TypeScript.",
5
5
  "exports": {
6
6
  "./*": {
7
7
  "import": {
8
- "types": "./npm/*.d.ts",
9
- "default": "./npm/*.js"
8
+ "default": "./npm/src/*.js",
9
+ "types": "./src/*.ts"
10
10
  }
11
11
  }
12
12
  },
13
+ "bin": "./npm/cdk/http-api-mock.js",
13
14
  "type": "module",
14
15
  "scripts": {
15
- "prepare": "husky",
16
- "prepublishOnly": "node --experimental-strip-types ./.npm/compile.ts && npx tsgo src/*.ts --emitDeclarationOnly --declaration --ignoreConfig --allowImportingTsExtensions --target esnext --module nodenext --moduleResolution nodenext --outDir ./npm && mv npm/src/* npm",
16
+ "prepare": "husky && case \"$npm_command\" in install|ci) check-node-version --package ;; esac",
17
+ "prepublishOnly": "node --no-warnings --experimental-transform-types ./.npm/compile.ts",
17
18
  "test": "node --no-warnings --experimental-transform-types --test \"!(node_modules|e2e-tests|integration-tests)/**/*.spec.ts\""
18
19
  },
19
20
  "repository": {
@@ -41,8 +42,8 @@
41
42
  ]
42
43
  },
43
44
  "engines": {
44
- "node": ">=24",
45
- "npm": ">=11"
45
+ "node": ">=24.19.0 <25",
46
+ "npm": ">=12.0.2 <13"
46
47
  },
47
48
  "release": {
48
49
  "branches": [
@@ -67,35 +68,37 @@
67
68
  },
68
69
  "files": [
69
70
  "npm",
71
+ "src",
70
72
  "LICENSE",
71
73
  "README.md"
72
74
  ],
73
75
  "prettier": "@bifravst/prettier-config",
74
76
  "dependencies": {
75
- "@aws-sdk/client-cloudformation": "3.981.0",
76
- "@aws-sdk/client-dynamodb": "3.981.0",
77
- "@aws-sdk/client-sts": "3.981.0",
78
- "@aws-sdk/util-dynamodb": "3.981.0",
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-sdk/util-dynamodb": "3.996.9",
79
81
  "@bifravst/aws-cdk-lambda-helpers": "4.0.96",
80
82
  "@bifravst/cloudformation-helpers": "9.1.1",
81
83
  "@bifravst/from-env": "3.0.2",
82
84
  "@bifravst/run": "1.2.0",
83
- "aws-cdk-lib": "2.260.0",
84
- "cdk": "2.1133.0",
85
- "chalk": "5.6.2",
86
- "tsx": "4.23.1"
85
+ "aws-cdk-lib": "2.265.0",
86
+ "cdk": "2.1136.0",
87
+ "chalk": "6.0.0"
87
88
  },
88
89
  "devDependencies": {
89
- "@aws-cdk/toolkit-lib": "1.29.1",
90
- "@bifravst/eslint-config-typescript": "6.4.4",
90
+ "@aws-cdk/toolkit-lib": "1.38.2",
91
+ "@bifravst/eslint-config-typescript": "7.0.34",
91
92
  "@bifravst/prettier-config": "1.1.17",
92
- "@commitlint/config-conventional": "20.5.3",
93
+ "@commitlint/config-conventional": "21.2.2",
93
94
  "@types/aws-lambda": "8.10.162",
94
95
  "@types/command-line-args": "5.2.3",
95
- "@types/node": "25.9.5",
96
- "@typescript/native-preview": "7.0.0-dev.20260309.1",
96
+ "@types/node": "26.2.0",
97
+ "@typescript/native": "npm:typescript@7.0.2",
98
+ "check-node-version": "4.2.1",
97
99
  "command-line-args": "6.0.2",
98
- "commitlint": "20.5.3",
99
- "husky": "9.1.7"
100
+ "commitlint": "21.2.2",
101
+ "husky": "9.1.7",
102
+ "typescript": "npm:@typescript/typescript6@6.0.2"
100
103
  }
101
104
  }
@@ -0,0 +1,52 @@
1
+ import type { AttributeValue, DynamoDBClient } from '@aws-sdk/client-dynamodb'
2
+ import { unmarshall } from '@aws-sdk/util-dynamodb'
3
+ import assert from 'node:assert/strict'
4
+ import { describe, it, mock as testMock } from 'node:test'
5
+ import { mock } from './mock.ts'
6
+
7
+ void describe('mock()', () => {
8
+ void it('should register a response', async () => {
9
+ const db = {
10
+ send: testMock.fn(async () => Promise.resolve(undefined)),
11
+ }
12
+ const httpApiMock = mock({
13
+ db: db as unknown as DynamoDBClient,
14
+ responsesTable: 'response-table',
15
+ })
16
+
17
+ await httpApiMock.response(`GET foo/bar?k=v`, {
18
+ status: 200,
19
+ headers: new Headers({
20
+ 'content-type': 'application/json; charset=utf-8',
21
+ }),
22
+ body: JSON.stringify({
23
+ result: 'some-value',
24
+ }),
25
+ })
26
+
27
+ assert.equal(db.send.mock.callCount(), 1)
28
+ const [{ input: args }] = db.send.mock.calls[0]?.arguments as unknown as [
29
+ {
30
+ input: {
31
+ TableName: string
32
+ Item: Record<string, AttributeValue>
33
+ }
34
+ },
35
+ ]
36
+ assert.equal(args.TableName, 'response-table')
37
+ const { methodPathQuery, statusCode, body, queryParams } = unmarshall(
38
+ args.Item,
39
+ )
40
+ assert.equal(statusCode, 200)
41
+ assert.equal(methodPathQuery, 'GET foo/bar?k=v')
42
+ assert.equal(
43
+ body,
44
+ [
45
+ `content-type: application/json; charset=utf-8`,
46
+ ``,
47
+ JSON.stringify({ result: 'some-value' }),
48
+ ].join('\n'),
49
+ )
50
+ assert.deepEqual(queryParams, { k: 'v' })
51
+ })
52
+ })
package/src/mock.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { DynamoDBClient } from '@aws-sdk/client-dynamodb'
2
+ import { registerResponse } from './responses.ts'
3
+
4
+ type MockResponseFn = (
5
+ // The expected request in the form 'GET resource/subresource?query=value
6
+ methodPathQuery: string,
7
+ // The response
8
+ response: Partial<{
9
+ headers: Headers
10
+ status: number
11
+ body: string
12
+ }>,
13
+ keep?: boolean,
14
+ ) => Promise<void>
15
+
16
+ export const mockResponse =
17
+ (db: DynamoDBClient, responsesTable: string): MockResponseFn =>
18
+ async (methodPathQuery, response, keep) => {
19
+ const [method, pathWithQuery] = methodPathQuery.split(' ', 2)
20
+ if (!/^[A-Z]+$/.test(method ?? ''))
21
+ throw new Error(`Invalid method ${method} in ${methodPathQuery}!`)
22
+ if (pathWithQuery === undefined)
23
+ throw new Error(`Missing path in ${methodPathQuery}!`)
24
+ const [path, query] = pathWithQuery.split('?', 2) as [
25
+ string,
26
+ string | undefined,
27
+ ]
28
+ if (path.startsWith('/'))
29
+ throw new Error(`Path ${path} must not start with /!`)
30
+
31
+ const bodyParts = []
32
+ if (response.headers !== undefined) {
33
+ for (const [k, v] of response.headers.entries()) {
34
+ bodyParts.push(`${k}: ${v}`)
35
+ }
36
+ bodyParts.push('')
37
+ }
38
+ if (response.body !== undefined) bodyParts.push(response.body)
39
+ await registerResponse(db, responsesTable, {
40
+ path,
41
+ method: method ?? 'GET',
42
+ queryParams: new URLSearchParams(query),
43
+ body: bodyParts.length > 0 ? bodyParts.join('\n') : undefined,
44
+ statusCode: response.status,
45
+ keep,
46
+ })
47
+ }
48
+
49
+ export type HttpAPIMock = {
50
+ response: MockResponseFn
51
+ }
52
+
53
+ export const mock = ({
54
+ db,
55
+ responsesTable,
56
+ }: {
57
+ db: DynamoDBClient
58
+ responsesTable: string
59
+ }): HttpAPIMock => ({
60
+ response: mockResponse(db, responsesTable),
61
+ })
@@ -0,0 +1,30 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { parseMockRequest } from './parseMockRequest.ts'
4
+
5
+ void describe('parseMockRequest()', () => {
6
+ void it('should parse method, resource, protocol, headers and body', () =>
7
+ assert.deepEqual(
8
+ parseMockRequest(
9
+ [
10
+ `PATCH /v1/devices/foo/state HTTP/1.1`,
11
+ `Content-Length: 36`,
12
+ `Content-Type: application/json`,
13
+ `If-Match: 8835`,
14
+ ``,
15
+ `{"desired":{"config":{"nod":null}}}`,
16
+ ].join('\n'),
17
+ ),
18
+ {
19
+ method: 'PATCH',
20
+ resource: '/v1/devices/foo/state',
21
+ protocol: 'HTTP/1.1',
22
+ headers: {
23
+ 'Content-Length': '36',
24
+ 'Content-Type': 'application/json',
25
+ 'If-Match': '8835',
26
+ },
27
+ body: '{"desired":{"config":{"nod":null}}}',
28
+ },
29
+ ))
30
+ })
@@ -0,0 +1,35 @@
1
+ export const parseMockRequest = (
2
+ r: string,
3
+ ): {
4
+ method: string
5
+ resource: string
6
+ protocol: string // 'HTTP/1.0' | 'HTTP/1.1'
7
+ headers: Record<string, string>
8
+ body: string
9
+ } => {
10
+ const lines = r.split('\n')
11
+ const methodResourceProtol = lines.shift()
12
+ const blankLineLocation = lines.indexOf('')
13
+ const headerLines =
14
+ blankLineLocation === -1 ? lines : lines.slice(0, blankLineLocation)
15
+ const body =
16
+ blankLineLocation === -1
17
+ ? ''
18
+ : lines.slice(blankLineLocation + 1).join('\n')
19
+
20
+ const requestInfo =
21
+ /^(?<method>[A-Z]+) (?<resource>[^ ]+) (?<protocol>HTTP\/[0-9.]+)/.exec(
22
+ methodResourceProtol ?? '',
23
+ )?.groups as { method: string; resource: string; protocol: string }
24
+
25
+ if (requestInfo === null)
26
+ throw new Error(`Invalid request info: ${methodResourceProtol}`)
27
+
28
+ return {
29
+ ...requestInfo,
30
+ headers: headerLines
31
+ .map((s) => s.split(':', 2))
32
+ .reduce((headers, [k, v]) => ({ ...headers, [k ?? '']: v?.trim() }), {}),
33
+ body,
34
+ }
35
+ }
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { parseMockResponse } from './parseMockResponse.ts'
4
+
5
+ void describe('parseMockResponse()', () => {
6
+ void it('should parse protocol, statusCode, headers and body', () =>
7
+ assert.deepEqual(
8
+ parseMockResponse(
9
+ [
10
+ `HTTP/1.1 202 Accepted`,
11
+ `Content-Length: 36`,
12
+ `Content-Type: application/json`,
13
+ ``,
14
+ `{"desired":{"config":{"nod":null}}}`,
15
+ ].join('\n'),
16
+ ),
17
+ {
18
+ statusCode: 202,
19
+ protocol: 'HTTP/1.1',
20
+ headers: {
21
+ 'Content-Length': '36',
22
+ 'Content-Type': 'application/json',
23
+ },
24
+ body: '{"desired":{"config":{"nod":null}}}',
25
+ },
26
+ ))
27
+ })
@@ -0,0 +1,35 @@
1
+ export const parseMockResponse = (
2
+ r: string,
3
+ ): {
4
+ statusCode: number
5
+ protocol: string // 'HTTP/1.0' | 'HTTP/1.1'
6
+ headers: Record<string, string>
7
+ body: string
8
+ } => {
9
+ const lines = r.split('\n')
10
+ const protocolStatusCode = lines.shift()
11
+ const blankLineLocation = lines.indexOf('')
12
+ const headerLines =
13
+ blankLineLocation === -1 ? lines : lines.slice(0, blankLineLocation)
14
+ const body =
15
+ blankLineLocation === -1
16
+ ? ''
17
+ : lines.slice(blankLineLocation + 1).join('\n')
18
+
19
+ const responseInfo =
20
+ /^(?<protocol>HTTP\/[0-9.]+) (?<statusCode>[0-9]+) /.exec(
21
+ protocolStatusCode ?? '',
22
+ )?.groups as { statusCode: string; protocol: string }
23
+
24
+ if (responseInfo === null)
25
+ throw new Error(`Invalid request info: ${protocolStatusCode}`)
26
+
27
+ return {
28
+ statusCode: parseInt(responseInfo.statusCode, 10),
29
+ protocol: responseInfo.protocol,
30
+ headers: headerLines
31
+ .map((s) => s.split(':', 2))
32
+ .reduce((headers, [k, v]) => ({ ...headers, [k ?? '']: v?.trim() }), {}),
33
+ body,
34
+ }
35
+ }
@@ -0,0 +1,7 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ export const randomString = (): string =>
4
+ crypto
5
+ .randomBytes(Math.ceil(8 * 0.5))
6
+ .toString('hex')
7
+ .slice(0, 8)
@@ -0,0 +1,28 @@
1
+ import { ScanCommand, type DynamoDBClient } from '@aws-sdk/client-dynamodb'
2
+ import { unmarshall } from '@aws-sdk/util-dynamodb'
3
+
4
+ export type Request = {
5
+ path: string //e.g.'555c3960-2092-438b-b2b0-f28eebd1f5bb'
6
+ query: null
7
+ timestamp: string //e.g.'2024-04-05T13:01:14.434Z'
8
+ ttl: string //e.g. 1712322374
9
+ headers: Record<string, string> //e.g. '{"Accept":"*/*","Accept-Encoding":"br, gzip, deflate","Accept-Language":"*","CloudFront-Forwarded-Proto":"https","CloudFront-Is-Desktop-Viewer":"true","CloudFront-Is-Mobile-Viewer":"false","CloudFront-Is-SmartTV-Viewer":"false","CloudFront-Is-Tablet-Viewer":"false","CloudFront-Viewer-ASN":"2116","CloudFront-Viewer-Country":"NO","Host":"idj1fffo0k.execute-api.eu-west-1.amazonaws.com","sec-fetch-mode":"cors","User-Agent":"node","Via":"1.1 b053873243f91b1bb6dc406ce0c67db4.cloudfront.net (CloudFront)","X-Amz-Cf-Id":"_vJIGo6Z89QxDzoqOZL4G0PQqPFWGesVXVan4ND934_Urqn2ifSOsQ==","X-Amzn-Trace-Id":"Root=1-660ff61a-25e1219a7f153e1b0c768358","X-Forwarded-For":"194.19.86.146, 130.176.182.18","X-Forwarded-Port":"443","X-Forwarded-Proto":"https"}'
10
+ method: string //e.g.'GET'
11
+ requestId: string //e.g.'f34b042b-e9a2-4089-97a2-241516d40d64'
12
+ body: string //e.g.'{}'
13
+ methodPathQuery: string //e.g.'GET 555c3960-2092-438b-b2b0-f28eebd1f5bb'
14
+ }
15
+
16
+ export const listRequests = async (
17
+ db: DynamoDBClient,
18
+ requestsTable: string,
19
+ ): Promise<Array<Request>> =>
20
+ ((await db.send(new ScanCommand({ TableName: requestsTable }))).Items ?? [])
21
+ .map((item) => {
22
+ const i = unmarshall(item)
23
+ return {
24
+ ...i,
25
+ headers: JSON.parse(i.headers),
26
+ } as Request
27
+ })
28
+ .sort((i1, i2) => i1.timestamp.localeCompare(i2.timestamp))
@@ -0,0 +1,50 @@
1
+ import { PutItemCommand, type DynamoDBClient } from '@aws-sdk/client-dynamodb'
2
+ import { marshall } from '@aws-sdk/util-dynamodb'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { sortQuery } from './sortQueryString.ts'
5
+
6
+ export type Response = {
7
+ // e.g. 'GET'
8
+ method: string
9
+ // without leading slash
10
+ path: string
11
+ queryParams?: URLSearchParams
12
+ statusCode?: number
13
+ /**
14
+ * Header + Body
15
+ *
16
+ * @see splitMockResponse
17
+ */
18
+ body?: string
19
+ ttl?: number
20
+ // Whether to delete the message after sending it
21
+ keep?: boolean
22
+ }
23
+
24
+ export const registerResponse = async (
25
+ db: DynamoDBClient,
26
+ responsesTable: string,
27
+ response: Response,
28
+ ): Promise<void> => {
29
+ await db.send(
30
+ new PutItemCommand({
31
+ TableName: responsesTable,
32
+ Item: marshall(
33
+ {
34
+ responseId: randomUUID(),
35
+ methodPathQuery: `${response.method} ${response.path}${response.queryParams !== undefined ? `?${sortQuery(response.queryParams)}` : ``}`,
36
+ timestamp: new Date().toISOString(),
37
+ statusCode: response.statusCode,
38
+ body: response.body,
39
+ queryParams:
40
+ response.queryParams !== undefined
41
+ ? Object.fromEntries(response.queryParams)
42
+ : undefined,
43
+ ttl: response.ttl,
44
+ keep: response.keep,
45
+ },
46
+ { removeUndefinedValues: true },
47
+ ),
48
+ }),
49
+ )
50
+ }
@@ -0,0 +1,38 @@
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.ts'
5
+
6
+ void describe('sortQueryString', () => {
7
+ void it('should sort the query part of a mock URL', () =>
8
+ assert.deepStrictEqual(
9
+ sortQueryString(
10
+ 'api.nrfcloud.com/v1/location/agps?eci=73393515&tac=132&requestType=custom&mcc=397&mnc=73&customTypes=2',
11
+ ),
12
+ 'api.nrfcloud.com/v1/location/agps?customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132',
13
+ ))
14
+ })
15
+
16
+ void describe('sortQuery', () => {
17
+ void it('should sort URLSearchParams', () =>
18
+ assert.equal(
19
+ sortQuery(
20
+ new URLSearchParams(
21
+ 'eci=73393515&tac=132&requestType=custom&mcc=397&mnc=73&customTypes=2',
22
+ ),
23
+ ),
24
+ 'customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132',
25
+ ))
26
+ void it('should sort a Record', () =>
27
+ assert.equal(
28
+ sortQuery({
29
+ eci: '73393515',
30
+ tac: '132',
31
+ requestType: 'custom',
32
+ mcc: '397',
33
+ mnc: '73',
34
+ customTypes: '2',
35
+ }),
36
+ 'customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132',
37
+ ))
38
+ })
@@ -0,0 +1,26 @@
1
+ import { URLSearchParams } from 'node:url'
2
+
3
+ export const sortQueryString = (mockUrl: string): string => {
4
+ const [host, query] = mockUrl.split('?', 2) as [string, string | undefined]
5
+ if (query === undefined || (query?.length ?? 0) === 0) return host
6
+ return `${host}?${sortQuery(new URLSearchParams(query))}`
7
+ }
8
+
9
+ export const sortQuery = (
10
+ query: URLSearchParams | Record<string, string>,
11
+ ): string => {
12
+ const params: string[][] = []
13
+ if (query instanceof URLSearchParams) {
14
+ query.forEach((v, k) => {
15
+ params.push([k, v])
16
+ })
17
+ } else {
18
+ params.push(...Object.entries(query))
19
+ }
20
+ params.sort(([k1], [k2]) => (k1 ?? '').localeCompare(k2 ?? ''))
21
+ const sortedParams = new URLSearchParams()
22
+ for (const [k, v] of params) {
23
+ sortedParams.append(k as string, v as string)
24
+ }
25
+ return sortedParams.toString()
26
+ }
package/npm/mock.d.ts DELETED
@@ -1,15 +0,0 @@
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 {};
@@ -1 +0,0 @@
1
- export {};
@@ -1,7 +0,0 @@
1
- export declare const parseMockRequest: (r: string) => {
2
- method: string;
3
- resource: string;
4
- protocol: string;
5
- headers: Record<string, string>;
6
- body: string;
7
- };
@@ -1 +0,0 @@
1
- export {};
@@ -1,6 +0,0 @@
1
- export declare const parseMockResponse: (r: string) => {
2
- statusCode: number;
3
- protocol: string;
4
- headers: Record<string, string>;
5
- body: string;
6
- };
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export declare const randomString: () => string;
package/npm/requests.d.ts DELETED
@@ -1,13 +0,0 @@
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<Request[]>;
@@ -1,16 +0,0 @@
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>;
@@ -1,3 +0,0 @@
1
- import { URLSearchParams } from 'node:url';
2
- export declare const sortQueryString: (mockUrl: string) => string;
3
- export declare const sortQuery: (query: Record<string, string> | URLSearchParams) => string;
@@ -1 +0,0 @@
1
- export {};
File without changes
File without changes
File without changes
File without changes