feat: CORS enable button...ish still now understanding this
This commit is contained in:
@@ -11,7 +11,9 @@ import {
|
||||
GetResourcesCommand,
|
||||
GetStagesCommand,
|
||||
PutIntegrationCommand,
|
||||
PutIntegrationResponseCommand,
|
||||
PutMethodCommand,
|
||||
PutMethodResponseCommand,
|
||||
type APIGatewayClient,
|
||||
} from '@aws-sdk/client-api-gateway'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
@@ -280,8 +282,8 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
|
||||
await expect(adapter.listApiGatewayRoutes('a1b2c3')).resolves.toEqual({
|
||||
routes: [
|
||||
{resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello'},
|
||||
{resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello'},
|
||||
{resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello', integrationType: null},
|
||||
{resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello', integrationType: null},
|
||||
],
|
||||
stages: ['dev', 'prod'],
|
||||
})
|
||||
@@ -308,6 +310,73 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
expect((lambdaCommands[0] as RemovePermissionCommand).input).toMatchObject({FunctionName: 'hello', StatementId: 'apigateway-a1b2c3-dev-POST-hello'})
|
||||
})
|
||||
|
||||
test('enables CORS for non-proxy methods, creates OPTIONS, and reports proxy methods', async () => {
|
||||
const commands: unknown[] = []
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
commands.push(command)
|
||||
if (command instanceof GetMethodCommand && command.input.httpMethod === 'GET') {
|
||||
return {
|
||||
methodResponses: {200: {
|
||||
responseParameters: {'method.response.header.X-Existing': true},
|
||||
responseModels: {'application/json': 'ExistingModel'},
|
||||
}},
|
||||
methodIntegration: {type: 'HTTP', integrationResponses: {200: {
|
||||
responseParameters: {'method.response.header.X-Existing': "'existing'"},
|
||||
responseTemplates: {'application/json': '$input.body'},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
if (command instanceof GetMethodCommand && command.input.httpMethod === 'POST') {
|
||||
return {methodIntegration: {type: 'AWS_PROXY'}}
|
||||
}
|
||||
if (command instanceof GetMethodCommand && command.input.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
methodResponses: {200: {
|
||||
responseParameters: {'method.response.header.Access-Control-Allow-Origin': true},
|
||||
responseModels: {'application/json': 'OptionsModel'},
|
||||
}},
|
||||
methodIntegration: {timeoutInMillis: 29000, integrationResponses: {200: {
|
||||
responseTemplates: {'text/plain': 'existing template'},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}))
|
||||
|
||||
await expect(adapter.enableApiGatewayCors('a1b2c3', {
|
||||
resourceId: 'resource456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'],
|
||||
})).resolves.toEqual({configuredMethods: ['GET'], proxyMethods: ['POST']})
|
||||
|
||||
expect(commands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([
|
||||
'GetMethodCommand', 'PutMethodResponseCommand', 'PutIntegrationResponseCommand', 'GetMethodCommand', 'GetMethodCommand',
|
||||
'PutMethodCommand', 'PutMethodResponseCommand', 'PutIntegrationCommand', 'PutIntegrationResponseCommand', 'CreateDeploymentCommand',
|
||||
])
|
||||
expect((commands[1] as PutMethodResponseCommand).input.responseParameters).toEqual({
|
||||
'method.response.header.X-Existing': true,
|
||||
'method.response.header.Access-Control-Allow-Origin': false,
|
||||
'method.response.header.Access-Control-Allow-Headers': false,
|
||||
'method.response.header.Access-Control-Allow-Methods': false,
|
||||
})
|
||||
expect((commands[1] as PutMethodResponseCommand).input.responseModels).toEqual({'application/json': 'ExistingModel'})
|
||||
expect((commands[2] as PutIntegrationResponseCommand).input.responseParameters).toMatchObject({
|
||||
'method.response.header.X-Existing': "'existing'",
|
||||
'method.response.header.Access-Control-Allow-Origin': "'*'",
|
||||
'method.response.header.Access-Control-Allow-Headers': "'Content-Type'",
|
||||
'method.response.header.Access-Control-Allow-Methods': "'GET,POST,OPTIONS'",
|
||||
})
|
||||
expect((commands[2] as PutIntegrationResponseCommand).input.responseTemplates).toEqual({'application/json': '$input.body'})
|
||||
expect((commands[5] as PutMethodCommand).input).toMatchObject({httpMethod: 'OPTIONS', authorizationType: 'NONE'})
|
||||
expect((commands[6] as PutMethodResponseCommand).input).toMatchObject({
|
||||
responseParameters: {'method.response.header.Access-Control-Allow-Origin': true},
|
||||
responseModels: {'application/json': 'OptionsModel'},
|
||||
})
|
||||
expect((commands[7] as PutIntegrationCommand).input).toMatchObject({
|
||||
type: 'MOCK', requestTemplates: {'application/json': '{"statusCode": 200}'}, timeoutInMillis: 29000,
|
||||
})
|
||||
expect((commands[8] as PutIntegrationResponseCommand).input.responseTemplates).toEqual({'text/plain': 'existing template'})
|
||||
expect((commands[9] as CreateDeploymentCommand).input).toMatchObject({stageName: 'dev'})
|
||||
})
|
||||
|
||||
test('returns the AWS API Gateway schema', () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({})))
|
||||
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
GetResourcesCommand,
|
||||
GetStagesCommand,
|
||||
PutIntegrationCommand,
|
||||
PutIntegrationResponseCommand,
|
||||
PutMethodCommand,
|
||||
PutMethodResponseCommand,
|
||||
type APIGatewayClient,
|
||||
type Integration,
|
||||
type IntegrationResponse,
|
||||
type MethodResponse,
|
||||
type RestApi,
|
||||
} from '@aws-sdk/client-api-gateway'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
@@ -19,7 +24,7 @@ import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLamb
|
||||
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {awsEndpoint} from '../service/runtimeProbe'
|
||||
import type {ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
@@ -200,6 +205,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
path: resource.path,
|
||||
method,
|
||||
functionName: lambdaFunctionName(result.methodIntegration?.uri),
|
||||
integrationType: result.methodIntegration?.type ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -245,6 +251,93 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
}))
|
||||
}
|
||||
|
||||
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 of GET, POST, PUT, DELETE, or OPTIONS')
|
||||
}
|
||||
|
||||
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 rootResource(apiId: string) {
|
||||
let position: string | undefined
|
||||
do {
|
||||
@@ -277,6 +370,26 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', '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).replace(/^\//, '')
|
||||
if (!/^[A-Za-z0-9._~-]+$/.test(value)) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
GetBucketNotificationConfigurationCommand,
|
||||
GetBucketLocationCommand,
|
||||
GetBucketTaggingCommand,
|
||||
GetBucketWebsiteCommand,
|
||||
ListBucketsCommand,
|
||||
PutBucketNotificationConfigurationCommand,
|
||||
PutBucketWebsiteCommand,
|
||||
type S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
@@ -29,6 +32,7 @@ function fakeS3(overrides: Partial<{
|
||||
if (overrides.taggingError) throw overrides.taggingError
|
||||
return {TagSet: overrides.tagSet ?? [{Key: 'Environment', Value: 'dev'}]}
|
||||
}
|
||||
if (command instanceof GetBucketLocationCommand) return {LocationConstraint: 'us-west-2'}
|
||||
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
||||
},
|
||||
} as unknown as S3Client
|
||||
@@ -104,6 +108,38 @@ describe('AwsStorageAdapter', () => {
|
||||
expect(requestedBuckets.sort()).toEqual(['prod-assets', 'prod-logs'])
|
||||
})
|
||||
|
||||
test('gets normalized website configuration and maps no website configuration to null', async () => {
|
||||
const s3 = {
|
||||
send: async (command: unknown) => {
|
||||
if (command instanceof GetBucketWebsiteCommand) {
|
||||
return {RedirectAllRequestsTo: {HostName: 'www.example.com', Protocol: 'https'}}
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
||||
},
|
||||
} as unknown as S3Client
|
||||
const adapter = new AwsStorageAdapter(s3)
|
||||
|
||||
expect(await adapter.getS3BucketWebsite('website-bucket')).toEqual({mode: 'redirect', hostName: 'www.example.com', protocol: 'https'})
|
||||
|
||||
const absent = new Error('No website configuration')
|
||||
absent.name = 'NoSuchWebsiteConfiguration'
|
||||
const missing = new AwsStorageAdapter({send: async () => { throw absent }} as unknown as S3Client)
|
||||
expect(await missing.getS3BucketWebsite('website-bucket')).toBeNull()
|
||||
})
|
||||
|
||||
test('puts mutually exclusive S3 website configuration shapes', async () => {
|
||||
const sent: unknown[] = []
|
||||
const s3 = {send: async (command: unknown) => { sent.push(command); return {} }} as unknown as S3Client
|
||||
const adapter = new AwsStorageAdapter(s3)
|
||||
|
||||
await adapter.putS3BucketWebsite('website-bucket', {mode: 'index', indexDocument: 'index.html', errorDocument: 'error.html'})
|
||||
await adapter.putS3BucketWebsite('website-bucket', {mode: 'redirect', hostName: 'www.example.com', protocol: 'https'})
|
||||
|
||||
const [index, redirect] = sent as PutBucketWebsiteCommand[]
|
||||
expect(index.input.WebsiteConfiguration).toEqual({IndexDocument: {Suffix: 'index.html'}, ErrorDocument: {Key: 'error.html'}})
|
||||
expect(redirect.input.WebsiteConfiguration).toEqual({RedirectAllRequestsTo: {HostName: 'www.example.com', Protocol: 'https'}})
|
||||
})
|
||||
|
||||
test('adds an S3 Lambda trigger without replacing other bucket notifications', async () => {
|
||||
const sent: unknown[] = []
|
||||
const s3 = {
|
||||
|
||||
@@ -4,15 +4,19 @@ import {
|
||||
CopyObjectCommand,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
DeleteBucketWebsiteCommand,
|
||||
DeleteObjectCommand,
|
||||
type Event,
|
||||
GetBucketTaggingCommand,
|
||||
GetBucketNotificationConfigurationCommand,
|
||||
GetObjectCommand,
|
||||
GetBucketLocationCommand,
|
||||
GetBucketWebsiteCommand,
|
||||
ListBucketsCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
PutBucketNotificationConfigurationCommand,
|
||||
PutBucketWebsiteCommand,
|
||||
type S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
@@ -28,6 +32,7 @@ import type {
|
||||
StorageObjectList,
|
||||
S3LambdaTriggerInput,
|
||||
S3LambdaTriggerOverview,
|
||||
S3BucketWebsiteConfiguration,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
export class AwsStorageAdapter implements CloudServiceAdapter {
|
||||
@@ -49,20 +54,20 @@ export class AwsStorageAdapter implements CloudServiceAdapter {
|
||||
const buckets = filterBucketsBySearch(res.Buckets ?? [], query.search)
|
||||
|
||||
return Promise.all(buckets.map(async (bucket): Promise<CloudResource> => {
|
||||
const {tags, tagsUnavailable} = await this.bucketTags(bucket.Name ?? '')
|
||||
const [tagResult, region] = await Promise.all([this.bucketTags(bucket.Name ?? ''), this.bucketRegion(bucket.Name ?? '')])
|
||||
return {
|
||||
id: bucket.Name ?? '',
|
||||
name: bucket.Name ?? '',
|
||||
cloud: 'aws',
|
||||
service: 'storage',
|
||||
type: 'bucket',
|
||||
region: null,
|
||||
region,
|
||||
createdAt: bucket.CreationDate?.toISOString() ?? null,
|
||||
metadata: {
|
||||
provider: 'aws',
|
||||
storageService: 's3',
|
||||
tags,
|
||||
tagsUnavailable,
|
||||
tags: tagResult.tags,
|
||||
tagsUnavailable: tagResult.tagsUnavailable,
|
||||
},
|
||||
}
|
||||
}))
|
||||
@@ -87,6 +92,16 @@ export class AwsStorageAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private async bucketRegion(bucketName: string): Promise<string | null> {
|
||||
if (!bucketName) return null
|
||||
try {
|
||||
const response = await this.s3.send(new GetBucketLocationCommand({Bucket: bucketName}))
|
||||
return response.LocationConstraint === 'EU' ? 'eu-west-1' : response.LocationConstraint ?? 'us-east-1'
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const resources = await this.list()
|
||||
return resources.find((resource) => resource.id === id) ?? null
|
||||
@@ -199,6 +214,51 @@ export class AwsStorageAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async getS3BucketWebsite(bucketName: string): Promise<S3BucketWebsiteConfiguration | null> {
|
||||
try {
|
||||
const response = await this.s3.send(new GetBucketWebsiteCommand({Bucket: bucketName}))
|
||||
if (response.RedirectAllRequestsTo?.HostName) {
|
||||
return {
|
||||
mode: 'redirect',
|
||||
hostName: response.RedirectAllRequestsTo.HostName,
|
||||
protocol: response.RedirectAllRequestsTo.Protocol ?? null,
|
||||
}
|
||||
}
|
||||
if (response.IndexDocument?.Suffix) {
|
||||
return {
|
||||
mode: 'index',
|
||||
indexDocument: response.IndexDocument.Suffix,
|
||||
errorDocument: response.ErrorDocument?.Key ?? null,
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'NoSuchWebsiteConfiguration') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async putS3BucketWebsite(bucketName: string, configuration: S3BucketWebsiteConfiguration): Promise<void> {
|
||||
await this.s3.send(new PutBucketWebsiteCommand({
|
||||
Bucket: bucketName,
|
||||
WebsiteConfiguration: configuration.mode === 'index'
|
||||
? {
|
||||
IndexDocument: {Suffix: configuration.indexDocument},
|
||||
...(configuration.errorDocument ? {ErrorDocument: {Key: configuration.errorDocument}} : {}),
|
||||
}
|
||||
: {
|
||||
RedirectAllRequestsTo: {
|
||||
HostName: configuration.hostName,
|
||||
...(configuration.protocol ? {Protocol: configuration.protocol} : {}),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async deleteS3BucketWebsite(bucketName: string): Promise<void> {
|
||||
await this.s3.send(new DeleteBucketWebsiteCommand({Bucket: bucketName}))
|
||||
}
|
||||
|
||||
async listObjects(resourceId: string, prefix = ''): Promise<StorageObjectList> {
|
||||
const res = await this.s3.send(new ListObjectsV2Command({
|
||||
Bucket: resourceId,
|
||||
|
||||
@@ -508,6 +508,7 @@ export interface ApiGatewayRouteOverview {
|
||||
path: string
|
||||
method: string
|
||||
functionName: string | null
|
||||
integrationType: string | null
|
||||
}
|
||||
|
||||
export interface ApiGatewayRoutesResult {
|
||||
@@ -522,6 +523,19 @@ export interface ApiGatewayDeleteRouteInput {
|
||||
stage: string
|
||||
}
|
||||
|
||||
export interface ApiGatewayCorsInput {
|
||||
resourceId: string
|
||||
stage: string
|
||||
origin: string
|
||||
headers: string
|
||||
methods: string[]
|
||||
}
|
||||
|
||||
export interface ApiGatewayCorsResult {
|
||||
configuredMethods: string[]
|
||||
proxyMethods: string[]
|
||||
}
|
||||
|
||||
export interface S3LambdaTriggerInput {
|
||||
functionName: string
|
||||
events: string[]
|
||||
@@ -538,6 +552,10 @@ export interface S3LambdaTriggerOverview {
|
||||
suffix: string | null
|
||||
}
|
||||
|
||||
export type S3BucketWebsiteConfiguration =
|
||||
| {mode: 'index'; indexDocument: string; errorDocument: string | null}
|
||||
| {mode: 'redirect'; hostName: string; protocol: 'http' | 'https' | null}
|
||||
|
||||
export interface EventBridgeLambdaTriggerInput {
|
||||
ruleName: string
|
||||
scheduleExpression: string
|
||||
@@ -663,9 +681,13 @@ export interface CloudServiceAdapter {
|
||||
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
|
||||
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
||||
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
|
||||
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
|
||||
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
|
||||
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
|
||||
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
|
||||
getS3BucketWebsite?(bucketName: string): Promise<S3BucketWebsiteConfiguration | null>
|
||||
putS3BucketWebsite?(bucketName: string, configuration: S3BucketWebsiteConfiguration): Promise<void>
|
||||
deleteS3BucketWebsite?(bucketName: string): Promise<void>
|
||||
createEventBridgeLambdaTrigger?(input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview>
|
||||
listEventBridgeLambdaTriggers?(functionName: string): Promise<EventBridgeLambdaTriggerOverview[]>
|
||||
deleteEventBridgeLambdaTrigger?(functionName: string, triggerId: string): Promise<void>
|
||||
|
||||
@@ -145,6 +145,61 @@ describe('EventBridge rule routes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Gateway CORS routes', () => {
|
||||
test('dispatches resource-scoped CORS configuration through the API Gateway adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'apigateway',
|
||||
enableApiGatewayCors: async (apiId, input) => {
|
||||
calls.push({apiId, input})
|
||||
return {configuredMethods: ['GET'], proxyMethods: ['POST']}
|
||||
},
|
||||
})])
|
||||
|
||||
const response = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/cors', {
|
||||
method: 'POST', headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST']}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({configuredMethods: ['GET'], proxyMethods: ['POST']})
|
||||
expect(calls).toEqual([{apiId: 'api-123', input: {
|
||||
resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'],
|
||||
}}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('S3 website routes', () => {
|
||||
test('routes normalized website configuration lifecycle requests through the storage adapter', async () => {
|
||||
const calls: Array<{operation: string; args: unknown[]}> = []
|
||||
const adapter = mockAdapter('aws', {
|
||||
getS3BucketWebsite: async (...args) => {
|
||||
calls.push({operation: 'get', args})
|
||||
return {mode: 'index', indexDocument: 'index.html', errorDocument: null}
|
||||
},
|
||||
putS3BucketWebsite: async (...args) => { calls.push({operation: 'put', args}) },
|
||||
deleteS3BucketWebsite: async (...args) => { calls.push({operation: 'delete', args}) },
|
||||
})
|
||||
const app = appWithRoutes([adapter])
|
||||
|
||||
const get = await app.request('/api/clouds/aws/services/storage/resources/assets/website')
|
||||
const put = await app.request('/api/clouds/aws/services/storage/resources/assets/website', {
|
||||
method: 'PUT', headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}),
|
||||
})
|
||||
const remove = await app.request('/api/clouds/aws/services/storage/resources/assets/website', {method: 'DELETE'})
|
||||
|
||||
expect(await get.json()).toEqual({mode: 'index', indexDocument: 'index.html', errorDocument: null})
|
||||
expect(put.status).toBe(200)
|
||||
expect(remove.status).toBe(200)
|
||||
expect(calls).toEqual([
|
||||
{operation: 'get', args: ['assets']},
|
||||
{operation: 'put', args: ['assets', {mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}]},
|
||||
{operation: 'delete', args: ['assets']},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SNS subscription routes', () => {
|
||||
test('routes subscription lifecycle requests through the Notifications adapter', async () => {
|
||||
const calls: Array<{operation: string; args: unknown[]}> = []
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CloudServiceType,
|
||||
CreateDatabaseSnapshotInput,
|
||||
KmsEncryptionAlgorithm,
|
||||
S3BucketWebsiteConfiguration,
|
||||
SqlConnectionInput,
|
||||
} from '../cloud-spi/types'
|
||||
import {clampLimit, type PageQuery} from '../cloud-spi/childCollections'
|
||||
@@ -554,6 +555,22 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/cors', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<Record<string, unknown>>()
|
||||
const result = await svc(c).enableApiGatewayCors(cloud, c.req.param('id'), {
|
||||
resourceId: typeof input.resourceId === 'string' ? input.resourceId : '',
|
||||
stage: typeof input.stage === 'string' ? input.stage : '',
|
||||
origin: typeof input.origin === 'string' ? input.origin : '',
|
||||
headers: typeof input.headers === 'string' ? input.headers : '',
|
||||
methods: Array.isArray(input.methods) ? input.methods.filter((method): method is string => typeof method === 'string') : [],
|
||||
})
|
||||
return c.json(result)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
@@ -602,6 +619,32 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/storage/resources/:id/website', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => c.json(await svc(c).getS3BucketWebsite(cloud, c.req.param('id'))))
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/storage/resources/:id/website', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await jsonBody<{mode?: unknown; indexDocument?: unknown; errorDocument?: unknown; hostName?: unknown; protocol?: unknown}>(c)
|
||||
const configuration = s3WebsiteConfiguration(input)
|
||||
await svc(c).putS3BucketWebsite(cloud, c.req.param('id'), configuration)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/storage/resources/:id/website', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteS3BucketWebsite(cloud, c.req.param('id'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/serverless/resources/:id/eventbridge-triggers', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
@@ -1052,6 +1095,36 @@ function isCloudProvider(value: string): value is CloudProvider {
|
||||
return value === 'aws' || value === 'azure' || value === 'gcp'
|
||||
}
|
||||
|
||||
function s3WebsiteConfiguration(input: {mode?: unknown; indexDocument?: unknown; errorDocument?: unknown; hostName?: unknown; protocol?: unknown}): S3BucketWebsiteConfiguration {
|
||||
if (input.mode === 'index') {
|
||||
if (typeof input.indexDocument !== 'string' || !input.indexDocument.trim()) {
|
||||
throw new ValidationError('indexDocument is required for index website hosting')
|
||||
}
|
||||
if (input.errorDocument !== undefined && typeof input.errorDocument !== 'string' && input.errorDocument !== null) {
|
||||
throw new ValidationError('errorDocument must be a string or null')
|
||||
}
|
||||
return {
|
||||
mode: 'index',
|
||||
indexDocument: input.indexDocument.trim(),
|
||||
errorDocument: typeof input.errorDocument === 'string' && input.errorDocument.trim() ? input.errorDocument.trim() : null,
|
||||
}
|
||||
}
|
||||
if (input.mode === 'redirect') {
|
||||
if (typeof input.hostName !== 'string' || !input.hostName.trim()) {
|
||||
throw new ValidationError('hostName is required for redirect website hosting')
|
||||
}
|
||||
if (input.protocol !== undefined && input.protocol !== null && input.protocol !== 'http' && input.protocol !== 'https') {
|
||||
throw new ValidationError('protocol must be http, https, or null')
|
||||
}
|
||||
return {
|
||||
mode: 'redirect',
|
||||
hostName: input.hostName.trim(),
|
||||
protocol: input.protocol === 'http' || input.protocol === 'https' ? input.protocol : null,
|
||||
}
|
||||
}
|
||||
throw new ValidationError('mode must be index or redirect')
|
||||
}
|
||||
|
||||
async function jsonBody<T>(c: Context): Promise<T> {
|
||||
try {
|
||||
const body = await c.req.json<unknown>()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
||||
import type {CloudServiceAdapter} from '../cloud-spi/types'
|
||||
import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry'
|
||||
import {CloudProxyService} from './CloudProxyService'
|
||||
|
||||
describe('CloudProxyService API Gateway CORS', () => {
|
||||
test('forwards resource-scoped CORS configuration to the API Gateway adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const adapter: CloudServiceAdapter = {
|
||||
cloud: 'aws', service: 'apigateway', schema: awsApiGatewaySchema,
|
||||
list: async () => [], get: async () => null, create: async () => ({
|
||||
id: 'api', name: 'api', cloud: 'aws', service: 'apigateway', type: 'rest-api', region: null, createdAt: null, metadata: {},
|
||||
}), delete: async () => {},
|
||||
enableApiGatewayCors: async (apiId, input) => {
|
||||
calls.push({apiId, input})
|
||||
return {configuredMethods: ['GET'], proxyMethods: ['POST']}
|
||||
},
|
||||
}
|
||||
const service = new CloudProxyService(new CloudAdapterRegistry([adapter]))
|
||||
|
||||
await expect(service.enableApiGatewayCors('aws', 'api-123', {
|
||||
resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'],
|
||||
})).resolves.toEqual({configuredMethods: ['GET'], proxyMethods: ['POST']})
|
||||
expect(calls).toEqual([{apiId: 'api-123', input: {
|
||||
resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'],
|
||||
}}])
|
||||
})
|
||||
})
|
||||
@@ -26,8 +26,11 @@ import type {
|
||||
ApiGatewayTestRouteResult,
|
||||
ApiGatewayRoutesResult,
|
||||
ApiGatewayDeleteRouteInput,
|
||||
ApiGatewayCorsInput,
|
||||
ApiGatewayCorsResult,
|
||||
S3LambdaTriggerInput,
|
||||
S3LambdaTriggerOverview,
|
||||
S3BucketWebsiteConfiguration,
|
||||
EventBridgeLambdaTriggerInput,
|
||||
EventBridgeLambdaTriggerOverview,
|
||||
EventBridgeRuleInput,
|
||||
@@ -280,6 +283,12 @@ export class CloudProxyService {
|
||||
await adapter.deleteApiGatewayRoute(apiId, input)
|
||||
}
|
||||
|
||||
async enableApiGatewayCors(cloud: CloudProvider, apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult> {
|
||||
const adapter = this.requireAdapter(cloud, 'apigateway')
|
||||
if (!adapter.enableApiGatewayCors) throw new NotSupportedError(`API Gateway CORS is not supported for ${cloud}`)
|
||||
return adapter.enableApiGatewayCors(apiId, input)
|
||||
}
|
||||
|
||||
async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
|
||||
const adapter = this.requireAdapter(cloud, 'storage')
|
||||
if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
|
||||
@@ -298,6 +307,24 @@ export class CloudProxyService {
|
||||
await adapter.deleteS3LambdaTrigger(bucketName, triggerId)
|
||||
}
|
||||
|
||||
async getS3BucketWebsite(cloud: CloudProvider, bucketName: string): Promise<S3BucketWebsiteConfiguration | null> {
|
||||
const adapter = this.requireAdapter(cloud, 'storage')
|
||||
if (!adapter.getS3BucketWebsite) throw new NotSupportedError(`S3 website hosting is not supported for ${cloud}`)
|
||||
return adapter.getS3BucketWebsite(bucketName)
|
||||
}
|
||||
|
||||
async putS3BucketWebsite(cloud: CloudProvider, bucketName: string, configuration: S3BucketWebsiteConfiguration): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'storage')
|
||||
if (!adapter.putS3BucketWebsite) throw new NotSupportedError(`S3 website hosting is not supported for ${cloud}`)
|
||||
await adapter.putS3BucketWebsite(bucketName, configuration)
|
||||
}
|
||||
|
||||
async deleteS3BucketWebsite(cloud: CloudProvider, bucketName: string): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'storage')
|
||||
if (!adapter.deleteS3BucketWebsite) throw new NotSupportedError(`S3 website hosting is not supported for ${cloud}`)
|
||||
await adapter.deleteS3BucketWebsite(bucketName)
|
||||
}
|
||||
|
||||
async createEventBridgeLambdaTrigger(cloud: CloudProvider, input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview> {
|
||||
const adapter = this.requireAdapter(cloud, 'serverless')
|
||||
if (!adapter.createEventBridgeLambdaTrigger) throw new NotSupportedError(`EventBridge Lambda triggers are not supported for ${cloud}`)
|
||||
|
||||
Reference in New Issue
Block a user