feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page
CI / checks (pull_request) Successful in 26m49s
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.
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
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 {
|
||||
@@ -32,6 +33,18 @@ function fakeLambda(handler: (command: unknown) => unknown): LambdaClient {
|
||||
} 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',
|
||||
@@ -44,7 +57,7 @@ const orders = {
|
||||
|
||||
describe('AwsApiGatewayAdapter', () => {
|
||||
test('lists every page and maps REST APIs to normalized resources', async () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
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'}]}
|
||||
@@ -70,7 +83,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
test('filters APIs by search term', async () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({
|
||||
const adapter = restOnlyAdapter(fakeClient(() => ({
|
||||
items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}],
|
||||
})))
|
||||
|
||||
@@ -92,7 +105,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
test('returns null when API Gateway reports a missing REST API', async () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => {
|
||||
const adapter = restOnlyAdapter(fakeClient(() => {
|
||||
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||
}))
|
||||
|
||||
@@ -123,7 +136,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
test('deletes the requested REST API', async () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
const adapter = restOnlyAdapter(fakeClient((command) => {
|
||||
expect(command).toBeInstanceOf(DeleteRestApiCommand)
|
||||
expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3')
|
||||
return {}
|
||||
|
||||
@@ -22,10 +22,13 @@ import {
|
||||
type RestApi,
|
||||
} from '@aws-sdk/client-api-gateway'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
|
||||
import type {ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
|
||||
import {apiGateway as defaultApiGateway, apiGatewayV2 as defaultApiGatewayV2, defaultAccountId, lambda as defaultLambda} from '../aws'
|
||||
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {awsEndpoint} from '../service/runtimeProbe'
|
||||
import {AwsHttpApiOperations} from './AwsHttpApiOperations'
|
||||
import type {HttpApiAuthorizerInput, HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteInput, HttpApiRouteOverview, HttpApiStageInput, HttpApiStageOverview, HttpApiWebSocketTestInput, HttpApiWebSocketTestResult} 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 {
|
||||
@@ -37,7 +40,13 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
private readonly lambda: LambdaClient = defaultLambda,
|
||||
private readonly accountId = defaultAccountId(),
|
||||
private readonly region = process.env.AWS_REGION ?? 'us-east-1',
|
||||
) {}
|
||||
apiGatewayV2: ApiGatewayV2Client = defaultApiGatewayV2,
|
||||
) {
|
||||
this.httpApis = new AwsHttpApiOperations(apiGatewayV2, lambda, accountId, region)
|
||||
}
|
||||
|
||||
/** HTTP and WebSocket APIs (API Gateway v2), listed and managed alongside REST APIs. */
|
||||
private readonly httpApis: AwsHttpApiOperations
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsApiGatewaySchema()
|
||||
@@ -53,7 +62,17 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
position = res.position
|
||||
} while (position)
|
||||
|
||||
return filterBySearch(apis.map(toResource), query.search)
|
||||
return filterBySearch([...apis.map(toResource), ...await this.listHttpApis()], query.search)
|
||||
}
|
||||
|
||||
/** A runtime without API Gateway v2 still lists its REST APIs. */
|
||||
private async listHttpApis(): Promise<CloudResource[]> {
|
||||
try {
|
||||
return await this.httpApis.list()
|
||||
} catch (error) {
|
||||
if (hasHttpStatus(error, 404) || hasHttpStatus(error, 501)) return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
@@ -61,12 +80,15 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id}))
|
||||
return toResource(res)
|
||||
} catch (error) {
|
||||
if (hasHttpStatus(error, 404)) return null
|
||||
throw error
|
||||
if (!hasHttpStatus(error, 404)) throw error
|
||||
}
|
||||
return this.httpApis.get(id)
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const protocol = stringValue(input.values.protocol) || 'REST'
|
||||
if (protocol === 'HTTP' || protocol === 'WEBSOCKET') return this.httpApis.create(input.values, protocol)
|
||||
if (protocol !== 'REST') throw new ValidationError('protocol must be REST, HTTP or WEBSOCKET')
|
||||
const name = stringValue(input.values.name)
|
||||
const description = stringValue(input.values.description)
|
||||
if (!name) throw new ValidationError('name is required')
|
||||
@@ -79,9 +101,54 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (await this.httpApis.get(id)) return this.httpApis.delete(id)
|
||||
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
|
||||
}
|
||||
|
||||
describeHttpApi(apiId: string): Promise<HttpApiOverview> {
|
||||
return this.httpApis.describe(apiId)
|
||||
}
|
||||
|
||||
createHttpApiRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApis.createRoute(apiId, input)
|
||||
}
|
||||
|
||||
deleteHttpApiRoute(apiId: string, routeId: string): Promise<void> {
|
||||
return this.httpApis.deleteRoute(apiId, routeId)
|
||||
}
|
||||
|
||||
setHttpApiRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApis.setRouteAuthorizer(apiId, routeId, authorizerId)
|
||||
}
|
||||
|
||||
createHttpApiStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||
return this.httpApis.createStage(apiId, input)
|
||||
}
|
||||
|
||||
deleteHttpApiStage(apiId: string, stageName: string): Promise<void> {
|
||||
return this.httpApis.deleteStage(apiId, stageName)
|
||||
}
|
||||
|
||||
deployHttpApi(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
return this.httpApis.deploy(apiId, stageName)
|
||||
}
|
||||
|
||||
createHttpApiAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
return this.httpApis.createAuthorizer(apiId, input)
|
||||
}
|
||||
|
||||
deleteHttpApiAuthorizer(apiId: string, authorizerId: string): Promise<void> {
|
||||
return this.httpApis.deleteAuthorizer(apiId, authorizerId)
|
||||
}
|
||||
|
||||
updateHttpApiCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
return this.httpApis.updateCors(apiId, cors)
|
||||
}
|
||||
|
||||
testWebSocketApi(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
|
||||
return this.httpApis.testWebSocket(apiId, input)
|
||||
}
|
||||
|
||||
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
|
||||
const method = input.method.toUpperCase()
|
||||
const stage = stageName(input.stage)
|
||||
@@ -470,7 +537,8 @@ function childPath(parentPath: string, pathPart: string): string {
|
||||
|
||||
function stageName(stage: string): string {
|
||||
const value = stringValue(stage)
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
||||
// $default is an HTTP API's catch-all stage; the deployed-request test serves both kinds of API.
|
||||
if (!/^(?:\$default|[A-Za-z0-9_-]+)$/.test(value)) {
|
||||
throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens')
|
||||
}
|
||||
return value
|
||||
@@ -518,6 +586,7 @@ function toResource(api: RestApi): CloudResource {
|
||||
metadata: {
|
||||
provider: 'aws',
|
||||
apigatewayService: 'rest',
|
||||
protocol: 'REST',
|
||||
description: api.description,
|
||||
version: api.version,
|
||||
endpointTypes: api.endpointConfiguration?.types,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,592 @@
|
||||
import {
|
||||
CreateApiCommand,
|
||||
CreateAuthorizerCommand,
|
||||
CreateDeploymentCommand,
|
||||
CreateIntegrationCommand,
|
||||
CreateRouteCommand,
|
||||
CreateRouteResponseCommand,
|
||||
CreateStageCommand,
|
||||
DeleteApiCommand,
|
||||
DeleteAuthorizerCommand,
|
||||
DeleteCorsConfigurationCommand,
|
||||
DeleteIntegrationCommand,
|
||||
DeleteRouteCommand,
|
||||
DeleteStageCommand,
|
||||
GetApiCommand,
|
||||
GetApisCommand,
|
||||
GetAuthorizersCommand,
|
||||
GetIntegrationsCommand,
|
||||
GetRoutesCommand,
|
||||
GetStageCommand,
|
||||
GetStagesCommand,
|
||||
UpdateApiCommand,
|
||||
UpdateRouteCommand,
|
||||
type Api,
|
||||
type ApiGatewayV2Client,
|
||||
type Authorizer,
|
||||
type Cors,
|
||||
type Integration,
|
||||
type Route,
|
||||
type Stage,
|
||||
} from '@aws-sdk/client-apigatewayv2'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import {awsEndpoint} from '../service/runtimeProbe'
|
||||
import type {
|
||||
CloudResource,
|
||||
HttpApiAuthorizerInput,
|
||||
HttpApiAuthorizerOverview,
|
||||
HttpApiCors,
|
||||
HttpApiIntegrationOverview,
|
||||
HttpApiOverview,
|
||||
HttpApiProtocol,
|
||||
HttpApiRouteInput,
|
||||
HttpApiRouteOverview,
|
||||
HttpApiStageInput,
|
||||
HttpApiStageOverview,
|
||||
HttpApiWebSocketTestInput,
|
||||
HttpApiWebSocketTestResult,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
export const HTTP_ROUTE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'ANY']
|
||||
export const CORS_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', '*']
|
||||
export const DEFAULT_ROUTE_SELECTION_EXPRESSION = '$request.body.action'
|
||||
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
|
||||
|
||||
/**
|
||||
* API Gateway v2: HTTP and WebSocket APIs. A separate AWS API from REST APIs, with its own
|
||||
* routes, integrations, stages and JWT authorizers, served by the same API Gateway page.
|
||||
*/
|
||||
export class AwsHttpApiOperations {
|
||||
constructor(
|
||||
private readonly client: ApiGatewayV2Client,
|
||||
private readonly lambda: LambdaClient,
|
||||
private readonly accountId: string,
|
||||
private readonly region: string,
|
||||
private readonly fetchJson: (url: string) => Promise<unknown> = defaultFetchJson,
|
||||
) {}
|
||||
|
||||
async list(): Promise<CloudResource[]> {
|
||||
const apis: Api[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const page = await this.client.send(new GetApisCommand({NextToken: next}))
|
||||
apis.push(...(page.Items ?? []))
|
||||
next = page.NextToken
|
||||
} while (next)
|
||||
return apis.map(toResource)
|
||||
}
|
||||
|
||||
/** The API, or null when no HTTP or WebSocket API has this id (it may be a REST API). */
|
||||
async get(apiId: string): Promise<CloudResource | null> {
|
||||
const api = await this.findApi(apiId)
|
||||
return api ? toResource(api) : null
|
||||
}
|
||||
|
||||
async create(values: Record<string, unknown>, protocol: HttpApiProtocol): Promise<CloudResource> {
|
||||
const name = stringValue(values.name)
|
||||
if (!name) throw new ValidationError('name is required')
|
||||
const description = stringValue(values.description) || undefined
|
||||
|
||||
if (protocol === 'WEBSOCKET') {
|
||||
const routeSelectionExpression = stringValue(values.routeSelectionExpression) || DEFAULT_ROUTE_SELECTION_EXPRESSION
|
||||
if (!/^\$request\.body\.[A-Za-z0-9_.]+$/.test(routeSelectionExpression)) {
|
||||
throw new ValidationError('routeSelectionExpression must name a field of the message body, for example $request.body.action')
|
||||
}
|
||||
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'WEBSOCKET', RouteSelectionExpression: routeSelectionExpression}))
|
||||
const stageName = stageNameValue(stringValue(values.stageName) || 'dev')
|
||||
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: stageName, AutoDeploy: true}))
|
||||
return toResource(api)
|
||||
}
|
||||
|
||||
// Quick create: a Lambda target gives the API a catch-all route and an auto-deployed $default stage.
|
||||
const functionName = stringValue(values.functionName)
|
||||
if (functionName) {
|
||||
const functionArn = await this.functionArn(functionName)
|
||||
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP', Target: functionArn}))
|
||||
await this.allowInvoke(api.ApiId!, functionName)
|
||||
return toResource(api)
|
||||
}
|
||||
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP'}))
|
||||
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: '$default', AutoDeploy: true}))
|
||||
return toResource(api)
|
||||
}
|
||||
|
||||
async delete(apiId: string): Promise<void> {
|
||||
const {integrations} = await this.describeParts(apiId)
|
||||
await this.client.send(new DeleteApiCommand({ApiId: apiId}))
|
||||
for (const functionName of new Set(integrations.map(integrationFunctionName).filter((name): name is string => Boolean(name)))) {
|
||||
await this.removeInvokePermission(apiId, functionName)
|
||||
}
|
||||
}
|
||||
|
||||
async describe(apiId: string): Promise<HttpApiOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const {routes, integrations, stages, authorizers} = await this.describeParts(apiId)
|
||||
const protocol = protocolOf(api)
|
||||
const integrationsById = new Map(integrations.map((integration) => [integration.IntegrationId, integration]))
|
||||
return {
|
||||
apiId,
|
||||
protocol,
|
||||
apiEndpoint: api.ApiEndpoint ?? null,
|
||||
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression ?? null : null,
|
||||
routes: routes.map((route) => toRouteOverview(route, integrationsById)).sort(compareRoutes),
|
||||
stages: stages.map((stage) => toStageOverview(apiId, protocol, stage)).sort((left, right) => left.stageName.localeCompare(right.stageName)),
|
||||
authorizers: authorizers.map(toAuthorizerOverview).sort((left, right) => left.name.localeCompare(right.name)),
|
||||
cors: protocol === 'HTTP' ? fromCors(api.CorsConfiguration) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async createRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const protocol = protocolOf(api)
|
||||
const routeKey = protocol === 'HTTP' ? httpRouteKey(input.routeKey) : webSocketRouteKey(input.routeKey)
|
||||
const functionName = stringValue(input.functionName)
|
||||
const httpUrl = stringValue(input.httpUrl)
|
||||
if (Boolean(functionName) === Boolean(httpUrl)) throw new ValidationError('Give the route either a Lambda function or an HTTP URL')
|
||||
if (httpUrl && protocol === 'WEBSOCKET') throw new ValidationError('TheCloud connects WebSocket routes to Lambda functions only')
|
||||
if (httpUrl && !/^https?:\/\/[^\s/]+/.test(httpUrl)) throw new ValidationError('httpUrl must be an http:// or https:// URL')
|
||||
|
||||
const {routes, integrations} = await this.describeParts(apiId)
|
||||
if (routes.some((route) => route.RouteKey === routeKey)) throw new ValidationError(`The API already has a ${routeKey} route`)
|
||||
const authorizerId = stringValue(input.authorizerId)
|
||||
if (authorizerId && protocol === 'WEBSOCKET') throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
|
||||
|
||||
let integration: Partial<Integration> | undefined
|
||||
if (functionName) {
|
||||
const functionArn = await this.functionArn(functionName)
|
||||
const uri = protocol === 'HTTP' ? functionArn : lambdaInvocationUri(this.region, functionArn)
|
||||
integration = integrations.find((candidate) => candidate.IntegrationType === 'AWS_PROXY' && candidate.IntegrationUri === uri)
|
||||
?? await this.client.send(new CreateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationType: 'AWS_PROXY',
|
||||
IntegrationUri: uri,
|
||||
IntegrationMethod: 'POST',
|
||||
PayloadFormatVersion: protocol === 'HTTP' ? '2.0' : undefined,
|
||||
}))
|
||||
await this.allowInvoke(apiId, functionName)
|
||||
} else {
|
||||
const method = routeKey === '$default' ? 'ANY' : routeKey.split(' ')[0]
|
||||
integration = integrations.find((candidate) => candidate.IntegrationType === 'HTTP_PROXY' && candidate.IntegrationUri === httpUrl && candidate.IntegrationMethod === method)
|
||||
?? await this.client.send(new CreateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationType: 'HTTP_PROXY',
|
||||
IntegrationUri: httpUrl,
|
||||
IntegrationMethod: method,
|
||||
PayloadFormatVersion: '1.0',
|
||||
}))
|
||||
}
|
||||
|
||||
const route = await this.client.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: routeKey,
|
||||
Target: `integrations/${integration.IntegrationId}`,
|
||||
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
|
||||
AuthorizerId: authorizerId || undefined,
|
||||
RouteResponseSelectionExpression: protocol === 'WEBSOCKET' && input.returnsResponse ? '$default' : undefined,
|
||||
}))
|
||||
// A WebSocket route sends its integration's response back only once it has a route response.
|
||||
if (protocol === 'WEBSOCKET' && input.returnsResponse) {
|
||||
await this.client.send(new CreateRouteResponseCommand({ApiId: apiId, RouteId: route.RouteId, RouteResponseKey: '$default'}))
|
||||
}
|
||||
return toRouteOverview(route, new Map([[integration.IntegrationId, integration]]))
|
||||
}
|
||||
|
||||
async deleteRoute(apiId: string, routeId: string): Promise<void> {
|
||||
const {routes, integrations} = await this.describeParts(apiId)
|
||||
const route = routes.find((candidate) => candidate.RouteId === routeId)
|
||||
if (!route) throw new NotFoundError(`Route ${routeId} not found`)
|
||||
await this.client.send(new DeleteRouteCommand({ApiId: apiId, RouteId: routeId}))
|
||||
|
||||
// Clean up what only this route used: its integration, then the Lambda's permission for this API.
|
||||
const integrationId = targetIntegrationId(route.Target)
|
||||
const stillUsed = routes.some((candidate) => candidate.RouteId !== routeId && targetIntegrationId(candidate.Target) === integrationId)
|
||||
const integration = integrations.find((candidate) => candidate.IntegrationId === integrationId)
|
||||
if (!integration || stillUsed) return
|
||||
await this.client.send(new DeleteIntegrationCommand({ApiId: apiId, IntegrationId: integrationId}))
|
||||
const functionName = integrationFunctionName(integration)
|
||||
const functionStillUsed = integrations.some((candidate) => candidate.IntegrationId !== integrationId && integrationFunctionName(candidate) === functionName)
|
||||
if (functionName && !functionStillUsed) await this.removeInvokePermission(apiId, functionName)
|
||||
}
|
||||
|
||||
async setRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) === 'WEBSOCKET' && authorizerId) throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
|
||||
const route = await this.client.send(new UpdateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteId: routeId,
|
||||
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
|
||||
AuthorizerId: authorizerId ?? undefined,
|
||||
}))
|
||||
const {integrations} = await this.describeParts(apiId)
|
||||
return toRouteOverview(route, new Map(integrations.map((integration) => [integration.IntegrationId, integration])))
|
||||
}
|
||||
|
||||
async createStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const stage = await this.client.send(new CreateStageCommand({ApiId: apiId, StageName: stageNameValue(input.stageName), AutoDeploy: Boolean(input.autoDeploy)}))
|
||||
// A stage without auto-deploy serves nothing until its first deployment.
|
||||
if (!input.autoDeploy) return this.deploy(apiId, stage.StageName!)
|
||||
return toStageOverview(apiId, protocolOf(api), stage)
|
||||
}
|
||||
|
||||
async deleteStage(apiId: string, stageName: string): Promise<void> {
|
||||
await this.client.send(new DeleteStageCommand({ApiId: apiId, StageName: stageNameValue(stageName)}))
|
||||
}
|
||||
|
||||
async deploy(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const name = stageNameValue(stageName)
|
||||
await this.client.send(new CreateDeploymentCommand({ApiId: apiId, StageName: name, Description: 'Deployed from TheCloud'}))
|
||||
const stage = await this.client.send(new GetStageCommand({ApiId: apiId, StageName: name}))
|
||||
return toStageOverview(apiId, protocolOf(api), stage)
|
||||
}
|
||||
|
||||
async createAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) === 'WEBSOCKET') throw new ValidationError('JWT authorizers are for HTTP APIs; WebSocket APIs use Lambda authorizers')
|
||||
const name = stringValue(input.name)
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(name)) throw new ValidationError('name must be 1-128 letters, numbers, hyphens or underscores')
|
||||
|
||||
let issuer = stringValue(input.issuer)
|
||||
let audience = (input.audience ?? []).map(stringValue).filter(Boolean)
|
||||
const userPoolId = stringValue(input.userPoolId)
|
||||
if (userPoolId) {
|
||||
const clientId = stringValue(input.userPoolClientId)
|
||||
if (!clientId) throw new ValidationError('Choose the app client whose tokens the authorizer accepts')
|
||||
issuer = await this.userPoolIssuer(userPoolId)
|
||||
audience = [clientId]
|
||||
}
|
||||
if (!/^https?:\/\/[^\s]+$/.test(issuer)) throw new ValidationError('issuer must be the token issuer URL, for example https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc')
|
||||
if (audience.length === 0) throw new ValidationError('audience needs at least one value, such as an app client id')
|
||||
|
||||
const authorizer = await this.client.send(new CreateAuthorizerCommand({
|
||||
ApiId: apiId,
|
||||
Name: name,
|
||||
AuthorizerType: 'JWT',
|
||||
IdentitySource: ['$request.header.Authorization'],
|
||||
JwtConfiguration: {Issuer: issuer, Audience: audience},
|
||||
}))
|
||||
return toAuthorizerOverview(authorizer)
|
||||
}
|
||||
|
||||
async deleteAuthorizer(apiId: string, authorizerId: string): Promise<void> {
|
||||
const {routes} = await this.describeParts(apiId)
|
||||
const users = routes.filter((route) => route.AuthorizationType !== 'NONE' && route.AuthorizerId === authorizerId).map((route) => route.RouteKey)
|
||||
if (users.length > 0) throw new ValidationError(`Remove the authorizer from ${users.join(', ')} first`)
|
||||
await this.client.send(new DeleteAuthorizerCommand({ApiId: apiId, AuthorizerId: authorizerId}))
|
||||
}
|
||||
|
||||
async updateCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) !== 'HTTP') throw new ValidationError('CORS applies to HTTP APIs only')
|
||||
if (!cors) {
|
||||
await this.client.send(new DeleteCorsConfigurationCommand({ApiId: apiId}))
|
||||
return null
|
||||
}
|
||||
const configuration = toCors(cors)
|
||||
const updated = await this.client.send(new UpdateApiCommand({ApiId: apiId, CorsConfiguration: configuration}))
|
||||
return fromCors(updated.CorsConfiguration)
|
||||
}
|
||||
|
||||
/** Opens a real connection to the deployed stage, sends one message and collects the replies. */
|
||||
async testWebSocket(apiId: string, input: HttpApiWebSocketTestInput, timeoutMs = 4000): Promise<HttpApiWebSocketTestResult> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) !== 'WEBSOCKET') throw new ValidationError('This is not a WebSocket API')
|
||||
const stage = stageNameValue(input.stage)
|
||||
const query = stringValue(input.query).replace(/^\?/, '')
|
||||
const url = `${awsEndpoint().replace(/\/$/, '').replace(/^http/, 'ws')}/ws/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${query ? `?${query}` : ''}`
|
||||
return exchangeWebSocketMessage(url, input.message, timeoutMs)
|
||||
}
|
||||
|
||||
private async describeParts(apiId: string) {
|
||||
const [routes, integrations, stages, authorizers] = await Promise.all([
|
||||
this.pages((NextToken) => this.client.send(new GetRoutesCommand({ApiId: apiId, NextToken}))),
|
||||
this.pages((NextToken) => this.client.send(new GetIntegrationsCommand({ApiId: apiId, NextToken}))),
|
||||
this.pages((NextToken) => this.client.send(new GetStagesCommand({ApiId: apiId, NextToken}))),
|
||||
this.pages((NextToken) => this.client.send(new GetAuthorizersCommand({ApiId: apiId, NextToken}))),
|
||||
])
|
||||
return {routes: routes as Route[], integrations: integrations as Integration[], stages: stages as Stage[], authorizers: authorizers as Authorizer[]}
|
||||
}
|
||||
|
||||
private async pages<T>(fetchPage: (next?: string) => Promise<{Items?: T[]; NextToken?: string}>): Promise<T[]> {
|
||||
const items: T[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const page = await fetchPage(next)
|
||||
items.push(...(page.Items ?? []))
|
||||
next = page.NextToken
|
||||
} while (next)
|
||||
return items
|
||||
}
|
||||
|
||||
private async findApi(apiId: string): Promise<Partial<Api> | null> {
|
||||
try {
|
||||
return await this.client.send(new GetApiCommand({ApiId: apiId}))
|
||||
} catch (error) {
|
||||
if (hasHttpStatus(error, 404)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async requireApi(apiId: string): Promise<Partial<Api>> {
|
||||
const api = await this.findApi(apiId)
|
||||
if (!api) throw new NotFoundError(`HTTP or WebSocket API ${apiId} not found`)
|
||||
return api
|
||||
}
|
||||
|
||||
private async functionArn(functionName: string): Promise<string> {
|
||||
const result = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
|
||||
const arn = result.Configuration?.FunctionArn
|
||||
if (!arn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
|
||||
return arn
|
||||
}
|
||||
|
||||
/** API Gateway may invoke the function for any route and stage of this API, as the AWS console grants it. */
|
||||
private async allowInvoke(apiId: string, functionName: string) {
|
||||
try {
|
||||
await this.lambda.send(new AddPermissionCommand({
|
||||
FunctionName: functionName,
|
||||
StatementId: invokeStatementId(apiId),
|
||||
Action: 'lambda:InvokeFunction',
|
||||
Principal: 'apigateway.amazonaws.com',
|
||||
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/*`,
|
||||
}))
|
||||
} catch (error) {
|
||||
if (!hasHttpStatus(error, 409)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async removeInvokePermission(apiId: string, functionName: string) {
|
||||
try {
|
||||
await this.lambda.send(new RemovePermissionCommand({FunctionName: functionName, StatementId: invokeStatementId(apiId)}))
|
||||
} catch (error) {
|
||||
if (!hasHttpStatus(error, 404)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** The pool's token issuer, read from its OpenID Connect discovery document as any relying party would. */
|
||||
private async userPoolIssuer(userPoolId: string): Promise<string> {
|
||||
if (!/^[\w-]+_[0-9A-Za-z]+$/.test(userPoolId)) throw new ValidationError('userPoolId is not a Cognito user pool id')
|
||||
const discovery = await this.fetchJson(`${awsEndpoint().replace(/\/$/, '')}/${userPoolId}/.well-known/openid-configuration`)
|
||||
const issuer = (discovery as {issuer?: unknown} | null)?.issuer
|
||||
if (typeof issuer !== 'string' || !issuer) throw new NotFoundError(`User pool ${userPoolId} has no OpenID Connect discovery document`)
|
||||
return issuer
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultFetchJson(url: string): Promise<unknown> {
|
||||
const response = await globalThis.fetch(url)
|
||||
if (!response.ok) throw new NotFoundError(`${url} answered ${response.status}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function exchangeWebSocketMessage(url: string, message: string, timeoutMs: number): Promise<HttpApiWebSocketTestResult> {
|
||||
const startedAt = performance.now()
|
||||
return new Promise((resolve) => {
|
||||
const messages: string[] = []
|
||||
let connected = false
|
||||
let settled = false
|
||||
let socket: WebSocket
|
||||
const finish = (closeCode: number | null, error: string | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000)
|
||||
resolve({connected, messages, closeCode, error, durationMs: Math.round(performance.now() - startedAt)})
|
||||
}
|
||||
// After the first reply, wait briefly for any more before hanging up.
|
||||
let quiet: ReturnType<typeof setTimeout> | undefined
|
||||
const timer = setTimeout(() => finish(null, connected ? null : 'Timed out connecting'), timeoutMs)
|
||||
try {
|
||||
socket = new WebSocket(url)
|
||||
} catch (error) {
|
||||
finish(null, error instanceof Error ? error.message : 'Could not connect')
|
||||
return
|
||||
}
|
||||
socket.onopen = () => {
|
||||
connected = true
|
||||
if (message) socket.send(message)
|
||||
else finish(null, null)
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
messages.push(typeof event.data === 'string' ? event.data : String(event.data))
|
||||
clearTimeout(quiet)
|
||||
quiet = setTimeout(() => finish(null, null), 500)
|
||||
}
|
||||
socket.onerror = () => finish(null, connected ? 'The connection failed' : 'The stage refused the connection')
|
||||
socket.onclose = (event) => finish(event.code, connected ? null : `The stage refused the connection (${event.code})`)
|
||||
})
|
||||
}
|
||||
|
||||
function toResource(api: Partial<Api>): CloudResource {
|
||||
const protocol = protocolOf(api)
|
||||
return {
|
||||
id: api.ApiId ?? '',
|
||||
name: api.Name ?? '',
|
||||
cloud: 'aws',
|
||||
service: 'apigateway',
|
||||
type: protocol === 'HTTP' ? 'http-api' : 'websocket-api',
|
||||
region: null,
|
||||
createdAt: api.CreatedDate?.toISOString() ?? null,
|
||||
metadata: {
|
||||
provider: 'aws',
|
||||
apigatewayService: protocol === 'HTTP' ? 'http' : 'websocket',
|
||||
protocol,
|
||||
description: api.Description,
|
||||
apiEndpoint: api.ApiEndpoint,
|
||||
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function protocolOf(api: Partial<Api>): HttpApiProtocol {
|
||||
return api.ProtocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP'
|
||||
}
|
||||
|
||||
function toRouteOverview(route: Partial<Route>, integrations: Map<string | undefined, Partial<Integration>>): HttpApiRouteOverview {
|
||||
const integration = integrations.get(targetIntegrationId(route.Target))
|
||||
return {
|
||||
routeId: route.RouteId ?? '',
|
||||
routeKey: route.RouteKey ?? '',
|
||||
integration: integration ? toIntegrationOverview(integration) : null,
|
||||
authorizationType: route.AuthorizationType ?? 'NONE',
|
||||
// Real API Gateway keeps a route's last AuthorizerId after its authorization goes back to NONE.
|
||||
authorizerId: route.AuthorizationType && route.AuthorizationType !== 'NONE' ? route.AuthorizerId || null : null,
|
||||
authorizationScopes: route.AuthorizationScopes ?? [],
|
||||
returnsResponse: Boolean(route.RouteResponseSelectionExpression),
|
||||
}
|
||||
}
|
||||
|
||||
function toIntegrationOverview(integration: Partial<Integration>): HttpApiIntegrationOverview {
|
||||
return {
|
||||
integrationId: integration.IntegrationId ?? '',
|
||||
type: integration.IntegrationType ?? '',
|
||||
uri: integration.IntegrationUri ?? null,
|
||||
functionName: integrationFunctionName(integration),
|
||||
payloadFormatVersion: integration.PayloadFormatVersion ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function toStageOverview(apiId: string, protocol: HttpApiProtocol, stage: Partial<Stage>): HttpApiStageOverview {
|
||||
const stageName = stage.StageName ?? ''
|
||||
return {
|
||||
stageName,
|
||||
autoDeploy: Boolean(stage.AutoDeploy),
|
||||
deploymentId: stage.DeploymentId ?? null,
|
||||
lastUpdatedAt: stage.LastUpdatedDate?.toISOString() ?? null,
|
||||
invokePath: `${protocol === 'WEBSOCKET' ? '/ws' : '/execute-api'}/${apiId}/${stageName}`,
|
||||
}
|
||||
}
|
||||
|
||||
function toAuthorizerOverview(authorizer: Partial<Authorizer>): HttpApiAuthorizerOverview {
|
||||
return {
|
||||
authorizerId: authorizer.AuthorizerId ?? '',
|
||||
name: authorizer.Name ?? '',
|
||||
type: authorizer.AuthorizerType ?? '',
|
||||
identitySource: authorizer.IdentitySource ?? [],
|
||||
issuer: authorizer.JwtConfiguration?.Issuer ?? null,
|
||||
audience: authorizer.JwtConfiguration?.Audience ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function fromCors(cors: Cors | undefined): HttpApiCors | null {
|
||||
if (!cors) return null
|
||||
return {
|
||||
allowOrigins: cors.AllowOrigins ?? [],
|
||||
allowMethods: cors.AllowMethods ?? [],
|
||||
allowHeaders: cors.AllowHeaders ?? [],
|
||||
exposeHeaders: cors.ExposeHeaders ?? [],
|
||||
maxAgeSeconds: cors.MaxAge ?? null,
|
||||
allowCredentials: Boolean(cors.AllowCredentials),
|
||||
}
|
||||
}
|
||||
|
||||
function toCors(cors: HttpApiCors): Cors {
|
||||
const clean = (values: string[]) => [...new Set((values ?? []).map(stringValue).filter(Boolean))]
|
||||
const allowOrigins = clean(cors.allowOrigins)
|
||||
const allowMethods = clean(cors.allowMethods).map((method) => method.toUpperCase())
|
||||
if (allowOrigins.length === 0) throw new ValidationError('allowOrigins needs at least one origin, or *')
|
||||
if (allowOrigins.some((origin) => origin !== '*' && !/^https?:\/\/[^\s/]+$/.test(origin))) {
|
||||
throw new ValidationError('Each allowed origin must be * or a scheme and host, such as https://app.example.com')
|
||||
}
|
||||
if (allowMethods.some((method) => !CORS_ALLOWED_METHODS.includes(method))) throw new ValidationError(`allowMethods may contain ${CORS_ALLOWED_METHODS.join(', ')}`)
|
||||
if (cors.allowCredentials && allowOrigins.includes('*')) throw new ValidationError('Credentials cannot be allowed for every origin (*); list the origins instead')
|
||||
const maxAge = cors.maxAgeSeconds
|
||||
if (maxAge !== null && maxAge !== undefined && (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > 86_400)) {
|
||||
throw new ValidationError('maxAgeSeconds must be a whole number from 0 to 86400')
|
||||
}
|
||||
return {
|
||||
AllowOrigins: allowOrigins,
|
||||
AllowMethods: allowMethods,
|
||||
AllowHeaders: clean(cors.allowHeaders),
|
||||
ExposeHeaders: clean(cors.exposeHeaders),
|
||||
MaxAge: maxAge ?? undefined,
|
||||
AllowCredentials: Boolean(cors.allowCredentials),
|
||||
}
|
||||
}
|
||||
|
||||
/** `$default`, or a method and a path such as `GET /orders/{id}` or `ANY /files/{proxy+}`. */
|
||||
export function httpRouteKey(value: string): string {
|
||||
const key = stringValue(value).replace(/\s+/g, ' ')
|
||||
if (key === '$default') return key
|
||||
const [method, path, ...rest] = key.split(' ')
|
||||
if (rest.length > 0 || !HTTP_ROUTE_METHODS.includes(method?.toUpperCase() ?? '') || !path) {
|
||||
throw new ValidationError('routeKey must be $default or a method and a path, for example GET /orders/{id}')
|
||||
}
|
||||
const segments = path === '/' ? [] : path.split('/').slice(1)
|
||||
if (!path.startsWith('/') || segments.some((segment, index) => !/^(?:[A-Za-z0-9._~-]+|\{[A-Za-z0-9_]+\}|\{[A-Za-z0-9_]+\+\})$/.test(segment)
|
||||
|| (segment.endsWith('+}') && index !== segments.length - 1))) {
|
||||
throw new ValidationError('The path must start with / and use segments such as orders, {id}, or a final {proxy+}')
|
||||
}
|
||||
return `${method.toUpperCase()} ${path}`
|
||||
}
|
||||
|
||||
/** `$connect`, `$disconnect`, `$default`, or a value of the route selection expression such as `sendMessage`. */
|
||||
export function webSocketRouteKey(value: string): string {
|
||||
const key = stringValue(value)
|
||||
if (WEBSOCKET_SYSTEM_ROUTES.includes(key)) return key
|
||||
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
|
||||
throw new ValidationError('routeKey must be $connect, $disconnect, $default, or a message action such as sendMessage')
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function stageNameValue(value: string): string {
|
||||
const name = stringValue(value)
|
||||
if (!/^(?:\$default|[A-Za-z0-9_-]{1,128})$/.test(name)) {
|
||||
throw new ValidationError('stage must be $default or letters, numbers, hyphens and underscores')
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
function compareRoutes(left: HttpApiRouteOverview, right: HttpApiRouteOverview): number {
|
||||
const path = (route: HttpApiRouteOverview) => route.routeKey.split(' ')[1] ?? route.routeKey
|
||||
return path(left).localeCompare(path(right)) || left.routeKey.localeCompare(right.routeKey)
|
||||
}
|
||||
|
||||
function lambdaInvocationUri(region: string, functionArn: string): string {
|
||||
return `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`
|
||||
}
|
||||
|
||||
function integrationFunctionName(integration: Partial<Integration>): string | null {
|
||||
if (integration.IntegrationType !== 'AWS_PROXY') return null
|
||||
return integration.IntegrationUri?.match(/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/)?.[1] ?? null
|
||||
}
|
||||
|
||||
function targetIntegrationId(target: string | undefined): string | undefined {
|
||||
return target?.startsWith('integrations/') ? target.slice('integrations/'.length) : undefined
|
||||
}
|
||||
|
||||
function invokeStatementId(apiId: string): string {
|
||||
return `apigatewayv2-${apiId}`
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function hasHttpStatus(error: unknown, status: number): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
return (error as {$metadata?: {httpStatusCode?: number}}).$metadata?.httpStatusCode === status
|
||||
}
|
||||
Reference in New Issue
Block a user