feat: CORS enable button...ish still now understanding this

This commit is contained in:
Zakaria
2026-09-27 14:46:04 -04:00
parent 8470b8d17a
commit b474e823ce
17 changed files with 943 additions and 31 deletions
@@ -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,