Compare commits

...

2 Commits

Author SHA1 Message Date
Zakaria 67660858eb Merge pull request 'feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page' (#20) from feat/apigateway-v2 into main
CI / checks (push) Has been cancelled
Reviewed-on: #20
2026-10-25 13:49:09 +00:00
Zakaria bb5ca85a88 feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page
CI / checks (pull_request) Successful in 26m49s
API Gateway v2 is a separate AWS API from REST APIs, so HTTP and WebSocket APIs
made with the SDK, SAM, CDK or Terraform never showed up. The API Gateway page
now lists all three kinds with a Type column, creates each from the same form,
and opens HTTP and WebSocket APIs in their own panel:

- Quick create: an HTTP API with a Lambda gets a catch-all route on an
  auto-deployed $default stage, plus the Lambda permission.
- Routes to Lambda (payload 2.0) or HTTP backends; deleting a route removes
  the integration and Lambda permission only it used.
- Stages with or without automatic deployment, and deploy on demand.
- JWT authorizers filled in from a Cognito pool's OpenID Connect discovery
  document and an app client, or from any issuer; protect routes with them.
- CORS configuration.
- A tester that sends a real request to a stage, and for WebSocket APIs one
  that connects, sends a message and collects the replies.

Capabilities are advertised under a new httpApiActions group, covered by the
cloudProxy guard test. Verified end to end against TheCloud's runtime.
2026-10-25 09:18:50 -04:00
22 changed files with 2410 additions and 68 deletions
+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