import { CreateDeploymentCommand, CreateResourceCommand, CreateRestApiCommand, DeleteMethodCommand, DeleteRestApiCommand, GetRestApiCommand, GetRestApisCommand, GetMethodCommand, GetResourcesCommand, GetStagesCommand, PutIntegrationCommand, PutIntegrationResponseCommand, PutMethodCommand, PutMethodResponseCommand, TestInvokeMethodCommand, type APIGatewayClient, type Integration, type IntegrationResponse, type MethodResponse, type Resource, 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 {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema' import {ValidationError} from '../cloud-spi/errors' import {awsEndpoint} from '../service/runtimeProbe' 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 { readonly cloud = 'aws' as const readonly service = 'apigateway' as const constructor( private readonly apiGateway: APIGatewayClient = defaultApiGateway, private readonly lambda: LambdaClient = defaultLambda, private readonly accountId = defaultAccountId(), private readonly region = process.env.AWS_REGION ?? 'us-east-1', ) {} schema(): ServiceSchema { return awsApiGatewaySchema() } async list(query: ResourceQuery = {}): Promise { const apis: RestApi[] = [] let position: string | undefined do { const res = await this.apiGateway.send(new GetRestApisCommand({position})) apis.push(...(res.items ?? [])) position = res.position } while (position) return filterBySearch(apis.map(toResource), query.search) } async get(id: string): Promise { try { const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id})) return toResource(res) } catch (error) { if (hasHttpStatus(error, 404)) return null throw error } } async create(input: CreateResourceInput): Promise { const name = stringValue(input.values.name) const description = stringValue(input.values.description) if (!name) throw new ValidationError('name is required') const res = await this.apiGateway.send(new CreateRestApiCommand({ name, description: description || undefined, })) return toResource(res) } async delete(id: string): Promise { await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id})) } async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise { const method = input.method.toUpperCase() const stage = stageName(input.stage) const functionName = stringValue(input.functionName) if (!functionName) throw new ValidationError('functionName is required') if (!CONFIGURABLE_METHODS.includes(method)) { throw new ValidationError('method must be GET, POST, PUT, DELETE, PATCH, ANY, or HEAD') } const resource = await this.resolveRouteResource(apiId, input) const resourceId = resource.id if (!resourceId) throw new ValidationError('API Gateway did not return a resource id') const resourcePath = resource.path ?? routePath(input.path) await this.apiGateway.send(new PutMethodCommand({ restApiId: apiId, resourceId, httpMethod: method, authorizationType: 'NONE', })) const functionResult = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName})) const functionArn = functionResult.Configuration?.FunctionArn if (!functionArn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`) await this.apiGateway.send(new PutIntegrationCommand({ restApiId: apiId, resourceId, httpMethod: method, type: 'AWS_PROXY', integrationHttpMethod: 'POST', uri: `arn:aws:apigateway:${this.region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`, })) const permission = { FunctionName: functionName, StatementId: apiGatewayPermissionStatementId(apiId, stage, method, resourcePath), Action: 'lambda:InvokeFunction', Principal: 'apigateway.amazonaws.com', SourceArn: apiGatewaySourceArn(this.region, this.accountId, apiId, stage, method, resourcePath), } try { await this.lambda.send(new AddPermissionCommand(permission)) } catch (error) { if (!hasHttpStatus(error, 409)) throw error await this.lambda.send(new RemovePermissionCommand({ FunctionName: functionName, StatementId: permission.StatementId, })) await this.lambda.send(new AddPermissionCommand(permission)) } const deployment = await this.apiGateway.send(new CreateDeploymentCommand({ restApiId: apiId, stageName: stage, description: `Lambda proxy route ${method} ${resourcePath}`, })) if (!deployment.id) throw new ValidationError('API Gateway did not return a deployment id') return { resourceId, deploymentId: deployment.id, invokeUrl: `${awsEndpoint().replace(/\/$/, '')}/execute-api/${apiId}/${stage}${resourcePath}`, } } async testApiGatewayRoute(apiId: string, input: ApiGatewayTestRouteInput): Promise { const path = testRoutePath(input.path) const method = input.method.toUpperCase() const stage = stageName(input.stage) if (!DEPLOYED_HTTP_METHODS.includes(method)) { throw new ValidationError('method must be GET, POST, PUT, DELETE, PATCH, or HEAD') } const encodedPath = path.split('/').map(encodeURIComponent).join('/') const url = new URL(`${awsEndpoint().replace(/\/$/, '')}/execute-api/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${encodedPath}`) url.search = queryString(input.query) const headers = new Headers(input.headers) if (input.body && !headers.has('content-type')) headers.set('content-type', 'application/json') const startedAt = performance.now() const response = await globalThis.fetch(url, { method, headers, body: ['GET', 'HEAD', 'OPTIONS'].includes(method) ? undefined : input.body || undefined, }) return { statusCode: response.status, headers: Object.fromEntries(response.headers.entries()), body: await response.text(), durationMs: Math.round(performance.now() - startedAt), } } async testApiGatewayMethod(apiId: string, input: ApiGatewayMethodTestInput): Promise { const resourceId = stringValue(input.resourceId) const httpMethod = input.httpMethod.toUpperCase() const pathWithQueryString = stringValue(input.pathWithQueryString) if (!resourceId) throw new ValidationError('resourceId is required') if (!TEST_INVOKE_METHODS.includes(httpMethod)) { throw new ValidationError('httpMethod must be a configured API Gateway method') } if (!pathWithQueryString.startsWith('/')) { throw new ValidationError('pathWithQueryString must start with /') } const result = await this.apiGateway.send(new TestInvokeMethodCommand({ restApiId: apiId, resourceId, httpMethod, pathWithQueryString, body: input.body, headers: input.headers, multiValueHeaders: input.multiValueHeaders, stageVariables: input.stageVariables, })) return { statusCode: result.status ?? 0, body: result.body ?? '', headers: result.headers ?? {}, multiValueHeaders: result.multiValueHeaders ?? {}, log: result.log ?? null, latency: result.latency ?? null, } } async listApiGatewayRoutes(apiId: string): Promise { const resources = await this.resources(apiId, true) const routes: ApiGatewayRouteOverview[] = [] for (const resource of resources) { const methods = resource.resourceMethods ? Object.keys(resource.resourceMethods).sort() : [] for (const method of methods) { if (!resource.id || !resource.path) continue let result try { result = await this.apiGateway.send(new GetMethodCommand({ restApiId: apiId, resourceId: resource.id, httpMethod: method, })) } catch (error) { if (hasHttpStatus(error, 404)) continue throw error } routes.push({ resourceId: resource.id, path: resource.path, method, functionName: lambdaFunctionName(result.methodIntegration?.uri), integrationType: result.methodIntegration?.type ?? null, }) } } const stages = await this.apiGateway.send(new GetStagesCommand({restApiId: apiId})) const resourcesOverview = resources .filter((resource): resource is Resource & {id: string; path: string} => Boolean(resource.id && resource.path)) .map((resource) => ({ resourceId: resource.id, parentId: resource.parentId ?? null, path: resource.path, pathPart: resource.pathPart ?? null, methods: routes.filter((route) => route.resourceId === resource.id), })) .sort((left, right) => left.path.localeCompare(right.path)) return { resources: resourcesOverview, routes: routes.sort((left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method)), stages: (stages.item ?? []).map((stage) => stage.stageName).filter((stage): stage is string => Boolean(stage)).sort(), } } async deleteApiGatewayRoute(apiId: string, input: ApiGatewayDeleteRouteInput): Promise { const method = input.method.toUpperCase() const stage = stageName(input.stage) if (!DELETABLE_METHODS.includes(method)) throw new ValidationError('method is not supported') if (!stringValue(input.resourceId)) throw new ValidationError('resourceId is required') const route = await this.apiGateway.send(new GetMethodCommand({ restApiId: apiId, resourceId: input.resourceId, httpMethod: method, })) await this.apiGateway.send(new DeleteMethodCommand({ restApiId: apiId, resourceId: input.resourceId, httpMethod: method, })) const functionName = lambdaFunctionName(route.methodIntegration?.uri) if (functionName) { try { await this.lambda.send(new RemovePermissionCommand({ FunctionName: functionName, StatementId: apiGatewayPermissionStatementId(apiId, stage, method, input.path), })) } catch (error) { if (!hasHttpStatus(error, 404)) throw error } } await this.apiGateway.send(new CreateDeploymentCommand({ restApiId: apiId, stageName: stage, description: `Delete ${method} method`, })) } async enableApiGatewayCors(apiId: string, input: ApiGatewayCorsInput): Promise { const resourceId = stringValue(input.resourceId) const stage = stageName(input.stage) const origin = stringValue(input.origin) const headers = stringValue(input.headers) const methods = [...new Set(input.methods.map((method) => method.toUpperCase()))] if (!resourceId) throw new ValidationError('resourceId is required') if (!origin) throw new ValidationError('origin is required') if (!headers) throw new ValidationError('headers are required') if (methods.length === 0 || methods.some((method) => !CORS_METHODS.includes(method))) { throw new ValidationError('methods must contain one or more supported HTTP methods') } const corsParameters = corsResponseParameters(origin, headers, methods) const configuredMethods: string[] = [] const proxyMethods: string[] = [] for (const method of methods) { if (method === 'OPTIONS') continue const route = await this.apiGateway.send(new GetMethodCommand({restApiId: apiId, resourceId, httpMethod: method})) if (route.methodIntegration?.type === 'AWS_PROXY') { proxyMethods.push(method) continue } const responseCodes = Object.keys(route.methodResponses ?? {200: {}}) for (const statusCode of responseCodes) { await this.apiGateway.send(new PutMethodResponseCommand({ restApiId: apiId, resourceId, httpMethod: method, statusCode, responseParameters: { ...route.methodResponses?.[statusCode]?.responseParameters, ...corsMethodResponseParameters(), }, responseModels: route.methodResponses?.[statusCode]?.responseModels, })) } const integrationResponses = route.methodIntegration?.integrationResponses ?? {200: {}} for (const [statusCode, response] of Object.entries(integrationResponses)) { await this.apiGateway.send(new PutIntegrationResponseCommand({ restApiId: apiId, resourceId, httpMethod: method, statusCode, selectionPattern: response.selectionPattern, responseParameters: {...response.responseParameters, ...corsParameters}, responseTemplates: response.responseTemplates, contentHandling: response.contentHandling, })) } configuredMethods.push(method) } let existingOptionsMethodResponse: MethodResponse | undefined let existingOptionsIntegration: Integration | undefined let existingOptionsIntegrationResponse: IntegrationResponse | undefined try { const optionsRoute = await this.apiGateway.send(new GetMethodCommand({ restApiId: apiId, resourceId, httpMethod: 'OPTIONS', })) existingOptionsMethodResponse = optionsRoute.methodResponses?.['200'] existingOptionsIntegration = optionsRoute.methodIntegration existingOptionsIntegrationResponse = optionsRoute.methodIntegration?.integrationResponses?.['200'] } catch (error) { if (!hasHttpStatus(error, 404)) throw error } await this.apiGateway.send(new PutMethodCommand({restApiId: apiId, resourceId, httpMethod: 'OPTIONS', authorizationType: 'NONE'})) await this.apiGateway.send(new PutMethodResponseCommand({ restApiId: apiId, resourceId, httpMethod: 'OPTIONS', statusCode: '200', responseParameters: { ...corsMethodResponseParameters(), ...existingOptionsMethodResponse?.responseParameters, }, responseModels: existingOptionsMethodResponse?.responseModels, })) await this.apiGateway.send(new PutIntegrationCommand({ restApiId: apiId, resourceId, httpMethod: 'OPTIONS', type: 'MOCK', requestTemplates: {'application/json': '{"statusCode": 200}'}, timeoutInMillis: existingOptionsIntegration?.timeoutInMillis, })) await this.apiGateway.send(new PutIntegrationResponseCommand({ restApiId: apiId, resourceId, httpMethod: 'OPTIONS', statusCode: '200', responseParameters: corsParameters, responseTemplates: existingOptionsIntegrationResponse?.responseTemplates, selectionPattern: existingOptionsIntegrationResponse?.selectionPattern, contentHandling: existingOptionsIntegrationResponse?.contentHandling, })) await this.apiGateway.send(new CreateDeploymentCommand({ restApiId: apiId, stageName: stage, description: `Enable CORS for resource ${resourceId}`, })) return {configuredMethods, proxyMethods} } private async resources(apiId: string, embedMethods = false): Promise { const resources: Resource[] = [] let position: string | undefined do { const result = await this.apiGateway.send(new GetResourcesCommand({ restApiId: apiId, position, embed: embedMethods ? ['methods'] : undefined, })) resources.push(...(result.items ?? [])) position = result.position } while (position) return resources } private async resolveRouteResource(apiId: string, input: ApiGatewayLambdaRouteInput): Promise { const resources = await this.resources(apiId) if (input.parentResourceId || input.pathPart) { const parentId = stringValue(input.parentResourceId) const pathPart = routePathPart(input.pathPart ?? '') const parent = resources.find((resource) => resource.id === parentId) if (!parent?.id || !parent.path) throw new ValidationError('parentResourceId must identify an existing API Gateway resource') const existing = resources.find((resource) => resource.parentId === parent.id && resource.pathPart === pathPart) if (existing?.id) return existing const created = await this.apiGateway.send(new CreateResourceCommand({restApiId: apiId, parentId: parent.id, pathPart})) return {...created, pathPart, parentId: parent.id, path: childPath(parent.path, pathPart)} } const parts = routePath(input.path).slice(1).split('/') const root = resources.find((resource) => resource.path === '/') if (!root?.id) throw new ValidationError('API Gateway root resource was not found') let parent: Resource = root for (const pathPart of parts) { const existing = resources.find((resource) => resource.parentId === parent.id && resource.pathPart === pathPart) if (existing?.id) { parent = existing continue } const created: Resource = await this.apiGateway.send(new CreateResourceCommand({restApiId: apiId, parentId: parent.id, pathPart})) parent = {...created, pathPart, parentId: parent.id, path: childPath(parent.path ?? '/', pathPart)} resources.push(parent) } return parent } } const CONFIGURABLE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'ANY', 'HEAD'] const DEPLOYED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'] const TEST_INVOKE_METHODS = [...CONFIGURABLE_METHODS, 'OPTIONS'] const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] const DELETABLE_METHODS = [...CONFIGURABLE_METHODS, 'OPTIONS'] function corsMethodResponseParameters(): Record { return { 'method.response.header.Access-Control-Allow-Origin': false, 'method.response.header.Access-Control-Allow-Headers': false, 'method.response.header.Access-Control-Allow-Methods': false, } } function corsResponseParameters(origin: string, headers: string, methods: string[]): Record { const requestedMethods = new Set([...methods, 'OPTIONS']) const allowedMethods = CORS_METHODS.filter((method) => requestedMethods.has(method)) return { 'method.response.header.Access-Control-Allow-Origin': `'${origin}'`, 'method.response.header.Access-Control-Allow-Headers': `'${headers}'`, 'method.response.header.Access-Control-Allow-Methods': `'${allowedMethods.join(',')}'`, } } function routePathPart(path: string): string { const value = stringValue(path) if (!/^(?:[A-Za-z0-9._-]+|\{[A-Za-z0-9._-]+\+?})$/.test(value)) { throw new ValidationError('pathPart must be one API Gateway segment, for example orders, {id}, or {proxy+}') } return value } function routePath(path: string): string { const value = stringValue(path) if (!value.startsWith('/') || value === '/' || value.endsWith('/')) { throw new ValidationError('path must start with / and contain at least one resource segment') } for (const part of value.slice(1).split('/')) routePathPart(part) return value } function childPath(parentPath: string, pathPart: string): string { return parentPath === '/' ? `/${pathPart}` : `${parentPath}/${pathPart}` } function stageName(stage: string): string { const value = stringValue(stage) if (!/^[A-Za-z0-9_-]+$/.test(value)) { throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens') } return value } function queryString(query: string): string { const value = stringValue(query) if (value.length > 8_192) throw new ValidationError('query must be at most 8192 characters') return value.replace(/^\?/, '') } function testRoutePath(path: string): string { const value = stringValue(path) if (!/^\/(?:[A-Za-z0-9._~{}+%-]+\/)*[A-Za-z0-9._~{}+%-]+$/.test(value)) { throw new ValidationError('path must be an API Gateway route path, for example /orders or /users/{id}') } return value } function lambdaFunctionName(uri?: string): string | null { const match = uri?.match(/\/functions\/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/) return match?.[1] ?? null } function apiGatewayPermissionStatementId(apiId: string, stage: string, method: string, path: string): string { return `apigateway-${apiId}-${stage}-${method}-${path.replace(/[^A-Za-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')}`.slice(0, 100) } function apiGatewaySourceArn(region: string, accountId: string, apiId: string, stage: string, method: string, path: string): string { const arnMethod = method === 'ANY' ? '*' : method const arnPath = path.slice(1).split('/').map((part) => part.startsWith('{') ? '*' : part).join('/') return `arn:aws:execute-api:${region}:${accountId}:${apiId}/${stage}/${arnMethod}/${arnPath}` } function toResource(api: RestApi): CloudResource { const id = api.id ?? '' return { id, name: api.name ?? '', cloud: 'aws', service: 'apigateway', type: 'rest-api', region: null, createdAt: api.createdDate?.toISOString() ?? null, metadata: { provider: 'aws', apigatewayService: 'rest', description: api.description, version: api.version, endpointTypes: api.endpointConfiguration?.types, rootResourceId: api.rootResourceId, }, } } function stringValue(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { const normalized = search?.trim().toLowerCase() if (!normalized) return resources return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) } function hasHttpStatus(error: unknown, status: number): boolean { if (typeof error !== 'object' || error === null) return false const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata return metadata?.httpStatusCode === status }