import {describe, expect, test} from 'bun:test' import { CreateDeploymentCommand, CreateResourceCommand, CreateRestApiCommand, DeleteMethodCommand, DeleteRestApiCommand, GetRestApiCommand, GetRestApisCommand, GetMethodCommand, GetResourcesCommand, GetStagesCommand, PutIntegrationCommand, PutIntegrationResponseCommand, PutMethodCommand, PutMethodResponseCommand, TestInvokeMethodCommand, type APIGatewayClient, } from '@aws-sdk/client-api-gateway' import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' import {GetApisCommand, type ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2' import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter' function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient { return { send: async (command: unknown) => handler(command), } as unknown as APIGatewayClient } function fakeLambda(handler: (command: unknown) => unknown): LambdaClient { return { send: async (command: unknown) => handler(command), } as unknown as LambdaClient } /** An API Gateway v2 runtime with no HTTP or WebSocket APIs, for tests about REST APIs. */ const noHttpApis = { send: async (command: unknown) => { if (command instanceof GetApisCommand) return {Items: []} throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}}) }, } as unknown as ApiGatewayV2Client function restOnlyAdapter(client: APIGatewayClient) { return new AwsApiGatewayAdapter(client, fakeLambda(() => ({})), '000000000000', 'us-east-1', noHttpApis) } const orders = { id: 'a1b2c3', name: 'orders-api', description: 'Orders REST API', createdDate: new Date('2026-01-02T03:04:05.000Z'), version: '1.0', endpointConfiguration: {types: ['REGIONAL']}, rootResourceId: 'root123', } describe('AwsApiGatewayAdapter', () => { test('lists every page and maps REST APIs to normalized resources', async () => { const adapter = restOnlyAdapter(fakeClient((command) => { if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command') if (!command.input.position) return {items: [orders], position: 'next'} return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]} })) const result = await adapter.list() expect(result).toHaveLength(2) expect(result[0]).toMatchObject({ id: 'a1b2c3', name: 'orders-api', cloud: 'aws', service: 'apigateway', type: 'rest-api', region: null, createdAt: '2026-01-02T03:04:05.000Z', }) expect(result[0].metadata).toMatchObject({ apigatewayService: 'rest', description: 'Orders REST API', rootResourceId: 'root123', }) }) test('filters APIs by search term', async () => { const adapter = restOnlyAdapter(fakeClient(() => ({ items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}], }))) const result = await adapter.list({search: 'ORDERS'}) expect(result.map((resource) => resource.name)).toEqual(['orders-api']) }) test('gets and maps one REST API', async () => { const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { expect(command).toBeInstanceOf(GetRestApiCommand) return orders })) const result = await adapter.get('a1b2c3') expect(result?.id).toBe('a1b2c3') expect(result?.metadata.rootResourceId).toBe('root123') }) test('returns null when API Gateway reports a missing REST API', async () => { const adapter = restOnlyAdapter(fakeClient(() => { throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}}) })) await expect(adapter.get('missing')).resolves.toBeNull() }) test('creates a REST API with an optional description', async () => { const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { expect(command).toBeInstanceOf(CreateRestApiCommand) expect((command as CreateRestApiCommand).input).toEqual({name: 'orders-api', description: 'Orders REST API'}) return orders })) const result = await adapter.create({values: {name: 'orders-api', description: 'Orders REST API'}}) expect(result.id).toBe('a1b2c3') }) test('rejects a missing name before calling API Gateway', async () => { let called = false const adapter = new AwsApiGatewayAdapter(fakeClient(() => { called = true return {} })) await expect(adapter.create({values: {}})).rejects.toThrow('name is required') expect(called).toBeFalse() }) test('deletes the requested REST API', async () => { const adapter = restOnlyAdapter(fakeClient((command) => { expect(command).toBeInstanceOf(DeleteRestApiCommand) expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3') return {} })) await adapter.delete('a1b2c3') }) test('creates and deploys a Lambda proxy route', async () => { const apiCommands: unknown[] = [] const lambdaCommands: unknown[] = [] const adapter = new AwsApiGatewayAdapter( fakeClient((command) => { apiCommands.push(command) if (command instanceof GetResourcesCommand) return {items: [{id: 'root123', path: '/'}]} if (command instanceof CreateResourceCommand) return {id: 'resource456'} if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'} return {} }), fakeLambda((command) => { lambdaCommands.push(command) if (command instanceof GetFunctionCommand) { return {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}} } return {} }), '000000000000', 'us-east-1', ) const result = await adapter.createApiGatewayLambdaRoute('a1b2c3', { path: '/hello', method: 'GET', stage: 'dev', functionName: 'hello', }) expect(apiCommands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([ 'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand', 'PutIntegrationCommand', 'CreateDeploymentCommand', ]) expect((apiCommands[1] as CreateResourceCommand).input).toMatchObject({ restApiId: 'a1b2c3', parentId: 'root123', pathPart: 'hello', }) expect((apiCommands[2] as PutMethodCommand).input).toMatchObject({ httpMethod: 'GET', authorizationType: 'NONE', }) expect((apiCommands[3] as PutIntegrationCommand).input).toMatchObject({ type: 'AWS_PROXY', integrationHttpMethod: 'POST', uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations', }) expect((lambdaCommands[1] as AddPermissionCommand).input).toMatchObject({ FunctionName: 'hello', Principal: 'apigateway.amazonaws.com', SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:a1b2c3/dev/GET/hello', }) expect(result).toEqual({ resourceId: 'resource456', deploymentId: 'deployment789', invokeUrl: 'http://localhost:4566/execute-api/a1b2c3/dev/hello', }) }) test('creates missing nested resources and reuses existing path segments', async () => { const apiCommands: unknown[] = [] const adapter = new AwsApiGatewayAdapter( fakeClient((command) => { apiCommands.push(command) if (command instanceof GetResourcesCommand) return {items: [ {id: 'root123', path: '/'}, {id: 'orders123', parentId: 'root123', pathPart: 'orders', path: '/orders'}, ]} if (command instanceof CreateResourceCommand) return {id: 'orderId123'} if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'} return {} }), fakeLambda((command) => command instanceof GetFunctionCommand ? {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}} : {}), ) const result = await adapter.createApiGatewayLambdaRoute('a1b2c3', { path: '/orders/{id}', method: 'PATCH', stage: 'dev', functionName: 'hello', }) const creates = apiCommands.filter((command) => command instanceof CreateResourceCommand) as CreateResourceCommand[] expect(creates).toHaveLength(1) expect(creates[0]?.input).toMatchObject({parentId: 'orders123', pathPart: '{id}'}) expect((apiCommands.find((command) => command instanceof PutMethodCommand) as PutMethodCommand).input.httpMethod).toBe('PATCH') expect(result).toMatchObject({resourceId: 'orderId123', invokeUrl: 'http://localhost:4566/execute-api/a1b2c3/dev/orders/{id}'}) }) test('creates a greedy child under the explicitly selected parent', async () => { const commands: unknown[] = [] const lambdaCommands: unknown[] = [] const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { commands.push(command) if (command instanceof GetResourcesCommand) return {items: [ {id: 'root123', path: '/'}, {id: 'files123', parentId: 'root123', pathPart: 'files', path: '/files'}, ]} if (command instanceof CreateResourceCommand) return {id: 'proxy123'} if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'} return {} }), fakeLambda((command) => { lambdaCommands.push(command) return command instanceof GetFunctionCommand ? {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:files'}} : {} })) await adapter.createApiGatewayLambdaRoute('a1b2c3', { path: '/files/{proxy+}', parentResourceId: 'files123', pathPart: '{proxy+}', method: 'ANY', stage: 'dev', functionName: 'files', }) expect((commands.find((command) => command instanceof CreateResourceCommand) as CreateResourceCommand).input).toMatchObject({ parentId: 'files123', pathPart: '{proxy+}', }) expect((lambdaCommands.find((command) => command instanceof AddPermissionCommand) as AddPermissionCommand).input).toMatchObject({ SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:a1b2c3/dev/*/files/*', }) }) test('adds a method to an existing resource without creating a duplicate path', async () => { const apiCommands: unknown[] = [] const adapter = new AwsApiGatewayAdapter( fakeClient((command) => { apiCommands.push(command) if (command instanceof GetResourcesCommand) { return {items: [ {id: 'resource456', parentId: 'root123', pathPart: 'hello', path: '/hello'}, {id: 'root123', path: '/'}, ]} } if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'} return {} }), fakeLambda((command) => command instanceof GetFunctionCommand ? {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}} : {}), '000000000000', 'us-east-1', ) await adapter.createApiGatewayLambdaRoute('a1b2c3', { path: '/hello', method: 'POST', stage: 'dev', functionName: 'hello', }) expect(apiCommands.some((command) => command instanceof CreateResourceCommand)).toBeFalse() expect((apiCommands.find((command) => command instanceof PutMethodCommand) as PutMethodCommand).input).toMatchObject({ resourceId: 'resource456', httpMethod: 'POST', }) }) test('tests a deployed route and preserves its HTTP response', async () => { const originalFetch = globalThis.fetch const requests: Request[] = [] globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { requests.push(new Request(input, init)) return new Response(JSON.stringify({error: 'invalid request'}), { status: 400, headers: {'content-type': 'application/json', 'x-request-id': 'request-123'}, }) }) as typeof fetch try { const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({}))) const result = await adapter.testApiGatewayRoute('a1b2c3', { path: '/hello', method: 'POST', stage: 'dev', body: '{"name":"Ada"}', headers: {'x-client': 'console'}, query: 'verbose=true', }) expect(requests).toHaveLength(1) expect(requests[0]?.url).toBe('http://localhost:4566/execute-api/a1b2c3/dev/hello?verbose=true') expect(requests[0]?.method).toBe('POST') expect(requests[0]?.headers.get('content-type')).toBe('application/json') expect(requests[0]?.headers.get('x-client')).toBe('console') expect(await requests[0]?.text()).toBe('{"name":"Ada"}') expect(result).toMatchObject({ statusCode: 400, headers: {'content-type': 'application/json', 'x-request-id': 'request-123'}, body: '{"error":"invalid request"}', }) } finally { globalThis.fetch = originalFetch } }) test('omits a body when testing a GET route', async () => { const originalFetch = globalThis.fetch const requests: Request[] = [] globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { requests.push(new Request(input, init)) return new Response('{}') }) as typeof fetch try { const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({}))) await adapter.testApiGatewayRoute('a1b2c3', { path: '/hello', method: 'GET', stage: 'dev', body: '{}', headers: {}, query: '', }) expect(await requests[0]?.text()).toBe('') } finally { globalThis.fetch = originalFetch } }) test('uses PATCH and HEAD for deployed requests and rejects ANY', async () => { const originalFetch = globalThis.fetch const requests: Request[] = [] globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { requests.push(new Request(input, init)) return new Response(null) }) as typeof fetch try { const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({}))) await adapter.testApiGatewayRoute('a1b2c3', {path: '/orders/1', method: 'PATCH', stage: 'dev', body: '{}', headers: {}, query: ''}) await adapter.testApiGatewayRoute('a1b2c3', {path: '/orders/1', method: 'HEAD', stage: 'dev', body: '{}', headers: {}, query: ''}) await expect(adapter.testApiGatewayRoute('a1b2c3', {path: '/orders/1', method: 'ANY', stage: 'dev', body: '', headers: {}, query: ''})).rejects.toThrow('method must be') expect(requests.map((request) => request.method)).toEqual(['PATCH', 'HEAD']) expect(await requests[1]?.text()).toBe('') } finally { globalThis.fetch = originalFetch } }) test('tests a configured method through the AWS SDK without a deployed stage', async () => { const commands: unknown[] = [] const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { commands.push(command) if (command instanceof TestInvokeMethodCommand) { return { status: 201, body: '{"id":"order-123"}', headers: {'x-request-id': 'request-123'}, multiValueHeaders: {'set-cookie': ['first', 'second']}, log: 'Execution completed', latency: 17, } } throw new Error('Unexpected command') })) await expect(adapter.testApiGatewayMethod('a1b2c3', { resourceId: 'resource456', httpMethod: 'ANY', pathWithQueryString: '/orders?dryRun=true', body: '{"name":"Ada"}', headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']}, stageVariables: {environment: 'test'}, })).resolves.toEqual({ statusCode: 201, body: '{"id":"order-123"}', headers: {'x-request-id': 'request-123'}, multiValueHeaders: {'set-cookie': ['first', 'second']}, log: 'Execution completed', latency: 17, }) expect((commands[0] as TestInvokeMethodCommand).input).toEqual({ restApiId: 'a1b2c3', resourceId: 'resource456', httpMethod: 'ANY', pathWithQueryString: '/orders?dryRun=true', body: '{"name":"Ada"}', headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']}, stageVariables: {environment: 'test'}, }) }) test('lists deployed methods, Lambda integrations, and stages', async () => { const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { if (command instanceof GetResourcesCommand) { return {items: [ {id: 'root123', path: '/', resourceMethods: {}}, {id: 'resource456', parentId: 'root123', pathPart: 'hello', path: '/hello', resourceMethods: {GET: true, POST: true}}, ]} } if (command instanceof GetMethodCommand) { return {methodIntegration: { uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations', }} } if (command instanceof GetStagesCommand) return {item: [{stageName: 'prod'}, {stageName: 'dev'}]} throw new Error('Unexpected command') })) await expect(adapter.listApiGatewayRoutes('a1b2c3')).resolves.toEqual({ resources: [ {resourceId: 'root123', parentId: null, path: '/', pathPart: null, methods: []}, {resourceId: 'resource456', parentId: 'root123', path: '/hello', pathPart: 'hello', methods: [ {resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello', integrationType: null}, {resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello', integrationType: null}, ]}, ], routes: [ {resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello', integrationType: null}, {resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello', integrationType: null}, ], stages: ['dev', 'prod'], }) }) test('paginates and returns resources that have no configured methods', async () => { const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { if (command instanceof GetResourcesCommand && !command.input.position) { return {items: [{id: 'root123', path: '/', resourceMethods: {}}], position: 'next'} } if (command instanceof GetResourcesCommand) { return {items: [{id: 'orders123', parentId: 'root123', pathPart: 'orders', path: '/orders'}]} } if (command instanceof GetStagesCommand) return {item: []} throw new Error('Unexpected command') })) await expect(adapter.listApiGatewayRoutes('a1b2c3')).resolves.toEqual({ resources: [ {resourceId: 'root123', parentId: null, path: '/', pathPart: null, methods: []}, {resourceId: 'orders123', parentId: 'root123', path: '/orders', pathPart: 'orders', methods: []}, ], routes: [], stages: [], }) }) test('deletes one method and redeploys its stage', async () => { const commands: unknown[] = [] const lambdaCommands: unknown[] = [] const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { commands.push(command) if (command instanceof GetMethodCommand) { return {methodIntegration: {uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations'}} } return {} }), fakeLambda((command) => { lambdaCommands.push(command) return {} })) await adapter.deleteApiGatewayRoute('a1b2c3', {resourceId: 'resource456', path: '/hello', method: 'POST', stage: 'dev'}) expect((commands[1] as DeleteMethodCommand).input).toMatchObject({restApiId: 'a1b2c3', resourceId: 'resource456', httpMethod: 'POST'}) expect((commands[2] as CreateDeploymentCommand).input).toMatchObject({restApiId: 'a1b2c3', stageName: 'dev'}) expect((lambdaCommands[0] as RemovePermissionCommand).input).toMatchObject({FunctionName: 'hello', StatementId: 'apigateway-a1b2c3-dev-POST-hello'}) }) test('deletes a CORS OPTIONS method and redeploys its stage', async () => { const commands: unknown[] = [] const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { commands.push(command) if (command instanceof GetMethodCommand) { return {methodIntegration: {type: 'MOCK'}} } return {} }), fakeLambda(() => ({}))) await adapter.deleteApiGatewayRoute('a1b2c3', {resourceId: 'resource456', path: '/hello', method: 'OPTIONS', stage: 'dev'}) expect(commands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([ 'GetMethodCommand', 'DeleteMethodCommand', 'CreateDeploymentCommand', ]) expect((commands[1] as DeleteMethodCommand).input).toMatchObject({httpMethod: 'OPTIONS'}) }) test('enables CORS for non-proxy methods, creates OPTIONS, and reports proxy methods', async () => { const commands: unknown[] = [] const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { commands.push(command) if (command instanceof GetMethodCommand && command.input.httpMethod === 'GET') { return { methodResponses: {200: { responseParameters: {'method.response.header.X-Existing': true}, responseModels: {'application/json': 'ExistingModel'}, }}, methodIntegration: {type: 'HTTP', integrationResponses: {200: { responseParameters: {'method.response.header.X-Existing': "'existing'"}, responseTemplates: {'application/json': '$input.body'}, }}}, } } if (command instanceof GetMethodCommand && command.input.httpMethod === 'POST') { return {methodIntegration: {type: 'AWS_PROXY'}} } if (command instanceof GetMethodCommand && command.input.httpMethod === 'OPTIONS') { return { methodResponses: {200: { responseParameters: {'method.response.header.Access-Control-Allow-Origin': true}, responseModels: {'application/json': 'OptionsModel'}, }}, methodIntegration: {timeoutInMillis: 29000, integrationResponses: {200: { responseTemplates: {'text/plain': 'existing template'}, }}}, } } return {} })) await expect(adapter.enableApiGatewayCors('a1b2c3', { resourceId: 'resource456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'], })).resolves.toEqual({configuredMethods: ['GET'], proxyMethods: ['POST']}) expect(commands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([ 'GetMethodCommand', 'PutMethodResponseCommand', 'PutIntegrationResponseCommand', 'GetMethodCommand', 'GetMethodCommand', 'PutMethodCommand', 'PutMethodResponseCommand', 'PutIntegrationCommand', 'PutIntegrationResponseCommand', 'CreateDeploymentCommand', ]) expect((commands[1] as PutMethodResponseCommand).input.responseParameters).toEqual({ 'method.response.header.X-Existing': true, 'method.response.header.Access-Control-Allow-Origin': false, 'method.response.header.Access-Control-Allow-Headers': false, 'method.response.header.Access-Control-Allow-Methods': false, }) expect((commands[1] as PutMethodResponseCommand).input.responseModels).toEqual({'application/json': 'ExistingModel'}) expect((commands[2] as PutIntegrationResponseCommand).input.responseParameters).toMatchObject({ 'method.response.header.X-Existing': "'existing'", 'method.response.header.Access-Control-Allow-Origin': "'*'", 'method.response.header.Access-Control-Allow-Headers': "'Content-Type'", 'method.response.header.Access-Control-Allow-Methods': "'GET,POST,OPTIONS'", }) expect((commands[2] as PutIntegrationResponseCommand).input.responseTemplates).toEqual({'application/json': '$input.body'}) expect((commands[5] as PutMethodCommand).input).toMatchObject({httpMethod: 'OPTIONS', authorizationType: 'NONE'}) expect((commands[6] as PutMethodResponseCommand).input).toMatchObject({ responseParameters: {'method.response.header.Access-Control-Allow-Origin': true}, responseModels: {'application/json': 'OptionsModel'}, }) expect((commands[7] as PutIntegrationCommand).input).toMatchObject({ type: 'MOCK', requestTemplates: {'application/json': '{"statusCode": 200}'}, timeoutInMillis: 29000, }) expect((commands[8] as PutIntegrationResponseCommand).input.responseTemplates).toEqual({'text/plain': 'existing template'}) expect((commands[9] as CreateDeploymentCommand).input).toMatchObject({stageName: 'dev'}) }) test('returns the AWS API Gateway schema', () => { const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({}))) expect(adapter.schema()).toMatchObject({ cloud: 'aws', service: 'apigateway', displayName: 'AWS API Gateway', actions: ['list', 'create', 'delete', 'inspect'], }) }) })