feat: Cloudformation + RDS

This commit is contained in:
Zakaria
2026-09-30 17:43:42 -04:00
parent 548a15d7ec
commit 7764ea0dc1
411 changed files with 10182 additions and 1063 deletions
@@ -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