first commit
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
CreateDeploymentCommand,
|
||||
CreateResourceCommand,
|
||||
CreateRestApiCommand,
|
||||
DeleteRestApiCommand,
|
||||
GetRestApiCommand,
|
||||
GetRestApisCommand,
|
||||
GetResourcesCommand,
|
||||
PutIntegrationCommand,
|
||||
PutMethodCommand,
|
||||
type APIGatewayClient,
|
||||
type RestApi,
|
||||
} from '@aws-sdk/client-api-gateway'
|
||||
import {AddPermissionCommand, GetFunctionCommand, 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 {ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, 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 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')
|
||||
}
|
||||
|
||||
const root = await this.rootResource(apiId)
|
||||
const resource = await this.apiGateway.send(new CreateResourceCommand({
|
||||
restApiId: apiId,
|
||||
parentId: root.id,
|
||||
pathPart,
|
||||
}))
|
||||
const resourceId = resource.id
|
||||
if (!resourceId) throw new ValidationError('API Gateway did not return a resource id')
|
||||
|
||||
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`,
|
||||
}))
|
||||
|
||||
await this.lambda.send(new AddPermissionCommand({
|
||||
FunctionName: functionName,
|
||||
StatementId: `apigateway-${apiId}-${stage}-${method}-${pathPart}`,
|
||||
Action: 'lambda:InvokeFunction',
|
||||
Principal: 'apigateway.amazonaws.com',
|
||||
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/${stage}/${method}/${pathPart}`,
|
||||
}))
|
||||
|
||||
const deployment = await this.apiGateway.send(new CreateDeploymentCommand({
|
||||
restApiId: apiId,
|
||||
stageName: stage,
|
||||
description: `Lambda proxy route ${method} /${pathPart}`,
|
||||
}))
|
||||
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}`,
|
||||
}
|
||||
}
|
||||
|
||||
private async rootResource(apiId: string) {
|
||||
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
|
||||
position = result.position
|
||||
} while (position)
|
||||
throw new ValidationError('API Gateway root resource was not found')
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
Reference in New Issue
Block a user