bb5ca85a88
CI / checks (pull_request) Successful in 26m49s
API Gateway v2 is a separate AWS API from REST APIs, so HTTP and WebSocket APIs made with the SDK, SAM, CDK or Terraform never showed up. The API Gateway page now lists all three kinds with a Type column, creates each from the same form, and opens HTTP and WebSocket APIs in their own panel: - Quick create: an HTTP API with a Lambda gets a catch-all route on an auto-deployed $default stage, plus the Lambda permission. - Routes to Lambda (payload 2.0) or HTTP backends; deleting a route removes the integration and Lambda permission only it used. - Stages with or without automatic deployment, and deploy on demand. - JWT authorizers filled in from a Cognito pool's OpenID Connect discovery document and an app client, or from any issuer; protect routes with them. - CORS configuration. - A tester that sends a real request to a stage, and for WebSocket APIs one that connects, sends a message and collects the replies. Capabilities are advertised under a new httpApiActions group, covered by the cloudProxy guard test. Verified end to end against TheCloud's runtime.
327 lines
19 KiB
TypeScript
327 lines
19 KiB
TypeScript
import {afterEach, describe, expect, test} from 'bun:test'
|
|
import {
|
|
CreateApiCommand,
|
|
CreateAuthorizerCommand,
|
|
CreateDeploymentCommand,
|
|
CreateIntegrationCommand,
|
|
CreateRouteCommand,
|
|
CreateRouteResponseCommand,
|
|
CreateStageCommand,
|
|
DeleteApiCommand,
|
|
DeleteCorsConfigurationCommand,
|
|
DeleteIntegrationCommand,
|
|
DeleteRouteCommand,
|
|
GetApiCommand,
|
|
GetApisCommand,
|
|
GetAuthorizersCommand,
|
|
GetIntegrationsCommand,
|
|
GetRoutesCommand,
|
|
GetStageCommand,
|
|
GetStagesCommand,
|
|
UpdateApiCommand,
|
|
UpdateRouteCommand,
|
|
type ApiGatewayV2Client,
|
|
} from '@aws-sdk/client-apigatewayv2'
|
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
|
import {AwsHttpApiOperations, httpRouteKey, webSocketRouteKey} from './AwsHttpApiOperations'
|
|
import {ValidationError} from '../cloud-spi/errors'
|
|
|
|
const CREATED = new Date('2026-10-25T10:00:00Z')
|
|
const httpApi = {ApiId: 'h1', Name: 'notes', ProtocolType: 'HTTP', ApiEndpoint: 'https://h1.execute-api.us-east-1.amazonaws.com', CreatedDate: CREATED}
|
|
const webSocketApi = {ApiId: 'w1', Name: 'chat', ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action', CreatedDate: CREATED}
|
|
const functionArn = 'arn:aws:lambda:us-east-1:000000000000:function:notes'
|
|
|
|
interface Runtime {
|
|
apis?: Record<string, unknown>
|
|
routes?: unknown[]
|
|
integrations?: unknown[]
|
|
stages?: unknown[]
|
|
authorizers?: unknown[]
|
|
reply?: (command: object) => unknown
|
|
}
|
|
|
|
function stubRuntime(runtime: Runtime = {}) {
|
|
const sent: object[] = []
|
|
const lambdaSent: object[] = []
|
|
const apis = runtime.apis ?? {h1: httpApi, w1: webSocketApi}
|
|
const client = {
|
|
async send(command: object) {
|
|
sent.push(command)
|
|
const custom = runtime.reply?.(command)
|
|
if (custom !== undefined) return custom
|
|
if (command instanceof GetApisCommand) return {Items: Object.values(apis)}
|
|
if (command instanceof GetApiCommand) {
|
|
const api = apis[command.input.ApiId!]
|
|
if (!api) throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
|
return api
|
|
}
|
|
if (command instanceof GetRoutesCommand) return {Items: runtime.routes ?? []}
|
|
if (command instanceof GetIntegrationsCommand) return {Items: runtime.integrations ?? []}
|
|
if (command instanceof GetStagesCommand) return {Items: runtime.stages ?? []}
|
|
if (command instanceof GetAuthorizersCommand) return {Items: runtime.authorizers ?? []}
|
|
if (command instanceof CreateIntegrationCommand) return {IntegrationId: 'new-integration', ...command.input}
|
|
if (command instanceof CreateRouteCommand) return {RouteId: 'new-route', ...command.input}
|
|
if (command instanceof CreateApiCommand) return {ApiId: 'new-api', CreatedDate: CREATED, ...command.input}
|
|
if (command instanceof CreateStageCommand) return {...command.input}
|
|
return {}
|
|
},
|
|
} as unknown as ApiGatewayV2Client
|
|
const lambda = {
|
|
async send(command: object) {
|
|
lambdaSent.push(command)
|
|
if (command instanceof GetFunctionCommand) return {Configuration: {FunctionArn: functionArn}}
|
|
return {}
|
|
},
|
|
} as unknown as LambdaClient
|
|
const discoveries: string[] = []
|
|
const fetchJson = async (url: string) => {
|
|
discoveries.push(url)
|
|
return {issuer: 'http://floci:4566/us-east-1_pool1'}
|
|
}
|
|
const operations = new AwsHttpApiOperations(client, lambda, '000000000000', 'us-east-1', fetchJson)
|
|
const of = <T extends object>(type: new (...args: never[]) => T): T[] => sent.filter((command) => command instanceof type) as T[]
|
|
const lambdaOf = <T extends object>(type: new (...args: never[]) => T): T[] => lambdaSent.filter((command) => command instanceof type) as T[]
|
|
return {operations, sent, of, lambdaOf, discoveries}
|
|
}
|
|
|
|
describe('AwsHttpApiOperations APIs', () => {
|
|
test('lists HTTP and WebSocket APIs with their protocol', async () => {
|
|
const {operations} = stubRuntime()
|
|
|
|
const apis = await operations.list()
|
|
expect(apis.map((api) => [api.id, api.type, api.metadata.protocol, api.metadata.apigatewayService])).toEqual([
|
|
['h1', 'http-api', 'HTTP', 'http'],
|
|
['w1', 'websocket-api', 'WEBSOCKET', 'websocket'],
|
|
])
|
|
expect(await operations.get('rest-id')).toBeNull()
|
|
})
|
|
|
|
test('quick-creates an HTTP API that sends every request to a Lambda', async () => {
|
|
const {operations, of, lambdaOf} = stubRuntime()
|
|
|
|
await operations.create({name: 'notes', functionName: 'notes'}, 'HTTP')
|
|
|
|
expect(of(CreateApiCommand)[0].input).toMatchObject({Name: 'notes', ProtocolType: 'HTTP', Target: functionArn})
|
|
expect(of(CreateStageCommand)).toHaveLength(0)
|
|
expect(lambdaOf(AddPermissionCommand)[0].input).toMatchObject({
|
|
FunctionName: 'notes', Principal: 'apigateway.amazonaws.com', SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:new-api/*',
|
|
})
|
|
})
|
|
|
|
test('gives an HTTP API without a target an auto-deployed $default stage', async () => {
|
|
const {operations, of} = stubRuntime()
|
|
|
|
await operations.create({name: 'empty'}, 'HTTP')
|
|
expect(of(CreateStageCommand)[0].input).toEqual({ApiId: 'new-api', StageName: '$default', AutoDeploy: true})
|
|
})
|
|
|
|
test('creates a WebSocket API with its route selection expression and a stage', async () => {
|
|
const {operations, of} = stubRuntime()
|
|
|
|
await operations.create({name: 'chat', stageName: 'prod'}, 'WEBSOCKET')
|
|
expect(of(CreateApiCommand)[0].input).toMatchObject({ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action'})
|
|
expect(of(CreateStageCommand)[0].input).toMatchObject({StageName: 'prod', AutoDeploy: true})
|
|
await expect(operations.create({name: 'chat', routeSelectionExpression: 'action'}, 'WEBSOCKET')).rejects.toThrow(/routeSelectionExpression/)
|
|
})
|
|
|
|
test('deletes an API and the Lambda permissions it granted', async () => {
|
|
const {operations, of, lambdaOf} = stubRuntime({integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}]})
|
|
|
|
await operations.delete('h1')
|
|
expect(of(DeleteApiCommand)[0].input).toEqual({ApiId: 'h1'})
|
|
expect(lambdaOf(RemovePermissionCommand)[0].input).toEqual({FunctionName: 'notes', StatementId: 'apigatewayv2-h1'})
|
|
})
|
|
})
|
|
|
|
describe('AwsHttpApiOperations routes', () => {
|
|
test('describes routes with their integration, stages, authorizers and CORS', async () => {
|
|
const {operations} = stubRuntime({
|
|
apis: {h1: {...httpApi, CorsConfiguration: {AllowOrigins: ['*'], AllowMethods: ['GET'], MaxAge: 300}}},
|
|
routes: [
|
|
{RouteId: 'r2', RouteKey: 'POST /notes', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
|
|
{RouteId: 'r1', RouteKey: 'GET /notes', Target: 'integrations/i1', AuthorizationType: 'NONE', AuthorizerId: 'a1'},
|
|
],
|
|
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn, PayloadFormatVersion: '2.0'}],
|
|
stages: [{StageName: '$default', AutoDeploy: true}],
|
|
authorizers: [{AuthorizerId: 'a1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}}],
|
|
})
|
|
|
|
const overview = await operations.describe('h1')
|
|
expect(overview.routes.map((route) => [route.routeKey, route.integration?.functionName, route.authorizerId])).toEqual([
|
|
['GET /notes', 'notes', null],
|
|
['POST /notes', 'notes', 'a1'],
|
|
])
|
|
expect(overview.stages).toEqual([{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}])
|
|
expect(overview.authorizers[0]).toMatchObject({name: 'cognito', issuer: 'http://floci:4566/us-east-1_pool1', audience: ['client1']})
|
|
expect(overview.cors).toMatchObject({allowOrigins: ['*'], allowMethods: ['GET'], maxAgeSeconds: 300})
|
|
})
|
|
|
|
test('adds a Lambda route, reusing the integration the function already has', async () => {
|
|
const {operations, of, lambdaOf} = stubRuntime({
|
|
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}],
|
|
})
|
|
|
|
const route = await operations.createRoute('h1', {routeKey: 'get /notes/{id}', functionName: 'notes', authorizerId: 'a1'})
|
|
expect(of(CreateIntegrationCommand)).toHaveLength(0)
|
|
expect(of(CreateRouteCommand)[0].input).toMatchObject({RouteKey: 'GET /notes/{id}', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'})
|
|
expect(lambdaOf(AddPermissionCommand)).toHaveLength(1)
|
|
expect(route.integration?.functionName).toBe('notes')
|
|
})
|
|
|
|
test('adds an HTTP proxy route with a payload 1.0 integration', async () => {
|
|
const {operations, of} = stubRuntime()
|
|
|
|
await operations.createRoute('h1', {routeKey: 'ANY /legacy/{proxy+}', httpUrl: 'https://example.com/{proxy}'})
|
|
expect(of(CreateIntegrationCommand)[0].input).toMatchObject({IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com/{proxy}', IntegrationMethod: 'ANY', PayloadFormatVersion: '1.0'})
|
|
})
|
|
|
|
test('a two-way WebSocket route gets a route response and an invocation-URI integration', async () => {
|
|
const {operations, of} = stubRuntime()
|
|
|
|
await operations.createRoute('w1', {routeKey: 'sendMessage', functionName: 'notes', returnsResponse: true})
|
|
expect(of(CreateIntegrationCommand)[0].input.IntegrationUri).toBe(`arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${functionArn}/invocations`)
|
|
expect(of(CreateRouteCommand)[0].input.RouteResponseSelectionExpression).toBe('$default')
|
|
expect(of(CreateRouteResponseCommand)[0].input).toEqual({ApiId: 'w1', RouteId: 'new-route', RouteResponseKey: '$default'})
|
|
})
|
|
|
|
test('checks a route before creating anything', async () => {
|
|
const {operations, sent} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /notes'}]})
|
|
const before = sent.length
|
|
|
|
await expect(operations.createRoute('h1', {routeKey: 'GET /notes', functionName: 'notes'})).rejects.toThrow(/already has/)
|
|
await expect(operations.createRoute('h1', {routeKey: 'GET /notes/x', functionName: 'notes', httpUrl: 'https://example.com'})).rejects.toThrow(/either/)
|
|
await expect(operations.createRoute('h1', {routeKey: 'FETCH /notes', functionName: 'notes'})).rejects.toThrow(ValidationError)
|
|
await expect(operations.createRoute('w1', {routeKey: 'send', httpUrl: 'https://example.com'})).rejects.toThrow(/Lambda functions only/)
|
|
await expect(operations.createRoute('w1', {routeKey: 'send', functionName: 'notes', authorizerId: 'a1'})).rejects.toThrow(/Lambda authorizers/)
|
|
expect(sent.slice(before).some((command) => command instanceof CreateRouteCommand || command instanceof CreateIntegrationCommand)).toBe(false)
|
|
})
|
|
|
|
test('deleting a route removes the integration and permission only it used', async () => {
|
|
const integrations = [
|
|
{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn},
|
|
{IntegrationId: 'i2', IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com'},
|
|
]
|
|
const shared = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}, {RouteId: 'r2', Target: 'integrations/i1'}], integrations})
|
|
await shared.operations.deleteRoute('h1', 'r1')
|
|
expect(shared.of(DeleteRouteCommand)).toHaveLength(1)
|
|
expect(shared.of(DeleteIntegrationCommand)).toHaveLength(0)
|
|
|
|
const last = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}], integrations})
|
|
await last.operations.deleteRoute('h1', 'r1')
|
|
expect(last.of(DeleteIntegrationCommand)[0].input).toEqual({ApiId: 'h1', IntegrationId: 'i1'})
|
|
expect(last.lambdaOf(RemovePermissionCommand)[0].input.FunctionName).toBe('notes')
|
|
})
|
|
|
|
test('protects a route with an authorizer and removes it again', async () => {
|
|
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateRouteCommand ? {RouteKey: 'GET /me', ...command.input} : undefined})
|
|
|
|
expect((await operations.setRouteAuthorizer('h1', 'r1', 'a1')).authorizerId).toBe('a1')
|
|
expect((await operations.setRouteAuthorizer('h1', 'r1', null)).authorizerId).toBeNull()
|
|
expect(of(UpdateRouteCommand).map((command) => command.input)).toEqual([
|
|
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
|
|
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'NONE', AuthorizerId: undefined},
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('AwsHttpApiOperations stages, authorizers and CORS', () => {
|
|
test('a stage without auto-deploy is deployed once when created', async () => {
|
|
const {operations, of} = stubRuntime({reply: (command) => command instanceof GetStageCommand ? {StageName: 'prod', DeploymentId: 'd1'} : undefined})
|
|
|
|
const stage = await operations.createStage('h1', {stageName: 'prod', autoDeploy: false})
|
|
expect(of(CreateDeploymentCommand)[0].input).toMatchObject({ApiId: 'h1', StageName: 'prod'})
|
|
expect(stage).toMatchObject({stageName: 'prod', deploymentId: 'd1', invokePath: '/execute-api/h1/prod'})
|
|
await expect(operations.createStage('h1', {stageName: 'pro d', autoDeploy: true})).rejects.toThrow(/stage/)
|
|
})
|
|
|
|
test('fills a JWT authorizer from a Cognito pool discovery document', async () => {
|
|
const {operations, of, discoveries} = stubRuntime({reply: (command) => command instanceof CreateAuthorizerCommand ? {AuthorizerId: 'a1', ...command.input} : undefined})
|
|
|
|
const authorizer = await operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1', userPoolClientId: 'client1'})
|
|
expect(discoveries[0]).toEndWith('/us-east-1_pool1/.well-known/openid-configuration')
|
|
expect(of(CreateAuthorizerCommand)[0].input).toEqual({
|
|
ApiId: 'h1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'],
|
|
JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']},
|
|
})
|
|
expect(authorizer.issuer).toBe('http://floci:4566/us-east-1_pool1')
|
|
await expect(operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1'})).rejects.toThrow(/app client/)
|
|
await expect(operations.createAuthorizer('h1', {name: 'other', issuer: 'https://issuer.example.com', audience: []})).rejects.toThrow(/audience/)
|
|
await expect(operations.createAuthorizer('w1', {name: 'x', issuer: 'https://issuer.example.com', audience: ['a']})).rejects.toThrow(/HTTP APIs/)
|
|
})
|
|
|
|
test('will not delete an authorizer that still protects a route', async () => {
|
|
const {operations} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /me', AuthorizationType: 'JWT', AuthorizerId: 'a1'}]})
|
|
|
|
await expect(operations.deleteAuthorizer('h1', 'a1')).rejects.toThrow('Remove the authorizer from GET /me first')
|
|
})
|
|
|
|
test('sets and removes the CORS configuration', async () => {
|
|
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateApiCommand ? {...httpApi, CorsConfiguration: command.input.CorsConfiguration} : undefined})
|
|
const cors = {allowOrigins: ['https://app.example.com'], allowMethods: ['get', 'POST'], allowHeaders: ['content-type'], exposeHeaders: [], maxAgeSeconds: 600, allowCredentials: true}
|
|
|
|
expect(await operations.updateCors('h1', cors)).toEqual({...cors, allowMethods: ['GET', 'POST']})
|
|
expect(await operations.updateCors('h1', null)).toBeNull()
|
|
expect(of(DeleteCorsConfigurationCommand)).toHaveLength(1)
|
|
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['*']})).rejects.toThrow(/Credentials/)
|
|
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['app.example.com']})).rejects.toThrow(/origin/)
|
|
await expect(operations.updateCors('w1', cors)).rejects.toThrow(/HTTP APIs only/)
|
|
})
|
|
})
|
|
|
|
describe('AwsHttpApiOperations WebSocket test', () => {
|
|
const endpoint = process.env.FLOCI_ENDPOINT
|
|
afterEach(() => {
|
|
if (endpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
|
else process.env.FLOCI_ENDPOINT = endpoint
|
|
})
|
|
|
|
test('connects to the stage, sends the message and collects the replies', async () => {
|
|
const paths: string[] = []
|
|
const server = Bun.serve({
|
|
port: 0,
|
|
fetch(request, upgrade) {
|
|
paths.push(new URL(request.url).pathname + new URL(request.url).search)
|
|
return upgrade.upgrade(request) ? undefined : new Response('no', {status: 400})
|
|
},
|
|
websocket: {message(socket, message) { socket.send(`echo ${message}`) }},
|
|
})
|
|
process.env.FLOCI_ENDPOINT = `http://localhost:${server.port}`
|
|
try {
|
|
const {operations} = stubRuntime()
|
|
const result = await operations.testWebSocket('w1', {stage: 'dev', message: '{"action":"ping"}', query: '?token=abc'})
|
|
expect(result).toMatchObject({connected: true, messages: ['echo {"action":"ping"}'], error: null})
|
|
expect(paths).toEqual(['/ws/w1/dev?token=abc'])
|
|
} finally {
|
|
server.stop(true)
|
|
}
|
|
})
|
|
|
|
test('reports a refused connection', async () => {
|
|
process.env.FLOCI_ENDPOINT = 'http://127.0.0.1:1'
|
|
const {operations} = stubRuntime()
|
|
|
|
const result = await operations.testWebSocket('w1', {stage: 'dev', message: 'hi'}, 2000)
|
|
expect(result.connected).toBe(false)
|
|
expect(result.error).toBeTruthy()
|
|
await expect(operations.testWebSocket('h1', {stage: 'dev', message: 'hi'})).rejects.toThrow(/not a WebSocket API/)
|
|
})
|
|
})
|
|
|
|
describe('route keys', () => {
|
|
test('HTTP route keys are $default or a method and a path', () => {
|
|
expect(httpRouteKey('$default')).toBe('$default')
|
|
expect(httpRouteKey(' any /files/{proxy+} ')).toBe('ANY /files/{proxy+}')
|
|
expect(httpRouteKey('GET /')).toBe('GET /')
|
|
for (const bad of ['GET', '/notes', 'GET notes', 'GET /{proxy+}/more', 'GET /a b', 'GET /notes/']) {
|
|
expect(() => httpRouteKey(bad), bad).toThrow(ValidationError)
|
|
}
|
|
})
|
|
|
|
test('WebSocket route keys are the system routes or an action name', () => {
|
|
expect(webSocketRouteKey('$connect')).toBe('$connect')
|
|
expect(webSocketRouteKey('sendMessage')).toBe('sendMessage')
|
|
expect(() => webSocketRouteKey('$other')).toThrow(ValidationError)
|
|
expect(() => webSocketRouteKey('send message')).toThrow(ValidationError)
|
|
})
|
|
})
|