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.
This commit is contained in:
Zakaria
2026-10-25 09:18:50 -04:00
parent 6acd3656a1
commit bb5ca85a88
22 changed files with 2410 additions and 68 deletions
@@ -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,