adding a real test Method for API gateway

This commit is contained in:
Zakaria
2026-09-27 20:14:59 -04:00
parent e8843a462b
commit ce8400757c
10 changed files with 321 additions and 23 deletions
@@ -14,6 +14,7 @@ import {
PutIntegrationResponseCommand,
PutMethodCommand,
PutMethodResponseCommand,
TestInvokeMethodCommand,
type APIGatewayClient,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
@@ -263,6 +264,47 @@ describe('AwsApiGatewayAdapter', () => {
}
})
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: 'POST',
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: 'POST',
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) {
@@ -13,6 +13,7 @@ import {
PutIntegrationResponseCommand,
PutMethodCommand,
PutMethodResponseCommand,
TestInvokeMethodCommand,
type APIGatewayClient,
type Integration,
type IntegrationResponse,
@@ -24,7 +25,7 @@ import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLamb
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
import {ValidationError} from '../cloud-spi/errors'
import {awsEndpoint} from '../service/runtimeProbe'
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayMethodTestInput, ApiGatewayMethodTestResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
@@ -173,6 +174,38 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
}
}
async testApiGatewayMethod(apiId: string, input: ApiGatewayMethodTestInput): Promise<ApiGatewayMethodTestResult> {
const resourceId = stringValue(input.resourceId)
const httpMethod = input.httpMethod.toUpperCase()
const pathWithQueryString = stringValue(input.pathWithQueryString)
if (!resourceId) throw new ValidationError('resourceId is required')
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(httpMethod)) {
throw new ValidationError('httpMethod must be GET, POST, PUT, or DELETE')
}
if (!pathWithQueryString.startsWith('/')) {
throw new ValidationError('pathWithQueryString must start with /')
}
const result = await this.apiGateway.send(new TestInvokeMethodCommand({
restApiId: apiId,
resourceId,
httpMethod,
pathWithQueryString,
body: input.body,
headers: input.headers,
multiValueHeaders: input.multiValueHeaders,
stageVariables: input.stageVariables,
}))
return {
statusCode: result.status ?? 0,
body: result.body ?? '',
headers: result.headers ?? {},
multiValueHeaders: result.multiValueHeaders ?? {},
log: result.log ?? null,
latency: result.latency ?? null,
}
}
async listApiGatewayRoutes(apiId: string): Promise<ApiGatewayRoutesResult> {
const resources = []
let position: string | undefined
+20
View File
@@ -503,6 +503,25 @@ export interface ApiGatewayTestRouteResult {
durationMs: number
}
export interface ApiGatewayMethodTestInput {
resourceId: string
httpMethod: string
pathWithQueryString: string
body: string
headers: Record<string, string>
multiValueHeaders: Record<string, string[]>
stageVariables: Record<string, string>
}
export interface ApiGatewayMethodTestResult {
statusCode: number
body: string
headers: Record<string, string>
multiValueHeaders: Record<string, string[]>
log: string | null
latency: number | null
}
export interface ApiGatewayRouteOverview {
resourceId: string
path: string
@@ -679,6 +698,7 @@ export interface CloudServiceAdapter {
deleteSnsSubscription?(subscriptionArn: string): Promise<void>
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
testApiGatewayMethod?(apiId: string, input: ApiGatewayMethodTestInput): Promise<ApiGatewayMethodTestResult>
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
+28
View File
@@ -169,6 +169,34 @@ describe('API Gateway CORS routes', () => {
})
})
describe('API Gateway method test routes', () => {
test('dispatches the AWS TestInvokeMethod input without requiring a stage', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'apigateway',
testApiGatewayMethod: async (apiId, input) => {
calls.push({apiId, input})
return {statusCode: 200, body: '{}', headers: {}, multiValueHeaders: {}, log: 'ok', latency: 4}
},
})])
const response = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/methods/test', {
method: 'POST', headers: {'content-type': 'application/json'},
body: JSON.stringify({
resourceId: 'resource-456', httpMethod: 'POST', pathWithQueryString: '/orders?dryRun=true', body: '{}',
headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']}, stageVariables: {environment: 'test'},
}),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({statusCode: 200, body: '{}', headers: {}, multiValueHeaders: {}, log: 'ok', latency: 4})
expect(calls).toEqual([{apiId: 'api-123', input: {
resourceId: 'resource-456', httpMethod: 'POST', pathWithQueryString: '/orders?dryRun=true', body: '{}',
headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']}, stageVariables: {environment: 'test'},
}}])
})
})
describe('S3 website routes', () => {
test('routes normalized website configuration lifecycle requests through the storage adapter', async () => {
const calls: Array<{operation: string; args: unknown[]}> = []
+26
View File
@@ -589,6 +589,25 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.post('/:cloud/services/apigateway/resources/:id/methods/test', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const result = await svc(c).testApiGatewayMethod(cloud, c.req.param('id'), {
resourceId: typeof input.resourceId === 'string' ? input.resourceId : '',
httpMethod: typeof input.httpMethod === 'string' ? input.httpMethod : '',
pathWithQueryString: typeof input.pathWithQueryString === 'string' ? input.pathWithQueryString : '',
body: typeof input.body === 'string' ? input.body : '',
headers: stringHeaders(input.headers),
multiValueHeaders: stringArrayHeaders(input.multiValueHeaders),
stageVariables: stringHeaders(input.stageVariables),
})
return c.json(result)
})
})
app.get('/:cloud/services/storage/resources/:id/lambda-triggers', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
@@ -1178,6 +1197,13 @@ function stringHeaders(value: unknown): Record<string, string> {
return Object.fromEntries(entries) as Record<string, string>
}
function stringArrayHeaders(value: unknown): Record<string, string[]> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return Object.fromEntries(Object.entries(value).flatMap(([name, values]) =>
Array.isArray(values) && values.every((header) => typeof header === 'string') ? [[name, values]] : [],
))
}
function decodeBase64(value: unknown, field: string, maxLength: number): Uint8Array {
if (typeof value !== 'string') throw new ValidationError(`${field} must be a base64 string`)
if (value.length > maxLength) throw new ValidationError(`${field} is too large`)
@@ -24,6 +24,8 @@ import type {
ApiGatewayLambdaRouteResult,
ApiGatewayTestRouteInput,
ApiGatewayTestRouteResult,
ApiGatewayMethodTestInput,
ApiGatewayMethodTestResult,
ApiGatewayRoutesResult,
ApiGatewayDeleteRouteInput,
ApiGatewayCorsInput,
@@ -269,6 +271,14 @@ export class CloudProxyService {
return adapter.testApiGatewayRoute(apiId, input)
}
async testApiGatewayMethod(cloud: CloudProvider, apiId: string, input: ApiGatewayMethodTestInput): Promise<ApiGatewayMethodTestResult> {
const adapter = this.requireAdapter(cloud, 'apigateway')
if (!adapter.testApiGatewayMethod) {
throw new NotSupportedError(`API Gateway method testing is not supported for ${cloud}`)
}
return adapter.testApiGatewayMethod(apiId, input)
}
async listApiGatewayRoutes(cloud: CloudProvider, apiId: string): Promise<ApiGatewayRoutesResult> {
const adapter = this.requireAdapter(cloud, 'apigateway')
if (!adapter.listApiGatewayRoutes) {