feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page #20

Merged
Zakaria merged 1 commits from feat/apigateway-v2 into main 2026-10-25 13:49:10 +00:00
22 changed files with 2410 additions and 68 deletions
Showing only changes of commit bb5ca85a88 - Show all commits
+8 -4
View File
@@ -259,14 +259,18 @@ Current gaps:
<details>
<summary><strong>API Gateway</strong></summary>
AWS only, through the generic apigateway service category.
AWS only, through the generic apigateway service category. REST APIs (v1) and HTTP and WebSocket APIs (v2) are listed together, with a Type column.
- List and inspect REST APIs.
- Create and delete REST APIs.
- REST APIs: create, delete and inspect; add Lambda proxy routes on a resource tree, enable CORS, test a method with TestInvokeMethod, and send a request to a deployed stage.
- HTTP APIs: quick-create with a Lambda (a catch-all route on an auto-deployed `$default` stage); add routes such as `GET /notes/{id}` to a Lambda or an HTTP backend; add stages with or without automatic deployment and deploy them; add JWT authorizers, filled in from a Cognito user pool and app client or from any OpenID Connect issuer, and protect routes with them; configure CORS; send a real request to a stage through its routes and authorizers.
- WebSocket APIs: create with a route selection expression and a stage; add `$connect`, `$disconnect`, `$default` and action routes to Lambda, optionally sending the function's response back; connect to a stage, send a message and read the replies.
Verified against TheCloud's runtime: quick create, Lambda and HTTP routes, a Cognito JWT authorizer (401 without a token, the token's claims reach the Lambda with one), CORS preflight, a stage deployed on demand, and a WebSocket round trip.
Current gaps:
- Resources, methods, deployments, and stages are not yet exposed.
- WebSocket APIs: Lambda (REQUEST) authorizers, `@connections` messages pushed from a backend, and route request and response models are not exposed.
- Custom domain names, API mappings, VPC links, usage plans and API keys are not exposed.
- No Azure or GCP API Gateway adapter yet.
</details>
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
"@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudfront": "^3.1146.0",
@@ -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
}
+4
View File
@@ -15,6 +15,7 @@ import { SQSClient } from "@aws-sdk/client-sqs";
import { IAMClient } from "@aws-sdk/client-iam";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
import { ApiGatewayV2Client } from "@aws-sdk/client-apigatewayv2";
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
import { AppConfigClient } from "@aws-sdk/client-appconfig";
import { SNSClient } from "@aws-sdk/client-sns";
@@ -79,6 +80,7 @@ export type AwsClients = {
iam: IAMClient;
dynamodb: DynamoDBClient;
apiGateway: APIGatewayClient;
apiGatewayV2: ApiGatewayV2Client;
cloudformation: CloudFormationClient;
appConfig: AppConfigClient;
sns: SNSClient;
@@ -127,6 +129,7 @@ function buildClients(accountId: string): AwsClients {
iam: new IAMClient(base),
dynamodb: new DynamoDBClient(base),
apiGateway: new APIGatewayClient(base),
apiGatewayV2: new ApiGatewayV2Client(base),
cloudformation: new CloudFormationClient(base),
appConfig: new AppConfigClient(base),
sns: new SNSClient(base),
@@ -181,6 +184,7 @@ export const sqs = awsClients.sqs;
export const iam = awsClients.iam;
export const dynamodb = awsClients.dynamodb;
export const apiGateway = awsClients.apiGateway;
export const apiGatewayV2 = awsClients.apiGatewayV2;
export const cloudformation = awsClients.cloudformation;
export const ecr = awsClients.ecr;
export const kinesis = awsClients.kinesis;
+62 -5
View File
@@ -1,4 +1,4 @@
import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types'
import type {CapabilitySchema, HttpApiActionName, ResourceActionName, ServiceSchema} from './types'
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true},
@@ -7,12 +7,42 @@ const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false},
]
const httpApiActions: CapabilitySchema<HttpApiActionName>[] = [
{name: 'describe', label: 'Show routes, stages and authorizers', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createRoute', label: 'Add a route', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteRoute', label: 'Delete a route', enabled: true, status: 'available', runtimeRequired: true},
{name: 'setRouteAuthorizer', label: 'Protect a route with an authorizer', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createStage', label: 'Add a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteStage', label: 'Delete a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deploy', label: 'Deploy to a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createAuthorizer', label: 'Add a JWT authorizer', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteAuthorizer', label: 'Delete an authorizer', enabled: true, status: 'available', runtimeRequired: true},
{name: 'updateCors', label: 'Configure CORS', enabled: true, status: 'available', runtimeRequired: true},
{name: 'sendRequest', label: 'Send a request to a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'testWebSocket', label: 'Connect and send a WebSocket message', enabled: true, status: 'available', runtimeRequired: true},
]
const httpOnly = {field: 'protocol', values: ['HTTP']}
const webSocketOnly = {field: 'protocol', values: ['WEBSOCKET']}
export function awsApiGatewaySchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'apigateway',
displayName: 'AWS API Gateway',
fields: [
{
name: 'protocol',
label: 'API type',
type: 'select',
required: true,
defaultValue: 'REST',
options: [
{label: 'REST API (resources, methods and stages)', value: 'REST'},
{label: 'HTTP API (routes to Lambda or HTTP backends, JWT authorizers)', value: 'HTTP'},
{label: 'WebSocket API (two-way messages routed to Lambda)', value: 'WEBSOCKET'},
],
},
{
name: 'name',
label: 'API Name',
@@ -30,20 +60,47 @@ export function awsApiGatewaySchema(): ServiceSchema {
label: 'Description',
type: 'text',
required: false,
description: 'Optional description for the REST API.',
description: 'Optional description for the API.',
validation: {
maxLength: 1024,
message: 'Description must be 1024 characters or fewer.',
},
},
{
name: 'functionName',
label: 'Lambda function',
type: 'text',
required: false,
visibleWhen: httpOnly,
description: 'Optional. Sends every request to this function through a catch-all route, on a $default stage that deploys automatically. Leave empty to add routes yourself.',
},
{
name: 'routeSelectionExpression',
label: 'Route selection expression',
type: 'text',
required: false,
defaultValue: '$request.body.action',
visibleWhen: webSocketOnly,
description: 'Which field of each message picks its route. With $request.body.action, {"action": "sendMessage"} goes to the sendMessage route.',
},
{
name: 'stageName',
label: 'Stage',
type: 'text',
required: false,
defaultValue: 'dev',
visibleWhen: webSocketOnly,
description: 'Created with automatic deployment, so route changes go live right away.',
validation: {pattern: '^[A-Za-z0-9_-]{1,128}$', message: 'Use letters, numbers, hyphens and underscores'},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions},
capabilities: {resourceActions, httpApiActions},
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'API Name'},
{name: 'type', label: 'Type'},
{name: 'createdAt', label: 'Created At'},
{name: 'protocol', label: 'Type', path: 'metadata.protocol', format: 'badge'},
{name: 'createdAt', label: 'Created At', format: 'relative'},
],
}
}
+127
View File
@@ -182,6 +182,19 @@ export type UserPoolActionName =
| 'updateClientOAuth'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type HttpApiActionName =
| 'describe'
| 'createRoute'
| 'deleteRoute'
| 'setRouteAuthorizer'
| 'createStage'
| 'deleteStage'
| 'deploy'
| 'createAuthorizer'
| 'deleteAuthorizer'
| 'updateCors'
| 'sendRequest'
| 'testWebSocket'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> {
@@ -228,6 +241,8 @@ export interface ServiceSchema {
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
distributionActions?: CapabilitySchema<DistributionActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
// Child levels advertise separately from the resource level because a
// store can be readable at the leaf and writable at the collection —
// CloudWatch Logs creates streams but cannot delete an event.
@@ -769,6 +784,107 @@ export interface ApiGatewayCorsResult {
proxyMethods: string[]
}
export type HttpApiProtocol = 'HTTP' | 'WEBSOCKET'
export interface HttpApiIntegrationOverview {
integrationId: string
type: string
uri: string | null
/** The Lambda function a proxy integration invokes, when it is one. */
functionName: string | null
payloadFormatVersion: string | null
}
export interface HttpApiRouteOverview {
routeId: string
/** `GET /orders/{id}`, `$default`, or a WebSocket key such as `$connect` or `sendMessage`. */
routeKey: string
integration: HttpApiIntegrationOverview | null
authorizationType: string
authorizerId: string | null
authorizationScopes: string[]
/** WebSocket only: whether the integration's response is sent back to the client. */
returnsResponse: boolean
}
export interface HttpApiStageOverview {
stageName: string
autoDeploy: boolean
deploymentId: string | null
lastUpdatedAt: string | null
/** Where the stage is served on the runtime endpoint: /execute-api/{api}/{stage} or, for WebSocket, /ws/{api}/{stage}. */
invokePath: string
}
export interface HttpApiAuthorizerOverview {
authorizerId: string
name: string
type: string
identitySource: string[]
issuer: string | null
audience: string[]
}
export interface HttpApiCors {
allowOrigins: string[]
allowMethods: string[]
allowHeaders: string[]
exposeHeaders: string[]
maxAgeSeconds: number | null
allowCredentials: boolean
}
export interface HttpApiOverview {
apiId: string
protocol: HttpApiProtocol
apiEndpoint: string | null
routeSelectionExpression: string | null
routes: HttpApiRouteOverview[]
stages: HttpApiStageOverview[]
authorizers: HttpApiAuthorizerOverview[]
cors: HttpApiCors | null
}
export interface HttpApiRouteInput {
routeKey: string
/** A Lambda function name; exactly one of functionName and httpUrl. */
functionName?: string
/** An HTTP backend the route proxies to; HTTP APIs only. */
httpUrl?: string
authorizerId?: string
/** WebSocket only: send the integration's response back to the client. */
returnsResponse?: boolean
}
export interface HttpApiStageInput {
stageName: string
autoDeploy: boolean
}
export interface HttpApiAuthorizerInput {
name: string
/** Fill the issuer and audience from a Cognito user pool and one of its app clients. */
userPoolId?: string
userPoolClientId?: string
issuer?: string
audience?: string[]
}
export interface HttpApiWebSocketTestInput {
stage: string
message: string
/** Sent as query string parameters on the connection request, for a $connect authorizer. */
query?: string
}
export interface HttpApiWebSocketTestResult {
connected: boolean
messages: string[]
closeCode: number | null
error: string | null
durationMs: number
}
export interface S3LambdaTriggerInput {
functionName: string
events: string[]
@@ -963,6 +1079,17 @@ export interface CloudServiceAdapter {
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
describeHttpApi?(apiId: string): Promise<HttpApiOverview>
createHttpApiRoute?(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview>
deleteHttpApiRoute?(apiId: string, routeId: string): Promise<void>
setHttpApiRouteAuthorizer?(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview>
createHttpApiStage?(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview>
deleteHttpApiStage?(apiId: string, stageName: string): Promise<void>
deployHttpApi?(apiId: string, stageName: string): Promise<HttpApiStageOverview>
createHttpApiAuthorizer?(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview>
deleteHttpApiAuthorizer?(apiId: string, authorizerId: string): Promise<void>
updateHttpApiCors?(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null>
testWebSocketApi?(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult>
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
+26 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/**
* Guards the schema-to-implementation contract for every registered adapter.
@@ -55,6 +55,21 @@ const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServ
setAlarmActions: 'setAlarmActionsEnabled',
}
const METHOD_FOR_HTTP_API_ACTION: Record<HttpApiActionName, keyof CloudServiceAdapter> = {
describe: 'describeHttpApi',
createRoute: 'createHttpApiRoute',
deleteRoute: 'deleteHttpApiRoute',
setRouteAuthorizer: 'setHttpApiRouteAuthorizer',
createStage: 'createHttpApiStage',
deleteStage: 'deleteHttpApiStage',
deploy: 'deployHttpApi',
createAuthorizer: 'createHttpApiAuthorizer',
deleteAuthorizer: 'deleteHttpApiAuthorizer',
updateCors: 'updateHttpApiCors',
sendRequest: 'testApiGatewayRoute',
testWebSocket: 'testWebSocketApi',
}
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
setEnabled: 'setDistributionEnabled',
createInvalidation: 'createDistributionInvalidation',
@@ -164,6 +179,15 @@ describe('registered adapters honour their schema', () => {
).toBe('function')
}
const httpApiClaims = (adapter.schema().capabilities?.httpApiActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of httpApiClaims) {
const method = METHOD_FOR_HTTP_API_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of distributionClaims) {
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
@@ -187,6 +211,7 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []),
...(adapter.schema().capabilities?.collectionActions ?? []),
...(adapter.schema().capabilities?.itemActions ?? []),
]
+1 -1
View File
@@ -85,7 +85,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsTargetGroupAdapter(clients.elbv2, clients.ec2),
new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
new AwsIamAdapter(clients.iam),
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)),
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId), undefined, clients.apiGatewayV2),
new AwsCloudFormationAdapter(clients.cloudformation),
new AwsSecretsAdapter(clients.secretsManager),
new AwsSesAdapter(),
+129
View File
@@ -803,6 +803,131 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
// HTTP and WebSocket APIs (API Gateway v2) live on the same page as REST APIs.
app.get('/:cloud/services/apigateway/resources/:id/http-api', 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).describeHttpApi(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/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 () => {
const input = await c.req.json<Record<string, unknown>>()
const route = await svc(c).createHttpApiRoute(cloud, c.req.param('id'), {
routeKey: typeof input.routeKey === 'string' ? input.routeKey : '',
functionName: typeof input.functionName === 'string' ? input.functionName : undefined,
httpUrl: typeof input.httpUrl === 'string' ? input.httpUrl : undefined,
authorizerId: typeof input.authorizerId === 'string' ? input.authorizerId : undefined,
returnsResponse: input.returnsResponse === true,
})
return c.json(route, 201)
})
})
app.delete('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId', 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 () => {
await svc(c).deleteHttpApiRoute(cloud, c.req.param('id'), c.req.param('routeId'))
return c.json({ok: true})
})
})
app.put('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer', 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<{authorizerId?: unknown}>()
const authorizerId = typeof input.authorizerId === 'string' && input.authorizerId ? input.authorizerId : null
return c.json(await svc(c).setHttpApiRouteAuthorizer(cloud, c.req.param('id'), c.req.param('routeId'), authorizerId))
})
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages', 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 stage = await svc(c).createHttpApiStage(cloud, c.req.param('id'), {
stageName: typeof input.stageName === 'string' ? input.stageName : '',
autoDeploy: input.autoDeploy === true,
})
return c.json(stage, 201)
})
})
app.delete('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName', 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 () => {
await svc(c).deleteHttpApiStage(cloud, c.req.param('id'), c.req.param('stageName'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments', 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).deployHttpApi(cloud, c.req.param('id'), c.req.param('stageName')), 201))
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/authorizers', 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 authorizer = await svc(c).createHttpApiAuthorizer(cloud, c.req.param('id'), {
name: typeof input.name === 'string' ? input.name : '',
userPoolId: typeof input.userPoolId === 'string' ? input.userPoolId : undefined,
userPoolClientId: typeof input.userPoolClientId === 'string' ? input.userPoolClientId : undefined,
issuer: typeof input.issuer === 'string' ? input.issuer : undefined,
audience: Array.isArray(input.audience) ? input.audience.filter((value): value is string => typeof value === 'string') : undefined,
})
return c.json(authorizer, 201)
})
})
app.delete('/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId', 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 () => {
await svc(c).deleteHttpApiAuthorizer(cloud, c.req.param('id'), c.req.param('authorizerId'))
return c.json({ok: true})
})
})
app.put('/:cloud/services/apigateway/resources/:id/http-api/cors', 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<{cors?: Record<string, unknown> | null}>()
const cors = input.cors ? {
allowOrigins: stringList(input.cors.allowOrigins),
allowMethods: stringList(input.cors.allowMethods),
allowHeaders: stringList(input.cors.allowHeaders),
exposeHeaders: stringList(input.cors.exposeHeaders),
maxAgeSeconds: typeof input.cors.maxAgeSeconds === 'number' ? input.cors.maxAgeSeconds : null,
allowCredentials: input.cors.allowCredentials === true,
} : null
return c.json({cors: await svc(c).updateHttpApiCors(cloud, c.req.param('id'), cors)})
})
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/websocket-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>>()
return c.json(await svc(c).testWebSocketApi(cloud, c.req.param('id'), {
stage: typeof input.stage === 'string' ? input.stage : '',
message: typeof input.message === 'string' ? input.message : '',
query: typeof input.query === 'string' ? input.query : undefined,
}))
})
})
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)
@@ -1662,6 +1787,10 @@ function isStringRecord(value: unknown): value is Record<string, string> {
&& Object.values(value).every((item) => typeof item === 'string')
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
function stringHeaders(value: unknown): Record<string, string> {
if (value === undefined) return {}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -30,6 +30,16 @@ import type {
ApiGatewayDeleteRouteInput,
ApiGatewayCorsInput,
ApiGatewayCorsResult,
HttpApiAuthorizerInput,
HttpApiAuthorizerOverview,
HttpApiCors,
HttpApiOverview,
HttpApiRouteInput,
HttpApiRouteOverview,
HttpApiStageInput,
HttpApiStageOverview,
HttpApiWebSocketTestInput,
HttpApiWebSocketTestResult,
S3LambdaTriggerInput,
S3LambdaTriggerOverview,
S3BucketWebsiteConfiguration,
@@ -330,6 +340,56 @@ export class CloudProxyService {
return adapter.enableApiGatewayCors(apiId, input)
}
async describeHttpApi(cloud: CloudProvider, apiId: string): Promise<HttpApiOverview> {
return this.httpApiAdapter(cloud, 'describeHttpApi').describeHttpApi!(apiId)
}
async createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
return this.httpApiAdapter(cloud, 'createHttpApiRoute').createHttpApiRoute!(apiId, input)
}
async deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
await this.httpApiAdapter(cloud, 'deleteHttpApiRoute').deleteHttpApiRoute!(apiId, routeId)
}
async setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
return this.httpApiAdapter(cloud, 'setHttpApiRouteAuthorizer').setHttpApiRouteAuthorizer!(apiId, routeId, authorizerId)
}
async createHttpApiStage(cloud: CloudProvider, apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
return this.httpApiAdapter(cloud, 'createHttpApiStage').createHttpApiStage!(apiId, input)
}
async deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
await this.httpApiAdapter(cloud, 'deleteHttpApiStage').deleteHttpApiStage!(apiId, stageName)
}
async deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
return this.httpApiAdapter(cloud, 'deployHttpApi').deployHttpApi!(apiId, stageName)
}
async createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
return this.httpApiAdapter(cloud, 'createHttpApiAuthorizer').createHttpApiAuthorizer!(apiId, input)
}
async deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
await this.httpApiAdapter(cloud, 'deleteHttpApiAuthorizer').deleteHttpApiAuthorizer!(apiId, authorizerId)
}
async updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
return this.httpApiAdapter(cloud, 'updateHttpApiCors').updateHttpApiCors!(apiId, cors)
}
async testWebSocketApi(cloud: CloudProvider, apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
return this.httpApiAdapter(cloud, 'testWebSocketApi').testWebSocketApi!(apiId, input)
}
private httpApiAdapter(cloud: CloudProvider, method: keyof CloudServiceAdapter): CloudServiceAdapter {
const adapter = this.requireAdapter(cloud, 'apigateway')
if (typeof adapter[method] !== 'function') throw new NotSupportedError(`HTTP and WebSocket APIs are not supported for ${cloud}`)
return adapter
}
async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
const adapter = this.requireAdapter(cloud, 'storage')
if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
+24
View File
@@ -130,6 +130,19 @@ export const apiEndpointKeys = {
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
moveTasks: "clouds.services.messaging.move-tasks",
},
httpApi: {
describe: "clouds.services.apigateway.http-api.describe",
createRoute: "clouds.services.apigateway.http-api.routes.create",
deleteRoute: "clouds.services.apigateway.http-api.routes.delete",
setRouteAuthorizer: "clouds.services.apigateway.http-api.routes.authorizer",
createStage: "clouds.services.apigateway.http-api.stages.create",
deleteStage: "clouds.services.apigateway.http-api.stages.delete",
deploy: "clouds.services.apigateway.http-api.stages.deploy",
createAuthorizer: "clouds.services.apigateway.http-api.authorizers.create",
deleteAuthorizer: "clouds.services.apigateway.http-api.authorizers.delete",
cors: "clouds.services.apigateway.http-api.cors",
webSocketTest: "clouds.services.apigateway.http-api.websocket-test",
},
monitoring: {
metrics: "clouds.services.monitoring.metrics",
metricData: "clouds.services.monitoring.metric-data",
@@ -897,6 +910,17 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST",
},
],
[apiEndpointKeys.clouds.httpApi.describe, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api", method: "GET"}],
[apiEndpointKeys.clouds.httpApi.createRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.deleteRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId", method: "DELETE"}],
[apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer", method: "PUT"}],
[apiEndpointKeys.clouds.httpApi.createStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.deleteStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName", method: "DELETE"}],
[apiEndpointKeys.clouds.httpApi.deploy, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.createAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.deleteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId", method: "DELETE"}],
[apiEndpointKeys.clouds.httpApi.cors, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/cors", method: "PUT"}],
[apiEndpointKeys.clouds.httpApi.webSocketTest, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/websocket-test", method: "POST"}],
[apiEndpointKeys.clouds.monitoring.metrics, {path: "/clouds/:cloud/services/monitoring/metrics", method: "GET"}],
[apiEndpointKeys.clouds.monitoring.metricData, {path: "/clouds/:cloud/services/monitoring/metric-data", method: "POST"}],
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
@@ -1546,6 +1546,139 @@ export async function setAlarmActionsEnabled(
);
}
export type HttpApiProtocol = "HTTP" | "WEBSOCKET";
export interface HttpApiIntegrationOverview {
integrationId: string;
type: string;
uri: string | null;
functionName: string | null;
payloadFormatVersion: string | null;
}
export interface HttpApiRouteOverview {
routeId: string;
routeKey: string;
integration: HttpApiIntegrationOverview | null;
authorizationType: string;
authorizerId: string | null;
authorizationScopes: string[];
returnsResponse: boolean;
}
export interface HttpApiStageOverview {
stageName: string;
autoDeploy: boolean;
deploymentId: string | null;
lastUpdatedAt: string | null;
invokePath: string;
}
export interface HttpApiAuthorizerOverview {
authorizerId: string;
name: string;
type: string;
identitySource: string[];
issuer: string | null;
audience: string[];
}
export interface HttpApiCors {
allowOrigins: string[];
allowMethods: string[];
allowHeaders: string[];
exposeHeaders: string[];
maxAgeSeconds: number | null;
allowCredentials: boolean;
}
export interface HttpApiOverview {
apiId: string;
protocol: HttpApiProtocol;
apiEndpoint: string | null;
routeSelectionExpression: string | null;
routes: HttpApiRouteOverview[];
stages: HttpApiStageOverview[];
authorizers: HttpApiAuthorizerOverview[];
cors: HttpApiCors | null;
}
export interface HttpApiRouteInput {
routeKey: string;
functionName?: string;
httpUrl?: string;
authorizerId?: string;
returnsResponse?: boolean;
}
export interface HttpApiAuthorizerInput {
name: string;
userPoolId?: string;
userPoolClientId?: string;
issuer?: string;
audience?: string[];
}
export interface HttpApiWebSocketTestResult {
connected: boolean;
messages: string[];
closeCode: number | null;
error: string | null;
durationMs: number;
}
export async function describeHttpApi(cloud: CloudProvider, apiId: string, signal?: AbortSignal): Promise<HttpApiOverview> {
const res = await apiClient.call<HttpApiOverview>(apiEndpointKeys.clouds.httpApi.describe, requestOptions(cloud, "apigateway", { signal }), { cloud, id: apiId });
return res.data;
}
export async function createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
const res = await apiClient.call<HttpApiRouteOverview, HttpApiRouteInput>(apiEndpointKeys.clouds.httpApi.createRoute, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteRoute, requestOptions(cloud, "apigateway"), { cloud, id: apiId, routeId });
}
export async function setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
const res = await apiClient.call<HttpApiRouteOverview, { authorizerId: string | null }>(apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, requestOptions(cloud, "apigateway", { body: { authorizerId } }), { cloud, id: apiId, routeId });
return res.data;
}
export async function createHttpApiStage(cloud: CloudProvider, apiId: string, input: { stageName: string; autoDeploy: boolean }): Promise<HttpApiStageOverview> {
const res = await apiClient.call<HttpApiStageOverview, typeof input>(apiEndpointKeys.clouds.httpApi.createStage, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteStage, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
}
export async function deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
const res = await apiClient.call<HttpApiStageOverview>(apiEndpointKeys.clouds.httpApi.deploy, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
return res.data;
}
export async function createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
const res = await apiClient.call<HttpApiAuthorizerOverview, HttpApiAuthorizerInput>(apiEndpointKeys.clouds.httpApi.createAuthorizer, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteAuthorizer, requestOptions(cloud, "apigateway"), { cloud, id: apiId, authorizerId });
}
export async function updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
const res = await apiClient.call<{ cors: HttpApiCors | null }, { cors: HttpApiCors | null }>(apiEndpointKeys.clouds.httpApi.cors, requestOptions(cloud, "apigateway", { body: { cors } }), { cloud, id: apiId });
return res.data.cors;
}
export async function testWebSocketApi(cloud: CloudProvider, apiId: string, input: { stage: string; message: string; query?: string }): Promise<HttpApiWebSocketTestResult> {
const res = await apiClient.call<HttpApiWebSocketTestResult, typeof input>(apiEndpointKeys.clouds.httpApi.webSocketTest, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function setDistributionEnabled(
cloud: CloudProvider,
distributionId: string,
@@ -8,6 +8,7 @@ import type {CloudResource} from '@/types/resource'
import {CopyValueButton} from './CopyValueButton'
import {LogsQueryPanel} from './LogsQueryPanel'
import {ServiceIcon} from '@/components/serviceIcons'
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
interface ApiGatewayRoutePanelProps {
cloud: CloudProvider
@@ -138,8 +139,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
return (
<section className="table-panel">
<div className="empty compact">
<h3>Select a REST API</h3>
<p>Select an API Gateway REST API to connect a route to a Lambda function.</p>
<h3>Select an API</h3>
<p>Select an API to see its routes, or create one.</p>
</div>
</section>
)
@@ -513,49 +514,6 @@ function MethodTestResponse({result, dismiss}: {result?: ApiGatewayMethodTestRes
</div>
}
function invokeEndpoint(runtimeEndpoint?: string): string | null {
if (!runtimeEndpoint) return null
try {
const endpoint = new URL(runtimeEndpoint)
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
endpoint.hostname = window.location.hostname
}
return endpoint.toString().replace(/\/$/, '')
} catch {
return runtimeEndpoint.replace(/\/$/, '')
}
}
function isDockerBridgeAddress(hostname: string): boolean {
const octets = hostname.split('.').map(Number)
return octets.length === 4
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
&& octets[0] === 172
&& octets[1] >= 16
&& octets[1] <= 31
}
function parseHeaders(value: string): Record<string, string> {
let parsed: unknown
try {
parsed = JSON.parse(value || '{}')
} catch {
throw new Error('Request headers must be valid JSON.')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((header) => typeof header !== 'string')) {
throw new Error('Request headers must be a JSON object with string values.')
}
return parsed as Record<string, string>
}
function formatResponseBody(body: string): string {
try {
return JSON.stringify(JSON.parse(body), null, 2)
} catch {
return body || '(empty response body)'
}
}
function buildCurlCommand(apiId: string, stage: string, path: string, method: string, headersText: string, query: string, body: string): string {
let headers: Record<string, string> = {}
try {
@@ -48,6 +48,7 @@ import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
import { AppConfigPanel } from "@/components/AppConfigPanel";
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
import { HttpApiPanel } from "@/components/HttpApiPanel";
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
@@ -1037,14 +1038,21 @@ export function DynamicResourceView({
)}
</>
)}
{service === "apigateway" && (
{service === "apigateway" && (activeSelected && ["http", "websocket"].includes(String(activeSelected.metadata.apigatewayService)) ? (
<HttpApiPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
/>
) : (
<ApiGatewayRoutePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
/>
)}
))}
{service === "events" && (
<EventBridgeRulePanel
cloud={cloud}
@@ -0,0 +1,126 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {HttpApiPanel} from './HttpApiPanel'
import type {HttpApiOverview} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
describeHttpApi: vi.fn(),
listCloudResources: vi.fn(),
createHttpApiRoute: vi.fn(),
deleteHttpApiRoute: vi.fn(),
setHttpApiRouteAuthorizer: vi.fn(),
createHttpApiStage: vi.fn(),
deleteHttpApiStage: vi.fn(),
deployHttpApi: vi.fn(),
createHttpApiAuthorizer: vi.fn(),
deleteHttpApiAuthorizer: vi.fn(),
updateHttpApiCors: vi.fn(),
testApiGatewayRoute: vi.fn(),
testWebSocketApi: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
const httpResource: CloudResource = {
id: 'h1', name: 'notes-api', cloud: 'aws', service: 'apigateway', type: 'http-api', region: null, createdAt: null,
metadata: {apigatewayService: 'http', protocol: 'HTTP'},
}
const httpOverview: HttpApiOverview = {
apiId: 'h1', protocol: 'HTTP', apiEndpoint: null, routeSelectionExpression: null,
routes: [
{routeId: 'r1', routeKey: 'GET /notes/{id}', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: false},
{routeId: 'r2', routeKey: 'GET /me', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'JWT', authorizerId: 'a1', authorizationScopes: [], returnsResponse: false},
],
stages: [{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}],
authorizers: [{authorizerId: 'a1', name: 'cognito', type: 'JWT', identitySource: ['$request.header.Authorization'], issuer: 'http://floci:4566/us-east-1_pool', audience: ['client1']}],
cors: null,
}
function renderPanel(resource: CloudResource = httpResource) {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><HttpApiPanel cloud="aws" resource={resource} runtimeReachable runtimeEndpoint="http://localhost:4566"/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.describeHttpApi.mockResolvedValue(httpOverview)
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => service === 'serverless'
? [{id: 'notes', name: 'notes', type: 'lambda', cloud: 'aws', service: 'serverless', region: null, createdAt: null, metadata: {}}]
: [{id: 'us-east-1_pool', name: 'notes-users', type: 'user-pool', cloud: 'aws', service: 'userpools', region: null, createdAt: null, metadata: {appClients: [{clientId: 'client1', clientName: 'web'}]}}])
api.createHttpApiRoute.mockResolvedValue(httpOverview.routes[0])
api.setHttpApiRouteAuthorizer.mockResolvedValue(httpOverview.routes[0])
api.createHttpApiAuthorizer.mockResolvedValue(httpOverview.authorizers[0])
api.updateHttpApiCors.mockResolvedValue({allowOrigins: ['*'], allowMethods: ['GET'], allowHeaders: [], exposeHeaders: [], maxAgeSeconds: null, allowCredentials: false})
api.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '{"ok":true}', durationMs: 12})
})
test('shows the stage URL, routes with their targets and who may call them', async () => {
renderPanel()
expect(await screen.findByText('http://localhost:4566/execute-api/h1/$default')).toBeInTheDocument()
const routes = within(screen.getByRole('region', {name: 'Routes'}))
expect(routes.getByText('GET /notes/{id}')).toBeInTheDocument()
expect(routes.getAllByText('Lambda: notes')).toHaveLength(2)
expect(routes.getByRole('combobox', {name: 'Access for GET /me'})).toHaveValue('a1')
expect(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'})).toHaveValue('')
})
test('adds a Lambda route and protects an existing one', async () => {
renderPanel()
const routes = within(await screen.findByRole('region', {name: 'Routes'}))
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Method'}), 'POST')
await userEvent.clear(routes.getByRole('textbox', {name: 'Path'}))
await userEvent.type(routes.getByRole('textbox', {name: 'Path'}), '/notes')
await routes.findByRole('option', {name: 'notes'})
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Lambda function'}), 'notes')
await userEvent.click(routes.getByRole('button', {name: 'Add route'}))
expect(api.createHttpApiRoute).toHaveBeenCalledWith('aws', 'h1', {routeKey: 'POST /notes', functionName: 'notes', httpUrl: undefined, authorizerId: undefined, returnsResponse: undefined})
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'}), 'a1')
expect(api.setHttpApiRouteAuthorizer).toHaveBeenCalledWith('aws', 'h1', 'r1', 'a1')
})
test('creates a JWT authorizer from a Cognito pool and app client', async () => {
renderPanel()
const security = within(await screen.findByRole('region', {name: 'Security'}))
await security.findByRole('option', {name: 'notes-users'})
await userEvent.selectOptions(security.getByRole('combobox', {name: 'User pool'}), 'us-east-1_pool')
await userEvent.selectOptions(security.getByRole('combobox', {name: 'App client'}), 'client1')
await userEvent.click(security.getByRole('button', {name: 'Add authorizer'}))
expect(api.createHttpApiAuthorizer).toHaveBeenCalledWith('aws', 'h1', {name: 'cognito', userPoolId: 'us-east-1_pool', userPoolClientId: 'client1'})
})
test('sends a request to the stage and shows the response', async () => {
renderPanel()
const tester = within(await screen.findByRole('region', {name: 'Send a request'}))
expect(tester.getByRole('textbox', {name: 'Request path'})).toHaveValue('/notes/1')
await userEvent.click(tester.getByRole('button', {name: 'Send request'}))
expect(api.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'h1', expect.objectContaining({stage: '$default', method: 'GET', path: '/notes/1'}))
expect(await tester.findByText('200')).toBeInTheDocument()
})
test('a WebSocket API shows its wss URL and sends a message, without HTTP-only security', async () => {
api.describeHttpApi.mockResolvedValue({
apiId: 'w1', protocol: 'WEBSOCKET', apiEndpoint: null, routeSelectionExpression: '$request.body.action',
routes: [{routeId: 'r1', routeKey: 'sendMessage', integration: null, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: true}],
stages: [{stageName: 'dev', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/ws/w1/dev'}],
authorizers: [], cors: null,
} satisfies HttpApiOverview)
api.testWebSocketApi.mockResolvedValue({connected: true, messages: ['{"echo":"hello"}'], closeCode: null, error: null, durationMs: 40})
renderPanel({...httpResource, id: 'w1', type: 'websocket-api', metadata: {apigatewayService: 'websocket', protocol: 'WEBSOCKET'}})
expect(await screen.findByText('ws://localhost:4566/ws/w1/dev')).toBeInTheDocument()
expect(screen.queryByRole('region', {name: 'Security'})).not.toBeInTheDocument()
const tester = within(screen.getByRole('region', {name: 'Send a message'}))
expect(tester.getByRole('textbox', {name: 'Message'})).toHaveValue(JSON.stringify({action: 'sendMessage', data: 'hello'}, null, 2))
await userEvent.click(tester.getByRole('button', {name: 'Connect and send'}))
expect(api.testWebSocketApi).toHaveBeenCalledWith('aws', 'w1', expect.objectContaining({stage: 'dev'}))
expect(await tester.findByText('Connected')).toBeInTheDocument()
})
@@ -0,0 +1,610 @@
import {useEffect, useState} from 'react'
import {Loader2, Plus, RefreshCw, Rocket, Send, ShieldCheck, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {
createHttpApiAuthorizer,
createHttpApiRoute,
createHttpApiStage,
deleteHttpApiAuthorizer,
deleteHttpApiRoute,
deleteHttpApiStage,
deployHttpApi,
describeHttpApi,
listCloudResources,
setHttpApiRouteAuthorizer,
testApiGatewayRoute,
testWebSocketApi,
updateHttpApiCors,
} from '@/api/cloudProxyClient'
import type {HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteOverview, HttpApiStageOverview} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
import {formatDateTime} from '@/lib/format'
import {CopyValueButton} from './CopyValueButton'
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
interface HttpApiPanelProps {
cloud: CloudProvider
resource: CloudResource
runtimeReachable: boolean
runtimeEndpoint?: string
}
const ROUTE_METHODS = ['ANY', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
const TEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']
const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', '*']
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
/** API Gateway v2: an HTTP or WebSocket API's routes, stages, security and a live tester. */
export function HttpApiPanel({cloud, resource, runtimeReachable, runtimeEndpoint}: HttpApiPanelProps) {
const queryKey = ['http-api', cloud, resource.id]
const query = useQuery({
queryKey,
queryFn: ({signal}) => describeHttpApi(cloud, resource.id, signal),
enabled: runtimeReachable,
})
const api = query.data
const endpoint = invokeEndpoint(runtimeEndpoint)
const isWebSocket = (api?.protocol ?? resource.metadata.protocol) === 'WEBSOCKET'
return <div className="http-api-workspace">
<section className="table-panel" aria-label={`${isWebSocket ? 'WebSocket' : 'HTTP'} API ${resource.name}`}>
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">{isWebSocket ? 'WebSocket API' : 'HTTP API'}</p>
<span className="muted">{isWebSocket
? <>Messages are routed by <code>{api?.routeSelectionExpression ?? String(resource.metadata.routeSelectionExpression ?? '')}</code>.</>
: 'Routes send requests to Lambda functions or HTTP backends.'}</span>
</div>
<button className="button" type="button" disabled={!runtimeReachable || query.isFetching} onClick={() => void query.refetch()}><RefreshCw size={14}/>{query.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
{query.isLoading && <p className="muted padded">Loading the API</p>}
{query.error instanceof Error && <p className="error-text compact-text padded" role="alert">{query.error.message}</p>}
{api && <StageUrls api={api} endpoint={endpoint}/>}
</section>
{api && <>
<RoutesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
<StagesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
{api.protocol === 'HTTP' && <SecuritySection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>}
{api.protocol === 'HTTP'
? <HttpTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>
: <WebSocketTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>}
</>}
</div>
}
function StageUrls({api, endpoint}: {api: HttpApiOverview; endpoint: string | null}) {
if (api.stages.length === 0) return <p className="muted padded">No stages yet. Add one below to serve the API.</p>
return <div className="http-api-urls">
{api.stages.map((stage) => {
const url = stageUrl(api, stage, endpoint)
return <div className="dynamic-field" key={stage.stageName}>
<span>{stage.stageName} {stage.autoDeploy && <small className="muted">· deploys automatically</small>}</span>
{url ? <div className="copy-value"><code>{url}</code><CopyValueButton value={url} label={`${stage.stageName} URL`}/></div> : <code>{stage.invokePath}</code>}
</div>
})}
</div>
}
type SectionProps = {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean; queryKey: unknown[]}
function RoutesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const isWebSocket = api.protocol === 'WEBSOCKET'
const [method, setMethod] = useState('GET')
const [path, setPath] = useState('/hello')
const [catchAll, setCatchAll] = useState(false)
const [webSocketKey, setWebSocketKey] = useState('$default')
const [targetKind, setTargetKind] = useState<'lambda' | 'http'>('lambda')
const [functionName, setFunctionName] = useState('')
const [httpUrl, setHttpUrl] = useState('')
const [authorizerId, setAuthorizerId] = useState('')
const [returnsResponse, setReturnsResponse] = useState(true)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
useEffect(() => {
setConfirmDelete(null)
setAuthorizerId('')
}, [api.apiId])
const functionsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'serverless'],
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
enabled: runtimeReachable,
})
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
const refresh = () => queryClient.invalidateQueries({queryKey})
const routeKey = isWebSocket ? webSocketKey.trim() : catchAll ? '$default' : `${method} ${path.trim()}`
const createMutation = useMutation({
mutationFn: () => createHttpApiRoute(cloud, api.apiId, {
routeKey,
functionName: targetKind === 'lambda' ? functionName : undefined,
httpUrl: targetKind === 'http' ? httpUrl.trim() : undefined,
authorizerId: authorizerId || undefined,
returnsResponse: isWebSocket ? returnsResponse : undefined,
}),
onSuccess: refresh,
})
const deleteMutation = useMutation({
mutationFn: (routeId: string) => deleteHttpApiRoute(cloud, api.apiId, routeId),
onSuccess: () => {
setConfirmDelete(null)
return refresh()
},
})
const authorizerMutation = useMutation({
mutationFn: ({routeId, authorizer}: {routeId: string; authorizer: string | null}) => setHttpApiRouteAuthorizer(cloud, api.apiId, routeId, authorizer),
onSuccess: refresh,
})
const target = targetKind === 'lambda' ? functionName : httpUrl.trim()
const canCreate = runtimeReachable && Boolean(routeKey && target) && !createMutation.isPending
const authorizerName = (id: string | null) => api.authorizers.find((authorizer) => authorizer.authorizerId === id)?.name ?? id
const error = createMutation.error ?? deleteMutation.error ?? authorizerMutation.error
return <section className="table-panel" aria-label="Routes">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Routes</p>
<span className="muted">{isWebSocket
? '$connect and $disconnect run when a client connects and leaves; other messages go to the route their action names, or $default.'
: 'The most specific route wins; $default catches every request no other route matches.'}</span>
</div>
</div>
{api.routes.length === 0 && <p className="muted padded">No routes yet.</p>}
{api.routes.length > 0 && <div className="cognito-table"><table className="table">
<thead><tr><th>Route</th><th>Target</th>{!isWebSocket && <th>Access</th>}<th aria-label="Actions"/></tr></thead>
<tbody>{api.routes.map((route) => <tr key={route.routeId}>
<td><code>{route.routeKey}</code>{isWebSocket && route.returnsResponse && <small className="muted"> · replies</small>}</td>
<td>{describeTarget(route)}</td>
{!isWebSocket && <td>
<select className="input http-api-access" aria-label={`Access for ${route.routeKey}`} value={route.authorizerId ?? ''} disabled={!runtimeReachable || authorizerMutation.isPending}
onChange={(event) => authorizerMutation.mutate({routeId: route.routeId, authorizer: event.target.value || null})}>
<option value="">Open to anyone</option>
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
</select>
{route.authorizerId && !api.authorizers.some((authorizer) => authorizer.authorizerId === route.authorizerId) && <small className="muted">Authorizer {authorizerName(route.authorizerId)}</small>}
</td>}
<td className="cognito-row-actions-cell">
{confirmDelete === route.routeId
? <span className="apigateway-route-confirm">
<span>Delete {route.routeKey}?</span>
<button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(route.routeId)}>Delete</button>
<button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDelete(null)}>Cancel</button>
</span>
: <button className="icon-btn" type="button" aria-label={`Delete ${route.routeKey}`} disabled={!runtimeReachable} onClick={() => setConfirmDelete(route.routeId)}><Trash2 size={13}/></button>}
</td>
</tr>)}</tbody>
</table></div>}
<form className="dynamic-form" aria-label="Add a route" onSubmit={(event) => {
event.preventDefault()
if (canCreate) createMutation.mutate()
}}>
{isWebSocket ? <label className="dynamic-field">
<span>Route key</span>
<input className="input" list="websocket-route-keys" value={webSocketKey} onChange={(event) => setWebSocketKey(event.target.value)} placeholder="sendMessage"/>
<datalist id="websocket-route-keys">{WEBSOCKET_SYSTEM_ROUTES.map((key) => <option key={key} value={key}/>)}</datalist>
<small>$connect, $disconnect, $default, or an action such as sendMessage.</small>
</label> : <>
<label className="dynamic-field">
<span>Method</span>
<select className="input" value={method} disabled={catchAll} onChange={(event) => setMethod(event.target.value)}>
{ROUTE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="dynamic-field">
<span>Path</span>
<input className="input" aria-label="Path" value={path} disabled={catchAll} onChange={(event) => setPath(event.target.value)} placeholder="/notes/{id}"/>
<small>Use {'{id}'} for a path parameter and a final {'{proxy+}'} for everything below.</small>
</label>
<label className="dynamic-field http-api-check-field">
<input type="checkbox" checked={catchAll} onChange={(event) => setCatchAll(event.target.checked)}/>
<span>Catch-all ($default)</span>
</label>
</>}
{!isWebSocket && <label className="dynamic-field">
<span>Target</span>
<select className="input" value={targetKind} onChange={(event) => setTargetKind(event.target.value as 'lambda' | 'http')}>
<option value="lambda">Lambda function</option>
<option value="http">HTTP backend</option>
</select>
</label>}
{targetKind === 'lambda' || isWebSocket
? <label className="dynamic-field">
<span>Lambda function</span>
<select className="input" aria-label="Lambda function" value={functionName} onChange={(event) => setFunctionName(event.target.value)}>
<option value="">Select a Lambda function</option>
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
</select>
<small>{functionsQuery.isLoading ? 'Loading functions' : functions.length === 0 ? 'Create a Lambda function first.' : isWebSocket ? 'Receives the message as a WebSocket event.' : 'Receives a version 2.0 proxy event.'}</small>
</label>
: <label className="dynamic-field">
<span>Backend URL</span>
<input className="input" value={httpUrl} onChange={(event) => setHttpUrl(event.target.value)} placeholder="https://backend.example.com/{proxy}"/>
<small>Requests are proxied here as they are.</small>
</label>}
{!isWebSocket && api.authorizers.length > 0 && <label className="dynamic-field">
<span>Access</span>
<select className="input" aria-label="Access for the new route" value={authorizerId} onChange={(event) => setAuthorizerId(event.target.value)}>
<option value="">Open to anyone</option>
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
</select>
</label>}
{isWebSocket && <label className="dynamic-field http-api-check-field">
<input type="checkbox" checked={returnsResponse} onChange={(event) => setReturnsResponse(event.target.checked)}/>
<span>Send the function's response back to the client</span>
</label>}
<button className="button primary" type="submit" disabled={!canCreate}>
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}{createMutation.isPending ? 'Adding' : 'Add route'}
</button>
</form>
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
</section>
}
function StagesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const [stageName, setStageName] = useState('prod')
const [autoDeploy, setAutoDeploy] = useState(true)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [notice, setNotice] = useState('')
const refresh = () => queryClient.invalidateQueries({queryKey})
useEffect(() => {
setConfirmDelete(null)
setNotice('')
}, [api.apiId])
const createMutation = useMutation({
mutationFn: () => createHttpApiStage(cloud, api.apiId, {stageName: stageName.trim(), autoDeploy}),
onSuccess: (stage) => {
setNotice(stage.autoDeploy ? `${stage.stageName} deploys every change automatically.` : `${stage.stageName} is deployed. Deploy it again after changing routes.`)
return refresh()
},
})
const deployMutation = useMutation({
mutationFn: (name: string) => deployHttpApi(cloud, api.apiId, name),
onSuccess: (stage) => {
setNotice(`Deployed the current routes to ${stage.stageName}.`)
return refresh()
},
})
const deleteMutation = useMutation({
mutationFn: (name: string) => deleteHttpApiStage(cloud, api.apiId, name),
onSuccess: () => {
setConfirmDelete(null)
return refresh()
},
})
const error = createMutation.error ?? deployMutation.error ?? deleteMutation.error
const busy = !runtimeReachable || createMutation.isPending || deployMutation.isPending || deleteMutation.isPending
return <section className="table-panel" aria-label="Stages">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Stages</p>
<span className="muted">A stage serves a deployment of the routes. With automatic deployment, every change is live right away.</span>
</div>
</div>
{api.stages.length > 0 && <div className="cognito-table"><table className="table">
<thead><tr><th>Stage</th><th>Deployment</th><th>Updated</th><th aria-label="Actions"/></tr></thead>
<tbody>{api.stages.map((stage) => <tr key={stage.stageName}>
<td><code>{stage.stageName}</code></td>
<td>{stage.autoDeploy ? 'Automatic' : stage.deploymentId ? <code>{stage.deploymentId}</code> : 'Not deployed'}</td>
<td>{formatDateTime(stage.lastUpdatedAt) ?? '—'}</td>
<td className="cognito-row-actions-cell">
{!stage.autoDeploy && <button className="button compact" type="button" disabled={busy} onClick={() => deployMutation.mutate(stage.stageName)}><Rocket size={13}/>Deploy</button>}
{confirmDelete === stage.stageName
? <span className="apigateway-route-confirm">
<span>Delete {stage.stageName}?</span>
<button className="button danger compact" type="button" disabled={busy} onClick={() => deleteMutation.mutate(stage.stageName)}>Delete</button>
<button className="button compact" type="button" onClick={() => setConfirmDelete(null)}>Cancel</button>
</span>
: <button className="icon-btn" type="button" aria-label={`Delete stage ${stage.stageName}`} disabled={busy} onClick={() => setConfirmDelete(stage.stageName)}><Trash2 size={13}/></button>}
</td>
</tr>)}</tbody>
</table></div>}
<form className="dynamic-form" aria-label="Add a stage" onSubmit={(event) => {
event.preventDefault()
setNotice('')
if (stageName.trim()) createMutation.mutate()
}}>
<label className="dynamic-field"><span>Stage name</span><input className="input" value={stageName} onChange={(event) => setStageName(event.target.value)} placeholder="prod"/></label>
<label className="dynamic-field http-api-check-field">
<input type="checkbox" checked={autoDeploy} onChange={(event) => setAutoDeploy(event.target.checked)}/>
<span>Deploy changes automatically</span>
</label>
<button className="button primary" type="submit" disabled={busy || !stageName.trim()}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add stage'}</button>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
</section>
}
function SecuritySection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
return <section className="table-panel" aria-label="Security">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow"><ShieldCheck size={13}/> Security</p>
<span className="muted">A JWT authorizer checks each request's bearer token; protect a route by choosing it under Access above.</span>
</div>
</div>
<AuthorizersSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
<CorsSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
</section>
}
function AuthorizersSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const [source, setSource] = useState<'cognito' | 'issuer'>('cognito')
const [name, setName] = useState('cognito')
const [userPoolId, setUserPoolId] = useState('')
const [clientId, setClientId] = useState('')
const [issuer, setIssuer] = useState('')
const [audience, setAudience] = useState('')
const refresh = () => queryClient.invalidateQueries({queryKey})
const poolsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'userpools'],
queryFn: ({signal}) => listCloudResources(cloud, 'userpools', undefined, signal),
enabled: runtimeReachable,
})
const pools = poolsQuery.data ?? []
const pool = pools.find((candidate) => candidate.id === userPoolId)
const clients = Array.isArray(pool?.metadata.appClients) ? pool.metadata.appClients as Array<{clientId: string; clientName: string}> : []
const createMutation = useMutation({
mutationFn: () => createHttpApiAuthorizer(cloud, api.apiId, source === 'cognito'
? {name: name.trim(), userPoolId, userPoolClientId: clientId}
: {name: name.trim(), issuer: issuer.trim(), audience: audience.split(',').map((value) => value.trim()).filter(Boolean)}),
onSuccess: refresh,
})
const deleteMutation = useMutation({
mutationFn: (authorizerId: string) => deleteHttpApiAuthorizer(cloud, api.apiId, authorizerId),
onSuccess: refresh,
})
const ready = source === 'cognito' ? Boolean(userPoolId && clientId) : Boolean(issuer.trim() && audience.trim())
const error = createMutation.error ?? deleteMutation.error
return <>
{api.authorizers.length > 0 && <div className="cognito-table"><table className="table">
<thead><tr><th>Authorizer</th><th>Issuer</th><th>Audience</th><th aria-label="Actions"/></tr></thead>
<tbody>{api.authorizers.map((authorizer: HttpApiAuthorizerOverview) => <tr key={authorizer.authorizerId}>
<td>{authorizer.name} <small className="muted">· {authorizer.type}</small></td>
<td className="mono http-api-wrap">{authorizer.issuer ?? '—'}</td>
<td className="mono http-api-wrap">{authorizer.audience.join(', ') || '—'}</td>
<td className="cognito-row-actions-cell"><button className="icon-btn" type="button" aria-label={`Delete authorizer ${authorizer.name}`} disabled={!runtimeReachable || deleteMutation.isPending} onClick={() => deleteMutation.mutate(authorizer.authorizerId)}><Trash2 size={13}/></button></td>
</tr>)}</tbody>
</table></div>}
<form className="dynamic-form" aria-label="Add a JWT authorizer" onSubmit={(event) => {
event.preventDefault()
if (ready && name.trim()) createMutation.mutate()
}}>
<label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)}/></label>
<label className="dynamic-field">
<span>Tokens from</span>
<select className="input" value={source} onChange={(event) => setSource(event.target.value as 'cognito' | 'issuer')}>
<option value="cognito">A Cognito user pool</option>
<option value="issuer">Another OpenID Connect issuer</option>
</select>
</label>
{source === 'cognito' ? <>
<label className="dynamic-field">
<span>User pool</span>
<select className="input" aria-label="User pool" value={userPoolId} onChange={(event) => { setUserPoolId(event.target.value); setClientId('') }}>
<option value="">Select a user pool</option>
{pools.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.name}</option>)}
</select>
{poolsQuery.isSuccess && pools.length === 0 && <small>Create a Cognito user pool first.</small>}
</label>
<label className="dynamic-field">
<span>App client</span>
<select className="input" aria-label="App client" value={clientId} disabled={!pool} onChange={(event) => setClientId(event.target.value)}>
<option value="">Select an app client</option>
{clients.map((client) => <option key={client.clientId} value={client.clientId}>{client.clientName || client.clientId}</option>)}
</select>
<small>Only this client's tokens are accepted.</small>
</label>
</> : <>
<label className="dynamic-field"><span>Issuer URL</span><input className="input" value={issuer} onChange={(event) => setIssuer(event.target.value)} placeholder="https://login.example.com/"/></label>
<label className="dynamic-field"><span>Audience</span><input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="client-id, another-client-id"/></label>
</>}
<button className="button primary" type="submit" disabled={!runtimeReachable || !ready || !name.trim() || createMutation.isPending}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add authorizer'}</button>
</form>
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
</>
}
function CorsSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const [origins, setOrigins] = useState('')
const [methods, setMethods] = useState<string[]>([])
const [headers, setHeaders] = useState('')
const [exposeHeaders, setExposeHeaders] = useState('')
const [maxAge, setMaxAge] = useState('')
const [credentials, setCredentials] = useState(false)
const [notice, setNotice] = useState('')
useEffect(() => {
const cors = api.cors
setOrigins(cors?.allowOrigins.join(', ') ?? '*')
setMethods(cors?.allowMethods ?? ['GET', 'POST', 'OPTIONS'])
setHeaders(cors?.allowHeaders.join(', ') ?? 'content-type, authorization')
setExposeHeaders(cors?.exposeHeaders.join(', ') ?? '')
setMaxAge(cors?.maxAgeSeconds === null || cors?.maxAgeSeconds === undefined ? '' : String(cors.maxAgeSeconds))
setCredentials(cors?.allowCredentials ?? false)
setNotice('')
}, [api.apiId, api.cors])
const mutation = useMutation({
mutationFn: (cors: HttpApiCors | null) => updateHttpApiCors(cloud, api.apiId, cors),
onSuccess: (cors) => {
setNotice(cors ? 'CORS saved. API Gateway now answers preflight requests itself.' : 'CORS removed.')
return queryClient.invalidateQueries({queryKey})
},
})
const split = (value: string) => value.split(',').map((item) => item.trim()).filter(Boolean)
return <>
<div className="input-row resource-table-bar"><div>
<p className="eyebrow">CORS</p>
<span className="muted">{api.cors ? 'On: browsers on the allowed origins can call this API.' : 'Off: browsers on other sites cannot call this API.'}</span>
</div></div>
<form className="dynamic-form" aria-label="CORS" onSubmit={(event) => {
event.preventDefault()
setNotice('')
mutation.mutate({allowOrigins: split(origins), allowMethods: methods, allowHeaders: split(headers), exposeHeaders: split(exposeHeaders), maxAgeSeconds: maxAge.trim() ? Number(maxAge) : null, allowCredentials: credentials})
}}>
<label className="dynamic-field"><span>Allowed origins</span><input className="input" value={origins} onChange={(event) => setOrigins(event.target.value)} placeholder="https://app.example.com"/><small>Comma separated, or * for any site.</small></label>
<div className="dynamic-field"><span>Allowed methods</span><div className="http-api-checks">{CORS_METHODS.map((value) => <label key={value}><input type="checkbox" checked={methods.includes(value)} onChange={() => setMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div></div>
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={headers} onChange={(event) => setHeaders(event.target.value)}/></label>
<label className="dynamic-field"><span>Exposed headers</span><input className="input" value={exposeHeaders} onChange={(event) => setExposeHeaders(event.target.value)} placeholder="x-request-id"/></label>
<label className="dynamic-field"><span>Max age (seconds)</span><input className="input" inputMode="numeric" value={maxAge} onChange={(event) => setMaxAge(event.target.value)} placeholder="300"/></label>
<label className="dynamic-field http-api-check-field"><input type="checkbox" checked={credentials} onChange={(event) => setCredentials(event.target.checked)}/><span>Allow credentials (cookies)</span></label>
<div className="cloudwatch-form-actions">
<button className="button primary" type="submit" disabled={!runtimeReachable || mutation.isPending}>{mutation.isPending ? 'Saving' : 'Save CORS'}</button>
{api.cors && <button className="button" type="button" disabled={!runtimeReachable || mutation.isPending} onClick={() => { setNotice(''); mutation.mutate(null) }}>Remove CORS</button>}
</div>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{mutation.error instanceof Error && <p className="error-text compact-text padded" role="alert">{mutation.error.message}</p>}
</>
}
function HttpTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '$default')
const [method, setMethod] = useState('GET')
const [path, setPath] = useState(suggestedPath(api.routes))
const [query, setQuery] = useState('')
const [headers, setHeaders] = useState('{\n "content-type": "application/json"\n}')
const [body, setBody] = useState('')
useEffect(() => {
setStage(api.stages[0]?.stageName ?? '$default')
setPath(suggestedPath(api.routes))
// Only when another API is opened; keep what the user typed while this one refreshes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api.apiId])
const mutation = useMutation({
mutationFn: () => testApiGatewayRoute(cloud, api.apiId, {path: path.trim(), method, stage, body, headers: parseHeaders(headers), query}),
})
const result = mutation.data
return <section className="table-panel apigateway-test-panel" aria-label="Send a request">
<div className="input-row resource-table-bar"><div>
<p className="eyebrow"><Send size={13}/> Send a request</p>
<span className="muted">Sends a real request to the stage, through its routes and authorizers. For a protected route, add an <code>authorization</code> header with <code>Bearer</code> and a token.</span>
</div></div>
<div className="apigateway-test-workspace">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (!mutation.isPending) mutation.mutate()
}}>
<label className="dynamic-field"><span>Stage</span>
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Method</span>
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>{TEST_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}</select>
</label>
<label className="dynamic-field"><span>Path</span><input className="input" aria-label="Request path" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/notes/123"/></label>
<label className="dynamic-field"><span>Query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="page=1"/></label>
<label className="dynamic-field"><span>Headers (JSON)</span><textarea className="textarea code-textarea small" value={headers} onChange={(event) => setHeaders(event.target.value)} spellCheck={false}/></label>
{!['GET', 'HEAD'].includes(method) && <label className="dynamic-field"><span>Body</span><textarea className="textarea code-textarea" aria-label="Request body" value={body} onChange={(event) => setBody(event.target.value)} spellCheck={false}/></label>}
<button className="button primary" type="submit" disabled={!runtimeReachable || api.stages.length === 0 || mutation.isPending}>
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Sending' : 'Send request'}
</button>
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
</form>
<div className="apigateway-test-response">
{result ? <div className="apigateway-test-result">
<div className="apigateway-test-result-header">
<strong>Response <span className={result.statusCode < 400 ? 'status-success' : 'status-error'}>{result.statusCode}</span></strong>
<small>{result.durationMs} ms</small>
</div>
<details><summary>Response headers</summary><pre>{JSON.stringify(result.headers, null, 2)}</pre></details>
<pre>{formatResponseBody(result.body)}</pre>
</div> : <p className="muted compact-text">Send a request to see the response.</p>}
</div>
</div>
</section>
}
function WebSocketTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
const actionField = api.routeSelectionExpression?.match(/^\$request\.body\.(.+)$/)?.[1] ?? 'action'
const firstAction = api.routes.find((route) => !WEBSOCKET_SYSTEM_ROUTES.includes(route.routeKey))?.routeKey ?? 'sendMessage'
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '')
const [message, setMessage] = useState(JSON.stringify({[actionField]: firstAction, data: 'hello'}, null, 2))
const [query, setQuery] = useState('')
useEffect(() => {
setStage(api.stages[0]?.stageName ?? '')
// Only when another API is opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api.apiId])
const mutation = useMutation({mutationFn: () => testWebSocketApi(cloud, api.apiId, {stage, message, query})})
const result = mutation.data
return <section className="table-panel apigateway-test-panel" aria-label="Send a message">
<div className="input-row resource-table-bar"><div>
<p className="eyebrow"><Send size={13}/> Send a message</p>
<span className="muted">Connects to the stage like a client would, sends one message, and shows what comes back. Replies need a route that sends the function's response back.</span>
</div></div>
<div className="apigateway-test-workspace">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (!mutation.isPending && stage) mutation.mutate()
}}>
<label className="dynamic-field"><span>Stage</span>
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Connection query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="token=abc"/><small>Passed to $connect.</small></label>
<label className="dynamic-field"><span>Message</span><textarea className="textarea code-textarea" aria-label="Message" value={message} onChange={(event) => setMessage(event.target.value)} spellCheck={false}/></label>
<button className="button primary" type="submit" disabled={!runtimeReachable || !stage || mutation.isPending}>
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Connecting' : 'Connect and send'}
</button>
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
</form>
<div className="apigateway-test-response">
{result ? <div className="apigateway-test-result">
<div className="apigateway-test-result-header">
<strong>{result.connected ? <span className="status-success">Connected</span> : <span className="status-error">Not connected</span>}</strong>
<small>{result.durationMs} ms</small>
</div>
{result.error && <p className="error-text compact-text">{result.error}</p>}
{result.connected && result.messages.length === 0 && <p className="muted compact-text">No reply. Check the route sends the function's response back.</p>}
{result.messages.map((reply, index) => <pre key={index}>{formatResponseBody(reply)}</pre>)}
</div> : <p className="muted compact-text">Send a message to see the replies.</p>}
</div>
</div>
</section>
}
function describeTarget(route: HttpApiRouteOverview): string {
const integration = route.integration
if (!integration) return 'No target'
if (integration.functionName) return `Lambda: ${integration.functionName}`
if (integration.type === 'HTTP_PROXY') return `HTTP: ${integration.uri ?? ''}`
return integration.type
}
function stageUrl(api: HttpApiOverview, stage: HttpApiStageOverview, endpoint: string | null): string | null {
if (!endpoint) return null
return `${api.protocol === 'WEBSOCKET' ? endpoint.replace(/^http/, 'ws') : endpoint}${stage.invokePath}`
}
/** A path to try first: the first route's path with its parameters filled in. */
function suggestedPath(routes: HttpApiRouteOverview[]): string {
const path = routes.map((route) => route.routeKey.split(' ')[1]).find(Boolean)
if (!path || path === '/') return '/hello'
return path.replace(/\{[^}]+\+\}/g, 'anything').replace(/\{[^}]+\}/g, '1')
}
+13
View File
@@ -6566,6 +6566,19 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.cloudwatch-form-actions { display: flex; flex-wrap: wrap; gap: 8px; align-self: end; }
.cognito-row-actions-cell { text-align: right; }
/* API Gateway v2 (HTTP and WebSocket APIs): one column of sections, unlike the REST API's two. */
.http-api-workspace { display: grid; gap: 16px; min-width: 0; }
.http-api-workspace .dynamic-form { padding: 4px 16px 16px; }
.http-api-workspace .apigateway-test-workspace { padding: 0 16px 16px; }
.http-api-urls { display: grid; gap: 8px; padding: 0 16px 12px; }
.http-api-urls .copy-value { overflow-wrap: anywhere; min-width: 0; }
.http-api-check-field { display: flex; align-items: center; gap: 8px; align-self: end; min-height: 36px; }
.http-api-check-field span { text-transform: none; font-size: 13px; font-weight: 500; color: var(--text-1); }
.http-api-checks { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.http-api-checks label { display: inline-flex; align-items: center; gap: 4px; }
.http-api-access { min-width: 0; max-width: 220px; }
.http-api-wrap { overflow-wrap: anywhere; }
.metric-chart { position: relative; width: 100%; min-width: 0; }
.metric-chart svg { display: block; max-width: 100%; height: auto; overflow: visible; }
.metric-chart svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
+45
View File
@@ -0,0 +1,45 @@
// Shared by the REST API and HTTP/WebSocket API panels.
/** The runtime endpoint as the browser can reach it, for building invoke URLs. */
export function invokeEndpoint(runtimeEndpoint?: string): string | null {
if (!runtimeEndpoint) return null
try {
const endpoint = new URL(runtimeEndpoint)
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
endpoint.hostname = window.location.hostname
}
return endpoint.toString().replace(/\/$/, '')
} catch {
return runtimeEndpoint.replace(/\/$/, '')
}
}
function isDockerBridgeAddress(hostname: string): boolean {
const octets = hostname.split('.').map(Number)
return octets.length === 4
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
&& octets[0] === 172
&& octets[1] >= 16
&& octets[1] <= 31
}
export function parseHeaders(value: string): Record<string, string> {
let parsed: unknown
try {
parsed = JSON.parse(value || '{}')
} catch {
throw new Error('Request headers must be valid JSON.')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((header) => typeof header !== 'string')) {
throw new Error('Request headers must be a JSON object with string values.')
}
return parsed as Record<string, string>
}
export function formatResponseBody(body: string): string {
try {
return JSON.stringify(JSON.parse(body), null, 2)
} catch {
return body || '(empty response body)'
}
}
+18
View File
@@ -13,6 +13,9 @@ importers:
'@aws-sdk/client-api-gateway':
specifier: ^3.1076.0
version: 3.1096.0
'@aws-sdk/client-apigatewayv2':
specifier: ^3.1076.0
version: 3.1156.0
'@aws-sdk/client-appconfig':
specifier: ^3.1076.0
version: 3.1130.0
@@ -299,6 +302,10 @@ packages:
resolution: {integrity: sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-apigatewayv2@3.1156.0':
resolution: {integrity: sha512-+PbvEXc5ZbfDARcjf6hCeWneq0pQ/ToyuhujoA/2c5eNXnEzi4SSAvh9lMDyWMx/iXOSYrARvdBw0up0D8sFag==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-appconfig@3.1130.0':
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
engines: {node: '>=20.0.0'}
@@ -2901,6 +2908,17 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/client-apigatewayv2@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-appconfig@3.1130.0':
dependencies:
'@aws-sdk/core': 3.978.0