bb5ca85a88
CI / checks (pull_request) Successful in 26m49s
API Gateway v2 is a separate AWS API from REST APIs, so HTTP and WebSocket APIs made with the SDK, SAM, CDK or Terraform never showed up. The API Gateway page now lists all three kinds with a Type column, creates each from the same form, and opens HTTP and WebSocket APIs in their own panel: - Quick create: an HTTP API with a Lambda gets a catch-all route on an auto-deployed $default stage, plus the Lambda permission. - Routes to Lambda (payload 2.0) or HTTP backends; deleting a route removes the integration and Lambda permission only it used. - Stages with or without automatic deployment, and deploy on demand. - JWT authorizers filled in from a Cognito pool's OpenID Connect discovery document and an app client, or from any issuer; protect routes with them. - CORS configuration. - A tester that sends a real request to a stage, and for WebSocket APIs one that connects, sends a message and collects the replies. Capabilities are advertised under a new httpApiActions group, covered by the cloudProxy guard test. Verified end to end against TheCloud's runtime.
613 lines
28 KiB
TypeScript
613 lines
28 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 type {ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
|
|
import {apiGateway as defaultApiGateway, apiGatewayV2 as defaultApiGatewayV2, defaultAccountId, lambda as defaultLambda} from '../aws'
|
|
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
|
import {ValidationError} from '../cloud-spi/errors'
|
|
import {awsEndpoint} from '../service/runtimeProbe'
|
|
import {AwsHttpApiOperations} from './AwsHttpApiOperations'
|
|
import type {HttpApiAuthorizerInput, HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteInput, HttpApiRouteOverview, HttpApiStageInput, HttpApiStageOverview, HttpApiWebSocketTestInput, HttpApiWebSocketTestResult} from '../cloud-spi/types'
|
|
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayMethodTestInput, ApiGatewayMethodTestResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
|
|
|
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|
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',
|
|
apiGatewayV2: ApiGatewayV2Client = defaultApiGatewayV2,
|
|
) {
|
|
this.httpApis = new AwsHttpApiOperations(apiGatewayV2, lambda, accountId, region)
|
|
}
|
|
|
|
/** HTTP and WebSocket APIs (API Gateway v2), listed and managed alongside REST APIs. */
|
|
private readonly httpApis: AwsHttpApiOperations
|
|
|
|
schema(): ServiceSchema {
|
|
return awsApiGatewaySchema()
|
|
}
|
|
|
|
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), ...await this.listHttpApis()], query.search)
|
|
}
|
|
|
|
/** A runtime without API Gateway v2 still lists its REST APIs. */
|
|
private async listHttpApis(): Promise<CloudResource[]> {
|
|
try {
|
|
return await this.httpApis.list()
|
|
} catch (error) {
|
|
if (hasHttpStatus(error, 404) || hasHttpStatus(error, 501)) return []
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async get(id: string): Promise<CloudResource | null> {
|
|
try {
|
|
const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id}))
|
|
return toResource(res)
|
|
} catch (error) {
|
|
if (!hasHttpStatus(error, 404)) throw error
|
|
}
|
|
return this.httpApis.get(id)
|
|
}
|
|
|
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
|
const protocol = stringValue(input.values.protocol) || 'REST'
|
|
if (protocol === 'HTTP' || protocol === 'WEBSOCKET') return this.httpApis.create(input.values, protocol)
|
|
if (protocol !== 'REST') throw new ValidationError('protocol must be REST, HTTP or WEBSOCKET')
|
|
const name = stringValue(input.values.name)
|
|
const description = stringValue(input.values.description)
|
|
if (!name) throw new ValidationError('name is required')
|
|
|
|
const res = await this.apiGateway.send(new CreateRestApiCommand({
|
|
name,
|
|
description: description || undefined,
|
|
}))
|
|
return toResource(res)
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
if (await this.httpApis.get(id)) return this.httpApis.delete(id)
|
|
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
|
|
}
|
|
|
|
describeHttpApi(apiId: string): Promise<HttpApiOverview> {
|
|
return this.httpApis.describe(apiId)
|
|
}
|
|
|
|
createHttpApiRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
|
return this.httpApis.createRoute(apiId, input)
|
|
}
|
|
|
|
deleteHttpApiRoute(apiId: string, routeId: string): Promise<void> {
|
|
return this.httpApis.deleteRoute(apiId, routeId)
|
|
}
|
|
|
|
setHttpApiRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
|
return this.httpApis.setRouteAuthorizer(apiId, routeId, authorizerId)
|
|
}
|
|
|
|
createHttpApiStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
|
return this.httpApis.createStage(apiId, input)
|
|
}
|
|
|
|
deleteHttpApiStage(apiId: string, stageName: string): Promise<void> {
|
|
return this.httpApis.deleteStage(apiId, stageName)
|
|
}
|
|
|
|
deployHttpApi(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
|
return this.httpApis.deploy(apiId, stageName)
|
|
}
|
|
|
|
createHttpApiAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
|
return this.httpApis.createAuthorizer(apiId, input)
|
|
}
|
|
|
|
deleteHttpApiAuthorizer(apiId: string, authorizerId: string): Promise<void> {
|
|
return this.httpApis.deleteAuthorizer(apiId, authorizerId)
|
|
}
|
|
|
|
updateHttpApiCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
|
return this.httpApis.updateCors(apiId, cors)
|
|
}
|
|
|
|
testWebSocketApi(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
|
|
return this.httpApis.testWebSocket(apiId, input)
|
|
}
|
|
|
|
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
|
|
const method = input.method.toUpperCase()
|
|
const stage = stageName(input.stage)
|
|
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)
|
|
// $default is an HTTP API's catch-all stage; the deployed-request test serves both kinds of API.
|
|
if (!/^(?:\$default|[A-Za-z0-9_-]+)$/.test(value)) {
|
|
throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens')
|
|
}
|
|
return value
|
|
}
|
|
|
|
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',
|
|
protocol: '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
|
|
}
|