feat: Cloudformation + RDS
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
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'
|
||||
@@ -82,18 +83,18 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
|
||||
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
|
||||
const pathPart = routePathPart(input.path)
|
||||
const method = input.method.toUpperCase()
|
||||
const stage = stageName(input.stage)
|
||||
const functionName = stringValue(input.functionName)
|
||||
if (!functionName) throw new ValidationError('functionName is required')
|
||||
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) {
|
||||
throw new ValidationError('method must be GET, POST, PUT, or DELETE')
|
||||
if (!CONFIGURABLE_METHODS.includes(method)) {
|
||||
throw new ValidationError('method must be GET, POST, PUT, DELETE, PATCH, ANY, or HEAD')
|
||||
}
|
||||
|
||||
const resource = await this.resourceForPath(apiId, `/${pathPart}`) ?? await this.createTopLevelResource(apiId, pathPart)
|
||||
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,
|
||||
@@ -116,10 +117,10 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
|
||||
const permission = {
|
||||
FunctionName: functionName,
|
||||
StatementId: apiGatewayPermissionStatementId(apiId, stage, method, `/${pathPart}`),
|
||||
StatementId: apiGatewayPermissionStatementId(apiId, stage, method, resourcePath),
|
||||
Action: 'lambda:InvokeFunction',
|
||||
Principal: 'apigateway.amazonaws.com',
|
||||
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/${stage}/${method}/${pathPart}`,
|
||||
SourceArn: apiGatewaySourceArn(this.region, this.accountId, apiId, stage, method, resourcePath),
|
||||
}
|
||||
try {
|
||||
await this.lambda.send(new AddPermissionCommand(permission))
|
||||
@@ -135,14 +136,14 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
const deployment = await this.apiGateway.send(new CreateDeploymentCommand({
|
||||
restApiId: apiId,
|
||||
stageName: stage,
|
||||
description: `Lambda proxy route ${method} /${pathPart}`,
|
||||
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}/${pathPart}`,
|
||||
invokeUrl: `${awsEndpoint().replace(/\/$/, '')}/execute-api/${apiId}/${stage}${resourcePath}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +151,8 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
const path = testRoutePath(input.path)
|
||||
const method = input.method.toUpperCase()
|
||||
const stage = stageName(input.stage)
|
||||
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) {
|
||||
throw new ValidationError('method must be GET, POST, PUT, or DELETE')
|
||||
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('/')
|
||||
@@ -179,8 +180,8 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
const httpMethod = input.httpMethod.toUpperCase()
|
||||
const pathWithQueryString = stringValue(input.pathWithQueryString)
|
||||
if (!resourceId) throw new ValidationError('resourceId is required')
|
||||
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(httpMethod)) {
|
||||
throw new ValidationError('httpMethod must be GET, POST, PUT, or DELETE')
|
||||
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 /')
|
||||
@@ -207,19 +208,13 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
|
||||
async listApiGatewayRoutes(apiId: string): Promise<ApiGatewayRoutesResult> {
|
||||
const resources = []
|
||||
let position: string | undefined
|
||||
do {
|
||||
const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position, embed: ['methods']}))
|
||||
resources.push(...(result.items ?? []))
|
||||
position = result.position
|
||||
} while (position)
|
||||
const resources = await this.resources(apiId, true)
|
||||
|
||||
const routes: ApiGatewayRouteOverview[] = []
|
||||
for (const resource of resources) {
|
||||
const methods = resource.resourceMethods
|
||||
? Object.keys(resource.resourceMethods).sort()
|
||||
: ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']
|
||||
: []
|
||||
for (const method of methods) {
|
||||
if (!resource.id || !resource.path) continue
|
||||
let result
|
||||
@@ -244,7 +239,18 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
@@ -253,7 +259,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
async deleteApiGatewayRoute(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void> {
|
||||
const method = input.method.toUpperCase()
|
||||
const stage = stageName(input.stage)
|
||||
if (!CORS_METHODS.includes(method)) throw new ValidationError('method must be GET, POST, PUT, DELETE, or OPTIONS')
|
||||
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({
|
||||
@@ -294,7 +300,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
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 of GET, POST, PUT, DELETE, or OPTIONS')
|
||||
throw new ValidationError('methods must contain one or more supported HTTP methods')
|
||||
}
|
||||
|
||||
const corsParameters = corsResponseParameters(origin, headers, methods)
|
||||
@@ -371,39 +377,57 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
return {configuredMethods, proxyMethods}
|
||||
}
|
||||
|
||||
private async rootResource(apiId: string) {
|
||||
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}))
|
||||
const root = result.items?.find((resource) => resource.path === '/')
|
||||
if (root?.id) return root
|
||||
const result = await this.apiGateway.send(new GetResourcesCommand({
|
||||
restApiId: apiId,
|
||||
position,
|
||||
embed: embedMethods ? ['methods'] : undefined,
|
||||
}))
|
||||
resources.push(...(result.items ?? []))
|
||||
position = result.position
|
||||
} while (position)
|
||||
throw new ValidationError('API Gateway root resource was not found')
|
||||
return resources
|
||||
}
|
||||
|
||||
private async resourceForPath(apiId: string, path: string) {
|
||||
let position: string | undefined
|
||||
do {
|
||||
const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position}))
|
||||
const resource = result.items?.find((item) => item.path === path)
|
||||
if (resource?.id) return resource
|
||||
position = result.position
|
||||
} while (position)
|
||||
return undefined
|
||||
}
|
||||
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)}
|
||||
}
|
||||
|
||||
private async createTopLevelResource(apiId: string, pathPart: string) {
|
||||
const root = await this.rootResource(apiId)
|
||||
return this.apiGateway.send(new CreateResourceCommand({
|
||||
restApiId: apiId,
|
||||
parentId: root.id,
|
||||
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 CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
|
||||
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 {
|
||||
@@ -424,13 +448,26 @@ function corsResponseParameters(origin: string, headers: string, methods: string
|
||||
}
|
||||
|
||||
function routePathPart(path: string): string {
|
||||
const value = stringValue(path).replace(/^\//, '')
|
||||
if (!/^[A-Za-z0-9._~-]+$/.test(value)) {
|
||||
throw new ValidationError('path must be one URL segment, for example /orders')
|
||||
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)) {
|
||||
@@ -447,7 +484,7 @@ function queryString(query: string): string {
|
||||
|
||||
function testRoutePath(path: string): string {
|
||||
const value = stringValue(path)
|
||||
if (!/^\/(?:[A-Za-z0-9._~{}-]+\/)*[A-Za-z0-9._~{}-]+$/.test(value)) {
|
||||
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
|
||||
@@ -459,7 +496,13 @@ function lambdaFunctionName(uri?: string): string | null {
|
||||
}
|
||||
|
||||
function apiGatewayPermissionStatementId(apiId: string, stage: string, method: string, path: string): string {
|
||||
return `apigateway-${apiId}-${stage}-${method}-${routePathPart(path)}`
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user