feat: add API Gateway route testing and discovery
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user