Files
floci-ui/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts
T
2026-09-30 17:43:42 -04:00

544 lines
24 KiB
TypeScript

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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
}
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
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<ApiGatewayTestRouteResult> {
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<ApiGatewayMethodTestResult> {
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<ApiGatewayRoutesResult> {
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<void> {
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<ApiGatewayCorsResult> {
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<Resource[]> {
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<Resource> {
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<string, boolean> {
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<string, string> {
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
}