feat: Cloudformation + RDS
This commit is contained in:
@@ -159,16 +159,16 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
expect(apiCommands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([
|
||||
'GetResourcesCommand', 'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
|
||||
'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
|
||||
'PutIntegrationCommand', 'CreateDeploymentCommand',
|
||||
])
|
||||
expect((apiCommands[2] as CreateResourceCommand).input).toMatchObject({
|
||||
expect((apiCommands[1] as CreateResourceCommand).input).toMatchObject({
|
||||
restApiId: 'a1b2c3', parentId: 'root123', pathPart: 'hello',
|
||||
})
|
||||
expect((apiCommands[3] as PutMethodCommand).input).toMatchObject({
|
||||
expect((apiCommands[2] as PutMethodCommand).input).toMatchObject({
|
||||
httpMethod: 'GET', authorizationType: 'NONE',
|
||||
})
|
||||
expect((apiCommands[4] as PutIntegrationCommand).input).toMatchObject({
|
||||
expect((apiCommands[3] as PutIntegrationCommand).input).toMatchObject({
|
||||
type: 'AWS_PROXY', integrationHttpMethod: 'POST',
|
||||
uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations',
|
||||
})
|
||||
@@ -182,13 +182,77 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('creates missing nested resources and reuses existing path segments', async () => {
|
||||
const apiCommands: unknown[] = []
|
||||
const adapter = new AwsApiGatewayAdapter(
|
||||
fakeClient((command) => {
|
||||
apiCommands.push(command)
|
||||
if (command instanceof GetResourcesCommand) return {items: [
|
||||
{id: 'root123', path: '/'},
|
||||
{id: 'orders123', parentId: 'root123', pathPart: 'orders', path: '/orders'},
|
||||
]}
|
||||
if (command instanceof CreateResourceCommand) return {id: 'orderId123'}
|
||||
if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'}
|
||||
return {}
|
||||
}),
|
||||
fakeLambda((command) => command instanceof GetFunctionCommand
|
||||
? {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}}
|
||||
: {}),
|
||||
)
|
||||
|
||||
const result = await adapter.createApiGatewayLambdaRoute('a1b2c3', {
|
||||
path: '/orders/{id}', method: 'PATCH', stage: 'dev', functionName: 'hello',
|
||||
})
|
||||
|
||||
const creates = apiCommands.filter((command) => command instanceof CreateResourceCommand) as CreateResourceCommand[]
|
||||
expect(creates).toHaveLength(1)
|
||||
expect(creates[0]?.input).toMatchObject({parentId: 'orders123', pathPart: '{id}'})
|
||||
expect((apiCommands.find((command) => command instanceof PutMethodCommand) as PutMethodCommand).input.httpMethod).toBe('PATCH')
|
||||
expect(result).toMatchObject({resourceId: 'orderId123', invokeUrl: 'http://localhost:4566/execute-api/a1b2c3/dev/orders/{id}'})
|
||||
})
|
||||
|
||||
test('creates a greedy child under the explicitly selected parent', async () => {
|
||||
const commands: unknown[] = []
|
||||
const lambdaCommands: unknown[] = []
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
commands.push(command)
|
||||
if (command instanceof GetResourcesCommand) return {items: [
|
||||
{id: 'root123', path: '/'},
|
||||
{id: 'files123', parentId: 'root123', pathPart: 'files', path: '/files'},
|
||||
]}
|
||||
if (command instanceof CreateResourceCommand) return {id: 'proxy123'}
|
||||
if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'}
|
||||
return {}
|
||||
}), fakeLambda((command) => {
|
||||
lambdaCommands.push(command)
|
||||
return command instanceof GetFunctionCommand
|
||||
? {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:files'}}
|
||||
: {}
|
||||
}))
|
||||
|
||||
await adapter.createApiGatewayLambdaRoute('a1b2c3', {
|
||||
path: '/files/{proxy+}', parentResourceId: 'files123', pathPart: '{proxy+}',
|
||||
method: 'ANY', stage: 'dev', functionName: 'files',
|
||||
})
|
||||
|
||||
expect((commands.find((command) => command instanceof CreateResourceCommand) as CreateResourceCommand).input).toMatchObject({
|
||||
parentId: 'files123', pathPart: '{proxy+}',
|
||||
})
|
||||
expect((lambdaCommands.find((command) => command instanceof AddPermissionCommand) as AddPermissionCommand).input).toMatchObject({
|
||||
SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:a1b2c3/dev/*/files/*',
|
||||
})
|
||||
})
|
||||
|
||||
test('adds a method to an existing resource without creating a duplicate path', async () => {
|
||||
const apiCommands: unknown[] = []
|
||||
const adapter = new AwsApiGatewayAdapter(
|
||||
fakeClient((command) => {
|
||||
apiCommands.push(command)
|
||||
if (command instanceof GetResourcesCommand) {
|
||||
return {items: [{id: 'resource456', path: '/hello'}, {id: 'root123', path: '/'}]}
|
||||
return {items: [
|
||||
{id: 'resource456', parentId: 'root123', pathPart: 'hello', path: '/hello'},
|
||||
{id: 'root123', path: '/'},
|
||||
]}
|
||||
}
|
||||
if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'}
|
||||
return {}
|
||||
@@ -264,6 +328,25 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('uses PATCH and HEAD for deployed requests and rejects ANY', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests: Request[] = []
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push(new Request(input, init))
|
||||
return new Response(null)
|
||||
}) as typeof fetch
|
||||
try {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({})))
|
||||
await adapter.testApiGatewayRoute('a1b2c3', {path: '/orders/1', method: 'PATCH', stage: 'dev', body: '{}', headers: {}, query: ''})
|
||||
await adapter.testApiGatewayRoute('a1b2c3', {path: '/orders/1', method: 'HEAD', stage: 'dev', body: '{}', headers: {}, query: ''})
|
||||
await expect(adapter.testApiGatewayRoute('a1b2c3', {path: '/orders/1', method: 'ANY', stage: 'dev', body: '', headers: {}, query: ''})).rejects.toThrow('method must be')
|
||||
expect(requests.map((request) => request.method)).toEqual(['PATCH', 'HEAD'])
|
||||
expect(await requests[1]?.text()).toBe('')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('tests a configured method through the AWS SDK without a deployed stage', async () => {
|
||||
const commands: unknown[] = []
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
@@ -283,7 +366,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
|
||||
await expect(adapter.testApiGatewayMethod('a1b2c3', {
|
||||
resourceId: 'resource456',
|
||||
httpMethod: 'POST',
|
||||
httpMethod: 'ANY',
|
||||
pathWithQueryString: '/orders?dryRun=true',
|
||||
body: '{"name":"Ada"}',
|
||||
headers: {'content-type': 'application/json'},
|
||||
@@ -298,7 +381,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
latency: 17,
|
||||
})
|
||||
expect((commands[0] as TestInvokeMethodCommand).input).toEqual({
|
||||
restApiId: 'a1b2c3', resourceId: 'resource456', httpMethod: 'POST',
|
||||
restApiId: 'a1b2c3', resourceId: 'resource456', httpMethod: 'ANY',
|
||||
pathWithQueryString: '/orders?dryRun=true', body: '{"name":"Ada"}',
|
||||
headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']},
|
||||
stageVariables: {environment: 'test'},
|
||||
@@ -310,7 +393,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
if (command instanceof GetResourcesCommand) {
|
||||
return {items: [
|
||||
{id: 'root123', path: '/', resourceMethods: {}},
|
||||
{id: 'resource456', path: '/hello', resourceMethods: {GET: true, POST: true}},
|
||||
{id: 'resource456', parentId: 'root123', pathPart: 'hello', path: '/hello', resourceMethods: {GET: true, POST: true}},
|
||||
]}
|
||||
}
|
||||
if (command instanceof GetMethodCommand) {
|
||||
@@ -323,6 +406,13 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
}))
|
||||
|
||||
await expect(adapter.listApiGatewayRoutes('a1b2c3')).resolves.toEqual({
|
||||
resources: [
|
||||
{resourceId: 'root123', parentId: null, path: '/', pathPart: null, methods: []},
|
||||
{resourceId: 'resource456', parentId: 'root123', path: '/hello', pathPart: 'hello', methods: [
|
||||
{resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello', integrationType: null},
|
||||
{resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello', integrationType: null},
|
||||
]},
|
||||
],
|
||||
routes: [
|
||||
{resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello', integrationType: null},
|
||||
{resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello', integrationType: null},
|
||||
@@ -331,6 +421,28 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('paginates and returns resources that have no configured methods', async () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
if (command instanceof GetResourcesCommand && !command.input.position) {
|
||||
return {items: [{id: 'root123', path: '/', resourceMethods: {}}], position: 'next'}
|
||||
}
|
||||
if (command instanceof GetResourcesCommand) {
|
||||
return {items: [{id: 'orders123', parentId: 'root123', pathPart: 'orders', path: '/orders'}]}
|
||||
}
|
||||
if (command instanceof GetStagesCommand) return {item: []}
|
||||
throw new Error('Unexpected command')
|
||||
}))
|
||||
|
||||
await expect(adapter.listApiGatewayRoutes('a1b2c3')).resolves.toEqual({
|
||||
resources: [
|
||||
{resourceId: 'root123', parentId: null, path: '/', pathPart: null, methods: []},
|
||||
{resourceId: 'orders123', parentId: 'root123', path: '/orders', pathPart: 'orders', methods: []},
|
||||
],
|
||||
routes: [],
|
||||
stages: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('deletes one method and redeploys its stage', async () => {
|
||||
const commands: unknown[] = []
|
||||
const lambdaCommands: unknown[] = []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
CreateStackCommand,
|
||||
CreateChangeSetCommand,
|
||||
DescribeChangeSetCommand,
|
||||
DeleteStackCommand,
|
||||
DescribeStackEventsCommand,
|
||||
DescribeStacksCommand,
|
||||
GetTemplateCommand,
|
||||
ListStackResourcesCommand,
|
||||
ListStacksCommand,
|
||||
UpdateStackCommand,
|
||||
type CloudFormationClient,
|
||||
} from '@aws-sdk/client-cloudformation'
|
||||
import {AwsCloudFormationAdapter} from './AwsCloudFormationAdapter'
|
||||
@@ -81,6 +86,8 @@ describe('AwsCloudFormationAdapter', () => {
|
||||
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
||||
if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]}
|
||||
if (command instanceof ListStackResourcesCommand) return {StackResourceSummaries: [bucketResource]}
|
||||
if (command instanceof DescribeStackEventsCommand) return {StackEvents: [{LogicalResourceId: 'OrdersBucket', ResourceStatus: 'CREATE_COMPLETE', Timestamp: new Date('2026-01-02T03:04:07.000Z')}]}
|
||||
if (command instanceof GetTemplateCommand) return {TemplateBody: '{"Resources":{}}'}
|
||||
throw new Error('Unexpected command')
|
||||
}))
|
||||
|
||||
@@ -96,6 +103,7 @@ describe('AwsCloudFormationAdapter', () => {
|
||||
resourceType: 'AWS::S3::Bucket',
|
||||
status: 'CREATE_COMPLETE',
|
||||
}])
|
||||
expect(result?.metadata.templateBody).toBe('{"Resources":{}}')
|
||||
})
|
||||
|
||||
test('paginates ListStackResources and combines resources from every page', async () => {
|
||||
@@ -107,6 +115,8 @@ describe('AwsCloudFormationAdapter', () => {
|
||||
}
|
||||
return {StackResourceSummaries: [{...bucketResource, LogicalResourceId: 'OrdersQueue', PhysicalResourceId: 'orders-queue', ResourceType: 'AWS::SQS::Queue'}]}
|
||||
}
|
||||
if (command instanceof DescribeStackEventsCommand) return {StackEvents: []}
|
||||
if (command instanceof GetTemplateCommand) return {TemplateBody: '{}'}
|
||||
throw new Error('Unexpected command')
|
||||
}))
|
||||
|
||||
@@ -153,6 +163,43 @@ describe('AwsCloudFormationAdapter', () => {
|
||||
expect(result.metadata.stackId).toBe(ordersStack.StackId)
|
||||
})
|
||||
|
||||
test('passes parameters and capabilities to CloudFormation', async () => {
|
||||
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
||||
if (command instanceof CreateStackCommand) {
|
||||
expect(command.input).toMatchObject({
|
||||
Parameters: [{ParameterKey: 'Environment', ParameterValue: 'test'}],
|
||||
Capabilities: ['CAPABILITY_NAMED_IAM'],
|
||||
})
|
||||
return {StackId: ordersStack.StackId}
|
||||
}
|
||||
if (command instanceof CreateChangeSetCommand) {
|
||||
expect(command.input).toMatchObject({
|
||||
Parameters: [{ParameterKey: 'Environment', ParameterValue: 'test'}],
|
||||
Capabilities: ['CAPABILITY_NAMED_IAM'],
|
||||
})
|
||||
return {Id: 'change-set-id'}
|
||||
}
|
||||
if (command instanceof DescribeChangeSetCommand) return {ChangeSetName: 'planned', Status: 'CREATE_COMPLETE', ExecutionStatus: 'AVAILABLE'}
|
||||
throw new Error('Unexpected command')
|
||||
}))
|
||||
|
||||
await adapter.create({values: {stackName: 'orders-stack', templateBody: '{}', parameters: '{"Environment":"test"}', capabilities: 'CAPABILITY_NAMED_IAM'}})
|
||||
await adapter.createCloudFormationChangeSet('orders-stack', {name: 'planned', templateBody: '{}', parameters: {Environment: 'test'}, capabilities: ['CAPABILITY_NAMED_IAM']})
|
||||
})
|
||||
|
||||
test('creates a stack from an S3 template URL', async () => {
|
||||
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
||||
expect(command).toBeInstanceOf(CreateStackCommand)
|
||||
expect((command as CreateStackCommand).input).toEqual({
|
||||
StackName: 'orders-stack',
|
||||
TemplateURL: 'https://templates.s3.amazonaws.com/orders.yaml',
|
||||
})
|
||||
return {StackId: ordersStack.StackId}
|
||||
}))
|
||||
|
||||
await expect(adapter.create({values: {stackName: 'orders-stack', templateUrl: 'https://templates.s3.amazonaws.com/orders.yaml'}})).resolves.toMatchObject({id: 'orders-stack'})
|
||||
})
|
||||
|
||||
test('rejects a missing stack name before calling CloudFormation', async () => {
|
||||
let called = false
|
||||
const adapter = new AwsCloudFormationAdapter(fakeClient(() => {
|
||||
@@ -164,14 +211,14 @@ describe('AwsCloudFormationAdapter', () => {
|
||||
expect(called).toBeFalse()
|
||||
})
|
||||
|
||||
test('rejects a missing template body before calling CloudFormation', async () => {
|
||||
test('rejects missing template input before calling CloudFormation', async () => {
|
||||
let called = false
|
||||
const adapter = new AwsCloudFormationAdapter(fakeClient(() => {
|
||||
called = true
|
||||
return {}
|
||||
}))
|
||||
|
||||
await expect(adapter.create({values: {stackName: 'orders-stack'}})).rejects.toThrow('templateBody is required')
|
||||
await expect(adapter.create({values: {stackName: 'orders-stack'}})).rejects.toThrow('templateBody or templateUrl is required')
|
||||
expect(called).toBeFalse()
|
||||
})
|
||||
|
||||
@@ -185,6 +232,22 @@ describe('AwsCloudFormationAdapter', () => {
|
||||
await adapter.delete('orders-stack')
|
||||
})
|
||||
|
||||
test('updates a stack template then reads back the stack workspace', async () => {
|
||||
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
||||
if (command instanceof UpdateStackCommand) {
|
||||
expect(command.input).toEqual({StackName: 'orders-stack', TemplateBody: '{"Resources":{"Queue":{"Type":"AWS::SQS::Queue"}}}'})
|
||||
return {StackId: ordersStack.StackId}
|
||||
}
|
||||
if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]}
|
||||
if (command instanceof ListStackResourcesCommand) return {StackResourceSummaries: []}
|
||||
if (command instanceof DescribeStackEventsCommand) return {StackEvents: []}
|
||||
if (command instanceof GetTemplateCommand) return {TemplateBody: '{}'}
|
||||
throw new Error('Unexpected command')
|
||||
}))
|
||||
|
||||
await expect(adapter.update('orders-stack', {values: {templateBody: '{"Resources":{"Queue":{"Type":"AWS::SQS::Queue"}}}'}})).resolves.toMatchObject({id: 'orders-stack'})
|
||||
})
|
||||
|
||||
test('returns the AWS CloudFormation schema', () => {
|
||||
const adapter = new AwsCloudFormationAdapter(fakeClient(() => ({})))
|
||||
|
||||
@@ -192,7 +255,7 @@ describe('AwsCloudFormationAdapter', () => {
|
||||
cloud: 'aws',
|
||||
service: 'iac',
|
||||
displayName: 'CloudFormation',
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
actions: ['list', 'create', 'update', 'delete', 'inspect'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,17 +2,28 @@ import {
|
||||
CloudFormationClient,
|
||||
CreateStackCommand,
|
||||
DeleteStackCommand,
|
||||
CreateChangeSetCommand,
|
||||
DeleteChangeSetCommand,
|
||||
DescribeStackEventsCommand,
|
||||
DescribeChangeSetCommand,
|
||||
DescribeStacksCommand,
|
||||
GetTemplateCommand,
|
||||
ExecuteChangeSetCommand,
|
||||
ListStackResourcesCommand,
|
||||
ListStacksCommand,
|
||||
ListChangeSetsCommand,
|
||||
UpdateStackCommand,
|
||||
UpdateTerminationProtectionCommand,
|
||||
type Stack,
|
||||
type DescribeChangeSetOutput,
|
||||
type Capability,
|
||||
type StackResourceSummary,
|
||||
type StackSummary,
|
||||
} from '@aws-sdk/client-cloudformation'
|
||||
import {cloudformation as defaultCloudFormation} from '../aws'
|
||||
import {awsCloudformationSchema} from '../cloud-spi/cloudformationSchema'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
import type {CloudFormationChangeSet, CloudFormationChangeSetInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema, UpdateResourceInput} from '../cloud-spi/types'
|
||||
|
||||
export class AwsCloudFormationAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
@@ -51,7 +62,11 @@ export class AwsCloudFormationAdapter implements CloudServiceAdapter {
|
||||
nextToken = resources.NextToken
|
||||
} while (nextToken)
|
||||
|
||||
return stackToResource(stack, resourceSummaries)
|
||||
const [events, template] = await Promise.all([
|
||||
this.cloudformation.send(new DescribeStackEventsCommand({StackName: id})),
|
||||
this.cloudformation.send(new GetTemplateCommand({StackName: id, TemplateStage: 'Original'})),
|
||||
])
|
||||
return stackToResource(stack, resourceSummaries, events.StackEvents ?? [], template.TemplateBody)
|
||||
} catch (error) {
|
||||
if (isStackNotFound(error)) return null
|
||||
throw error
|
||||
@@ -61,10 +76,12 @@ export class AwsCloudFormationAdapter implements CloudServiceAdapter {
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const stackName = stringValue(input.values.stackName)
|
||||
const templateBody = stringValue(input.values.templateBody)
|
||||
const templateUrl = stringValue(input.values.templateUrl)
|
||||
if (!stackName) throw new ValidationError('stackName is required')
|
||||
if (!templateBody) throw new ValidationError('templateBody is required')
|
||||
if (!templateBody && !templateUrl) throw new ValidationError('templateBody or templateUrl is required')
|
||||
|
||||
const res = await this.cloudformation.send(new CreateStackCommand({StackName: stackName, TemplateBody: templateBody}))
|
||||
const options = stackOptions(input.values)
|
||||
const res = await this.cloudformation.send(new CreateStackCommand({StackName: stackName, ...(templateBody ? {TemplateBody: templateBody} : {TemplateURL: templateUrl}), ...options}))
|
||||
return {
|
||||
id: stackName,
|
||||
name: stackName,
|
||||
@@ -84,6 +101,46 @@ export class AwsCloudFormationAdapter implements CloudServiceAdapter {
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.cloudformation.send(new DeleteStackCommand({StackName: id}))
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
|
||||
const templateBody = stringValue(input.values.templateBody)
|
||||
if (!templateBody) throw new ValidationError('templateBody is required')
|
||||
await this.cloudformation.send(new UpdateStackCommand({StackName: id, TemplateBody: templateBody, ...stackOptions(input.values)}))
|
||||
const stack = await this.get(id)
|
||||
if (!stack) throw new ValidationError(`Stack ${id} was updated but could not be read back.`)
|
||||
return stack
|
||||
}
|
||||
|
||||
async listCloudFormationChangeSets(stackName: string): Promise<CloudFormationChangeSet[]> {
|
||||
const response = await this.cloudformation.send(new ListChangeSetsCommand({StackName: stackName}))
|
||||
return Promise.all((response.Summaries ?? []).filter((summary) => Boolean(summary.ChangeSetName)).map(async (summary) => {
|
||||
const name = summary.ChangeSetName as string
|
||||
const detail = await this.cloudformation.send(new DescribeChangeSetCommand({StackName: stackName, ChangeSetName: name}))
|
||||
return changeSet(detail, name)
|
||||
}))
|
||||
}
|
||||
|
||||
async createCloudFormationChangeSet(stackName: string, input: CloudFormationChangeSetInput): Promise<CloudFormationChangeSet> {
|
||||
const name = stringValue(input.name)
|
||||
const templateBody = stringValue(input.templateBody)
|
||||
if (!name || !templateBody) throw new ValidationError('change set name and templateBody are required')
|
||||
const capabilities = capabilitiesValue(input.capabilities)
|
||||
const created = await this.cloudformation.send(new CreateChangeSetCommand({StackName: stackName, ChangeSetName: name, ChangeSetType: 'UPDATE', TemplateBody: templateBody, ...(input.parameters ? {Parameters: parameterEntries(input.parameters)} : {}), ...(capabilities.length ? {Capabilities: capabilities} : {})}))
|
||||
const detail = await this.cloudformation.send(new DescribeChangeSetCommand({StackName: stackName, ChangeSetName: created.Id ?? name}))
|
||||
return changeSet(detail, name)
|
||||
}
|
||||
|
||||
async executeCloudFormationChangeSet(stackName: string, changeSetName: string): Promise<void> {
|
||||
await this.cloudformation.send(new ExecuteChangeSetCommand({StackName: stackName, ChangeSetName: changeSetName}))
|
||||
}
|
||||
|
||||
async deleteCloudFormationChangeSet(stackName: string, changeSetName: string): Promise<void> {
|
||||
await this.cloudformation.send(new DeleteChangeSetCommand({StackName: stackName, ChangeSetName: changeSetName}))
|
||||
}
|
||||
|
||||
async updateCloudFormationTerminationProtection(stackName: string, enabled: boolean): Promise<void> {
|
||||
await this.cloudformation.send(new UpdateTerminationProtectionCommand({StackName: stackName, EnableTerminationProtection: enabled}))
|
||||
}
|
||||
}
|
||||
|
||||
function summaryToResource(summary: StackSummary): CloudResource {
|
||||
@@ -105,7 +162,7 @@ function summaryToResource(summary: StackSummary): CloudResource {
|
||||
}
|
||||
}
|
||||
|
||||
function stackToResource(stack: Stack, resources: StackResourceSummary[]): CloudResource {
|
||||
function stackToResource(stack: Stack, resources: StackResourceSummary[], events: Array<{Timestamp?: Date; ResourceStatus?: string; LogicalResourceId?: string; ResourceStatusReason?: string}> = [], templateBody?: string): CloudResource {
|
||||
const id = stack.StackName ?? ''
|
||||
return {
|
||||
id,
|
||||
@@ -122,6 +179,8 @@ function stackToResource(stack: Stack, resources: StackResourceSummary[]): Cloud
|
||||
description: stack.Description,
|
||||
lastUpdatedTime: stack.LastUpdatedTime?.toISOString(),
|
||||
capabilities: stack.Capabilities,
|
||||
templateBody: templateBody ?? null,
|
||||
parameters: (stack.Parameters ?? []).map((parameter) => ({key: parameter.ParameterKey, value: parameter.ParameterValue})),
|
||||
enableTerminationProtection: stack.EnableTerminationProtection,
|
||||
outputs: (stack.Outputs ?? []).map((output) => ({
|
||||
key: output.OutputKey,
|
||||
@@ -135,14 +194,57 @@ function stackToResource(stack: Stack, resources: StackResourceSummary[]): Cloud
|
||||
resourceType: resource.ResourceType,
|
||||
status: resource.ResourceStatus,
|
||||
})),
|
||||
events: events.map((event) => ({timestamp: event.Timestamp?.toISOString(), status: event.ResourceStatus, logicalId: event.LogicalResourceId, reason: event.ResourceStatusReason})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function changeSet(detail: DescribeChangeSetOutput, fallbackName: string): CloudFormationChangeSet {
|
||||
return {
|
||||
name: detail.ChangeSetName ?? fallbackName,
|
||||
id: detail.ChangeSetId ?? detail.ChangeSetName ?? fallbackName,
|
||||
status: detail.Status ?? null,
|
||||
executionStatus: detail.ExecutionStatus ?? null,
|
||||
changes: (detail.Changes ?? []).map((change) => ({
|
||||
action: change.ResourceChange?.Action ?? null,
|
||||
logicalResourceId: change.ResourceChange?.LogicalResourceId ?? null,
|
||||
resourceType: change.ResourceChange?.ResourceType ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function stackOptions(values: Record<string, unknown>): {Parameters?: Array<{ParameterKey: string; ParameterValue: string}>; Capabilities?: Capability[]} {
|
||||
const parameters = parametersValue(values.parameters)
|
||||
const capabilities = capabilitiesValue(values.capabilities)
|
||||
return {...(Object.keys(parameters).length ? {Parameters: parameterEntries(parameters)} : {}), ...(capabilities.length ? {Capabilities: capabilities} : {})}
|
||||
}
|
||||
|
||||
function parametersValue(value: unknown): Record<string, string> {
|
||||
if (!value) return {}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
|
||||
if (typeof value !== 'string' || !value.trim()) return {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((item) => typeof item !== 'string')) throw new Error()
|
||||
return parsed as Record<string, string>
|
||||
} catch {
|
||||
throw new ValidationError('parameters must be a JSON object with string values')
|
||||
}
|
||||
}
|
||||
|
||||
function capabilitiesValue(value: unknown): Capability[] {
|
||||
const capabilities = Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) : typeof value === 'string' ? value.split(',').map((item) => item.trim()).filter(Boolean) : []
|
||||
return capabilities as Capability[]
|
||||
}
|
||||
|
||||
function parameterEntries(parameters: Record<string, string>): Array<{ParameterKey: string; ParameterValue: string}> {
|
||||
return Object.entries(parameters).map(([ParameterKey, ParameterValue]) => ({ParameterKey, ParameterValue}))
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
CreateTableCommand,
|
||||
DeleteItemCommand,
|
||||
DeleteTableCommand,
|
||||
DescribeTableCommand,
|
||||
ListTablesCommand,
|
||||
PutItemCommand,
|
||||
QueryCommand,
|
||||
ScanCommand,
|
||||
type DynamoDBClient,
|
||||
} from '@aws-sdk/client-dynamodb'
|
||||
import {AwsDynamoDbAdapter} from './AwsDynamoDbAdapter'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type Command = CreateTableCommand | DeleteTableCommand | DescribeTableCommand | ListTablesCommand | PutItemCommand | ScanCommand
|
||||
type Command = CreateTableCommand | DeleteItemCommand | DeleteTableCommand | DescribeTableCommand | ListTablesCommand | PutItemCommand | QueryCommand | ScanCommand
|
||||
|
||||
function fakeClient(send: (command: Command) => Promise<unknown>): DynamoDBClient {
|
||||
return {send} as unknown as DynamoDBClient
|
||||
@@ -192,7 +194,43 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
expect(captured?.input.TableName).toBe('orders')
|
||||
})
|
||||
|
||||
test('lists and unmarshalls table records across scan pages', async () => {
|
||||
test('deletes an item with its typed DynamoDB key', async () => {
|
||||
let captured: DeleteItemCommand | undefined
|
||||
const client = fakeClient(async (command) => {
|
||||
if (command instanceof DescribeTableCommand) return {Table: {
|
||||
KeySchema: [{AttributeName: 'accountId', KeyType: 'HASH'}, {AttributeName: 'sequence', KeyType: 'RANGE'}],
|
||||
AttributeDefinitions: [{AttributeName: 'accountId', AttributeType: 'S'}, {AttributeName: 'sequence', AttributeType: 'N'}],
|
||||
}}
|
||||
captured = command as DeleteItemCommand
|
||||
return {}
|
||||
})
|
||||
|
||||
await new AwsDynamoDbAdapter(client).items.deleteItem!('orders', '{"accountId":"acct-1","sequence":"9007199254740993"}')
|
||||
|
||||
expect(captured?.input).toEqual({
|
||||
TableName: 'orders',
|
||||
Key: {accountId: {S: 'acct-1'}, sequence: {N: '9007199254740993'}},
|
||||
})
|
||||
})
|
||||
|
||||
test('does not delete an item without its complete primary key', async () => {
|
||||
let deleteCalled = false
|
||||
const client = fakeClient(async (command) => {
|
||||
if (command instanceof DescribeTableCommand) return {Table: {
|
||||
KeySchema: [{AttributeName: 'accountId', KeyType: 'HASH'}, {AttributeName: 'sequence', KeyType: 'RANGE'}],
|
||||
AttributeDefinitions: [{AttributeName: 'accountId', AttributeType: 'S'}, {AttributeName: 'sequence', AttributeType: 'N'}],
|
||||
}}
|
||||
if (command instanceof DeleteItemCommand) deleteCalled = true
|
||||
return {}
|
||||
})
|
||||
|
||||
await expect(new AwsDynamoDbAdapter(client).items.deleteItem!('orders', '{"accountId":"acct-1"}'))
|
||||
.rejects.toThrow('Key attribute sequence is required')
|
||||
|
||||
expect(deleteCalled).toBe(false)
|
||||
})
|
||||
|
||||
test('lists one unmarshalled scan page and returns an opaque cursor', async () => {
|
||||
const client = fakeClient(async (command) => {
|
||||
if (command instanceof DescribeTableCommand) {
|
||||
return {Table: {KeySchema: [
|
||||
@@ -201,31 +239,25 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
]}}
|
||||
}
|
||||
if (command instanceof ScanCommand) {
|
||||
expect(command.input.Limit).toBe(command.input.ExclusiveStartKey ? 99 : 100)
|
||||
return command.input.ExclusiveStartKey
|
||||
? {Items: [{accountId: {S: 'acct-2'}, createdAt: {N: '9007199254740993'}, active: {BOOL: false}, roles: {SS: ['admin', 'editor']}}]}
|
||||
: {
|
||||
Items: [{accountId: {S: 'acct-1'}, createdAt: {N: '1'}, active: {BOOL: true}}],
|
||||
LastEvaluatedKey: {accountId: {S: 'acct-1'}, createdAt: {N: '1'}},
|
||||
}
|
||||
expect(command.input.Limit).toBe(100)
|
||||
return {
|
||||
Items: [{accountId: {S: 'acct-1'}, createdAt: {N: '1'}, active: {BOOL: true}}],
|
||||
LastEvaluatedKey: {accountId: {S: 'acct-1'}, createdAt: {N: '1'}}, Count: 1, ScannedCount: 3,
|
||||
ConsumedCapacity: {CapacityUnits: 0.5},
|
||||
}
|
||||
}
|
||||
throw new Error('Unexpected command')
|
||||
})
|
||||
|
||||
const result = await new AwsDynamoDbAdapter(client).listNoSqlItems('orders')
|
||||
const result = await new AwsDynamoDbAdapter(client).items.listItems('orders')
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toMatchObject({items: [
|
||||
{
|
||||
id: '{"accountId":"acct-1","createdAt":1}',
|
||||
key: {accountId: 'acct-1', createdAt: 1},
|
||||
document: {accountId: 'acct-1', createdAt: 1, active: true},
|
||||
id: '{"accountId":"acct-1","createdAt":"1"}', collectionId: null, timestamp: null,
|
||||
body: {accountId: 'acct-1', createdAt: 1, active: true}, metadata: {key: {accountId: 'acct-1', createdAt: '1'}},
|
||||
},
|
||||
{
|
||||
id: '{"accountId":"acct-2","createdAt":"9007199254740993"}',
|
||||
key: {accountId: 'acct-2', createdAt: '9007199254740993'},
|
||||
document: {accountId: 'acct-2', createdAt: '9007199254740993', active: false, roles: ['admin', 'editor']},
|
||||
},
|
||||
])
|
||||
], count: 1, scannedCount: 3, consumedCapacity: 0.5})
|
||||
expect(result.nextCursor).toBeString()
|
||||
})
|
||||
|
||||
test('stops scanning after 100 records', async () => {
|
||||
@@ -245,10 +277,84 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
throw new Error('Unexpected command')
|
||||
})
|
||||
|
||||
const result = await new AwsDynamoDbAdapter(client).listNoSqlItems('orders')
|
||||
const result = await new AwsDynamoDbAdapter(client).items.listItems('orders')
|
||||
|
||||
expect(result).toHaveLength(100)
|
||||
expect(result.items).toHaveLength(100)
|
||||
expect(scanCount).toBe(1)
|
||||
expect(result.nextCursor).toBeString()
|
||||
})
|
||||
|
||||
test('round-trips typed pagination keys through the opaque cursor', async () => {
|
||||
const scans: ScanCommand[] = []
|
||||
const client = fakeClient(async (command) => {
|
||||
if (command instanceof DescribeTableCommand) return {Table: {
|
||||
KeySchema: [{AttributeName: 'pk', KeyType: 'HASH'}, {AttributeName: 'sk', KeyType: 'RANGE'}],
|
||||
AttributeDefinitions: [{AttributeName: 'pk', AttributeType: 'B'}, {AttributeName: 'sk', AttributeType: 'N'}],
|
||||
}}
|
||||
if (command instanceof ScanCommand) {
|
||||
scans.push(command)
|
||||
return scans.length === 1 ? {LastEvaluatedKey: {pk: {B: Buffer.from('key')}, sk: {N: '9007199254740993'}}} : {}
|
||||
}
|
||||
throw new Error('Unexpected command')
|
||||
})
|
||||
const adapter = new AwsDynamoDbAdapter(client)
|
||||
|
||||
const first = await adapter.items.listItems('orders', {limit: 10})
|
||||
await adapter.items.listItems('orders', {limit: 10, cursor: first.nextCursor!})
|
||||
|
||||
expect(first.nextCursor).not.toContain('9007199254740993')
|
||||
expect(scans[1].input.ExclusiveStartKey).toEqual({pk: {B: Buffer.from('key')}, sk: {N: '9007199254740993'}})
|
||||
await expect(adapter.items.listItems('other', {cursor: first.nextCursor!})).rejects.toThrow('cursor is not valid')
|
||||
})
|
||||
|
||||
test('builds a typed server-side scan filter', async () => {
|
||||
let captured: ScanCommand | undefined
|
||||
const client = fakeClient(async (command) => {
|
||||
if (command instanceof DescribeTableCommand) return {Table: {KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}]}}
|
||||
captured = command as ScanCommand
|
||||
return {Count: 0, ScannedCount: 8}
|
||||
})
|
||||
|
||||
const result = await new AwsDynamoDbAdapter(client).items.queryItems!('orders', {
|
||||
operation: 'scan', filter: {attribute: 'total', operator: 'BETWEEN', type: 'N', value: '10.5', secondValue: '20'},
|
||||
}, {limit: 25})
|
||||
|
||||
expect(captured?.input).toMatchObject({
|
||||
TableName: 'orders', Limit: 25, FilterExpression: '#filter BETWEEN :filter AND :filter2',
|
||||
ExpressionAttributeNames: {'#filter': 'total'},
|
||||
ExpressionAttributeValues: {':filter': {N: '10.5'}, ':filter2': {N: '20'}},
|
||||
})
|
||||
expect(result).toMatchObject({count: 0, scannedCount: 8})
|
||||
})
|
||||
|
||||
test('queries a selected GSI with typed partition and sort conditions', async () => {
|
||||
let captured: QueryCommand | undefined
|
||||
const client = fakeClient(async (command) => {
|
||||
if (command instanceof DescribeTableCommand) return {Table: {
|
||||
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],
|
||||
AttributeDefinitions: [
|
||||
{AttributeName: 'id', AttributeType: 'S'}, {AttributeName: 'accountId', AttributeType: 'S'}, {AttributeName: 'sequence', AttributeType: 'N'},
|
||||
],
|
||||
GlobalSecondaryIndexes: [{IndexName: 'account-sequence', KeySchema: [
|
||||
{AttributeName: 'accountId', KeyType: 'HASH'}, {AttributeName: 'sequence', KeyType: 'RANGE'},
|
||||
]}],
|
||||
}}
|
||||
captured = command as QueryCommand
|
||||
return {Items: [{id: {S: 'order-1'}, accountId: {S: 'acct-1'}, sequence: {N: '12'}}], Count: 1}
|
||||
})
|
||||
|
||||
const result = await new AwsDynamoDbAdapter(client).items.queryItems!('orders', {
|
||||
operation: 'query', indexName: 'account-sequence', partitionKeyValue: 'acct-1',
|
||||
sortKeyCondition: {operator: 'BETWEEN', value: '10', secondValue: '20'},
|
||||
}, {limit: 15})
|
||||
|
||||
expect(captured?.input).toMatchObject({
|
||||
TableName: 'orders', IndexName: 'account-sequence', Limit: 15,
|
||||
KeyConditionExpression: '#pk = :pk AND #sk BETWEEN :sk AND :sk2',
|
||||
ExpressionAttributeNames: {'#pk': 'accountId', '#sk': 'sequence'},
|
||||
ExpressionAttributeValues: {':pk': {S: 'acct-1'}, ':sk': {N: '10'}, ':sk2': {N: '20'}},
|
||||
})
|
||||
expect(result.items[0].metadata.key).toEqual({id: 'order-1'})
|
||||
})
|
||||
|
||||
test('puts a record using the table key types', async () => {
|
||||
@@ -273,7 +379,7 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
throw new Error('Unexpected command')
|
||||
})
|
||||
|
||||
const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('orders', {
|
||||
const result = await new AwsDynamoDbAdapter(client).items.putItem('orders', {
|
||||
accountId: 'acct-1',
|
||||
sequence: '9007199254740993',
|
||||
active: true,
|
||||
@@ -289,8 +395,10 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
})
|
||||
expect(result).toEqual({
|
||||
id: '{"accountId":"acct-1","sequence":"9007199254740993"}',
|
||||
key: {accountId: 'acct-1', sequence: '9007199254740993'},
|
||||
document: {accountId: 'acct-1', sequence: '9007199254740993', active: true},
|
||||
collectionId: null,
|
||||
timestamp: null,
|
||||
body: {accountId: 'acct-1', sequence: '9007199254740993', active: true},
|
||||
metadata: {key: {accountId: 'acct-1', sequence: '9007199254740993'}},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -307,10 +415,32 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
return {}
|
||||
})
|
||||
|
||||
const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('blobs', {id: 'aGVsbG8=', name: 'hello'})
|
||||
const result = await new AwsDynamoDbAdapter(client).items.putItem('blobs', {id: 'aGVsbG8=', name: 'hello'})
|
||||
|
||||
expect(captured?.input.Item?.id.B).toEqual(Buffer.from('hello'))
|
||||
expect(result.key).toEqual({id: 'aGVsbG8='})
|
||||
expect(result.metadata.key).toEqual({id: 'aGVsbG8='})
|
||||
})
|
||||
|
||||
test('puts DynamoDB AttributeValue JSON without remarshalshalling typed attributes', async () => {
|
||||
let captured: PutItemCommand | undefined
|
||||
const client = fakeClient(async (command) => {
|
||||
if (command instanceof DescribeTableCommand) return {Table: {
|
||||
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],
|
||||
AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'S'}],
|
||||
}}
|
||||
captured = command as PutItemCommand
|
||||
return {}
|
||||
})
|
||||
|
||||
await new AwsDynamoDbAdapter(client).items.putItem('orders', {
|
||||
id: {S: 'order-1'}, count: {N: '9007199254740993'}, tags: {SS: ['new', 'priority']},
|
||||
payload: {B: 'aGVsbG8='}, details: {M: {active: {BOOL: true}}}, entries: {L: [{NULL: true}]},
|
||||
})
|
||||
|
||||
expect(captured?.input.Item).toEqual({
|
||||
id: {S: 'order-1'}, count: {N: '9007199254740993'}, tags: {SS: ['new', 'priority']},
|
||||
payload: {B: Buffer.from('hello')}, details: {M: {active: {BOOL: true}}}, entries: {L: [{NULL: true}]},
|
||||
})
|
||||
})
|
||||
|
||||
test('validates required and typed keys before putting a record', async () => {
|
||||
@@ -327,10 +457,10 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
})
|
||||
const adapter = new AwsDynamoDbAdapter(client)
|
||||
|
||||
await expect(adapter.putNoSqlItem('orders', {})).rejects.toThrow('Key attribute id is required')
|
||||
await expect(adapter.putNoSqlItem('orders', {id: 'not-a-number'})).rejects.toThrow('Key attribute id must be a number')
|
||||
await expect(adapter.items.putItem('orders', {})).rejects.toThrow('Key attribute id is required')
|
||||
await expect(adapter.items.putItem('orders', {id: 'not-a-number'})).rejects.toThrow('Key attribute id must be a number')
|
||||
for (const json of ['1', '1.5', '9007199254740993', '123456789.123456789', '1.0000000000000000001']) {
|
||||
await expect(adapter.putNoSqlItem('orders', {id: JSON.parse(json)}))
|
||||
await expect(adapter.items.putItem('orders', {id: JSON.parse(json)}))
|
||||
.rejects.toThrow('must be a quoted number to preserve precision')
|
||||
}
|
||||
expect(putCalls).toBe(0)
|
||||
@@ -351,12 +481,12 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
})
|
||||
const adapter = new AwsDynamoDbAdapter(client)
|
||||
|
||||
const created = await adapter.putNoSqlItem('orders', {id})
|
||||
const listed = await adapter.listNoSqlItems('orders')
|
||||
const created = await adapter.items.putItem('orders', {id})
|
||||
const listed = await adapter.items.listItems('orders')
|
||||
|
||||
expect(captured?.input.Item?.id).toEqual({N: id})
|
||||
expect(created).toEqual({id: JSON.stringify({id}), key: {id}, document: {id}})
|
||||
expect(listed).toEqual([created])
|
||||
expect(created).toEqual({id: JSON.stringify({id}), collectionId: null, timestamp: null, body: {id}, metadata: {key: {id}}})
|
||||
expect(listed.items).toEqual([created])
|
||||
})
|
||||
|
||||
test.each([
|
||||
@@ -377,7 +507,7 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
return {}
|
||||
})
|
||||
|
||||
await expect(new AwsDynamoDbAdapter(client).putNoSqlItem('orders', JSON.parse(json)))
|
||||
await expect(new AwsDynamoDbAdapter(client).items.putItem('orders', JSON.parse(json)))
|
||||
.rejects.toThrow(new ValidationError(`Item value at ${path} must quote integers outside JavaScript's safe range.`))
|
||||
expect(putCalls).toBe(0)
|
||||
})
|
||||
@@ -421,12 +551,12 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
})
|
||||
const adapter = new AwsDynamoDbAdapter(client)
|
||||
|
||||
const created = await adapter.putNoSqlItem('orders', {accountId: 'acct-1', sequence: input})
|
||||
const listed = await adapter.listNoSqlItems('orders')
|
||||
const key = {accountId: 'acct-1', sequence: normalized}
|
||||
const created = await adapter.items.putItem('orders', {accountId: 'acct-1', sequence: input})
|
||||
const listed = await adapter.items.listItems('orders')
|
||||
const key = {accountId: 'acct-1', sequence: String(normalized)}
|
||||
|
||||
expect(created).toEqual({id: JSON.stringify(key), key, document: key})
|
||||
expect(listed).toEqual([created])
|
||||
expect(created).toEqual({id: JSON.stringify(key), collectionId: null, timestamp: null, body: {accountId: 'acct-1', sequence: normalized}, metadata: {key}})
|
||||
expect(listed.items).toEqual([created])
|
||||
})
|
||||
|
||||
test('preserves safe integers, fractions, and quoted large values in nested records', async () => {
|
||||
@@ -447,13 +577,13 @@ describe('AwsDynamoDbAdapter', () => {
|
||||
details: {count: '9007199254740993', active: true, optional: null},
|
||||
}
|
||||
|
||||
const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('orders', document)
|
||||
const result = await new AwsDynamoDbAdapter(client).items.putItem('orders', document)
|
||||
|
||||
expect(captured?.input.Item?.values).toEqual({L: [
|
||||
{N: '-9007199254740991'}, {N: '9007199254740991'}, {N: '0'}, {N: '1.5'},
|
||||
]})
|
||||
expect(captured?.input.Item?.details.M?.count).toEqual({S: '9007199254740993'})
|
||||
expect(result.document).toEqual(document)
|
||||
expect(result.body).toEqual(document)
|
||||
})
|
||||
|
||||
test('returns the AWS DynamoDB schema', () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
CreateTableCommand,
|
||||
DeleteItemCommand,
|
||||
DeleteTableCommand,
|
||||
DescribeTableCommand,
|
||||
ListTablesCommand,
|
||||
PutItemCommand,
|
||||
QueryCommand,
|
||||
ScanCommand,
|
||||
type AttributeDefinition,
|
||||
type AttributeValue,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
type TableDescription,
|
||||
} from '@aws-sdk/client-dynamodb'
|
||||
import {marshall, unmarshall} from '@aws-sdk/util-dynamodb'
|
||||
import {DEFAULT_PAGE_LIMIT, type ChildItem, type CollectionPage, type ItemStoreAdapter, type PageQuery} from '../cloud-spi/childCollections'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {dynamodb as defaultDynamoDb} from '../aws'
|
||||
import {awsDynamoDbSchema} from '../cloud-spi/dynamodbSchema'
|
||||
@@ -20,19 +23,29 @@ import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
NoSqlItem,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
const DESCRIBE_BATCH_SIZE = 10
|
||||
const ITEM_SCAN_LIMIT = 100
|
||||
interface DynamoDbItemStore extends ItemStoreAdapter {
|
||||
putItem(resourceId: string, body: Record<string, unknown>): Promise<ChildItem>
|
||||
deleteItem(resourceId: string, itemId: string): Promise<void>
|
||||
}
|
||||
|
||||
export class AwsDynamoDbAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'nosql' as const
|
||||
readonly items: DynamoDbItemStore
|
||||
|
||||
constructor(private readonly dynamodb: DynamoDBClient = defaultDynamoDb) {}
|
||||
constructor(private readonly dynamodb: DynamoDBClient = defaultDynamoDb) {
|
||||
this.items = {
|
||||
listItems: (resourceId, page) => this.listItems(resourceId, page),
|
||||
queryItems: (resourceId, query, page) => this.queryItems(resourceId, query, page),
|
||||
putItem: (resourceId, document) => this.putItem(resourceId, document),
|
||||
deleteItem: (resourceId, itemId) => this.deleteItem(resourceId, itemId),
|
||||
}
|
||||
}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsDynamoDbSchema()
|
||||
@@ -104,31 +117,76 @@ export class AwsDynamoDbAdapter implements CloudServiceAdapter {
|
||||
await this.dynamodb.send(new DeleteTableCommand({TableName: id}))
|
||||
}
|
||||
|
||||
async listNoSqlItems(resourceId: string): Promise<NoSqlItem[]> {
|
||||
const table = await this.describeTable(resourceId)
|
||||
const keyNames = keyAttributes(table).map(({name}) => name)
|
||||
const items: NoSqlItem[] = []
|
||||
let exclusiveStartKey: Record<string, AttributeValue> | undefined
|
||||
|
||||
do {
|
||||
const response = await this.dynamodb.send(new ScanCommand({
|
||||
TableName: resourceId,
|
||||
ExclusiveStartKey: exclusiveStartKey,
|
||||
Limit: ITEM_SCAN_LIMIT - items.length,
|
||||
}))
|
||||
for (const rawItem of response.Items ?? []) {
|
||||
const document = normalizeDocument(unmarshall(rawItem, {wrapNumbers: normalizeNumber}))
|
||||
const key = Object.fromEntries(keyNames.map((name) => [name, document[name]]))
|
||||
items.push({id: JSON.stringify(key), key, document})
|
||||
if (items.length === ITEM_SCAN_LIMIT) break
|
||||
}
|
||||
exclusiveStartKey = response.LastEvaluatedKey
|
||||
} while (items.length < ITEM_SCAN_LIMIT && exclusiveStartKey && Object.keys(exclusiveStartKey).length > 0)
|
||||
|
||||
return items
|
||||
private async listItems(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||
return this.scanItems(resourceId, {}, page)
|
||||
}
|
||||
|
||||
async putNoSqlItem(resourceId: string, document: Record<string, unknown>): Promise<NoSqlItem> {
|
||||
private async queryItems(resourceId: string, rawQuery: string | Record<string, unknown>, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||
if (!rawQuery || typeof rawQuery !== 'object' || Array.isArray(rawQuery)) {
|
||||
throw new ValidationError('DynamoDB item query must be an object.')
|
||||
}
|
||||
const query = rawQuery as DynamoItemQuery
|
||||
if (query.operation === 'scan') return this.scanItems(resourceId, query, page)
|
||||
if (query.operation === 'query') return this.queryTableItems(resourceId, query, page)
|
||||
throw new ValidationError('operation must be scan or query.')
|
||||
}
|
||||
|
||||
private async scanItems(resourceId: string, query: DynamoItemQuery, page: PageQuery): Promise<CollectionPage<ChildItem>> {
|
||||
const table = await this.describeTable(resourceId)
|
||||
const keyNames = keyAttributes(table).map(({name}) => name)
|
||||
const indexName = optionalString(query.indexName)
|
||||
indexKeyAttributes(table, indexName)
|
||||
const filter = filterExpression(query.filter)
|
||||
const response = await this.dynamodb.send(new ScanCommand({
|
||||
TableName: resourceId,
|
||||
...(indexName ? {IndexName: indexName} : {}),
|
||||
ExclusiveStartKey: decodeCursor(page.cursor, resourceId, indexName),
|
||||
Limit: page.limit ?? DEFAULT_PAGE_LIMIT,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
...filter,
|
||||
}))
|
||||
return itemPage(response, keyNames, resourceId, indexName)
|
||||
}
|
||||
|
||||
private async queryTableItems(resourceId: string, query: DynamoItemQuery, page: PageQuery): Promise<CollectionPage<ChildItem>> {
|
||||
const table = await this.describeTable(resourceId)
|
||||
const primaryKeyNames = keyAttributes(table).map(({name}) => name)
|
||||
const indexName = optionalString(query.indexName)
|
||||
const keys = indexKeyAttributes(table, indexName)
|
||||
const partitionKey = keys.find(({keyType}) => keyType === 'HASH')
|
||||
const sortKey = keys.find(({keyType}) => keyType === 'RANGE')
|
||||
if (!partitionKey) throw new RuntimeError(`DynamoDB table ${resourceId} has no partition key`)
|
||||
if (typeof query.partitionKeyValue !== 'string' || query.partitionKeyValue.length === 0) {
|
||||
throw new ValidationError(`Partition key ${partitionKey.name} is required.`)
|
||||
}
|
||||
|
||||
const names: Record<string, string> = {'#pk': partitionKey.name}
|
||||
const values: Record<string, AttributeValue> = {':pk': marshalKey(partitionKey, query.partitionKeyValue)}
|
||||
let keyConditionExpression = '#pk = :pk'
|
||||
if (query.sortKeyCondition !== undefined) {
|
||||
if (!sortKey) throw new ValidationError('The selected table or index does not have a sort key.')
|
||||
const condition = keyCondition(query.sortKeyCondition, sortKey)
|
||||
names['#sk'] = sortKey.name
|
||||
Object.assign(values, condition.values)
|
||||
keyConditionExpression += ` AND ${condition.expression}`
|
||||
}
|
||||
|
||||
const filter = filterExpression(query.filter, names, values)
|
||||
const response = await this.dynamodb.send(new QueryCommand({
|
||||
TableName: resourceId,
|
||||
...(indexName ? {IndexName: indexName} : {}),
|
||||
KeyConditionExpression: keyConditionExpression,
|
||||
ExpressionAttributeNames: names,
|
||||
ExpressionAttributeValues: values,
|
||||
ExclusiveStartKey: decodeCursor(page.cursor, resourceId, indexName),
|
||||
Limit: page.limit ?? DEFAULT_PAGE_LIMIT,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
...filter,
|
||||
}))
|
||||
return itemPage(response, primaryKeyNames, resourceId, indexName)
|
||||
}
|
||||
|
||||
private async putItem(resourceId: string, document: Record<string, unknown>): Promise<ChildItem> {
|
||||
if (!document || typeof document !== 'object' || Array.isArray(document)) {
|
||||
throw new ValidationError('Item must be a JSON object.')
|
||||
}
|
||||
@@ -139,12 +197,21 @@ export class AwsDynamoDbAdapter implements CloudServiceAdapter {
|
||||
const untypedKey = keys.find(({type}) => !type)
|
||||
if (untypedKey) throw new RuntimeError(`DynamoDB table ${resourceId} has no type definition for key ${untypedKey.name}`)
|
||||
|
||||
const item = marshalDocument(document, keys)
|
||||
const item = attributeValueDocument(document) ?? marshalDocument(document, keys)
|
||||
validateTypedKeys(item, keys)
|
||||
await this.dynamodb.send(new PutItemCommand({TableName: resourceId, Item: item}))
|
||||
|
||||
const normalizedDocument = normalizeDocument(unmarshall(item, {wrapNumbers: normalizeNumber}))
|
||||
const key = Object.fromEntries(keys.map(({name}) => [name, normalizedDocument[name]]))
|
||||
return {id: JSON.stringify(key), key, document: normalizedDocument}
|
||||
const key = itemKeyDocument(item, keys.map(({name}) => name))
|
||||
return toItem(JSON.stringify(key), key, normalizedDocument)
|
||||
}
|
||||
|
||||
private async deleteItem(resourceId: string, itemId: string): Promise<void> {
|
||||
const table = await this.describeTable(resourceId)
|
||||
const keys = keyAttributes(table)
|
||||
if (keys.length === 0) throw new RuntimeError(`DynamoDB table ${resourceId} has no key schema`)
|
||||
const key = keyFromItemId(itemId, keys)
|
||||
await this.dynamodb.send(new DeleteItemCommand({TableName: resourceId, Key: key}))
|
||||
}
|
||||
|
||||
private async listTableNames(): Promise<string[]> {
|
||||
@@ -173,9 +240,82 @@ export class AwsDynamoDbAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
function toItem(id: string, key: Record<string, unknown>, body: Record<string, unknown>): ChildItem {
|
||||
return {id, collectionId: null, timestamp: null, body, metadata: {key}}
|
||||
}
|
||||
|
||||
function keyFromItemId(itemId: string, keys: KeyAttribute[]): Record<string, AttributeValue> {
|
||||
let key: unknown
|
||||
try {
|
||||
key = JSON.parse(itemId)
|
||||
} catch {
|
||||
throw new ValidationError('Item identifier must be a DynamoDB key JSON object.')
|
||||
}
|
||||
if (!key || typeof key !== 'object' || Array.isArray(key)) {
|
||||
throw new ValidationError('Item identifier must be a DynamoDB key JSON object.')
|
||||
}
|
||||
const document = key as Record<string, unknown>
|
||||
return Object.fromEntries(keys.map((keyAttribute) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(document, keyAttribute.name)) {
|
||||
throw new ValidationError(`Key attribute ${keyAttribute.name} is required.`)
|
||||
}
|
||||
return [keyAttribute.name, marshalKey(keyAttribute, document[keyAttribute.name])]
|
||||
}))
|
||||
}
|
||||
|
||||
function attributeValueDocument(document: Record<string, unknown>): Record<string, AttributeValue> | undefined {
|
||||
const entries = Object.entries(document)
|
||||
if (entries.length === 0 || !entries.every(([, value]) => isAttributeValueJson(value))) return undefined
|
||||
return Object.fromEntries(entries.map(([name, value]) => [name, fromAttributeValueJson(value as Record<string, unknown>)]))
|
||||
}
|
||||
|
||||
function isAttributeValueJson(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length !== 1) return false
|
||||
const [type, raw] = entries[0]
|
||||
if (type === 'S') return typeof raw === 'string'
|
||||
if (type === 'N') return typeof raw === 'string' && isDynamoNumber(raw)
|
||||
if (type === 'B') return typeof raw === 'string' && isBase64(raw)
|
||||
if (type === 'BOOL') return typeof raw === 'boolean'
|
||||
if (type === 'NULL') return raw === true
|
||||
if (type === 'SS') return Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string')
|
||||
if (type === 'NS') return Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string' && isDynamoNumber(item))
|
||||
if (type === 'BS') return Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string' && isBase64(item))
|
||||
if (type === 'L') return Array.isArray(raw) && raw.every(isAttributeValueJson)
|
||||
if (type === 'M') return Boolean(raw) && typeof raw === 'object' && !Array.isArray(raw) && Object.values(raw).every(isAttributeValueJson)
|
||||
return false
|
||||
}
|
||||
|
||||
function fromAttributeValueJson(value: Record<string, unknown>): AttributeValue {
|
||||
const [type, raw] = Object.entries(value)[0]
|
||||
if (type === 'B') return {B: Buffer.from(raw as string, 'base64')}
|
||||
if (type === 'BS') return {BS: (raw as string[]).map((item) => Buffer.from(item, 'base64'))}
|
||||
if (type === 'L') return {L: (raw as Record<string, unknown>[]).map(fromAttributeValueJson)}
|
||||
if (type === 'M') return {M: Object.fromEntries(Object.entries(raw as Record<string, Record<string, unknown>>).map(([name, child]) => [name, fromAttributeValueJson(child)]))}
|
||||
if (type === 'S') return {S: raw as string}
|
||||
if (type === 'N') return {N: raw as string}
|
||||
if (type === 'BOOL') return {BOOL: raw as boolean}
|
||||
if (type === 'NULL') return {NULL: true}
|
||||
if (type === 'SS') return {SS: raw as string[]}
|
||||
if (type === 'NS') return {NS: raw as string[]}
|
||||
throw new ValidationError('Item contains an invalid DynamoDB AttributeValue.')
|
||||
}
|
||||
|
||||
function validateTypedKeys(item: Record<string, AttributeValue>, keys: KeyAttribute[]): void {
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(item, key.name)) throw new ValidationError(`Key attribute ${key.name} is required.`)
|
||||
const value = item[key.name]
|
||||
if (key.type === 'N' && typeof value.N !== 'string') throw new ValidationError(`Key attribute ${key.name} must be a number.`)
|
||||
if (key.type === 'B' && !value.B) throw new ValidationError(`Binary key attribute ${key.name} must be a non-empty base64 string.`)
|
||||
if (key.type === 'S' && !value.S) throw new ValidationError(`Key attribute ${key.name} must be a non-empty string.`)
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyAttribute {
|
||||
name: string
|
||||
type?: ScalarAttributeType
|
||||
keyType?: 'HASH' | 'RANGE'
|
||||
}
|
||||
|
||||
function keyAttributes(table: TableDescription): KeyAttribute[] {
|
||||
@@ -186,7 +326,181 @@ function keyAttributes(table: TableDescription): KeyAttribute[] {
|
||||
)
|
||||
return (table.KeySchema ?? [])
|
||||
.filter((key): key is KeySchemaElement & {AttributeName: string} => Boolean(key.AttributeName))
|
||||
.map((key) => ({name: key.AttributeName, type: attributeTypes.get(key.AttributeName)}))
|
||||
.map((key) => ({name: key.AttributeName, type: attributeTypes.get(key.AttributeName), keyType: key.KeyType}))
|
||||
}
|
||||
|
||||
interface DynamoItemQuery {
|
||||
operation?: unknown
|
||||
indexName?: unknown
|
||||
partitionKeyValue?: unknown
|
||||
sortKeyCondition?: unknown
|
||||
filter?: unknown
|
||||
}
|
||||
|
||||
interface DynamoCondition {
|
||||
operator?: unknown
|
||||
value?: unknown
|
||||
secondValue?: unknown
|
||||
}
|
||||
|
||||
interface DynamoFilter extends DynamoCondition {
|
||||
attribute?: unknown
|
||||
type?: unknown
|
||||
}
|
||||
|
||||
const COMPARISON_OPERATORS = new Map([
|
||||
['EQ', '='], ['NE', '<>'], ['LT', '<'], ['LE', '<='], ['GT', '>'], ['GE', '>='],
|
||||
])
|
||||
|
||||
function indexKeyAttributes(table: TableDescription, indexName?: string): KeyAttribute[] {
|
||||
if (!indexName) return keyAttributes(table)
|
||||
const indexes = [...(table.GlobalSecondaryIndexes ?? []), ...(table.LocalSecondaryIndexes ?? [])]
|
||||
const index = indexes.find((candidate) => candidate.IndexName === indexName)
|
||||
if (!index) throw new ValidationError(`DynamoDB index ${indexName} does not exist.`)
|
||||
return keyAttributes({...table, KeySchema: index.KeySchema})
|
||||
}
|
||||
|
||||
function keyCondition(raw: unknown, sortKey: KeyAttribute): {expression: string; values: Record<string, AttributeValue>} {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new ValidationError('sortKeyCondition must be an object.')
|
||||
const condition = raw as DynamoCondition
|
||||
const operator = typeof condition.operator === 'string' ? condition.operator : ''
|
||||
const value = requiredConditionValue(condition.value, 'Sort key value')
|
||||
const values: Record<string, AttributeValue> = {':sk': marshalKey(sortKey, value)}
|
||||
const comparison = COMPARISON_OPERATORS.get(operator)
|
||||
if (comparison) return {expression: `#sk ${comparison} :sk`, values}
|
||||
if (operator === 'begins_with') return {expression: 'begins_with(#sk, :sk)', values}
|
||||
if (operator === 'BETWEEN') {
|
||||
const secondValue = requiredConditionValue(condition.secondValue, 'Second sort key value')
|
||||
values[':sk2'] = marshalKey(sortKey, secondValue)
|
||||
return {expression: '#sk BETWEEN :sk AND :sk2', values}
|
||||
}
|
||||
throw new ValidationError('Sort key operator must be EQ, LT, LE, GT, GE, BETWEEN, or begins_with.')
|
||||
}
|
||||
|
||||
function filterExpression(
|
||||
raw: unknown,
|
||||
names: Record<string, string> = {},
|
||||
values: Record<string, AttributeValue> = {},
|
||||
): {FilterExpression?: string; ExpressionAttributeNames?: Record<string, string>; ExpressionAttributeValues?: Record<string, AttributeValue>} {
|
||||
if (raw === undefined || raw === null) return {}
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) throw new ValidationError('filter must be an object.')
|
||||
const filter = raw as DynamoFilter
|
||||
const attribute = requiredConditionValue(filter.attribute, 'Filter attribute')
|
||||
const operator = typeof filter.operator === 'string' ? filter.operator : ''
|
||||
names['#filter'] = attribute
|
||||
|
||||
if (operator === 'attribute_exists' || operator === 'attribute_not_exists') {
|
||||
return {FilterExpression: `${operator}(#filter)`, ExpressionAttributeNames: names, ...(Object.keys(values).length ? {ExpressionAttributeValues: values} : {})}
|
||||
}
|
||||
|
||||
values[':filter'] = typedAttributeValue(filter.type, filter.value, 'Filter value')
|
||||
const comparison = COMPARISON_OPERATORS.get(operator)
|
||||
let expression: string
|
||||
if (comparison) expression = `#filter ${comparison} :filter`
|
||||
else if (operator === 'begins_with' || operator === 'contains') expression = `${operator}(#filter, :filter)`
|
||||
else if (operator === 'BETWEEN') {
|
||||
values[':filter2'] = typedAttributeValue(filter.type, filter.secondValue, 'Second filter value')
|
||||
expression = '#filter BETWEEN :filter AND :filter2'
|
||||
} else {
|
||||
throw new ValidationError('Filter operator is not supported.')
|
||||
}
|
||||
return {FilterExpression: expression, ExpressionAttributeNames: names, ExpressionAttributeValues: values}
|
||||
}
|
||||
|
||||
function typedAttributeValue(type: unknown, raw: unknown, field: string): AttributeValue {
|
||||
const value = requiredConditionValue(raw, field)
|
||||
if (type === 'S') return {S: value}
|
||||
if (type === 'N') {
|
||||
if (!isDynamoNumber(value)) throw new ValidationError(`${field} must be a number.`)
|
||||
return {N: value}
|
||||
}
|
||||
if (type === 'B') {
|
||||
if (!isBase64(value)) throw new ValidationError(`${field} must be non-empty base64.`)
|
||||
return {B: Buffer.from(value, 'base64')}
|
||||
}
|
||||
if (type === 'BOOL') {
|
||||
if (value !== 'true' && value !== 'false') throw new ValidationError(`${field} must be true or false.`)
|
||||
return {BOOL: value === 'true'}
|
||||
}
|
||||
throw new ValidationError('Filter type must be S, N, B, or BOOL.')
|
||||
}
|
||||
|
||||
function requiredConditionValue(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new ValidationError(`${field} is required.`)
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function itemPage(
|
||||
response: {Items?: Record<string, AttributeValue>[]; LastEvaluatedKey?: Record<string, AttributeValue>; Count?: number; ScannedCount?: number; ConsumedCapacity?: {CapacityUnits?: number}},
|
||||
keyNames: string[],
|
||||
tableName: string,
|
||||
indexName?: string,
|
||||
): CollectionPage<ChildItem> {
|
||||
const items = (response.Items ?? []).map((rawItem) => {
|
||||
const document = normalizeDocument(unmarshall(rawItem, {wrapNumbers: normalizeNumber}))
|
||||
const key = itemKeyDocument(rawItem, keyNames)
|
||||
return toItem(JSON.stringify(key), key, document)
|
||||
})
|
||||
return {
|
||||
items,
|
||||
nextCursor: response.LastEvaluatedKey && Object.keys(response.LastEvaluatedKey).length
|
||||
? encodeCursor(response.LastEvaluatedKey, tableName, indexName)
|
||||
: null,
|
||||
count: response.Count ?? items.length,
|
||||
...(response.ScannedCount === undefined ? {} : {scannedCount: response.ScannedCount}),
|
||||
...(response.ConsumedCapacity?.CapacityUnits === undefined ? {} : {consumedCapacity: response.ConsumedCapacity.CapacityUnits}),
|
||||
}
|
||||
}
|
||||
|
||||
function itemKeyDocument(item: Record<string, AttributeValue>, keyNames: string[]): Record<string, unknown> {
|
||||
return Object.fromEntries(keyNames.map((name) => {
|
||||
const value = item[name]
|
||||
if (!value) throw new RuntimeError(`DynamoDB item is missing key attribute ${name}`)
|
||||
if (value.S !== undefined) return [name, value.S]
|
||||
if (value.N !== undefined) return [name, String(normalizeNumber(value.N))]
|
||||
if (value.B !== undefined) return [name, Buffer.from(value.B).toString('base64')]
|
||||
throw new RuntimeError(`DynamoDB key attribute ${name} has an unsupported type`)
|
||||
}))
|
||||
}
|
||||
|
||||
function encodeCursor(key: Record<string, AttributeValue>, tableName: string, indexName?: string): string {
|
||||
const payload = {v: 1, tableName, indexName: indexName ?? null, key: Object.fromEntries(Object.entries(key).map(([name, value]) => [name, toAttributeValueJson(value)]))}
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string | undefined, tableName: string, indexName?: string): Record<string, AttributeValue> | undefined {
|
||||
if (!cursor) return undefined
|
||||
try {
|
||||
const decoded = Buffer.from(cursor, 'base64url')
|
||||
if (decoded.toString('base64url') !== cursor) throw new Error('non-canonical cursor')
|
||||
const payload = JSON.parse(decoded.toString()) as {v?: unknown; tableName?: unknown; indexName?: unknown; key?: unknown}
|
||||
if (payload.v !== 1 || payload.tableName !== tableName || payload.indexName !== (indexName ?? null) || !payload.key || typeof payload.key !== 'object' || Array.isArray(payload.key)) {
|
||||
throw new Error('cursor context mismatch')
|
||||
}
|
||||
const entries = Object.entries(payload.key)
|
||||
if (!entries.length || !entries.every(([, value]) => isAttributeValueJson(value))) throw new Error('invalid cursor key')
|
||||
return Object.fromEntries(entries.map(([name, value]) => [name, fromAttributeValueJson(value as Record<string, unknown>)]))
|
||||
} catch (error) {
|
||||
throw new ValidationError('cursor is not valid for this DynamoDB table and index.', {cause: error})
|
||||
}
|
||||
}
|
||||
|
||||
function toAttributeValueJson(value: AttributeValue): Record<string, unknown> {
|
||||
if ('B' in value && value.B !== undefined) return {B: Buffer.from(value.B).toString('base64')}
|
||||
if ('BS' in value && value.BS !== undefined) return {BS: value.BS.map((item) => Buffer.from(item).toString('base64'))}
|
||||
if ('L' in value && value.L !== undefined) return {L: value.L.map(toAttributeValueJson)}
|
||||
if ('M' in value && value.M !== undefined) return {M: Object.fromEntries(Object.entries(value.M).map(([name, child]) => [name, toAttributeValueJson(child)]))}
|
||||
if ('S' in value) return {S: value.S}
|
||||
if ('N' in value) return {N: value.N}
|
||||
if ('BOOL' in value) return {BOOL: value.BOOL}
|
||||
if ('NULL' in value) return {NULL: value.NULL}
|
||||
if ('SS' in value) return {SS: value.SS}
|
||||
if ('NS' in value) return {NS: value.NS}
|
||||
throw new ValidationError('DynamoDB returned an unsupported pagination key.')
|
||||
}
|
||||
|
||||
function marshalDocument(document: Record<string, unknown>, keys: KeyAttribute[]): Record<string, AttributeValue> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ListRulesCommand,
|
||||
ListTargetsByRuleCommand,
|
||||
PutRuleCommand,
|
||||
PutEventsCommand,
|
||||
PutTargetsCommand,
|
||||
RemoveTargetsCommand,
|
||||
type EventBridgeClient,
|
||||
@@ -120,6 +121,16 @@ describe('AwsEventBridgeAdapter', () => {
|
||||
expect((commands[2] as AddPermissionCommand).input).toMatchObject({FunctionName: 'arn:aws:lambda:us-east-1:000000000000:function:worker', Principal: 'events.amazonaws.com', SourceArn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly'})
|
||||
})
|
||||
|
||||
test('publishes a validated event to the selected bus', async () => {
|
||||
const adapter = createAdapter(async (command) => {
|
||||
expect(command).toBeInstanceOf(PutEventsCommand)
|
||||
expect((command as PutEventsCommand).input).toEqual({Entries: [{EventBusName: 'orders', Source: 'orders', DetailType: 'order.created', Detail: '{"id":"123"}'}]})
|
||||
return {Entries: [{EventId: 'event-123'}]}
|
||||
})
|
||||
|
||||
await expect(adapter.putEventBridgeEvent('event-bus:orders', {source: 'orders', detailType: 'order.created', detail: '{"id":"123"}'})).resolves.toEqual({eventId: 'event-123', errorCode: null, errorMessage: null})
|
||||
})
|
||||
|
||||
test('removes targets before deleting a rule', async () => {
|
||||
const commands: unknown[] = []
|
||||
const adapter = createAdapter(async (command) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ListRulesCommand,
|
||||
ListTargetsByRuleCommand,
|
||||
PutRuleCommand,
|
||||
PutEventsCommand,
|
||||
PutTargetsCommand,
|
||||
RemoveTargetsCommand,
|
||||
type EventBridgeClient,
|
||||
@@ -25,6 +26,8 @@ import type {
|
||||
EventBridgeRuleInput,
|
||||
EventBridgeRuleOverview,
|
||||
EventBridgeRuleTarget,
|
||||
EventBridgePutEventInput,
|
||||
EventBridgePutEventResult,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
@@ -152,6 +155,24 @@ export class AwsEventBridgeAdapter implements CloudServiceAdapter {
|
||||
await this.eventbridge.send(new DeleteRuleCommand({Name: name, ...eventBusInput(busName)}))
|
||||
}
|
||||
|
||||
async putEventBridgeEvent(eventBusId: string, input: EventBridgePutEventInput): Promise<EventBridgePutEventResult> {
|
||||
const source = requiredValue(input.source, 'source')
|
||||
const detailType = requiredValue(input.detailType, 'detailType')
|
||||
const detail = requiredValue(input.detail, 'detail')
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(detail)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object')
|
||||
} catch {
|
||||
throw new ValidationError('detail must be a JSON object')
|
||||
}
|
||||
const response = await this.eventbridge.send(new PutEventsCommand({Entries: [{
|
||||
EventBusName: eventBusName(eventBusId), Source: source, DetailType: detailType, Detail: detail,
|
||||
...(input.resources?.length ? {Resources: input.resources} : {}),
|
||||
}]}))
|
||||
const entry = response.Entries?.[0]
|
||||
return {eventId: entry?.EventId ?? null, errorCode: entry?.ErrorCode ?? null, errorMessage: entry?.ErrorMessage ?? null}
|
||||
}
|
||||
|
||||
private async listTargets(ruleName: string, busName: string): Promise<Target[]> {
|
||||
const targets: Target[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
@@ -215,6 +215,42 @@ describe('AwsLogsAdapter queryLogs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsLogsAdapter filterLogEvents', () => {
|
||||
test('filters across streams and preserves event identity and pagination', async () => {
|
||||
const {client, calls} = stubClient({
|
||||
FilterLogEventsCommand: () => ({
|
||||
events: [{eventId: 'event-1', logStreamName: 'stream-a', timestamp: 1785260000000, message: 'ERROR failed', ingestionTime: 1785260001000}],
|
||||
nextToken: 'page-2',
|
||||
}),
|
||||
})
|
||||
|
||||
const page = await new AwsLogsAdapter(client).filterLogEvents('/floci/probe', {
|
||||
filterPattern: 'ERROR',
|
||||
logStreamNames: ['stream-a'],
|
||||
startTime: 1000,
|
||||
endTime: 2000,
|
||||
}, {cursor: 'page-1', limit: 25})
|
||||
|
||||
expect(calls[0]).toMatchObject({command: 'FilterLogEventsCommand', input: {
|
||||
logGroupName: '/floci/probe', filterPattern: 'ERROR', logStreamNames: ['stream-a'],
|
||||
startTime: 1000, endTime: 2000, nextToken: 'page-1', limit: 25,
|
||||
}})
|
||||
expect(page).toEqual({
|
||||
items: [{
|
||||
id: 'event-1', collectionId: 'stream-a', timestamp: new Date(1785260000000).toISOString(),
|
||||
body: {message: 'ERROR failed'}, metadata: {ingestionTime: new Date(1785260001000).toISOString()},
|
||||
}],
|
||||
nextCursor: 'page-2',
|
||||
})
|
||||
})
|
||||
|
||||
test('retains a continuation token on an empty page', async () => {
|
||||
const {client} = stubClient({FilterLogEventsCommand: () => ({events: [], nextToken: 'keep-going'})})
|
||||
await expect(new AwsLogsAdapter(client).filterLogEvents('/floci/probe', {}))
|
||||
.resolves.toEqual({items: [], nextCursor: 'keep-going'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsLogsAdapter documents', () => {
|
||||
test('lists log streams as collections, newest activity first', async () => {
|
||||
const {client, calls} = stubClient({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DeleteLogStreamCommand,
|
||||
DescribeLogGroupsCommand,
|
||||
DescribeLogStreamsCommand,
|
||||
FilterLogEventsCommand,
|
||||
GetLogEventsCommand,
|
||||
GetQueryResultsCommand,
|
||||
StartQueryCommand,
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
CreateResourceInput,
|
||||
LogsInsightsQueryInput,
|
||||
LogsInsightsQueryResult,
|
||||
LogsFilterInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
@@ -150,6 +152,30 @@ export class AwsLogsAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async filterLogEvents(logGroupName: string, input: LogsFilterInput, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||
const response = await this.client.send(new FilterLogEventsCommand({
|
||||
logGroupName,
|
||||
filterPattern: input.filterPattern || undefined,
|
||||
logStreamNames: input.logStreamNames?.length ? input.logStreamNames : undefined,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
limit: page.limit,
|
||||
nextToken: page.cursor,
|
||||
}))
|
||||
return {
|
||||
items: (response.events ?? []).map((event, index) => ({
|
||||
id: event.eventId ?? `${event.logStreamName ?? ''}-${event.timestamp ?? 0}-${index}`,
|
||||
collectionId: event.logStreamName ?? null,
|
||||
timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null,
|
||||
body: {message: event.message ?? ''},
|
||||
metadata: {
|
||||
ingestionTime: event.ingestionTime ? new Date(event.ingestionTime).toISOString() : null,
|
||||
},
|
||||
})),
|
||||
nextCursor: response.nextToken ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DescribeLogGroups filters by prefix, so `/floci/probe` also returns
|
||||
* `/floci/probe-two`. Filtering to an exact match keeps `get` honest.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import {
|
||||
CreateQueueCommand,
|
||||
DeleteMessageCommand,
|
||||
DeleteQueueCommand,
|
||||
GetQueueAttributesCommand,
|
||||
GetQueueUrlCommand,
|
||||
ListQueuesCommand,
|
||||
PurgeQueueCommand,
|
||||
ReceiveMessageCommand,
|
||||
SendMessageCommand,
|
||||
SetQueueAttributesCommand,
|
||||
StartMessageMoveTaskCommand,
|
||||
type SQSClient,
|
||||
} from '@aws-sdk/client-sqs'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
@@ -15,6 +21,12 @@ import type {
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
SqsMessage,
|
||||
SqsMoveTaskInput,
|
||||
SqsMoveTaskResult,
|
||||
SqsReceiveMessagesInput,
|
||||
SqsSendMessageInput,
|
||||
UpdateResourceInput,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/**
|
||||
@@ -38,6 +50,7 @@ interface QueueAttributes {
|
||||
CreatedTimestamp?: string
|
||||
LastModifiedTimestamp?: string
|
||||
FifoQueue?: string
|
||||
RedrivePolicy?: string
|
||||
}
|
||||
|
||||
export class AwsSqsAdapter implements CloudServiceAdapter {
|
||||
@@ -111,6 +124,69 @@ export class AwsSqsAdapter implements CloudServiceAdapter {
|
||||
await this.sqs.send(new DeleteQueueCommand({QueueUrl: url}))
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
|
||||
const url = await this.requireQueueUrl(id)
|
||||
const attributes: Record<string, string> = {}
|
||||
const visibility = numericAttribute(input.values.visibilityTimeout, 'visibilityTimeout', 0, 43_200)
|
||||
const retention = numericAttribute(input.values.messageRetentionPeriod, 'messageRetentionPeriod', 60, 1_209_600)
|
||||
if (visibility) attributes.VisibilityTimeout = visibility
|
||||
if (retention) attributes.MessageRetentionPeriod = retention
|
||||
const redrive = input.values.redrivePolicy
|
||||
if (redrive !== undefined) {
|
||||
if (!redrive || typeof redrive !== 'object' || Array.isArray(redrive)) throw new ValidationError('redrivePolicy must be an object.')
|
||||
const policy = redrive as {deadLetterTargetArn?: unknown; maxReceiveCount?: unknown}
|
||||
if (typeof policy.deadLetterTargetArn !== 'string' || !policy.deadLetterTargetArn) throw new ValidationError('A dead-letter queue is required.')
|
||||
const count = numericAttribute(policy.maxReceiveCount, 'maxReceiveCount', 1, 1_000)
|
||||
if (!count) throw new ValidationError('maxReceiveCount is required.')
|
||||
attributes.RedrivePolicy = JSON.stringify({deadLetterTargetArn: policy.deadLetterTargetArn, maxReceiveCount: count})
|
||||
}
|
||||
if (Object.keys(attributes).length === 0) throw new ValidationError('No SQS queue settings were supplied.')
|
||||
await this.sqs.send(new SetQueueAttributesCommand({QueueUrl: url, Attributes: attributes}))
|
||||
return (await this.get(id))!
|
||||
}
|
||||
|
||||
async sendSqsMessage(queueId: string, input: SqsSendMessageInput): Promise<{messageId: string}> {
|
||||
const url = await this.requireQueueUrl(queueId)
|
||||
if (!input.messageBody) throw new ValidationError('messageBody is required.')
|
||||
const queue = await this.get(queueId)
|
||||
if (queue?.type === 'fifo-queue' && !input.messageGroupId) throw new ValidationError('messageGroupId is required for FIFO queues.')
|
||||
const messageAttributes = Object.fromEntries(Object.entries(input.messageAttributes ?? {}).map(([name, value]) => [name, {
|
||||
DataType: value.dataType,
|
||||
...(value.stringValue !== undefined ? {StringValue: value.stringValue} : {}),
|
||||
...(value.binaryValue !== undefined ? {BinaryValue: Buffer.from(value.binaryValue, 'base64')} : {}),
|
||||
}]))
|
||||
const response = await this.sqs.send(new SendMessageCommand({QueueUrl: url, MessageBody: input.messageBody, MessageAttributes: messageAttributes, MessageGroupId: input.messageGroupId, MessageDeduplicationId: input.messageDeduplicationId}))
|
||||
return {messageId: response.MessageId ?? ''}
|
||||
}
|
||||
|
||||
async receiveSqsMessages(queueId: string, input: SqsReceiveMessagesInput): Promise<SqsMessage[]> {
|
||||
const url = await this.requireQueueUrl(queueId)
|
||||
const max = input.maxNumberOfMessages ?? 1
|
||||
if (!Number.isInteger(max) || max < 1 || max > 10) throw new ValidationError('maxNumberOfMessages must be between 1 and 10.')
|
||||
const response = await this.sqs.send(new ReceiveMessageCommand({QueueUrl: url, MaxNumberOfMessages: max, MessageSystemAttributeNames: ['All'], MessageAttributeNames: ['All']}))
|
||||
return (response.Messages ?? []).map((message) => ({
|
||||
messageId: message.MessageId ?? '', receiptHandle: message.ReceiptHandle ?? '', body: message.Body ?? '',
|
||||
attributes: message.Attributes ?? {},
|
||||
messageAttributes: Object.fromEntries(Object.entries(message.MessageAttributes ?? {}).map(([name, value]) => [name, {dataType: value.DataType ?? '', stringValue: value.StringValue, binaryValue: value.BinaryValue ? Buffer.from(value.BinaryValue).toString('base64') : undefined}])),
|
||||
}))
|
||||
}
|
||||
|
||||
async deleteSqsMessage(queueId: string, receiptHandle: string): Promise<void> {
|
||||
await this.sqs.send(new DeleteMessageCommand({QueueUrl: await this.requireQueueUrl(queueId), ReceiptHandle: receiptHandle}))
|
||||
}
|
||||
|
||||
async purgeSqsQueue(queueId: string): Promise<void> {
|
||||
await this.sqs.send(new PurgeQueueCommand({QueueUrl: await this.requireQueueUrl(queueId)}))
|
||||
}
|
||||
|
||||
async startSqsMoveTask(queueId: string, input: SqsMoveTaskInput): Promise<SqsMoveTaskResult> {
|
||||
const queue = await this.get(queueId)
|
||||
const sourceArn = typeof queue?.metadata.arn === 'string' ? queue.metadata.arn : undefined
|
||||
if (!sourceArn) throw new NotFoundError(`Queue ${queueId} does not exist`)
|
||||
const response = await this.sqs.send(new StartMessageMoveTaskCommand({SourceArn: sourceArn, DestinationArn: input.destinationArn}))
|
||||
return {taskHandle: response.TaskHandle ?? ''}
|
||||
}
|
||||
|
||||
/** Listing queue names is cheaper than describing each one. */
|
||||
async health(): Promise<void> {
|
||||
await this.sqs.send(new ListQueuesCommand({}))
|
||||
@@ -126,6 +202,12 @@ export class AwsSqsAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private async requireQueueUrl(name: string): Promise<string> {
|
||||
const url = await this.queueUrl(name)
|
||||
if (!url) throw new NotFoundError(`Queue ${name} does not exist`)
|
||||
return url
|
||||
}
|
||||
|
||||
private async attributesFor(url: string): Promise<QueueAttributes> {
|
||||
if (!url) return {}
|
||||
const res = await this.sqs.send(
|
||||
@@ -157,10 +239,21 @@ function toResource(name: string, url: string, attributes: QueueAttributes): Clo
|
||||
maximumMessageSize: numberValue(attributes.MaximumMessageSize),
|
||||
delaySeconds: numberValue(attributes.DelaySeconds),
|
||||
lastModified: epochSecondsToIso(attributes.LastModifiedTimestamp),
|
||||
redrivePolicy: parseRedrivePolicy(attributes.RedrivePolicy),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseRedrivePolicy(value: string | undefined): {deadLetterTargetArn?: string; maxReceiveCount?: number} | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
const policy = JSON.parse(value) as {deadLetterTargetArn?: unknown; maxReceiveCount?: unknown}
|
||||
return {deadLetterTargetArn: typeof policy.deadLetterTargetArn === 'string' ? policy.deadLetterTargetArn : undefined, maxReceiveCount: typeof policy.maxReceiveCount === 'string' ? Number(policy.maxReceiveCount) : undefined}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** SQS reports timestamps as epoch seconds; the contract uses ISO strings. */
|
||||
function epochSecondsToIso(value: string | undefined): string | null {
|
||||
if (!value) return null
|
||||
|
||||
Reference in New Issue
Block a user