feat: add API Gateway route testing and discovery

This commit is contained in:
Zakaria
2026-09-24 08:56:22 -04:00
parent 7e49be27f1
commit 9bbe80b5f2
10 changed files with 802 additions and 6 deletions
@@ -6,7 +6,9 @@ import {
DeleteRestApiCommand,
GetRestApiCommand,
GetRestApisCommand,
GetMethodCommand,
GetResourcesCommand,
GetStagesCommand,
PutIntegrationCommand,
PutMethodCommand,
type APIGatewayClient,
@@ -176,6 +178,86 @@ describe('AwsApiGatewayAdapter', () => {
})
})
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('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', 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({
routes: [
{resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello'},
{resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello'},
],
stages: ['dev', 'prod'],
})
})
test('returns the AWS API Gateway schema', () => {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({})))
@@ -5,7 +5,9 @@ import {
DeleteRestApiCommand,
GetRestApiCommand,
GetRestApisCommand,
GetMethodCommand,
GetResourcesCommand,
GetStagesCommand,
PutIntegrationCommand,
PutMethodCommand,
type APIGatewayClient,
@@ -16,7 +18,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 {ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
import type {ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
@@ -132,6 +134,77 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
}
}
async testApiGatewayRoute(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult> {
const path = testRoutePath(input.path)
const method = input.method.toUpperCase()
const stage = stageName(input.stage)
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) {
throw new ValidationError('method must be GET, POST, PUT, or DELETE')
}
const encodedPath = path.split('/').map(encodeURIComponent).join('/')
const url = new URL(`${awsEndpoint().replace(/\/$/, '')}/execute-api/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${encodedPath}`)
url.search = queryString(input.query)
const headers = new Headers(input.headers)
if (input.body && !headers.has('content-type')) headers.set('content-type', 'application/json')
const startedAt = performance.now()
const response = await globalThis.fetch(url, {
method,
headers,
body: ['GET', 'HEAD', 'OPTIONS'].includes(method) ? undefined : input.body || undefined,
})
return {
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()),
body: await response.text(),
durationMs: Math.round(performance.now() - startedAt),
}
}
async listApiGatewayRoutes(apiId: string): Promise<ApiGatewayRoutesResult> {
const resources = []
let position: string | undefined
do {
const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position, embed: ['methods']}))
resources.push(...(result.items ?? []))
position = result.position
} while (position)
const routes: ApiGatewayRouteOverview[] = []
for (const resource of resources) {
const methods = resource.resourceMethods
? Object.keys(resource.resourceMethods).sort()
: ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']
for (const method of methods) {
if (!resource.id || !resource.path) continue
let result
try {
result = await this.apiGateway.send(new GetMethodCommand({
restApiId: apiId,
resourceId: resource.id,
httpMethod: method,
}))
} catch (error) {
if (hasHttpStatus(error, 404)) continue
throw error
}
routes.push({
resourceId: resource.id,
path: resource.path,
method,
functionName: lambdaFunctionName(result.methodIntegration?.uri),
})
}
}
const stages = await this.apiGateway.send(new GetStagesCommand({restApiId: apiId}))
return {
routes: routes.sort((left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method)),
stages: (stages.item ?? []).map((stage) => stage.stageName).filter((stage): stage is string => Boolean(stage)).sort(),
}
}
private async rootResource(apiId: string) {
let position: string | undefined
do {
@@ -160,6 +233,25 @@ function stageName(stage: string): string {
return value
}
function queryString(query: string): string {
const value = stringValue(query)
if (value.length > 8_192) throw new ValidationError('query must be at most 8192 characters')
return value.replace(/^\?/, '')
}
function testRoutePath(path: string): string {
const value = stringValue(path)
if (!/^\/(?:[A-Za-z0-9._~{}-]+\/)*[A-Za-z0-9._~{}-]+$/.test(value)) {
throw new ValidationError('path must be an API Gateway route path, for example /orders or /users/{id}')
}
return value
}
function lambdaFunctionName(uri?: string): string | null {
const match = uri?.match(/\/functions\/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/)
return match?.[1] ?? null
}
function toResource(api: RestApi): CloudResource {
const id = api.id ?? ''
return {
+30
View File
@@ -463,6 +463,34 @@ export interface ApiGatewayLambdaRouteResult {
invokeUrl: string
}
export interface ApiGatewayTestRouteInput {
path: string
method: string
stage: string
body: string
headers: Record<string, string>
query: string
}
export interface ApiGatewayTestRouteResult {
statusCode: number
headers: Record<string, string>
body: string
durationMs: number
}
export interface ApiGatewayRouteOverview {
resourceId: string
path: string
method: string
functionName: string | null
}
export interface ApiGatewayRoutesResult {
routes: ApiGatewayRouteOverview[]
stages: string[]
}
export type KmsEncryptionAlgorithm =
| 'SYMMETRIC_DEFAULT'
| 'RSAES_OAEP_SHA_1'
@@ -525,6 +553,8 @@ export interface CloudServiceAdapter {
listDatabaseOrderableInstanceClasses?(engine?: string): Promise<string[]>
invoke?(id: string, payload: string): Promise<ServerlessInvokeResult>
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
encrypt?(id: string, input: KmsEncryptInput): Promise<KmsEncryptResult>
decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult>
// Lifecycle verbs. Optional because most categories have no notion of them;
+38
View File
@@ -522,6 +522,31 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.get('/:cloud/services/apigateway/resources/:id/routes', 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 () => c.json(await svc(c).listApiGatewayRoutes(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/apigateway/resources/:id/routes/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).testApiGatewayRoute(cloud, c.req.param('id'), {
path: typeof input.path === 'string' ? input.path : '',
method: typeof input.method === 'string' ? input.method : '',
stage: typeof input.stage === 'string' ? input.stage : '',
body: typeof input.body === 'string' ? input.body : '',
headers: stringHeaders(input.headers),
query: typeof input.query === 'string' ? input.query : '',
})
return c.json(result)
})
})
app.get('/:cloud/services/:service/resources/:id/collections', async (c) => {
const target = childTarget(c)
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
@@ -857,6 +882,19 @@ function encryptionContext(value: unknown): Record<string, string> | undefined {
return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record<string, string>
}
function stringHeaders(value: unknown): Record<string, string> {
if (value === undefined) return {}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ValidationError('headers must be an object of string values')
}
const entries = Object.entries(value)
if (entries.some(([, headerValue]) => typeof headerValue !== 'string')) {
throw new ValidationError('headers must be an object of string values')
}
return Object.fromEntries(entries) as Record<string, string>
}
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`)
@@ -22,6 +22,9 @@ import type {
AppConfigHostedConfigurationVersion,
ApiGatewayLambdaRouteInput,
ApiGatewayLambdaRouteResult,
ApiGatewayTestRouteInput,
ApiGatewayTestRouteResult,
ApiGatewayRoutesResult,
DatabaseSnapshot,
KubernetesFargateProfile,
KubernetesNodegroup,
@@ -242,6 +245,22 @@ export class CloudProxyService {
return adapter.createApiGatewayLambdaRoute(apiId, input)
}
async testApiGatewayRoute(cloud: CloudProvider, apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult> {
const adapter = this.requireAdapter(cloud, 'apigateway')
if (!adapter.testApiGatewayRoute) {
throw new NotSupportedError(`API Gateway route testing is not supported for ${cloud}`)
}
return adapter.testApiGatewayRoute(apiId, input)
}
async listApiGatewayRoutes(cloud: CloudProvider, apiId: string): Promise<ApiGatewayRoutesResult> {
const adapter = this.requireAdapter(cloud, 'apigateway')
if (!adapter.listApiGatewayRoutes) {
throw new NotSupportedError(`API Gateway route discovery is not supported for ${cloud}`)
}
return adapter.listApiGatewayRoutes(apiId)
}
async updateResource(cloud: CloudProvider, service: CloudServiceType, id: string, input: UpdateResourceInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`)