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 = defaultFetchJson, ) {} async list(): Promise { 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 { const api = await this.findApi(apiId) return api ? toResource(api) : null } async create(values: Record, protocol: HttpApiProtocol): Promise { 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 { 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 { 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 { 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 | 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 { 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 { 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 { 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 { await this.client.send(new DeleteStageCommand({ApiId: apiId, StageName: stageNameValue(stageName)})) } async deploy(apiId: string, stageName: string): Promise { 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 { 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 { 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 { 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 { 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(fetchPage: (next?: string) => Promise<{Items?: T[]; NextToken?: string}>): Promise { 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 | 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> { 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 { 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 { 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 { 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 { 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 | 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): 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): HttpApiProtocol { return api.ProtocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP' } function toRouteOverview(route: Partial, integrations: Map>): 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): 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): 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): 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): 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 }