Files
floci-ui/packages/api/src/adapter-aws/AwsHttpApiOperations.ts
T
Zakaria bb5ca85a88
CI / checks (pull_request) Successful in 26m49s
feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page
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

593 lines
30 KiB
TypeScript

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
}