Compare commits

..

1 Commits

Author SHA1 Message Date
Zakaria a2a9c19ff6 Merge pull request 'feat(api): add EFS file systems to the cloud explorer' (#27) from feat/efs into main
CI / checks (push) Has been cancelled
Reviewed-on: #27
2026-10-25 23:44:41 +00:00
33 changed files with 120 additions and 3715 deletions
-3
View File
@@ -81,7 +81,6 @@ cd packages/api && bun run scripts/service-matrix.ts
| Networking | CloudFront / CDN | Yes (list, create, delete, inspect) | No | No |
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
| Integration | AppSync / GraphQL APIs | Yes (list, create, delete, inspect) | No | No |
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
| Integration | EventBridge Pipes / Pipes | Yes (list, create, delete, inspect) | No | No |
@@ -91,8 +90,6 @@ cd packages/api && bun run scripts/service-matrix.ts
| Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No |
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
| Analytics | Glue Data Catalog / Data Catalog | Yes (list, create, delete, inspect) | No | No |
| Analytics | Athena / Query | Yes (list, create, delete, inspect) | No | No |
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
-3
View File
@@ -14,8 +14,6 @@
"@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
"@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-appsync": "^3.1146.0",
"@aws-sdk/client-athena": "^3.1146.0",
"@aws-sdk/client-auto-scaling": "^3.1146.0",
"@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudfront": "^3.1146.0",
@@ -34,7 +32,6 @@
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
"@aws-sdk/client-elasticache": "^3.1146.0",
"@aws-sdk/client-eventbridge": "^3.1076.0",
"@aws-sdk/client-glue": "^3.1146.0",
"@aws-sdk/client-iam": "^3.1076.0",
"@aws-sdk/client-kafka": "^3.1146.0",
"@aws-sdk/client-kafkaconnect": "^3.1146.0",
@@ -1,155 +0,0 @@
import {describe, expect, test} from 'bun:test'
import {
CreateDatabaseCommand,
CreateTableCommand,
DeleteDatabaseCommand,
DeleteTableCommand,
GetDatabaseCommand,
GetDatabasesCommand,
GetTablesCommand,
type GlueClient,
} from '@aws-sdk/client-glue'
import {AwsDataCatalogAdapter, describeTable} from './AwsDataCatalogAdapter'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
const database = {Name: 'shop', Description: 'Orders and customers', LocationUri: 's3://data/shop/', CatalogId: '000000000000', CreateTime: new Date('2026-10-25T18:00:00Z')}
const ordersTable = {
Name: 'orders',
TableType: 'EXTERNAL_TABLE',
Parameters: {classification: 'csv', 'skip.header.line.count': '1'},
StorageDescriptor: {
Columns: [{Name: 'id', Type: 'int'}, {Name: 'amount', Type: 'double'}],
Location: 's3://data/orders/',
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
SerdeInfo: {SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe', Parameters: {'field.delim': ';'}},
},
PartitionKeys: [{Name: 'day', Type: 'string'}],
}
const eventsTable = {
Name: 'events',
StorageDescriptor: {Columns: [{Name: 'kind', Type: 'string'}], Location: 's3://data/events/', SerdeInfo: {SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe'}},
}
function adapterWith(handler: (command: object) => SendResult | undefined = () => undefined) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
const result = handler(command)
if (result) return result
if (command instanceof GetDatabasesCommand) return {DatabaseList: [database]}
if (command instanceof GetDatabaseCommand) return {Database: database}
if (command instanceof GetTablesCommand) return {TableList: [ordersTable, eventsTable]}
return {}
},
}
return {sent, adapter: new AwsDataCatalogAdapter(client as unknown as GlueClient)}
}
describe('AwsDataCatalogAdapter', () => {
test('lists databases across pages with their table counts', async () => {
const {adapter} = adapterWith((command) => {
if (command instanceof GetDatabasesCommand) {
return command.input.NextToken ? {DatabaseList: [{Name: 'logs'}]} : {DatabaseList: [database], NextToken: 'n'}
}
if (command instanceof GetTablesCommand && command.input.DatabaseName === 'logs') return {TableList: []}
return undefined
})
const listed = await adapter.list()
expect(listed.map((db) => [db.name, db.metadata.tableCount, db.metadata.description])).toEqual([['shop', 2, 'Orders and customers'], ['logs', 0, null]])
expect((await adapter.list({search: 'customers'})).map((db) => db.name)).toEqual(['shop'])
})
test('describes each table with its format, location and columns, sorted by name', async () => {
const {adapter} = adapterWith()
const detail = await adapter.get('shop')
const tables = detail?.metadata.tables as Array<Record<string, unknown>>
expect(tables.map((table) => table.name)).toEqual(['events', 'orders'])
expect(tables[1]).toMatchObject({format: 'csv', delimiter: ';', headerRow: true, location: 's3://data/orders/', columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}], partitionKeys: [{name: 'day', type: 'string'}]})
expect(tables[0]).toMatchObject({format: 'json', delimiter: null, headerRow: false})
})
test('reads the format the way the query engine does', () => {
expect(describeTable({Name: 'p', StorageDescriptor: {InputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat'}}).format).toBe('parquet')
expect(describeTable({Name: 'i', Parameters: {table_type: 'ICEBERG'}}).format).toBe('iceberg')
expect(describeTable({Name: 'c'}).format).toBe('csv')
})
test('returns null for a database that does not exist', async () => {
const missing = Object.assign(new Error('missing'), {name: 'EntityNotFoundException'})
const {adapter} = adapterWith((command) => { if (command instanceof GetDatabaseCommand) throw missing; return undefined })
expect(await adapter.get('nope')).toBeNull()
})
test('creates a database with an optional description and location', async () => {
const {adapter, sent} = adapterWith()
await adapter.create({values: {name: 'shop', description: 'Orders', locationUri: 's3://data/shop/'}})
expect((sent.find((command) => command instanceof CreateDatabaseCommand) as CreateDatabaseCommand).input).toEqual({DatabaseInput: {Name: 'shop', Description: 'Orders', LocationUri: 's3://data/shop/'}})
await expect(adapter.create({values: {name: 'Shop'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {name: 'shop', locationUri: 'https://example.com'}})).rejects.toBeInstanceOf(ValidationError)
})
test('creates a CSV table Athena can read, with its delimiter and header row', async () => {
const {adapter, sent} = adapterWith()
await adapter.createCatalogTable('shop', {name: 'orders', location: 's3://data/orders', format: 'csv', delimiter: ';', headerRow: true, columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'DOUBLE'}]})
const create = sent.find((command) => command instanceof CreateTableCommand) as CreateTableCommand
expect(create.input).toEqual({
DatabaseName: 'shop',
TableInput: {
Name: 'orders',
TableType: 'EXTERNAL_TABLE',
Parameters: {classification: 'csv', EXTERNAL: 'TRUE', 'skip.header.line.count': '1'},
StorageDescriptor: {
Columns: [{Name: 'id', Type: 'int'}, {Name: 'amount', Type: 'double'}],
Location: 's3://data/orders/',
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
SerdeInfo: {SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe', Parameters: {'field.delim': ';'}},
},
},
})
})
test('creates JSON and Parquet tables with their own SerDe', async () => {
const {adapter, sent} = adapterWith()
await adapter.createCatalogTable('shop', {name: 'events', location: 's3://data/events/', format: 'json', columns: [{name: 'kind', type: 'string'}]})
await adapter.createCatalogTable('shop', {name: 'facts', location: 's3://data/facts/', format: 'parquet', columns: [{name: 'total', type: 'decimal(12,2)'}]})
const [json, parquet] = sent.filter((command) => command instanceof CreateTableCommand) as CreateTableCommand[]
expect(json.input.TableInput?.StorageDescriptor?.SerdeInfo).toEqual({SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe'})
expect(parquet.input.TableInput?.StorageDescriptor?.InputFormat).toBe('org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat')
expect(parquet.input.TableInput?.StorageDescriptor?.Columns).toEqual([{Name: 'total', Type: 'decimal(12,2)'}])
})
test('refuses tables Athena could not query', async () => {
const {adapter} = adapterWith()
const table = {name: 'orders', location: 's3://data/orders/', format: 'csv' as const, columns: [{name: 'id', type: 'int'}]}
await expect(adapter.createCatalogTable('shop', {...table, columns: []})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createCatalogTable('shop', {...table, columns: [{name: 'id', type: 'int'}, {name: 'id', type: 'string'}]})).rejects.toThrow(/listed twice/)
await expect(adapter.createCatalogTable('shop', {...table, columns: [{name: 'id', type: 'uuid'}]})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createCatalogTable('shop', {...table, location: '/tmp/orders'})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createCatalogTable('shop', {...table, delimiter: '||'})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createCatalogTable('shop', {...table, format: 'avro' as never})).rejects.toBeInstanceOf(ValidationError)
})
test('creating a table in a missing database is a not-found', async () => {
const missing = Object.assign(new Error('missing'), {name: 'EntityNotFoundException'})
const {adapter} = adapterWith((command) => { if (command instanceof GetDatabaseCommand) throw missing; return undefined })
await expect(adapter.createCatalogTable('nope', {name: 'orders', location: 's3://data/orders/', format: 'json', columns: [{name: 'id', type: 'int'}]})).rejects.toBeInstanceOf(NotFoundError)
})
test('deletes tables and databases', async () => {
const {adapter, sent} = adapterWith()
await adapter.deleteCatalogTable('shop', 'orders')
await adapter.delete('shop')
expect((sent.find((command) => command instanceof DeleteTableCommand) as DeleteTableCommand).input).toEqual({DatabaseName: 'shop', Name: 'orders'})
expect((sent.find((command) => command instanceof DeleteDatabaseCommand) as DeleteDatabaseCommand).input).toEqual({Name: 'shop'})
})
})
@@ -1,257 +0,0 @@
import {
CreateDatabaseCommand,
CreateTableCommand,
type Database,
DeleteDatabaseCommand,
DeleteTableCommand,
GetDatabaseCommand,
GetDatabasesCommand,
GetTablesCommand,
type GlueClient,
type StorageDescriptor,
type Table,
} from '@aws-sdk/client-glue'
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsDataCatalogSchema, CATALOG_COLUMN_TYPES, CATALOG_NAME_PATTERN, CATALOG_TABLE_FORMATS} from '../cloud-spi/dataCatalogSchema'
import type {CatalogTableInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
const S3_URI = /^s3:\/\/[a-z0-9.-]{3,63}(\/.*)?$/
const DECIMAL_TYPE = /^decimal\(\d{1,2},\d{1,2}\)$/
/** The Hive storage settings Athena expects for each format, as the Glue console writes them. */
const STORAGE_FOR_FORMAT = {
csv: {
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe',
},
json: {
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe',
},
parquet: {
InputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat',
OutputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat',
SerializationLibrary: 'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe',
},
} as const
/**
* Talks to the AWS Glue Data Catalog, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - The catalog is what TheCloud's Athena reads: a table over CSV, JSON lines
* or Parquet files in S3 can be queried as soon as it exists.
* - Glue jobs and crawlers are stored but never run, so the console offers
* only the catalog: databases and the tables in them.
* - DeleteDatabase removes the database's tables too, as it does in AWS.
*/
export class AwsDataCatalogAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'datacatalog' as const
constructor(private readonly glue: GlueClient) {}
schema(): ServiceSchema {
return awsDataCatalogSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const found: Database[] = []
let next: string | undefined
do {
const res = await this.glue.send(new GetDatabasesCommand({MaxResults: 100, ...(next ? {NextToken: next} : {})}))
found.push(...(res.DatabaseList ?? []))
next = res.NextToken
} while (next)
const search = query.search?.trim().toLowerCase()
const matching = found.filter((database) => !search
|| (database.Name ?? '').toLowerCase().includes(search)
|| (database.Description ?? '').toLowerCase().includes(search))
return Promise.all(matching.map(async (database) => toResource(database, {tableCount: (await this.tables(database.Name ?? '')).length})))
}
async get(id: string): Promise<CloudResource | null> {
const database = await this.database(id)
if (!database) return null
const tables = await this.tables(id)
return toResource(database, {tableCount: tables.length, tables})
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const name = catalogName(values.name, 'name')
const description = text(values.description)
const locationUri = text(values.locationUri)
if (locationUri && !S3_URI.test(locationUri)) throw new ValidationError('locationUri must be an S3 URI such as s3://my-bucket/warehouse/')
await this.glue.send(new CreateDatabaseCommand({
DatabaseInput: {
Name: name,
...(description ? {Description: description} : {}),
...(locationUri ? {LocationUri: locationUri} : {}),
},
}))
const created = await this.get(name)
if (!created) throw new RuntimeError(`Glue did not return the new database ${name}`)
return created
}
async delete(id: string): Promise<void> {
await this.glue.send(new DeleteDatabaseCommand({Name: id}))
}
async createCatalogTable(databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
const name = catalogName(input.name, 'name')
const location = text(input.location)
if (!S3_URI.test(location)) throw new ValidationError('location must be an S3 URI such as s3://my-bucket/orders/')
const format = text(input.format) as CatalogTableInput['format']
if (!(CATALOG_TABLE_FORMATS as readonly string[]).includes(format)) throw new ValidationError(`format must be one of ${CATALOG_TABLE_FORMATS.join(', ')}`)
const columns = tableColumns(input.columns)
const delimiter = typeof input.delimiter === 'string' && input.delimiter !== '' ? input.delimiter : ','
if (format === 'csv' && [...delimiter].length !== 1) throw new ValidationError('delimiter must be a single character')
const description = text(input.description)
if (!await this.database(databaseName)) throw new NotFoundError(`Database ${databaseName} not found`)
const storage = STORAGE_FOR_FORMAT[format]
const csv = format === 'csv'
await this.glue.send(new CreateTableCommand({
DatabaseName: databaseName,
TableInput: {
Name: name,
TableType: 'EXTERNAL_TABLE',
...(description ? {Description: description} : {}),
Parameters: {
classification: format,
EXTERNAL: 'TRUE',
...(csv && input.headerRow ? {'skip.header.line.count': '1'} : {}),
},
StorageDescriptor: {
Columns: columns.map((column) => ({Name: column.name, Type: column.type})),
// Athena reads every file under the prefix, so the location is a directory.
Location: location.endsWith('/') ? location : `${location}/`,
InputFormat: storage.InputFormat,
OutputFormat: storage.OutputFormat,
SerdeInfo: {
SerializationLibrary: storage.SerializationLibrary,
...(csv ? {Parameters: {'field.delim': delimiter}} : {}),
},
},
},
}))
return this.detail(databaseName)
}
async deleteCatalogTable(databaseName: string, tableName: string): Promise<CloudResource> {
await this.glue.send(new DeleteTableCommand({DatabaseName: databaseName, Name: tableName}))
return this.detail(databaseName)
}
private async database(name: string): Promise<Database | null> {
try {
return (await this.glue.send(new GetDatabaseCommand({Name: name}))).Database ?? null
} catch (error) {
if (errorName(error) === 'EntityNotFoundException') return null
throw error
}
}
private async detail(name: string): Promise<CloudResource> {
const detail = await this.get(name)
if (!detail) throw new NotFoundError(`Database ${name} not found`)
return detail
}
private async tables(databaseName: string): Promise<Table[]> {
const found: Table[] = []
let next: string | undefined
do {
const res = await this.glue.send(new GetTablesCommand({DatabaseName: databaseName, MaxResults: 100, ...(next ? {NextToken: next} : {})}))
found.push(...(res.TableList ?? []))
next = res.NextToken
} while (next)
return found.sort((a, b) => (a.Name ?? '').localeCompare(b.Name ?? ''))
}
}
function toResource(database: Database, detail: {tableCount: number; tables?: Table[]}): CloudResource {
const name = database.Name ?? ''
return {
id: name,
name,
cloud: 'aws',
service: 'datacatalog',
type: 'glueDatabase',
region: null,
createdAt: database.CreateTime ? database.CreateTime.toISOString() : null,
metadata: {
description: database.Description || null,
locationUri: database.LocationUri || null,
catalogId: database.CatalogId ?? null,
tableCount: detail.tableCount,
...(detail.tables ? {tables: detail.tables.map(describeTable)} : {}),
},
}
}
/** What the panel shows for a table: its files, how they are read, and its columns. */
export function describeTable(table: Table) {
const storage: StorageDescriptor = table.StorageDescriptor ?? {}
const format = tableFormat(table)
return {
name: table.Name ?? '',
description: table.Description || null,
format,
location: storage.Location ?? null,
delimiter: format === 'csv' ? storage.SerdeInfo?.Parameters?.['field.delim'] ?? ',' : null,
headerRow: table.Parameters?.['skip.header.line.count'] === '1',
columns: (storage.Columns ?? []).map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
partitionKeys: (table.PartitionKeys ?? []).map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
tableType: table.TableType ?? null,
createdAt: table.CreateTime ? table.CreateTime.toISOString() : null,
updatedAt: table.UpdateTime ? table.UpdateTime.toISOString() : null,
}
}
/** The same order the runtime's Athena engine checks a table's storage in. */
function tableFormat(table: Table): string {
const classification = table.Parameters?.classification?.toLowerCase()
if (classification === 'iceberg' || table.Parameters?.table_type?.toLowerCase() === 'iceberg') return 'iceberg'
const storage = table.StorageDescriptor
const hints = `${storage?.InputFormat ?? ''} ${storage?.SerdeInfo?.SerializationLibrary ?? ''}`.toLowerCase()
if (hints.includes('parquet')) return 'parquet'
if (hints.includes('json')) return 'json'
return classification || 'csv'
}
function tableColumns(value: unknown): Array<{name: string; type: string}> {
if (!Array.isArray(value) || value.length === 0) throw new ValidationError('columns must list at least one column')
const seen = new Set<string>()
return value.map((column, index) => {
const entry = typeof column === 'object' && column !== null ? column as Record<string, unknown> : {}
const name = catalogName(entry.name, `columns[${index}].name`)
if (seen.has(name)) throw new ValidationError(`column ${name} is listed twice`)
seen.add(name)
const type = text(entry.type).toLowerCase()
if (!(CATALOG_COLUMN_TYPES as readonly string[]).includes(type) && !DECIMAL_TYPE.test(type)) {
throw new ValidationError(`columns[${index}].type must be one of ${CATALOG_COLUMN_TYPES.join(', ')}`)
}
return {name, type}
})
}
function catalogName(value: unknown, field: string): string {
const name = text(value)
if (!new RegExp(CATALOG_NAME_PATTERN).test(name)) throw new ValidationError(`${field} must be lowercase letters, numbers and underscores, starting with a letter or underscore`)
return name
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function errorName(error: unknown): string | undefined {
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
}
@@ -1,165 +0,0 @@
import {describe, expect, test} from 'bun:test'
import {
type AppSyncClient,
CreateApiKeyCommand,
CreateDataSourceCommand,
CreateGraphqlApiCommand,
CreateResolverCommand,
DeleteResolverCommand,
GetGraphqlApiCommand,
GetIntrospectionSchemaCommand,
GetSchemaCreationStatusCommand,
ListApiKeysCommand,
ListDataSourcesCommand,
ListGraphqlApisCommand,
ListResolversCommand,
StartSchemaCreationCommand,
} from '@aws-sdk/client-appsync'
import {AwsGraphqlAdapter, objectTypes, schemaFailure, sdlFrom} from './AwsGraphqlAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
const sdl = `"""A note"""
type Note { id: ID! text: String }
# the entry points
type Query { note(id: ID!): Note notes(filter: NoteFilter, limit: Int = 10): [Note] }
type Mutation {
addNote(text: String!): Note
}
input NoteFilter { text: String }
schema { query: Query mutation: Mutation }`
const api = {
apiId: 'a1', name: 'notes-api', authenticationType: 'API_KEY', arn: 'arn:aws:appsync:eu-west-1:000000000000:apis/a1',
uris: {GRAPHQL: 'http://localhost:4566/v1/apis/a1/graphql', REALTIME: 'ws://localhost:4566/v1/apis/a1/graphql/realtime'},
}
function adapterWith(handler: (command: object) => SendResult | undefined = () => undefined) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
const result = handler(command)
if (result) return result
if (command instanceof GetGraphqlApiCommand) return {graphqlApi: api}
if (command instanceof CreateGraphqlApiCommand) return {graphqlApi: api}
if (command instanceof GetIntrospectionSchemaCommand) return {schema: new TextEncoder().encode(sdl)}
if (command instanceof GetSchemaCreationStatusCommand) return {status: 'SUCCESS'}
if (command instanceof ListApiKeysCommand) return {apiKeys: [{id: 'da2-key', description: 'web', expires: 1793570400}]}
if (command instanceof ListDataSourcesCommand) return {dataSources: [{name: 'notesTable', type: 'AMAZON_DYNAMODB', dynamodbConfig: {tableName: 'notes'}, serviceRoleArn: 'arn:aws:iam::000000000000:role/appsync'}]}
if (command instanceof ListResolversCommand) {
return command.input.typeName === 'Query' ? {resolvers: [{typeName: 'Query', fieldName: 'note', dataSourceName: 'notesTable', kind: 'UNIT', runtime: {name: 'APPSYNC_JS'}, code: 'export function request() {}'}]} : {resolvers: []}
}
return {}
},
}
return {sent, adapter: new AwsGraphqlAdapter(client as unknown as AppSyncClient, async () => {})}
}
describe('AwsGraphqlAdapter', () => {
test('reads the object types and fields a resolver can attach to', () => {
expect(objectTypes(sdl)).toEqual([
{name: 'Query', fields: ['note', 'notes']},
{name: 'Mutation', fields: ['addNote']},
{name: 'Note', fields: ['id', 'text']},
])
expect(objectTypes('type Query { a: String }\nextend type Query { b: Int }')).toEqual([{name: 'Query', fields: ['a', 'b']}])
})
test('reads the SDL whether it comes bare, as from AWS, or wrapped, as from the runtime', () => {
expect(sdlFrom('type Query { a: String }')).toBe('type Query { a: String }')
expect(sdlFrom(JSON.stringify({schema: {definition: 'type Query { a: String }'}}))).toBe('type Query { a: String }')
})
test('explains a rejected schema in the parser\'s words', () => {
const runtime = JSON.stringify({reason: 'CODE_ERROR', detail: {codeErrors: [{errorType: 'PARSER_ERROR', value: "Invalid syntax with offending token '<EOF>' at line 1 column 20"}]}})
expect(schemaFailure(runtime)).toBe("Invalid syntax with offending token '<EOF>' at line 1 column 20")
expect(schemaFailure('Failed to parse schema document')).toBe('Failed to parse schema document')
expect(schemaFailure(undefined)).toBe('AppSync rejected it')
})
test('lists APIs with their endpoint and authorization', async () => {
const {adapter} = adapterWith((command) => command instanceof ListGraphqlApisCommand ? {graphqlApis: [api]} : undefined)
const [listed] = await adapter.list()
expect([listed.id, listed.name, listed.metadata.authenticationLabel, listed.metadata.graphqlUrl]).toEqual(['a1', 'notes-api', 'API key', 'http://localhost:4566/v1/apis/a1/graphql'])
expect(await adapter.list({search: 'other'})).toEqual([])
})
test('describes the schema, keys, data sources and resolvers', async () => {
const {adapter, sent} = adapterWith()
const detail = await adapter.get('a1')
expect(detail?.metadata.definition).toBe(sdl)
expect(detail?.metadata.apiKeys).toEqual([{id: 'da2-key', description: 'web', expiresAt: '2026-11-01T22:00:00.000Z'}])
expect(detail?.metadata.dataSources).toEqual([{name: 'notesTable', type: 'AMAZON_DYNAMODB', description: null, tableName: 'notes', functionArn: null, serviceRoleArn: 'arn:aws:iam::000000000000:role/appsync'}])
expect(detail?.metadata.resolvers).toEqual([{typeName: 'Query', fieldName: 'note', dataSourceName: 'notesTable', kind: 'UNIT', runtime: 'APPSYNC_JS', code: 'export function request() {}'}])
expect((sent.filter((command) => command instanceof ListResolversCommand) as ListResolversCommand[]).map((command) => command.input.typeName)).toEqual(['Query', 'Mutation', 'Note'])
})
test('a new API-key API gets its first key, and Cognito needs a user pool', async () => {
const {adapter, sent} = adapterWith()
await adapter.create({values: {name: 'notes-api', authenticationType: 'API_KEY'}})
expect(sent.some((command) => command instanceof CreateApiKeyCommand)).toBe(true)
await adapter.create({values: {name: 'members', authenticationType: 'AMAZON_COGNITO_USER_POOLS', userPoolId: 'us-east-1_AbC123'}})
const cognito = (sent.filter((command) => command instanceof CreateGraphqlApiCommand) as CreateGraphqlApiCommand[])[1]
expect(cognito.input.userPoolConfig).toEqual({userPoolId: 'us-east-1_AbC123', awsRegion: 'us-east-1', defaultAction: 'ALLOW'})
await expect(adapter.create({values: {name: 'members', authenticationType: 'AMAZON_COGNITO_USER_POOLS'}})).rejects.toBeInstanceOf(ValidationError)
})
test('saving a schema waits for AppSync to compile it and reports a rejection', async () => {
let polls = 0
const {adapter, sent} = adapterWith((command) => command instanceof GetSchemaCreationStatusCommand ? {status: ++polls < 3 ? 'PROCESSING' : 'SUCCESS'} : undefined)
await adapter.updateGraphqlSchema('a1', 'type Query { a: String }')
expect(new TextDecoder().decode((sent.find((command) => command instanceof StartSchemaCreationCommand) as StartSchemaCreationCommand).input.definition)).toBe('type Query { a: String }')
expect(polls).toBeGreaterThanOrEqual(3)
const failing = adapterWith((command) => command instanceof GetSchemaCreationStatusCommand ? {status: 'FAILED', details: 'Syntax Error: Unexpected Name "typo"'} : undefined)
await expect(failing.adapter.updateGraphqlSchema('a1', 'typo Query')).rejects.toThrow('The schema was not saved: Syntax Error: Unexpected Name "typo"')
})
test('creates an API key that expires after the given days', async () => {
const {adapter, sent} = adapterWith()
const before = Math.floor(Date.now() / 1000)
await adapter.createGraphqlApiKey('a1', {description: 'mobile', expiresInDays: 30})
const create = sent.find((command) => command instanceof CreateApiKeyCommand) as CreateApiKeyCommand
expect(create.input.description).toBe('mobile')
expect((create.input.expires ?? 0) - before).toBeGreaterThanOrEqual(30 * 86_400)
await expect(adapter.createGraphqlApiKey('a1', {expiresInDays: 400})).rejects.toBeInstanceOf(ValidationError)
})
test('creates DynamoDB and Lambda data sources with a role, in the API region', async () => {
const {adapter, sent} = adapterWith()
const role = 'arn:aws:iam::000000000000:role/appsync'
await adapter.createGraphqlDataSource('a1', {name: 'notesTable', type: 'AMAZON_DYNAMODB', tableName: 'notes', serviceRoleArn: role})
await adapter.createGraphqlDataSource('a1', {name: 'notesFn', type: 'AWS_LAMBDA', functionArn: 'arn:aws:lambda:eu-west-1:000000000000:function:notes', serviceRoleArn: role})
await adapter.createGraphqlDataSource('a1', {name: 'local', type: 'NONE'})
const [table, fn, none] = (sent.filter((command) => command instanceof CreateDataSourceCommand) as CreateDataSourceCommand[]).map((command) => command.input)
expect(table).toEqual({apiId: 'a1', name: 'notesTable', type: 'AMAZON_DYNAMODB', serviceRoleArn: role, dynamodbConfig: {tableName: 'notes', awsRegion: 'eu-west-1'}})
expect(fn.lambdaConfig).toEqual({lambdaFunctionArn: 'arn:aws:lambda:eu-west-1:000000000000:function:notes'})
expect(none).toEqual({apiId: 'a1', name: 'local', type: 'NONE'})
await expect(adapter.createGraphqlDataSource('a1', {name: 'notesTable', type: 'AMAZON_DYNAMODB', tableName: 'notes'})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createGraphqlDataSource('a1', {name: 'bad-name', type: 'NONE'})).rejects.toBeInstanceOf(ValidationError)
})
test('attaches a JavaScript resolver only to a field the schema has', async () => {
const {adapter, sent} = adapterWith()
const code = 'export function request(ctx) { return {} }\nexport function response(ctx) { return ctx.result }'
await adapter.createGraphqlResolver('a1', {typeName: 'Mutation', fieldName: 'addNote', dataSourceName: 'notesTable', code})
expect((sent.find((command) => command instanceof CreateResolverCommand) as CreateResolverCommand).input).toEqual({
apiId: 'a1', typeName: 'Mutation', fieldName: 'addNote', dataSourceName: 'notesTable', kind: 'UNIT', code, runtime: {name: 'APPSYNC_JS', runtimeVersion: '1.0.0'},
})
await expect(adapter.createGraphqlResolver('a1', {typeName: 'Query', fieldName: 'nope', dataSourceName: 'notesTable', code})).rejects.toThrow('Query has no field nope')
await expect(adapter.createGraphqlResolver('a1', {typeName: 'Ghost', fieldName: 'a', dataSourceName: 'notesTable', code})).rejects.toThrow('The schema has no type Ghost')
await expect(adapter.createGraphqlResolver('a1', {typeName: 'Query', fieldName: 'note', dataSourceName: 'notesTable', code: 'return 1'})).rejects.toBeInstanceOf(ValidationError)
})
test('detaches a resolver', async () => {
const {adapter, sent} = adapterWith()
await adapter.deleteGraphqlResolver('a1', 'Query', 'note')
expect((sent.find((command) => command instanceof DeleteResolverCommand) as DeleteResolverCommand).input).toEqual({apiId: 'a1', typeName: 'Query', fieldName: 'note'})
})
})
@@ -1,405 +0,0 @@
import {
type ApiKey,
type AppSyncClient,
CreateApiKeyCommand,
CreateDataSourceCommand,
CreateGraphqlApiCommand,
CreateResolverCommand,
type DataSource,
DeleteApiKeyCommand,
DeleteDataSourceCommand,
DeleteGraphqlApiCommand,
DeleteResolverCommand,
GetGraphqlApiCommand,
GetIntrospectionSchemaCommand,
GetSchemaCreationStatusCommand,
type GetSchemaCreationStatusCommandOutput,
type GraphqlApi,
ListApiKeysCommand,
ListDataSourcesCommand,
ListGraphqlApisCommand,
ListResolversCommand,
type Resolver,
StartSchemaCreationCommand,
} from '@aws-sdk/client-appsync'
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
import {
awsGraphqlSchema,
GRAPHQL_API_KEY_MAX_DAYS,
GRAPHQL_API_NAME_PATTERN,
GRAPHQL_AUTH_TYPES,
GRAPHQL_DATA_SOURCE_NAME_PATTERN,
GRAPHQL_DATA_SOURCE_TYPES,
} from '../cloud-spi/graphqlSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
GraphqlApiKeyInput,
GraphqlDataSourceInput,
GraphqlResolverInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
const AUTH_LABELS: Record<string, string> = {API_KEY: 'API key', AWS_IAM: 'IAM', AMAZON_COGNITO_USER_POOLS: 'Cognito user pool', OPENID_CONNECT: 'OpenID Connect', AWS_LAMBDA: 'Lambda'}
const DAY_SECONDS = 86_400
/** How long a schema save waits for AppSync to finish compiling it before reporting PROCESSING. */
const SCHEMA_WAIT_ATTEMPTS = 20
const SCHEMA_WAIT_MS = 250
const ROLE_ARN = /^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/
const LAMBDA_ARN = /^arn:aws[a-z-]*:lambda:[a-z0-9-]+:\d{12}:function:.+$/
/**
* Talks to AWS AppSync, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - APIs, schemas, API keys, data sources and resolvers are all stored, and the
* GraphQL endpoint checks queries against the schema and the caller's key.
* - Resolvers are never run, so every field of a query comes back null. The
* console says so instead of offering a query runner that looks broken.
* - GetIntrospectionSchema wraps the SDL in {"schema": {"definition": …}} where
* AWS returns the SDL itself; both are read.
* - ListTypes returns nothing, so the types a resolver can attach to come from
* the schema's own SDL.
*/
export class AwsGraphqlAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'graphql' as const
constructor(private readonly appsync: AppSyncClient, private readonly sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {}
schema(): ServiceSchema {
return awsGraphqlSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const found: GraphqlApi[] = []
let next: string | undefined
do {
const res = await this.appsync.send(new ListGraphqlApisCommand({maxResults: 25, ...(next ? {nextToken: next} : {})}))
found.push(...(res.graphqlApis ?? []))
next = res.nextToken
} while (next)
const search = query.search?.trim().toLowerCase()
return found.map((api) => toResource(api)).filter((api) => !search
|| api.name.toLowerCase().includes(search)
|| api.id.toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
let api: GraphqlApi | undefined
try {
api = (await this.appsync.send(new GetGraphqlApiCommand({apiId: id}))).graphqlApi
} catch (error) {
if (errorName(error) === 'NotFoundException') return null
throw error
}
if (!api) return null
const [definition, schemaStatus, apiKeys, dataSources] = await Promise.all([this.definition(id), this.schemaStatus(id), this.apiKeys(id), this.dataSources(id)])
const types = definition ? objectTypes(definition) : []
const resolvers = (await Promise.all(types.map((type) => this.resolvers(id, type.name)))).flat()
return toResource(api, {definition, schemaStatus, apiKeys, dataSources, resolvers, types})
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const name = text(values.name)
if (!new RegExp(GRAPHQL_API_NAME_PATTERN).test(name)) throw new ValidationError('name must be up to 128 letters, numbers, spaces, dashes or underscores')
const authenticationType = text(values.authenticationType) || 'API_KEY'
if (!(GRAPHQL_AUTH_TYPES as readonly string[]).includes(authenticationType)) throw new ValidationError(`authenticationType must be one of ${GRAPHQL_AUTH_TYPES.join(', ')}`)
const userPoolId = text(values.userPoolId)
if (authenticationType === 'AMAZON_COGNITO_USER_POOLS' && !userPoolId) throw new ValidationError('userPoolId is required for Cognito user pool authorization')
const created = await this.appsync.send(new CreateGraphqlApiCommand({
name,
authenticationType: authenticationType as (typeof GRAPHQL_AUTH_TYPES)[number],
...(authenticationType === 'AMAZON_COGNITO_USER_POOLS' ? {userPoolConfig: {userPoolId, awsRegion: userPoolId.split('_')[0], defaultAction: 'ALLOW'}} : {}),
}))
const apiId = created.graphqlApi?.apiId
if (!apiId) throw new RuntimeError('AppSync did not return the new API id')
// The AppSync console gives a new API-key API its first key, valid for seven days.
if (authenticationType === 'API_KEY') await this.appsync.send(new CreateApiKeyCommand({apiId, expires: expiry(7)}))
return this.detail(apiId)
}
async delete(id: string): Promise<void> {
await this.appsync.send(new DeleteGraphqlApiCommand({apiId: id}))
}
async updateGraphqlSchema(apiId: string, definition: string): Promise<CloudResource> {
const sdl = typeof definition === 'string' ? definition.trim() : ''
if (!sdl) throw new ValidationError('Enter a schema to save')
await this.appsync.send(new StartSchemaCreationCommand({apiId, definition: new TextEncoder().encode(sdl)}))
let status: GetSchemaCreationStatusCommandOutput | null = null
for (let attempt = 0; attempt < SCHEMA_WAIT_ATTEMPTS; attempt++) {
status = await this.appsync.send(new GetSchemaCreationStatusCommand({apiId}))
if (status.status !== 'PROCESSING') break
await this.sleep(SCHEMA_WAIT_MS)
}
if (status?.status === 'FAILED') throw new ValidationError(`The schema was not saved: ${schemaFailure(status.details)}`)
return this.detail(apiId)
}
async createGraphqlApiKey(apiId: string, input: GraphqlApiKeyInput): Promise<CloudResource> {
const days = input.expiresInDays ?? 7
if (!Number.isInteger(days) || days < 1 || days > GRAPHQL_API_KEY_MAX_DAYS) throw new ValidationError(`expiresInDays must be a whole number from 1 to ${GRAPHQL_API_KEY_MAX_DAYS}`)
const description = text(input.description)
await this.appsync.send(new CreateApiKeyCommand({apiId, expires: expiry(days), ...(description ? {description} : {})}))
return this.detail(apiId)
}
async deleteGraphqlApiKey(apiId: string, keyId: string): Promise<CloudResource> {
await this.appsync.send(new DeleteApiKeyCommand({apiId, id: keyId}))
return this.detail(apiId)
}
async createGraphqlDataSource(apiId: string, input: GraphqlDataSourceInput): Promise<CloudResource> {
const name = text(input.name)
if (!new RegExp(GRAPHQL_DATA_SOURCE_NAME_PATTERN).test(name)) throw new ValidationError('name must start with a letter or underscore and use only letters, numbers and underscores')
const type = text(input.type)
if (!(GRAPHQL_DATA_SOURCE_TYPES as readonly string[]).includes(type)) throw new ValidationError(`type must be one of ${GRAPHQL_DATA_SOURCE_TYPES.join(', ')}`)
const description = text(input.description)
const serviceRoleArn = text(input.serviceRoleArn)
if (type !== 'NONE' && !ROLE_ARN.test(serviceRoleArn)) throw new ValidationError('serviceRoleArn must be the IAM role AppSync uses to reach the table or function')
const tableName = text(input.tableName)
if (type === 'AMAZON_DYNAMODB' && !tableName) throw new ValidationError('tableName is required for a DynamoDB data source')
const functionArn = text(input.functionArn)
if (type === 'AWS_LAMBDA' && !LAMBDA_ARN.test(functionArn)) throw new ValidationError('functionArn must be a Lambda function ARN')
const region = await this.region(apiId)
await this.appsync.send(new CreateDataSourceCommand({
apiId,
name,
type: type as (typeof GRAPHQL_DATA_SOURCE_TYPES)[number],
...(description ? {description} : {}),
...(type !== 'NONE' ? {serviceRoleArn} : {}),
...(type === 'AMAZON_DYNAMODB' ? {dynamodbConfig: {tableName, awsRegion: region}} : {}),
...(type === 'AWS_LAMBDA' ? {lambdaConfig: {lambdaFunctionArn: functionArn}} : {}),
}))
return this.detail(apiId)
}
async deleteGraphqlDataSource(apiId: string, name: string): Promise<CloudResource> {
await this.appsync.send(new DeleteDataSourceCommand({apiId, name}))
return this.detail(apiId)
}
async createGraphqlResolver(apiId: string, input: GraphqlResolverInput): Promise<CloudResource> {
const typeName = text(input.typeName)
const fieldName = text(input.fieldName)
const dataSourceName = text(input.dataSourceName)
const code = typeof input.code === 'string' ? input.code : ''
if (!dataSourceName) throw new ValidationError('dataSourceName is required')
if (!/export\s+function\s+request\b/.test(code) || !/export\s+function\s+response\b/.test(code)) {
throw new ValidationError('code must export a request(ctx) and a response(ctx) function')
}
// AppSync refuses a resolver for a field the schema does not have; the runtime does not, so check here.
const definition = await this.definition(apiId)
const type = definition ? objectTypes(definition).find((candidate) => candidate.name === typeName) : undefined
if (!type) throw new ValidationError(`The schema has no type ${typeName}`)
if (!type.fields.includes(fieldName)) throw new ValidationError(`${typeName} has no field ${fieldName}`)
await this.appsync.send(new CreateResolverCommand({
apiId, typeName, fieldName, dataSourceName, kind: 'UNIT', code, runtime: {name: 'APPSYNC_JS', runtimeVersion: '1.0.0'},
}))
return this.detail(apiId)
}
async deleteGraphqlResolver(apiId: string, typeName: string, fieldName: string): Promise<CloudResource> {
await this.appsync.send(new DeleteResolverCommand({apiId, typeName, fieldName}))
return this.detail(apiId)
}
private async detail(apiId: string): Promise<CloudResource> {
const detail = await this.get(apiId)
if (!detail) throw new NotFoundError(`GraphQL API ${apiId} not found`)
return detail
}
private async region(apiId: string): Promise<string> {
const api = (await this.appsync.send(new GetGraphqlApiCommand({apiId}))).graphqlApi
return api?.arn?.split(':')[3] || 'us-east-1'
}
private async definition(apiId: string): Promise<string | null> {
try {
const res = await this.appsync.send(new GetIntrospectionSchemaCommand({apiId, format: 'SDL'}))
return res.schema ? sdlFrom(new TextDecoder().decode(res.schema)) : null
} catch (error) {
if (errorName(error) === 'NotFoundException') return null
throw error
}
}
private async schemaStatus(apiId: string): Promise<{status: string; details: string | null} | null> {
try {
const res = await this.appsync.send(new GetSchemaCreationStatusCommand({apiId}))
return res.status ? {status: res.status, details: res.details ?? null} : null
} catch (error) {
if (errorName(error) === 'NotFoundException') return null
throw error
}
}
private async apiKeys(apiId: string): Promise<ApiKey[]> {
const found: ApiKey[] = []
let next: string | undefined
do {
const res = await this.appsync.send(new ListApiKeysCommand({apiId, maxResults: 25, ...(next ? {nextToken: next} : {})}))
found.push(...(res.apiKeys ?? []))
next = res.nextToken
} while (next)
return found
}
private async dataSources(apiId: string): Promise<DataSource[]> {
const found: DataSource[] = []
let next: string | undefined
do {
const res = await this.appsync.send(new ListDataSourcesCommand({apiId, maxResults: 25, ...(next ? {nextToken: next} : {})}))
found.push(...(res.dataSources ?? []))
next = res.nextToken
} while (next)
return found.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
}
private async resolvers(apiId: string, typeName: string): Promise<Resolver[]> {
const found: Resolver[] = []
let next: string | undefined
do {
const res = await this.appsync.send(new ListResolversCommand({apiId, typeName, maxResults: 25, ...(next ? {nextToken: next} : {})}))
found.push(...(res.resolvers ?? []))
next = res.nextToken
} while (next)
return found
}
}
type ApiDetail = {
definition: string | null
schemaStatus: {status: string; details: string | null} | null
apiKeys: ApiKey[]
dataSources: DataSource[]
resolvers: Resolver[]
types: Array<{name: string; fields: string[]}>
}
function toResource(api: GraphqlApi, detail?: ApiDetail): CloudResource {
const id = api.apiId ?? ''
const authenticationType = api.authenticationType ?? null
return {
id,
name: api.name ?? id,
cloud: 'aws',
service: 'graphql',
type: 'graphqlApi',
region: api.arn?.split(':')[3] || null,
createdAt: null,
metadata: {
apiId: id,
arn: api.arn ?? null,
authenticationType,
authenticationLabel: authenticationType ? AUTH_LABELS[authenticationType] ?? authenticationType : null,
userPoolId: api.userPoolConfig?.userPoolId ?? null,
graphqlUrl: api.uris?.GRAPHQL ?? null,
realtimeUrl: api.uris?.REALTIME ?? null,
...(detail ? {
definition: detail.definition,
schemaStatus: detail.schemaStatus,
types: detail.types,
apiKeys: detail.apiKeys.map((key) => ({
id: key.id ?? '',
description: key.description || null,
expiresAt: key.expires ? new Date(key.expires * 1000).toISOString() : null,
})),
dataSources: detail.dataSources.map((source) => ({
name: source.name ?? '',
type: source.type ?? null,
description: source.description || null,
tableName: source.dynamodbConfig?.tableName ?? null,
functionArn: source.lambdaConfig?.lambdaFunctionArn ?? null,
serviceRoleArn: source.serviceRoleArn ?? null,
})),
resolvers: detail.resolvers.map((resolver) => ({
typeName: resolver.typeName ?? '',
fieldName: resolver.fieldName ?? '',
dataSourceName: resolver.dataSourceName ?? null,
kind: resolver.kind ?? null,
runtime: resolver.runtime?.name ?? (resolver.requestMappingTemplate ? 'VTL' : null),
code: resolver.code ?? null,
})),
} : {}),
},
}
}
/**
* Why a schema was rejected. AWS sends a sentence; the runtime sends JSON whose
* codeErrors carry the parser's messages, which are what a person needs.
*/
export function schemaFailure(details: string | undefined): string {
if (!details) return 'AppSync rejected it'
try {
const parsed = JSON.parse(details) as {detail?: {codeErrors?: Array<{value?: unknown}>}}
const messages = (parsed.detail?.codeErrors ?? []).map((error) => error.value).filter((value): value is string => typeof value === 'string' && value !== '')
return messages.length > 0 ? messages.join('; ') : details
} catch {
return details
}
}
/** AWS returns the SDL itself; the runtime wraps it as {"schema": {"definition": …}}. */
export function sdlFrom(body: string): string {
const trimmed = body.trim()
if (!trimmed.startsWith('{"')) return body
try {
const parsed = JSON.parse(trimmed) as {schema?: {definition?: unknown}}
return typeof parsed.schema?.definition === 'string' ? parsed.schema.definition : body
} catch {
return body
}
}
/**
* The object types of an SDL schema and their field names: what a resolver can
* attach to. A small reader, not a GraphQL parser: it drops comments, strings
* and arguments, then reads `type Name { field: … }` blocks.
*/
export function objectTypes(sdl: string): Array<{name: string; fields: string[]}> {
const stripped = sdl
.replace(/"""[\s\S]*?"""/g, '')
.replace(/"(?:[^"\\\n]|\\.)*"/g, '')
.replace(/#[^\n]*/g, '')
const types: Array<{name: string; fields: string[]}> = []
for (const match of stripped.matchAll(/(?:^|\s)(?:extend\s+)?type\s+([_A-Za-z][_0-9A-Za-z]*)[^{]*\{([^}]*)\}/g)) {
let body = match[2]
// Arguments may nest one level of input object literals; drop them before reading fields.
for (let previous = ''; previous !== body;) { previous = body; body = body.replace(/\([^()]*\)/g, '') }
const fields = [...body.matchAll(/([_A-Za-z][_0-9A-Za-z]*)\s*:/g)].map((field) => field[1])
const existing = types.find((type) => type.name === match[1])
if (existing) existing.fields.push(...fields.filter((field) => !existing.fields.includes(field)))
else types.push({name: match[1], fields})
}
const rootOrder = ['Query', 'Mutation', 'Subscription']
return types.sort((a, b) => {
const rank = (name: string) => rootOrder.includes(name) ? rootOrder.indexOf(name) : rootOrder.length
return rank(a.name) - rank(b.name) || a.name.localeCompare(b.name)
})
}
function expiry(days: number): number {
return Math.floor(Date.now() / 1000) + days * DAY_SECONDS
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function errorName(error: unknown): string | undefined {
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
}
@@ -1,166 +0,0 @@
import {describe, expect, test} from 'bun:test'
import {
type AthenaClient,
CreateWorkGroupCommand,
DeleteWorkGroupCommand,
GetQueryExecutionCommand,
GetQueryResultsCommand,
GetWorkGroupCommand,
ListDatabasesCommand,
ListQueryExecutionsCommand,
ListTableMetadataCommand,
ListWorkGroupsCommand,
StartQueryExecutionCommand,
StopQueryExecutionCommand,
} from '@aws-sdk/client-athena'
import {AwsQueryAdapter} from './AwsQueryAdapter'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function execution(id: string, overrides: Record<string, unknown> = {}) {
return {
QueryExecutionId: id,
Query: 'SELECT name, amount FROM orders',
StatementType: 'DML',
WorkGroup: 'analysts',
QueryExecutionContext: {Database: 'shop', Catalog: 'AwsDataCatalog'},
ResultConfiguration: {OutputLocation: `s3://results/${id}.csv`},
Status: {State: 'SUCCEEDED', SubmissionDateTime: new Date('2026-10-25T21:00:00Z'), CompletionDateTime: new Date('2026-10-25T21:00:01Z')},
Statistics: {TotalExecutionTimeInMillis: 42, DataScannedInBytes: 0},
...overrides,
}
}
const header = {Data: [{VarCharValue: 'name'}, {VarCharValue: 'amount'}]}
const columnInfo = [{Name: 'name', Label: 'name', Type: 'varchar'}, {Name: 'amount', Label: 'amount', Type: 'double'}]
const row = (name: string, amount: string) => ({Data: [{VarCharValue: name}, {VarCharValue: amount}]})
function adapterWith(handler: (command: object) => SendResult | undefined = () => undefined) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
const result = handler(command)
if (result) return result
if (command instanceof GetQueryExecutionCommand) return {QueryExecution: execution(command.input.QueryExecutionId ?? '')}
if (command instanceof GetWorkGroupCommand) {
return {WorkGroup: {Name: command.input.WorkGroup, State: 'ENABLED', Description: 'Team', Configuration: {ResultConfiguration: {OutputLocation: 's3://results/'}, EnforceWorkGroupConfiguration: true, EngineVersion: {EffectiveEngineVersion: 'Athena engine version 3'}}}}
}
if (command instanceof StartQueryExecutionCommand) return {QueryExecutionId: 'q-new'}
return {}
},
}
return {sent, adapter: new AwsQueryAdapter(client as unknown as AthenaClient)}
}
describe('AwsQueryAdapter', () => {
test('lists workgroups across pages and describes one with its result location', async () => {
const {adapter} = adapterWith((command) => command instanceof ListWorkGroupsCommand
? command.input.NextToken ? {WorkGroups: [{Name: 'analysts', State: 'ENABLED', Description: 'Team'}]} : {WorkGroups: [{Name: 'primary', State: 'ENABLED'}], NextToken: 'n'}
: undefined)
expect((await adapter.list()).map((wg) => [wg.name, wg.status])).toEqual([['primary', 'ENABLED'], ['analysts', 'ENABLED']])
expect((await adapter.list({search: 'team'})).map((wg) => wg.name)).toEqual(['analysts'])
expect((await adapter.get('analysts'))?.metadata).toEqual({description: 'Team', outputLocation: 's3://results/', enforceConfiguration: true, engineVersion: 'Athena engine version 3'})
})
test('creates a workgroup with a result folder, and keeps the primary workgroup', async () => {
const {adapter, sent} = adapterWith()
await adapter.create({values: {name: 'analysts', description: 'Team', outputLocation: 's3://results/athena', enforce: 'true'}})
expect((sent.find((command) => command instanceof CreateWorkGroupCommand) as CreateWorkGroupCommand).input).toEqual({
Name: 'analysts', Description: 'Team', Configuration: {ResultConfiguration: {OutputLocation: 's3://results/athena/'}, EnforceWorkGroupConfiguration: true},
})
await expect(adapter.create({values: {name: 'primary'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {name: 'bad name'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.delete('primary')).rejects.toBeInstanceOf(ValidationError)
await adapter.delete('analysts')
expect((sent.find((command) => command instanceof DeleteWorkGroupCommand) as DeleteWorkGroupCommand).input).toEqual({WorkGroup: 'analysts', RecursiveDeleteOption: true})
})
test('starts a query in the workgroup against a database', async () => {
const {adapter, sent} = adapterWith()
const started = await adapter.startQuery('analysts', {sql: ' SELECT 1 ', database: 'shop'})
expect((sent.find((command) => command instanceof StartQueryExecutionCommand) as StartQueryExecutionCommand).input).toEqual({
QueryString: 'SELECT 1', WorkGroup: 'analysts', QueryExecutionContext: {Catalog: 'AwsDataCatalog', Database: 'shop'},
})
expect(started).toMatchObject({id: 'q-new', state: 'SUCCEEDED', runtimeMs: 42, outputLocation: 's3://results/q-new.csv'})
await expect(adapter.startQuery('analysts', {sql: ' '})).rejects.toBeInstanceOf(ValidationError)
})
test('returns a succeeded query with its columns and rows, without the header row', async () => {
const {adapter} = adapterWith((command) => command instanceof GetQueryResultsCommand
? {ResultSet: {ResultSetMetadata: {ColumnInfo: columnInfo}, Rows: [header, row('alpha', '10'), row('beta', '25')]}}
: undefined)
const result = await adapter.getQueryResult('analysts', 'q1')
expect(result.columns).toEqual([{name: 'name', type: 'varchar'}, {name: 'amount', type: 'double'}])
expect(result.rows).toEqual([['alpha', '10'], ['beta', '25']])
expect(result.truncated).toBe(false)
})
test('stops reading after the row limit and says the result is truncated', async () => {
const page = Array.from({length: 1000}, (_, i) => row(`n${i}`, String(i)))
const {adapter, sent} = adapterWith((command) => command instanceof GetQueryResultsCommand
? command.input.NextToken ? {ResultSet: {Rows: page}, NextToken: 'more'} : {ResultSet: {ResultSetMetadata: {ColumnInfo: columnInfo}, Rows: [header, ...page.slice(0, 999)]}, NextToken: 'p2'}
: undefined)
const result = await adapter.getQueryResult('analysts', 'q1')
expect(result.rows).toHaveLength(1000)
expect(result.truncated).toBe(true)
expect(sent.filter((command) => command instanceof GetQueryResultsCommand)).toHaveLength(2)
})
test('does not read results of a query that has not succeeded', async () => {
const {adapter, sent} = adapterWith((command) => command instanceof GetQueryExecutionCommand
? {QueryExecution: execution('q1', {Status: {State: 'FAILED', StateChangeReason: 'Table orders not found'}})}
: undefined)
const result = await adapter.getQueryResult('analysts', 'q1')
expect(result.execution).toMatchObject({state: 'FAILED', stateReason: 'Table orders not found'})
expect(result.rows).toEqual([])
expect(sent.some((command) => command instanceof GetQueryResultsCommand)).toBe(false)
})
test('refuses a query from another workgroup', async () => {
const {adapter, sent} = adapterWith()
await expect(adapter.getQueryResult('primary', 'q1')).rejects.toBeInstanceOf(NotFoundError)
await expect(adapter.stopQuery('primary', 'q1')).rejects.toBeInstanceOf(NotFoundError)
expect(sent.some((command) => command instanceof StopQueryExecutionCommand)).toBe(false)
await adapter.stopQuery('analysts', 'q1')
expect(sent.some((command) => command instanceof StopQueryExecutionCommand)).toBe(true)
})
test('lists the workgroup queries newest first', async () => {
const {adapter} = adapterWith((command) => {
if (command instanceof ListQueryExecutionsCommand) return {QueryExecutionIds: ['old', 'new', 'other']}
if (command instanceof GetQueryExecutionCommand) {
const id = command.input.QueryExecutionId
return {QueryExecution: execution(id ?? '', {
WorkGroup: id === 'other' ? 'primary' : 'analysts',
Status: {State: 'SUCCEEDED', SubmissionDateTime: new Date(id === 'new' ? '2026-10-25T22:00:00Z' : '2026-10-25T21:00:00Z')},
})}
}
return undefined
})
expect((await adapter.listQueries('analysts')).map((query) => query.id)).toEqual(['new', 'old'])
})
test('lists databases and the tables of the chosen one', async () => {
const {adapter, sent} = adapterWith((command) => {
if (command instanceof ListDatabasesCommand) return {DatabaseList: [{Name: 'shop'}, {Name: 'logs'}]}
if (command instanceof ListTableMetadataCommand) {
return {TableMetadataList: [{Name: 'orders', Columns: [{Name: 'id', Type: 'int'}], PartitionKeys: [{Name: 'day', Type: 'string'}]}]}
}
return undefined
})
expect(await adapter.describeQueryCatalog('shop')).toEqual({databases: ['logs', 'shop'], database: 'shop', tables: [{name: 'orders', columns: [{name: 'id', type: 'int'}, {name: 'day', type: 'string'}]}]})
expect((await adapter.describeQueryCatalog('missing')).database).toBe('logs')
expect((sent.filter((command) => command instanceof ListTableMetadataCommand) as ListTableMetadataCommand[]).map((command) => command.input.DatabaseName)).toEqual(['shop', 'logs'])
})
})
@@ -1,268 +0,0 @@
import {
type AthenaClient,
CreateWorkGroupCommand,
DeleteWorkGroupCommand,
GetQueryExecutionCommand,
GetQueryResultsCommand,
type GetQueryResultsCommandOutput,
GetWorkGroupCommand,
ListDatabasesCommand,
ListQueryExecutionsCommand,
ListTableMetadataCommand,
type ListTableMetadataCommandOutput,
ListWorkGroupsCommand,
type QueryExecution,
StartQueryExecutionCommand,
StopQueryExecutionCommand,
type TableMetadata,
type WorkGroup,
type WorkGroupSummary,
} from '@aws-sdk/client-athena'
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
import {
awsQuerySchema,
PRIMARY_WORKGROUP,
QUERY_HISTORY_LIMIT,
QUERY_RESULT_ROW_LIMIT,
WORKGROUP_NAME_PATTERN,
} from '../cloud-spi/querySchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
QueryCatalog,
QueryExecutionSummary,
QueryInput,
QueryResult,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/** Athena's own catalog name for the Glue Data Catalog. */
const DATA_CATALOG = 'AwsDataCatalog'
/** Athena's cap on a query string. */
const MAX_QUERY_LENGTH = 262_144
const S3_URI = /^s3:\/\/[a-z0-9.-]{3,63}(\/.*)?$/
/**
* Talks to Amazon Athena, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - Queries really run: TheCloud executes the SQL with DuckDB over the files a
* Glue table points at, and writes the result to S3 as CSV, as Athena does.
* - Statements that return no rows, such as SHOW TABLES, fail in the runtime,
* so the editor lists databases and tables through the catalog APIs instead.
* - Data scanned is always reported as 0 bytes, so the editor shows only the
* run time.
*/
export class AwsQueryAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'query' as const
constructor(private readonly athena: AthenaClient) {}
schema(): ServiceSchema {
return awsQuerySchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const found: WorkGroupSummary[] = []
let next: string | undefined
do {
const res = await this.athena.send(new ListWorkGroupsCommand({MaxResults: 50, ...(next ? {NextToken: next} : {})}))
found.push(...(res.WorkGroups ?? []))
next = res.NextToken
} while (next)
const search = query.search?.trim().toLowerCase()
return found.map(toResource).filter((workGroup) => !search
|| workGroup.name.toLowerCase().includes(search)
|| String(workGroup.metadata.description ?? '').toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
try {
const workGroup = (await this.athena.send(new GetWorkGroupCommand({WorkGroup: id}))).WorkGroup
return workGroup ? toResource(workGroup) : null
} catch (error) {
if (errorName(error) === 'InvalidRequestException') return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const name = text(values.name)
if (!new RegExp(WORKGROUP_NAME_PATTERN).test(name)) throw new ValidationError('name must be up to 128 letters, numbers, dots, dashes or underscores')
if (name === PRIMARY_WORKGROUP) throw new ValidationError('Every account already has the primary workgroup')
const outputLocation = text(values.outputLocation)
if (outputLocation && !S3_URI.test(outputLocation)) throw new ValidationError('outputLocation must be an S3 URI such as s3://my-bucket/athena-results/')
const description = text(values.description)
await this.athena.send(new CreateWorkGroupCommand({
Name: name,
...(description ? {Description: description} : {}),
Configuration: {
...(outputLocation ? {ResultConfiguration: {OutputLocation: outputLocation.endsWith('/') ? outputLocation : `${outputLocation}/`}} : {}),
EnforceWorkGroupConfiguration: values.enforce === 'true',
},
}))
const created = await this.get(name)
if (!created) throw new RuntimeError(`Athena did not return the new workgroup ${name}`)
return created
}
async delete(id: string): Promise<void> {
if (id === PRIMARY_WORKGROUP) throw new ValidationError('The primary workgroup cannot be deleted')
await this.athena.send(new DeleteWorkGroupCommand({WorkGroup: id, RecursiveDeleteOption: true}))
}
async startQuery(workGroup: string, input: QueryInput): Promise<QueryExecutionSummary> {
const sql = typeof input.sql === 'string' ? input.sql.trim() : ''
if (!sql) throw new ValidationError('Enter a query to run')
if (sql.length > MAX_QUERY_LENGTH) throw new ValidationError(`A query can be at most ${MAX_QUERY_LENGTH} characters`)
const database = text(input.database)
const started = await this.athena.send(new StartQueryExecutionCommand({
QueryString: sql,
WorkGroup: workGroup,
...(database ? {QueryExecutionContext: {Catalog: DATA_CATALOG, Database: database}} : {}),
}))
if (!started.QueryExecutionId) throw new RuntimeError('Athena did not return a query id')
return (await this.execution(workGroup, started.QueryExecutionId))
}
async getQueryResult(workGroup: string, queryId: string): Promise<QueryResult> {
const execution = await this.execution(workGroup, queryId)
if (execution.state !== 'SUCCEEDED' || !execution.outputLocation) return {execution, columns: [], rows: [], truncated: false}
const pages: GetQueryResultsCommandOutput[] = []
let next: string | undefined
let rowCount = 0
do {
const page = await this.athena.send(new GetQueryResultsCommand({QueryExecutionId: queryId, MaxResults: 1000, ...(next ? {NextToken: next} : {})}))
pages.push(page)
rowCount += page.ResultSet?.Rows?.length ?? 0
next = page.NextToken
} while (next && rowCount <= QUERY_RESULT_ROW_LIMIT)
const columnInfo = pages[0]?.ResultSet?.ResultSetMetadata?.ColumnInfo ?? []
const raw = pages.flatMap((page) => page.ResultSet?.Rows ?? []).map((row) => (row.Data ?? []).map((cell) => cell.VarCharValue ?? null))
const header = raw[0] ?? []
const columns = columnInfo.length > 0
? columnInfo.map((column) => ({name: column.Label ?? column.Name ?? '', type: column.Type ?? null}))
: header.map((name) => ({name: name ?? '', type: null}))
// Athena repeats the column names as the first row of a SELECT's results.
const body = raw.length > 0 && header.length === columns.length && header.every((name, index) => name === columns[index].name) ? raw.slice(1) : raw
return {execution, columns, rows: body.slice(0, QUERY_RESULT_ROW_LIMIT), truncated: body.length > QUERY_RESULT_ROW_LIMIT || Boolean(next)}
}
async stopQuery(workGroup: string, queryId: string): Promise<void> {
await this.execution(workGroup, queryId)
await this.athena.send(new StopQueryExecutionCommand({QueryExecutionId: queryId}))
}
async listQueries(workGroup: string): Promise<QueryExecutionSummary[]> {
const ids = (await this.athena.send(new ListQueryExecutionsCommand({WorkGroup: workGroup, MaxResults: QUERY_HISTORY_LIMIT}))).QueryExecutionIds ?? []
const executions = await Promise.all(ids.slice(0, QUERY_HISTORY_LIMIT).map(async (id) => {
const res = await this.athena.send(new GetQueryExecutionCommand({QueryExecutionId: id}))
return res.QueryExecution ? toExecution(res.QueryExecution) : null
}))
return executions
.filter((execution): execution is QueryExecutionSummary => execution !== null && (execution.workGroup ?? PRIMARY_WORKGROUP) === workGroup)
.sort((a, b) => (b.submittedAt ?? '').localeCompare(a.submittedAt ?? ''))
}
async describeQueryCatalog(database?: string): Promise<QueryCatalog> {
const databases: string[] = []
let next: string | undefined
do {
const res = await this.athena.send(new ListDatabasesCommand({CatalogName: DATA_CATALOG, MaxResults: 50, ...(next ? {NextToken: next} : {})}))
databases.push(...(res.DatabaseList ?? []).map((entry) => entry.Name ?? '').filter(Boolean))
next = res.NextToken
} while (next)
databases.sort()
const chosen = database && databases.includes(database) ? database : databases[0] ?? null
if (!chosen) return {databases, database: null, tables: []}
const tables: TableMetadata[] = []
let tableToken: string | undefined
do {
const res: ListTableMetadataCommandOutput = await this.athena.send(new ListTableMetadataCommand({CatalogName: DATA_CATALOG, DatabaseName: chosen, MaxResults: 50, ...(tableToken ? {NextToken: tableToken} : {})}))
tables.push(...(res.TableMetadataList ?? []))
tableToken = res.NextToken
} while (tableToken)
return {
databases,
database: chosen,
tables: tables.map((table) => ({
name: table.Name ?? '',
columns: [...(table.Columns ?? []), ...(table.PartitionKeys ?? [])].map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
})).sort((a, b) => a.name.localeCompare(b.name)),
}
}
/** A query, refused unless it ran in this workgroup, so one workgroup's page never shows another's. */
private async execution(workGroup: string, queryId: string): Promise<QueryExecutionSummary> {
let execution: QueryExecution | undefined
try {
execution = (await this.athena.send(new GetQueryExecutionCommand({QueryExecutionId: queryId}))).QueryExecution
} catch (error) {
if (errorName(error) === 'InvalidRequestException') throw new NotFoundError(`Query ${queryId} not found`)
throw error
}
if (!execution || (execution.WorkGroup ?? PRIMARY_WORKGROUP) !== workGroup) throw new NotFoundError(`Query ${queryId} is not in the ${workGroup} workgroup`)
return toExecution(execution)
}
}
function toResource(workGroup: WorkGroup | WorkGroupSummary): CloudResource {
const name = workGroup.Name ?? ''
const detail = 'Configuration' in workGroup ? workGroup : undefined
const configuration = detail?.Configuration
return {
id: name,
name,
cloud: 'aws',
service: 'query',
type: 'athenaWorkGroup',
region: null,
createdAt: workGroup.CreationTime ? workGroup.CreationTime.toISOString() : null,
status: workGroup.State ?? null,
metadata: {
description: workGroup.Description || null,
...(detail ? {
outputLocation: configuration?.ResultConfiguration?.OutputLocation ?? null,
enforceConfiguration: configuration?.EnforceWorkGroupConfiguration === true,
engineVersion: configuration?.EngineVersion?.EffectiveEngineVersion ?? configuration?.EngineVersion?.SelectedEngineVersion ?? null,
} : {}),
},
}
}
function toExecution(execution: QueryExecution): QueryExecutionSummary {
const status = execution.Status
return {
id: execution.QueryExecutionId ?? '',
sql: execution.Query ?? '',
database: execution.QueryExecutionContext?.Database ?? null,
workGroup: execution.WorkGroup ?? null,
state: status?.State ?? 'QUEUED',
stateReason: status?.StateChangeReason ?? status?.AthenaError?.ErrorMessage ?? null,
statementType: execution.StatementType ?? null,
submittedAt: status?.SubmissionDateTime ? status.SubmissionDateTime.toISOString() : null,
completedAt: status?.CompletionDateTime ? status.CompletionDateTime.toISOString() : null,
runtimeMs: execution.Statistics?.TotalExecutionTimeInMillis ?? execution.Statistics?.EngineExecutionTimeInMillis ?? null,
dataScannedBytes: execution.Statistics?.DataScannedInBytes ?? null,
outputLocation: execution.ResultConfiguration?.OutputLocation ?? null,
}
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function errorName(error: unknown): string | undefined {
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
}
-9
View File
@@ -38,9 +38,6 @@ import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
import { ACMClient } from "@aws-sdk/client-acm";
import { PipesClient } from "@aws-sdk/client-pipes";
import { EFSClient } from "@aws-sdk/client-efs";
import { GlueClient } from "@aws-sdk/client-glue";
import { AthenaClient } from "@aws-sdk/client-athena";
import { AppSyncClient } from "@aws-sdk/client-appsync";
const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
@@ -110,9 +107,6 @@ export type AwsClients = {
acm: ACMClient;
pipes: PipesClient;
efs: EFSClient;
glue: GlueClient;
athena: AthenaClient;
appsync: AppSyncClient;
};
export type AwsClientName = keyof AwsClients;
@@ -166,9 +160,6 @@ function buildClients(accountId: string): AwsClients {
acm: new ACMClient(base),
pipes: new PipesClient(base),
efs: new EFSClient(base),
glue: new GlueClient(base),
athena: new AthenaClient(base),
appsync: new AppSyncClient(base),
};
}
@@ -1,68 +0,0 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/**
* Glue's rule for database and table names. Glue lowercases them, and Athena
* needs them to start with a letter or underscore to query them unquoted.
*/
export const CATALOG_NAME_PATTERN = '^[a-z_][a-z0-9_]{0,254}$'
/** The data formats TheCloud's Athena engine reads from a table's S3 location. */
export const CATALOG_TABLE_FORMATS = ['csv', 'json', 'parquet'] as const
/** Hive column types Athena understands and the console offers. */
export const CATALOG_COLUMN_TYPES = ['string', 'int', 'bigint', 'double', 'float', 'boolean', 'date', 'timestamp', 'decimal(10,2)'] as const
const databaseColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'tables', label: 'Tables', path: 'metadata.tableCount', emptyText: '—'},
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
{name: 'location', label: 'Location', path: 'metadata.locationUri', emptyText: '—'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const databaseFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsDataCatalogSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'datacatalog',
displayName: 'Glue Data Catalog databases',
fields: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
description: 'Lowercase letters, numbers and underscores, starting with a letter or underscore, so Athena can query it.',
validation: {pattern: CATALOG_NAME_PATTERN, message: 'Use lowercase letters, numbers and underscores, starting with a letter or underscore'},
},
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 2048}},
{
name: 'locationUri',
label: 'Location',
type: 'text',
required: false,
span: true,
description: 'Optional S3 location for the database, such as s3://my-bucket/warehouse/.',
validation: {pattern: '^s3://[a-z0-9.-]{3,63}(/.*)?$', message: 'Use an S3 URI such as s3://my-bucket/warehouse/'},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List databases', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create database', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete database', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect database', enabled: true, status: 'available', runtimeRequired: true},
],
dataCatalogActions: [
{name: 'createTable', label: 'Create table', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteTable', label: 'Delete table', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: databaseFilters,
columns: databaseColumns,
}
}
export function dataCatalogSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsDataCatalogSchema() : null
}
@@ -1,83 +0,0 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** CreateGraphqlApi's name characters; the console keeps names to a readable length. */
export const GRAPHQL_API_NAME_PATTERN = '^[A-Za-z0-9_\\- ]{1,128}$'
/** CreateDataSource's name rule: a GraphQL-style identifier. */
export const GRAPHQL_DATA_SOURCE_NAME_PATTERN = '^[_A-Za-z][_0-9A-Za-z]{0,64}$'
export const GRAPHQL_AUTH_TYPES = ['API_KEY', 'AWS_IAM', 'AMAZON_COGNITO_USER_POOLS'] as const
export const GRAPHQL_DATA_SOURCE_TYPES = ['NONE', 'AMAZON_DYNAMODB', 'AWS_LAMBDA'] as const
/** AppSync's bounds on an API key's lifetime. */
export const GRAPHQL_API_KEY_MAX_DAYS = 365
const apiColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'id', label: 'API ID', path: 'metadata.apiId'},
{name: 'auth', label: 'Authorization', path: 'metadata.authenticationLabel'},
{name: 'endpoint', label: 'GraphQL endpoint', path: 'metadata.graphqlUrl', emptyText: '—'},
]
const apiFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsGraphqlSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'graphql',
displayName: 'AppSync GraphQL APIs',
fields: [
{
name: 'name',
label: 'API name',
type: 'text',
required: true,
validation: {pattern: GRAPHQL_API_NAME_PATTERN, message: 'Use up to 128 letters, numbers, spaces, dashes and underscores'},
},
{
name: 'authenticationType',
label: 'Authorization',
type: 'select',
required: false,
defaultValue: 'API_KEY',
description: 'How clients prove who they are. With an API key, AppSync creates the first key for you.',
options: [
{label: 'API key', value: 'API_KEY'},
{label: 'IAM (signed requests)', value: 'AWS_IAM'},
{label: 'Cognito user pool', value: 'AMAZON_COGNITO_USER_POOLS'},
],
},
{
name: 'userPoolId',
label: 'User pool ID',
type: 'text',
required: false,
requiredWhen: {field: 'authenticationType', equals: 'AMAZON_COGNITO_USER_POOLS'},
visibleWhen: {field: 'authenticationType', values: ['AMAZON_COGNITO_USER_POOLS']},
description: 'The Cognito user pool whose ID tokens the API accepts, such as us-east-1_AbC123.',
validation: {pattern: '^[\\w-]+_[0-9a-zA-Z]+$', message: 'Use a user pool ID such as us-east-1_AbC123'},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List GraphQL APIs', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create GraphQL API', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete GraphQL API', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect GraphQL API', enabled: true, status: 'available', runtimeRequired: true},
],
graphqlActions: [
{name: 'updateSchema', label: 'Save schema', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createApiKey', label: 'Create API key', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteApiKey', label: 'Delete API key', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createDataSource', label: 'Create data source', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteDataSource', label: 'Delete data source', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createResolver', label: 'Attach resolver', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteResolver', label: 'Detach resolver', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: apiFilters,
columns: apiColumns,
}
}
export function graphqlSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsGraphqlSchema() : null
}
-80
View File
@@ -1,80 +0,0 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** CreateWorkGroup's name rule. */
export const WORKGROUP_NAME_PATTERN = '^[a-zA-Z0-9._-]{1,128}$'
/** The workgroup every account has, which Athena does not let you delete. */
export const PRIMARY_WORKGROUP = 'primary'
/** Rows the editor shows for one query; the full result stays in the CSV file in S3. */
export const QUERY_RESULT_ROW_LIMIT = 1000
/** Recent queries the history shows. */
export const QUERY_HISTORY_LIMIT = 25
const workGroupColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
{name: 'createdAt', label: 'Created', format: 'datetime', emptyText: '—'},
]
const workGroupFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsQuerySchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'query',
displayName: 'Athena workgroups',
fields: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
validation: {pattern: WORKGROUP_NAME_PATTERN, message: 'Use up to 128 letters, numbers, dots, dashes and underscores'},
},
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 1024}},
{
name: 'outputLocation',
label: 'Query result location',
type: 'text',
required: false,
span: true,
description: 'Where each query writes its results as a CSV file, such as s3://my-bucket/athena-results/. Leave empty to use TheCloud\'s default results bucket.',
validation: {pattern: '^s3://[a-z0-9.-]{3,63}(/.*)?$', message: 'Use an S3 URI such as s3://my-bucket/athena-results/'},
},
{
name: 'enforce',
label: 'Override client-side settings',
type: 'select',
required: false,
defaultValue: 'false',
description: 'When on, queries always use this workgroup\'s result location, even if a client asks for another.',
options: [
{label: 'Off', value: 'false'},
{label: 'On', value: 'true'},
],
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List workgroups', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create workgroup', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete workgroup', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect workgroup', enabled: true, status: 'available', runtimeRequired: true},
],
queryActions: [
{name: 'run', label: 'Run query', enabled: true, status: 'available', runtimeRequired: true},
{name: 'results', label: 'Get query results', enabled: true, status: 'available', runtimeRequired: true},
{name: 'stop', label: 'Cancel query', enabled: true, status: 'available', runtimeRequired: true},
{name: 'history', label: 'Recent queries', enabled: true, status: 'available', runtimeRequired: true},
{name: 'catalog', label: 'Browse databases and tables', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: workGroupFilters,
columns: workGroupColumns,
}
}
export function querySchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsQuerySchema() : null
}
@@ -222,29 +222,8 @@ export const SERVICE_CATALOG = {
group: 'Analytics',
order: 18,
},
datacatalog: {
displayName: 'Data Catalog',
displayNameByCloud: {aws: 'Glue Data Catalog'},
iconKey: 'datacatalog',
group: 'Analytics',
order: 20,
},
query: {
displayName: 'Query',
displayNameByCloud: {aws: 'Athena'},
iconKey: 'query',
group: 'Analytics',
order: 21,
},
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
graphql: {
displayName: 'GraphQL APIs',
displayNameByCloud: {aws: 'AppSync'},
iconKey: 'graphql',
group: 'Integration',
order: 12,
},
email: {
displayName: 'Email',
displayNameByCloud: {aws: 'SES Mailbox'},
-103
View File
@@ -188,9 +188,6 @@ export type UserPoolActionName =
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke'
export type GraphqlActionName = 'updateSchema' | 'createApiKey' | 'deleteApiKey' | 'createDataSource' | 'deleteDataSource' | 'createResolver' | 'deleteResolver'
export type QueryActionName = 'run' | 'results' | 'stop' | 'history' | 'catalog'
export type DataCatalogActionName = 'createTable' | 'deleteTable'
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type HttpApiActionName =
@@ -254,9 +251,6 @@ export interface ServiceSchema {
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
certificateActions?: CapabilitySchema<CertificateActionName>[]
fileSystemActions?: CapabilitySchema<FileSystemActionName>[]
dataCatalogActions?: CapabilitySchema<DataCatalogActionName>[]
queryActions?: CapabilitySchema<QueryActionName>[]
graphqlActions?: CapabilitySchema<GraphqlActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
@@ -564,87 +558,6 @@ export interface AccessPointInput {
permissions: string
}
export interface GraphqlApiKeyInput {
description?: string
/** Days until the key expires: 1 to 365, 7 when left out, as in the AppSync console. */
expiresInDays?: number
}
export interface GraphqlDataSourceInput {
name: string
description?: string
type: 'NONE' | 'AMAZON_DYNAMODB' | 'AWS_LAMBDA'
/** AMAZON_DYNAMODB: the table the resolvers read and write. */
tableName?: string
/** AWS_LAMBDA: the function the resolvers invoke. */
functionArn?: string
/** The role AppSync assumes to reach the table or function; AWS requires one for both. */
serviceRoleArn?: string
}
export interface GraphqlResolverInput {
typeName: string
fieldName: string
dataSourceName: string
/** An APPSYNC_JS resolver: request() and response() exported from one module. */
code: string
}
export interface QueryInput {
sql: string
/** The Glue database unqualified table names resolve in. */
database?: string
}
export interface QueryExecutionSummary {
id: string
sql: string
database: string | null
workGroup: string | null
/** QUEUED, RUNNING, SUCCEEDED, FAILED or CANCELLED. */
state: string
stateReason: string | null
statementType: string | null
submittedAt: string | null
completedAt: string | null
runtimeMs: number | null
dataScannedBytes: number | null
/** The S3 object holding the full result as CSV. */
outputLocation: string | null
}
export interface QueryResult {
execution: QueryExecutionSummary
columns: Array<{name: string; type: string | null}>
rows: Array<Array<string | null>>
/** More rows exist than the console returns; the full result is in the output file. */
truncated: boolean
}
export interface QueryCatalog {
databases: string[]
database: string | null
tables: Array<{name: string; columns: Array<{name: string; type: string}>}>
}
export interface CatalogColumnInput {
name: string
type: string
}
export interface CatalogTableInput {
name: string
description?: string
/** The S3 prefix holding the table's files, such as s3://bucket/orders/. */
location: string
format: 'csv' | 'json' | 'parquet'
/** CSV only: the field separator, a comma unless given. */
delimiter?: string
/** CSV only: whether the first line of each file is a header to skip. */
headerRow?: boolean
columns: CatalogColumnInput[]
}
export interface FileSystemAttachmentInput {
accessPointId: string
functionName: string
@@ -1322,22 +1235,6 @@ export interface CloudServiceAdapter {
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
getCertificatePem?(certificateId: string): Promise<CertificatePem>
updateGraphqlSchema?(apiId: string, definition: string): Promise<CloudResource>
createGraphqlApiKey?(apiId: string, input: GraphqlApiKeyInput): Promise<CloudResource>
deleteGraphqlApiKey?(apiId: string, keyId: string): Promise<CloudResource>
createGraphqlDataSource?(apiId: string, input: GraphqlDataSourceInput): Promise<CloudResource>
deleteGraphqlDataSource?(apiId: string, name: string): Promise<CloudResource>
createGraphqlResolver?(apiId: string, input: GraphqlResolverInput): Promise<CloudResource>
deleteGraphqlResolver?(apiId: string, typeName: string, fieldName: string): Promise<CloudResource>
startQuery?(workGroup: string, input: QueryInput): Promise<QueryExecutionSummary>
/** The query's state, and its first rows once it has succeeded. */
getQueryResult?(workGroup: string, queryId: string): Promise<QueryResult>
stopQuery?(workGroup: string, queryId: string): Promise<void>
listQueries?(workGroup: string): Promise<QueryExecutionSummary[]>
/** The databases a query can use and, for the one named, its tables. */
describeQueryCatalog?(database?: string): Promise<QueryCatalog>
createCatalogTable?(databaseName: string, input: CatalogTableInput): Promise<CloudResource>
deleteCatalogTable?(databaseName: string, tableName: string): Promise<CloudResource>
createMountTarget?(fileSystemId: string, subnetId: string): Promise<CloudResource>
deleteMountTarget?(fileSystemId: string, mountTargetId: string): Promise<CloudResource>
createAccessPoint?(fileSystemId: string, input: AccessPointInput): Promise<CloudResource>
+1 -54
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog'
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DataCatalogActionName, DistributionActionName, FileSystemActionName, GraphqlActionName, HttpApiActionName, QueryActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, FileSystemActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/**
* Guards the schema-to-implementation contract for every registered adapter.
@@ -81,29 +81,6 @@ const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudSe
revoke: 'revokeCertificate',
}
const METHOD_FOR_GRAPHQL_ACTION: Record<GraphqlActionName, keyof CloudServiceAdapter> = {
updateSchema: 'updateGraphqlSchema',
createApiKey: 'createGraphqlApiKey',
deleteApiKey: 'deleteGraphqlApiKey',
createDataSource: 'createGraphqlDataSource',
deleteDataSource: 'deleteGraphqlDataSource',
createResolver: 'createGraphqlResolver',
deleteResolver: 'deleteGraphqlResolver',
}
const METHOD_FOR_QUERY_ACTION: Record<QueryActionName, keyof CloudServiceAdapter> = {
run: 'startQuery',
results: 'getQueryResult',
stop: 'stopQuery',
history: 'listQueries',
catalog: 'describeQueryCatalog',
}
const METHOD_FOR_DATA_CATALOG_ACTION: Record<DataCatalogActionName, keyof CloudServiceAdapter> = {
createTable: 'createCatalogTable',
deleteTable: 'deleteCatalogTable',
}
const METHOD_FOR_FILE_SYSTEM_ACTION: Record<FileSystemActionName, keyof CloudServiceAdapter> = {
createMountTarget: 'createMountTarget',
deleteMountTarget: 'deleteMountTarget',
@@ -249,33 +226,6 @@ describe('registered adapters honour their schema', () => {
).toBe('function')
}
const graphqlClaims = (adapter.schema().capabilities?.graphqlActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of graphqlClaims) {
const method = METHOD_FOR_GRAPHQL_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const queryClaims = (adapter.schema().capabilities?.queryActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of queryClaims) {
const method = METHOD_FOR_QUERY_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const dataCatalogClaims = (adapter.schema().capabilities?.dataCatalogActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of dataCatalogClaims) {
const method = METHOD_FOR_DATA_CATALOG_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const fileSystemClaims = (adapter.schema().capabilities?.fileSystemActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of fileSystemClaims) {
const method = METHOD_FOR_FILE_SYSTEM_ACTION[capability.name]
@@ -309,9 +259,6 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.autoScalingActions ?? []),
...(adapter.schema().capabilities?.fileSystemActions ?? []),
...(adapter.schema().capabilities?.dataCatalogActions ?? []),
...(adapter.schema().capabilities?.queryActions ?? []),
...(adapter.schema().capabilities?.graphqlActions ?? []),
...(adapter.schema().capabilities?.certificateActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []),
-6
View File
@@ -54,9 +54,6 @@ import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
import {AwsFileSystemsAdapter} from './adapter-aws/AwsFileSystemsAdapter'
import {AwsDataCatalogAdapter} from './adapter-aws/AwsDataCatalogAdapter'
import {AwsQueryAdapter} from './adapter-aws/AwsQueryAdapter'
import {AwsGraphqlAdapter} from './adapter-aws/AwsGraphqlAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
@@ -114,9 +111,6 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
new AwsPipesAdapter(clients.pipes),
new AwsFileSystemsAdapter(clients.efs, clients.lambda, clients.ec2),
new AwsDataCatalogAdapter(clients.glue),
new AwsQueryAdapter(clients.athena),
new AwsGraphqlAdapter(clients.appsync),
new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
-72
View File
@@ -20,9 +20,6 @@ import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
import {awsFileSystemsSchema} from '../cloud-spi/fileSystemsSchema'
import {awsDataCatalogSchema} from '../cloud-spi/dataCatalogSchema'
import {awsQuerySchema} from '../cloud-spi/querySchema'
import {awsGraphqlSchema} from '../cloud-spi/graphqlSchema'
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
@@ -879,75 +876,6 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
})
test('manages the schema, keys, data sources and resolvers through the AppSync adapter', async () => {
const calls: unknown[] = []
const api = {id: 'a1', name: 'notes', cloud: 'aws' as const, service: 'graphql' as const, type: 'graphqlApi', region: null, createdAt: null, metadata: {}}
const app = appWithRoutes([mockAdapter('aws', {
service: 'graphql',
schema: awsGraphqlSchema,
updateGraphqlSchema: async (id, definition) => { calls.push(['schema', id, definition]); return api },
createGraphqlApiKey: async (id, input) => { calls.push(['key', id, input.expiresInDays]); return api },
deleteGraphqlApiKey: async (id, keyId) => { calls.push(['delete-key', id, keyId]); return api },
createGraphqlDataSource: async (id, input) => { calls.push(['source', id, input.name, input.type]); return api },
deleteGraphqlDataSource: async (id, name) => { calls.push(['delete-source', id, name]); return api },
createGraphqlResolver: async (id, input) => { calls.push(['resolver', id, input.typeName, input.fieldName]); return api },
deleteGraphqlResolver: async (id, typeName, fieldName) => { calls.push(['delete-resolver', id, typeName, fieldName]); return api },
})])
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/graphql/resources/a1'
expect((await app.request(`${base}/schema`, json('PUT', {definition: 'type Query { a: String }'}))).status).toBe(200)
expect((await app.request(`${base}/api-keys`, json('POST', {expiresInDays: 30}))).status).toBe(201)
expect((await app.request(`${base}/api-keys/da2-x`, {method: 'DELETE'})).status).toBe(200)
expect((await app.request(`${base}/data-sources`, json('POST', {name: 'none', type: 'NONE'}))).status).toBe(201)
expect((await app.request(`${base}/data-sources/none`, {method: 'DELETE'})).status).toBe(200)
expect((await app.request(`${base}/resolvers`, json('POST', {typeName: 'Query', fieldName: 'a', dataSourceName: 'none', code: ''}))).status).toBe(201)
expect((await app.request(`${base}/resolvers/Query/a`, {method: 'DELETE'})).status).toBe(200)
expect(calls).toEqual([
['schema', 'a1', 'type Query { a: String }'], ['key', 'a1', 30], ['delete-key', 'a1', 'da2-x'],
['source', 'a1', 'none', 'NONE'], ['delete-source', 'a1', 'none'], ['resolver', 'a1', 'Query', 'a'], ['delete-resolver', 'a1', 'Query', 'a'],
])
})
test('runs, reads, lists and cancels queries through the Athena adapter', async () => {
const calls: unknown[] = []
const execution = {id: 'q1', sql: 'SELECT 1', database: 'shop', workGroup: 'analysts', state: 'SUCCEEDED', stateReason: null, statementType: 'DML', submittedAt: null, completedAt: null, runtimeMs: 5, dataScannedBytes: 0, outputLocation: 's3://r/q1.csv'}
const app = appWithRoutes([mockAdapter('aws', {
service: 'query',
schema: awsQuerySchema,
startQuery: async (id, input) => { calls.push(['start', id, input.sql, input.database]); return execution },
getQueryResult: async (id, queryId) => { calls.push(['result', id, queryId]); return {execution, columns: [{name: '_col0', type: 'integer'}], rows: [['1']], truncated: false} },
stopQuery: async (id, queryId) => { calls.push(['stop', id, queryId]) },
listQueries: async (id) => { calls.push(['list', id]); return [execution] },
describeQueryCatalog: async (database) => { calls.push(['catalog', database]); return {databases: ['shop'], database: 'shop', tables: []} },
})])
const base = '/api/clouds/aws/services/query/resources/analysts/queries'
expect((await app.request(base, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({sql: 'SELECT 1', database: 'shop'})})).status).toBe(201)
expect((await (await app.request(`${base}/q1`)).json()).rows).toEqual([['1']])
expect((await app.request(base)).status).toBe(200)
expect((await app.request(`${base}/q1/stop`, {method: 'POST'})).status).toBe(200)
expect((await (await app.request('/api/clouds/aws/services/query/catalog?database=shop')).json()).databases).toEqual(['shop'])
expect(calls).toEqual([['start', 'analysts', 'SELECT 1', 'shop'], ['result', 'analysts', 'q1'], ['list', 'analysts'], ['stop', 'analysts', 'q1'], ['catalog', 'shop']])
})
test('creates and deletes catalog tables through the Glue adapter', async () => {
const calls: unknown[] = []
const database = {id: 'shop', name: 'shop', cloud: 'aws' as const, service: 'datacatalog' as const, type: 'glueDatabase', region: null, createdAt: null, metadata: {}}
const app = appWithRoutes([mockAdapter('aws', {
service: 'datacatalog',
schema: awsDataCatalogSchema,
createCatalogTable: async (id, input) => { calls.push(['create', id, input.name, input.format]); return database },
deleteCatalogTable: async (id, tableName) => { calls.push(['delete', id, tableName]); return database },
})])
const base = '/api/clouds/aws/services/datacatalog/resources/shop/tables'
const created = await app.request(base, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({name: 'orders', location: 's3://data/orders/', format: 'csv', columns: [{name: 'id', type: 'int'}]})})
expect(created.status).toBe(201)
expect((await app.request(`${base}/orders`, {method: 'DELETE'})).status).toBe(200)
expect(calls).toEqual([['create', 'shop', 'orders', 'csv'], ['delete', 'shop', 'orders']])
})
test('manages mount targets, access points and function mounts through the EFS adapter', async () => {
const calls: unknown[] = []
const fileSystem = {id: 'fs-1', name: 'shared', cloud: 'aws' as const, service: 'filesystems' as const, type: 'fileSystem', region: null, createdAt: null, metadata: {}}
-109
View File
@@ -4,11 +4,6 @@ import type {
AlarmStateValue,
AutoScalingCapacityInput,
AccessPointInput,
CatalogTableInput,
QueryInput,
GraphqlApiKeyInput,
GraphqlDataSourceInput,
GraphqlResolverInput,
FileSystemAttachmentInput,
CertificateRevocationReason,
CloudProvider,
@@ -561,110 +556,6 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.put('/:cloud/services/graphql/resources/:id/schema', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{definition?: string}>()
return c.json(await svc(c).updateGraphqlSchema(cloud, c.req.param('id'), body.definition ?? ''))
})
})
app.post('/:cloud/services/graphql/resources/:id/api-keys', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).createGraphqlApiKey(cloud, c.req.param('id'), await c.req.json<GraphqlApiKeyInput>()), 201))
})
app.delete('/:cloud/services/graphql/resources/:id/api-keys/:keyId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).deleteGraphqlApiKey(cloud, c.req.param('id'), c.req.param('keyId'))))
})
app.post('/:cloud/services/graphql/resources/:id/data-sources', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).createGraphqlDataSource(cloud, c.req.param('id'), await c.req.json<GraphqlDataSourceInput>()), 201))
})
app.delete('/:cloud/services/graphql/resources/:id/data-sources/:name', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).deleteGraphqlDataSource(cloud, c.req.param('id'), c.req.param('name'))))
})
app.post('/:cloud/services/graphql/resources/:id/resolvers', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).createGraphqlResolver(cloud, c.req.param('id'), await c.req.json<GraphqlResolverInput>()), 201))
})
app.delete('/:cloud/services/graphql/resources/:id/resolvers/:typeName/:fieldName', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).deleteGraphqlResolver(cloud, c.req.param('id'), c.req.param('typeName'), c.req.param('fieldName'))))
})
app.get('/:cloud/services/query/catalog', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).describeQueryCatalog(cloud, c.req.query('database') || undefined)))
})
app.get('/:cloud/services/query/resources/:id/queries', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listQueries(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/query/resources/:id/queries', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).startQuery(cloud, c.req.param('id'), await c.req.json<QueryInput>()), 201))
})
app.get('/:cloud/services/query/resources/:id/queries/:queryId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).getQueryResult(cloud, c.req.param('id'), c.req.param('queryId'))))
})
app.post('/:cloud/services/query/resources/:id/queries/:queryId/stop', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
await svc(c).stopQuery(cloud, c.req.param('id'), c.req.param('queryId'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/datacatalog/resources/:id/tables', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).createCatalogTable(cloud, c.req.param('id'), await c.req.json<CatalogTableInput>()), 201))
})
app.delete('/:cloud/services/datacatalog/resources/:id/tables/:tableName', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).deleteCatalogTable(cloud, c.req.param('id'), c.req.param('tableName'))))
})
app.post('/:cloud/services/filesystems/resources/:id/mount-targets', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -95,14 +95,6 @@ import type {
DistributionInvalidation,
AutoScalingCapacityInput,
AccessPointInput,
CatalogTableInput,
QueryCatalog,
GraphqlApiKeyInput,
GraphqlDataSourceInput,
GraphqlResolverInput,
QueryExecutionSummary,
QueryInput,
QueryResult,
FileSystemAttachmentInput,
CertificatePem,
CertificateRevocationReason,
@@ -974,90 +966,6 @@ export class CloudProxyService {
return adapter.revokeCertificate(certificateId, reason)
}
async updateGraphqlSchema(cloud: CloudProvider, apiId: string, definition: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.updateGraphqlSchema) throw new NotSupportedError(`Saving schemas is not supported for ${cloud}/graphql`)
return adapter.updateGraphqlSchema(apiId, definition)
}
async createGraphqlApiKey(cloud: CloudProvider, apiId: string, input: GraphqlApiKeyInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.createGraphqlApiKey) throw new NotSupportedError(`API keys are not supported for ${cloud}/graphql`)
return adapter.createGraphqlApiKey(apiId, input)
}
async deleteGraphqlApiKey(cloud: CloudProvider, apiId: string, keyId: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.deleteGraphqlApiKey) throw new NotSupportedError(`API keys are not supported for ${cloud}/graphql`)
return adapter.deleteGraphqlApiKey(apiId, keyId)
}
async createGraphqlDataSource(cloud: CloudProvider, apiId: string, input: GraphqlDataSourceInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.createGraphqlDataSource) throw new NotSupportedError(`Data sources are not supported for ${cloud}/graphql`)
return adapter.createGraphqlDataSource(apiId, input)
}
async deleteGraphqlDataSource(cloud: CloudProvider, apiId: string, name: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.deleteGraphqlDataSource) throw new NotSupportedError(`Data sources are not supported for ${cloud}/graphql`)
return adapter.deleteGraphqlDataSource(apiId, name)
}
async createGraphqlResolver(cloud: CloudProvider, apiId: string, input: GraphqlResolverInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.createGraphqlResolver) throw new NotSupportedError(`Resolvers are not supported for ${cloud}/graphql`)
return adapter.createGraphqlResolver(apiId, input)
}
async deleteGraphqlResolver(cloud: CloudProvider, apiId: string, typeName: string, fieldName: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.deleteGraphqlResolver) throw new NotSupportedError(`Resolvers are not supported for ${cloud}/graphql`)
return adapter.deleteGraphqlResolver(apiId, typeName, fieldName)
}
async startQuery(cloud: CloudProvider, workGroup: string, input: QueryInput): Promise<QueryExecutionSummary> {
const adapter = this.requireAdapter(cloud, 'query')
if (!adapter.startQuery) throw new NotSupportedError(`Running queries is not supported for ${cloud}/query`)
return adapter.startQuery(workGroup, input)
}
async getQueryResult(cloud: CloudProvider, workGroup: string, queryId: string): Promise<QueryResult> {
const adapter = this.requireAdapter(cloud, 'query')
if (!adapter.getQueryResult) throw new NotSupportedError(`Query results are not supported for ${cloud}/query`)
return adapter.getQueryResult(workGroup, queryId)
}
async stopQuery(cloud: CloudProvider, workGroup: string, queryId: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'query')
if (!adapter.stopQuery) throw new NotSupportedError(`Cancelling queries is not supported for ${cloud}/query`)
return adapter.stopQuery(workGroup, queryId)
}
async listQueries(cloud: CloudProvider, workGroup: string): Promise<QueryExecutionSummary[]> {
const adapter = this.requireAdapter(cloud, 'query')
if (!adapter.listQueries) throw new NotSupportedError(`Query history is not supported for ${cloud}/query`)
return adapter.listQueries(workGroup)
}
async describeQueryCatalog(cloud: CloudProvider, database?: string): Promise<QueryCatalog> {
const adapter = this.requireAdapter(cloud, 'query')
if (!adapter.describeQueryCatalog) throw new NotSupportedError(`Browsing the catalog is not supported for ${cloud}/query`)
return adapter.describeQueryCatalog(database)
}
async createCatalogTable(cloud: CloudProvider, databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'datacatalog')
if (!adapter.createCatalogTable) throw new NotSupportedError(`Creating tables is not supported for ${cloud}/datacatalog`)
return adapter.createCatalogTable(databaseName, input)
}
async deleteCatalogTable(cloud: CloudProvider, databaseName: string, tableName: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'datacatalog')
if (!adapter.deleteCatalogTable) throw new NotSupportedError(`Deleting tables is not supported for ${cloud}/datacatalog`)
return adapter.deleteCatalogTable(databaseName, tableName)
}
async createMountTarget(cloud: CloudProvider, fileSystemId: string, subnetId: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'filesystems')
if (!adapter.createMountTarget) throw new NotSupportedError(`Mount targets are not supported for ${cloud}/filesystems`)
-118
View File
@@ -155,26 +155,6 @@ export const apiEndpointKeys = {
export: "clouds.services.certificates.export",
revoke: "clouds.services.certificates.revoke",
},
graphql: {
schema: "clouds.services.graphql.schema",
apiKeys: "clouds.services.graphql.apiKeys",
apiKey: "clouds.services.graphql.apiKey",
dataSources: "clouds.services.graphql.dataSources",
dataSource: "clouds.services.graphql.dataSource",
resolvers: "clouds.services.graphql.resolvers",
resolver: "clouds.services.graphql.resolver",
},
query: {
catalog: "clouds.services.query.catalog",
queries: "clouds.services.query.queries",
start: "clouds.services.query.start",
result: "clouds.services.query.result",
stop: "clouds.services.query.stop",
},
datacatalog: {
tables: "clouds.services.datacatalog.tables",
table: "clouds.services.datacatalog.table",
},
filesystems: {
mountTargets: "clouds.services.filesystems.mountTargets",
mountTarget: "clouds.services.filesystems.mountTarget",
@@ -984,104 +964,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST",
},
],
[
apiEndpointKeys.clouds.graphql.schema,
{
path: "/clouds/:cloud/services/graphql/resources/:id/schema",
method: "PUT",
},
],
[
apiEndpointKeys.clouds.graphql.apiKeys,
{
path: "/clouds/:cloud/services/graphql/resources/:id/api-keys",
method: "POST",
},
],
[
apiEndpointKeys.clouds.graphql.apiKey,
{
path: "/clouds/:cloud/services/graphql/resources/:id/api-keys/:keyId",
method: "DELETE",
},
],
[
apiEndpointKeys.clouds.graphql.dataSources,
{
path: "/clouds/:cloud/services/graphql/resources/:id/data-sources",
method: "POST",
},
],
[
apiEndpointKeys.clouds.graphql.dataSource,
{
path: "/clouds/:cloud/services/graphql/resources/:id/data-sources/:name",
method: "DELETE",
},
],
[
apiEndpointKeys.clouds.graphql.resolvers,
{
path: "/clouds/:cloud/services/graphql/resources/:id/resolvers",
method: "POST",
},
],
[
apiEndpointKeys.clouds.graphql.resolver,
{
path: "/clouds/:cloud/services/graphql/resources/:id/resolvers/:typeName/:fieldName",
method: "DELETE",
},
],
[
apiEndpointKeys.clouds.query.catalog,
{
path: "/clouds/:cloud/services/query/catalog",
method: "GET",
},
],
[
apiEndpointKeys.clouds.query.queries,
{
path: "/clouds/:cloud/services/query/resources/:id/queries",
method: "GET",
},
],
[
apiEndpointKeys.clouds.query.start,
{
path: "/clouds/:cloud/services/query/resources/:id/queries",
method: "POST",
},
],
[
apiEndpointKeys.clouds.query.result,
{
path: "/clouds/:cloud/services/query/resources/:id/queries/:queryId",
method: "GET",
},
],
[
apiEndpointKeys.clouds.query.stop,
{
path: "/clouds/:cloud/services/query/resources/:id/queries/:queryId/stop",
method: "POST",
},
],
[
apiEndpointKeys.clouds.datacatalog.tables,
{
path: "/clouds/:cloud/services/datacatalog/resources/:id/tables",
method: "POST",
},
],
[
apiEndpointKeys.clouds.datacatalog.table,
{
path: "/clouds/:cloud/services/datacatalog/resources/:id/tables/:tableName",
method: "DELETE",
},
],
[
apiEndpointKeys.clouds.filesystems.mountTargets,
{
@@ -35,14 +35,6 @@ import type {
DistributionInvalidation,
AutoScalingCapacityInput,
AccessPointInput,
CatalogTableInput,
QueryCatalog,
GraphqlApiKeyInput,
GraphqlDataSourceInput,
GraphqlResolverInput,
QueryExecutionSummary,
QueryInput,
QueryResult,
FileSystemAttachmentInput,
CertificatePem,
CertificateRevocationReason,
@@ -1731,189 +1723,6 @@ export async function revokeCertificate(
return res.data;
}
export async function updateGraphqlSchema(
cloud: CloudProvider,
apiId: string,
definition: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, { definition: string }>(
apiEndpointKeys.clouds.graphql.schema,
requestOptions(cloud, "graphql", { body: { definition } }),
{ cloud, id: apiId },
);
return res.data;
}
export async function createGraphqlApiKey(
cloud: CloudProvider,
apiId: string,
input: GraphqlApiKeyInput,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, GraphqlApiKeyInput>(
apiEndpointKeys.clouds.graphql.apiKeys,
requestOptions(cloud, "graphql", { body: input }),
{ cloud, id: apiId },
);
return res.data;
}
export async function deleteGraphqlApiKey(
cloud: CloudProvider,
apiId: string,
keyId: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource>(
apiEndpointKeys.clouds.graphql.apiKey,
requestOptions(cloud, "graphql"),
{ cloud, id: apiId, keyId },
);
return res.data;
}
export async function createGraphqlDataSource(
cloud: CloudProvider,
apiId: string,
input: GraphqlDataSourceInput,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, GraphqlDataSourceInput>(
apiEndpointKeys.clouds.graphql.dataSources,
requestOptions(cloud, "graphql", { body: input }),
{ cloud, id: apiId },
);
return res.data;
}
export async function deleteGraphqlDataSource(
cloud: CloudProvider,
apiId: string,
name: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource>(
apiEndpointKeys.clouds.graphql.dataSource,
requestOptions(cloud, "graphql"),
{ cloud, id: apiId, name },
);
return res.data;
}
export async function createGraphqlResolver(
cloud: CloudProvider,
apiId: string,
input: GraphqlResolverInput,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, GraphqlResolverInput>(
apiEndpointKeys.clouds.graphql.resolvers,
requestOptions(cloud, "graphql", { body: input }),
{ cloud, id: apiId },
);
return res.data;
}
export async function deleteGraphqlResolver(
cloud: CloudProvider,
apiId: string,
typeName: string,
fieldName: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource>(
apiEndpointKeys.clouds.graphql.resolver,
requestOptions(cloud, "graphql"),
{ cloud, id: apiId, typeName, fieldName },
);
return res.data;
}
export async function describeQueryCatalog(
cloud: CloudProvider,
database?: string,
signal?: AbortSignal,
): Promise<QueryCatalog> {
const res = await apiClient.call<QueryCatalog>(
apiEndpointKeys.clouds.query.catalog,
requestOptions(cloud, "query", { signal, params: { database } }),
{ cloud },
);
return res.data;
}
export async function listQueries(
cloud: CloudProvider,
workGroup: string,
signal?: AbortSignal,
): Promise<QueryExecutionSummary[]> {
const res = await apiClient.call<QueryExecutionSummary[]>(
apiEndpointKeys.clouds.query.queries,
requestOptions(cloud, "query", { signal }),
{ cloud, id: workGroup },
);
return res.data;
}
export async function startQuery(
cloud: CloudProvider,
workGroup: string,
input: QueryInput,
): Promise<QueryExecutionSummary> {
const res = await apiClient.call<QueryExecutionSummary, QueryInput>(
apiEndpointKeys.clouds.query.start,
requestOptions(cloud, "query", { body: input }),
{ cloud, id: workGroup },
);
return res.data;
}
export async function getQueryResult(
cloud: CloudProvider,
workGroup: string,
queryId: string,
signal?: AbortSignal,
): Promise<QueryResult> {
const res = await apiClient.call<QueryResult>(
apiEndpointKeys.clouds.query.result,
requestOptions(cloud, "query", { signal }),
{ cloud, id: workGroup, queryId },
);
return res.data;
}
export async function stopQuery(
cloud: CloudProvider,
workGroup: string,
queryId: string,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.query.stop,
requestOptions(cloud, "query"),
{ cloud, id: workGroup, queryId },
);
}
export async function createCatalogTable(
cloud: CloudProvider,
databaseName: string,
input: CatalogTableInput,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, CatalogTableInput>(
apiEndpointKeys.clouds.datacatalog.tables,
requestOptions(cloud, "datacatalog", { body: input }),
{ cloud, id: databaseName },
);
return res.data;
}
export async function deleteCatalogTable(
cloud: CloudProvider,
databaseName: string,
tableName: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource>(
apiEndpointKeys.clouds.datacatalog.table,
requestOptions(cloud, "datacatalog"),
{ cloud, id: databaseName, tableName },
);
return res.data;
}
export async function createMountTarget(
cloud: CloudProvider,
fileSystemId: string,
@@ -1,92 +0,0 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {MemoryRouter} from 'react-router-dom'
import {beforeEach, expect, test, vi} from 'vitest'
import {DataCatalogPanel} from './DataCatalogPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
createCatalogTable: vi.fn(),
deleteCatalogTable: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
function database(tables: unknown[] = [{
name: 'orders', description: null, format: 'csv', location: 's3://data/orders/', delimiter: ';', headerRow: true,
columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}], partitionKeys: [], createdAt: null, updatedAt: null,
}]): CloudResource {
return {id: 'shop', name: 'shop', cloud: 'aws', service: 'datacatalog', type: 'glueDatabase', region: null, createdAt: null, metadata: {description: 'Orders', tableCount: tables.length, tables}}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<MemoryRouter><QueryClientProvider client={client}><DataCatalogPanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
}
beforeEach(() => {
vi.clearAllMocks()
api.createCatalogTable.mockResolvedValue(database())
api.deleteCatalogTable.mockResolvedValue(database([]))
})
test('lists tables with their format, location and columns', async () => {
renderPanel(database())
const row = (await screen.findByText('orders')).closest('tr') as HTMLElement
expect(within(row).getByText('s3://data/orders/')).toBeInTheDocument()
expect(within(row).getByText('semicolon-separated, header row')).toBeInTheDocument()
expect(within(row).getByText('amount')).toBeInTheDocument()
expect(screen.getByRole('link', {name: 'Query in Athena'})).toHaveAttribute('href', '/cloud-explorer/aws/query')
})
test('creates a CSV table from the columns entered', async () => {
const user = userEvent.setup()
renderPanel(database([]))
const form = await screen.findByRole('form', {name: 'Create table'})
await user.type(within(form).getByLabelText('Name'), 'events')
const location = within(form).getByLabelText('S3 location')
await user.clear(location)
await user.type(location, 's3://data/events/')
await user.type(within(form).getByLabelText('Column 1 name'), 'id')
await user.selectOptions(within(form).getByLabelText('Column 1 type'), 'int')
await user.click(within(form).getByRole('button', {name: 'Add column'}))
await user.type(within(form).getByLabelText('Column 2 name'), 'kind')
await user.click(within(form).getByRole('button', {name: 'Create table'}))
expect(api.createCatalogTable).toHaveBeenCalledWith('aws', 'shop', {
name: 'events', location: 's3://data/events/', format: 'csv', delimiter: ',', headerRow: true,
columns: [{name: 'id', type: 'int'}, {name: 'kind', type: 'string'}],
})
expect(await screen.findByText('Created table events. Athena can query it now.')).toBeInTheDocument()
})
test('JSON tables need no delimiter or header', async () => {
const user = userEvent.setup()
renderPanel(database([]))
const form = await screen.findByRole('form', {name: 'Create table'})
await user.selectOptions(within(form).getByLabelText('Format'), 'json')
expect(within(form).queryByLabelText('Delimiter')).not.toBeInTheDocument()
await user.type(within(form).getByLabelText('Name'), 'events')
await user.type(within(form).getByLabelText('S3 location'), 'data/events/')
await user.type(within(form).getByLabelText('Column 1 name'), 'kind')
await user.click(within(form).getByRole('button', {name: 'Create table'}))
expect(api.createCatalogTable).toHaveBeenCalledWith('aws', 'shop', {name: 'events', location: 's3://data/events/', format: 'json', columns: [{name: 'kind', type: 'string'}]})
})
test('deletes a table after confirming', async () => {
const user = userEvent.setup()
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel(database())
await user.click(await screen.findByRole('button', {name: 'Delete table orders'}))
expect(api.deleteCatalogTable).toHaveBeenCalledWith('aws', 'shop', 'orders')
expect(await screen.findByText('Deleted table orders. Its files stay in S3.')).toBeInTheDocument()
})
@@ -1,147 +0,0 @@
import {Plus, RefreshCw, X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {Link} from 'react-router-dom'
import {createCatalogTable, deleteCatalogTable, getCloudResource} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CatalogTableInput, CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type Column = {name: string; type: string}
type CatalogTable = {
name: string
description: string | null
format: string
location: string | null
delimiter: string | null
headerRow: boolean
columns: Column[]
partitionKeys: Column[]
updatedAt: string | null
createdAt: string | null
}
type TableForm = {name: string; location: string; format: CatalogTableInput['format']; delimiter: string; headerRow: boolean; columns: Column[]}
/** Matches the types the API accepts; decimal(p,s) can also be typed in by hand through the API. */
const COLUMN_TYPES = ['string', 'int', 'bigint', 'double', 'float', 'boolean', 'date', 'timestamp', 'decimal(10,2)']
const FORMAT_LABELS: Record<string, string> = {csv: 'CSV', json: 'JSON lines', parquet: 'Parquet', iceberg: 'Iceberg'}
const DELIMITER_NAMES: Record<string, string> = {',': 'comma-separated', ';': 'semicolon-separated', '\t': 'tab-separated', '|': 'pipe-separated'}
const delimiterLabel = (delimiter: string | null) => DELIMITER_NAMES[delimiter ?? ','] ?? `separated by "${delimiter}"`
const emptyForm = (): TableForm => ({name: '', location: 's3://', format: 'csv', delimiter: ',', headerRow: true, columns: [{name: '', type: 'string'}]})
export function DataCatalogPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [form, setForm] = useState<TableForm>(emptyForm)
const [notice, setNotice] = useState('')
const detailKey = ['glue-database', accountId, resource?.id]
const detailQuery = useQuery({
queryKey: detailKey,
queryFn: ({signal}) => getCloudResource('aws', 'datacatalog', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setNotice('')
setForm(emptyForm())
}, [resource?.id])
const settle = (updated: CloudResource, message: string) => {
queryClient.setQueryData(detailKey, updated)
setNotice(message)
return queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'datacatalog')})
}
const createTable = useMutation({
mutationFn: () => createCatalogTable('aws', resource?.id ?? '', {
name: form.name.trim(),
location: form.location.trim(),
format: form.format,
...(form.format === 'csv' ? {delimiter: form.delimiter, headerRow: form.headerRow} : {}),
columns: form.columns.map((column) => ({name: column.name.trim(), type: column.type})),
}),
onSuccess: (updated) => {
const name = form.name.trim()
setForm(emptyForm())
return settle(updated, `Created table ${name}. Athena can query it now.`)
},
})
const deleteTable = useMutation({
mutationFn: (tableName: string) => deleteCatalogTable('aws', resource?.id ?? '', tableName),
onSuccess: (updated, tableName) => settle(updated, `Deleted table ${tableName}. Its files stay in S3.`),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a database</h3><p>Choose a database to see its tables, or describe files in S3 as a new table.</p></div></section>
}
const database = detailQuery.data ?? resource
const tables = Array.isArray(database.metadata.tables) ? database.metadata.tables as CatalogTable[] : []
const disabled = !runtimeReachable || createTable.isPending || deleteTable.isPending
const error = detailQuery.error ?? createTable.error ?? deleteTable.error
const setColumn = (index: number, patch: Partial<Column>) => setForm({...form, columns: form.columns.map((column, i) => i === index ? {...column, ...patch} : column)})
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Database ${database.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Glue database</p><span className="muted">{database.metadata.description ? String(database.metadata.description) : 'Tables here describe files in S3 so Athena can query them.'}</span></div>
<div className="cdn-actions">
{tables.length > 0 && <Link className="button" to="/cloud-explorer/aws/query">Query in Athena</Link>}
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Tables</p><span className="muted">{tables.length === 1 ? '1 table' : `${tables.length} tables`}</span></div></div>
{tables.length === 0 ? <p className="muted compact-text catalog-note">No tables yet. Describe a folder of CSV, JSON or Parquet files below.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Table</th><th>Format</th><th>Location</th><th>Columns</th><th>Updated</th><th aria-label="Actions"/></tr></thead><tbody>{tables.map((table) => (
<tr key={table.name}>
<td className="mono">{table.name}{table.description && <><br/><small className="muted">{table.description}</small></>}</td>
<td>{FORMAT_LABELS[table.format] ?? table.format}{table.format === 'csv' && <><br/><small className="muted">{delimiterLabel(table.delimiter)}{table.headerRow ? ', header row' : ''}</small></>}</td>
<td className="mono catalog-location">{table.location ?? '—'}</td>
<td className="catalog-columns">{table.columns.map((column) => <span key={column.name}><code>{column.name}</code> <small className="muted">{column.type}</small></span>)}
{table.partitionKeys.length > 0 && <small className="muted">partitioned by {table.partitionKeys.map((key) => key.name).join(', ')}</small>}</td>
<td>{formatDateTime(table.updatedAt ?? table.createdAt) ?? '—'}</td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete table ${table.name}`} onClick={() => {
setNotice('')
if (window.confirm(`Delete table ${table.name}? Its files stay in S3.`)) deleteTable.mutate(table.name)
}}>Delete</button></td>
</tr>
))}</tbody></table></div>}
<form className="catalog-form" aria-label="Create table" onSubmit={(event) => { event.preventDefault(); setNotice(''); createTable.mutate() }}>
<p className="eyebrow">New table</p>
<div className="dynamic-form catalog-form-fields">
<label className="dynamic-field"><span>Name</span><input className="input mono" value={form.name} onChange={(event) => setForm({...form, name: event.target.value})} disabled={disabled} required pattern="[a-z_][a-z0-9_]*" title="Lowercase letters, numbers and underscores"/></label>
<label className="dynamic-field catalog-span"><span>S3 location</span><input className="input mono" value={form.location} onChange={(event) => setForm({...form, location: event.target.value})} disabled={disabled} required pattern="s3://[a-z0-9.\-]{3,63}(/.*)?" title="A folder such as s3://my-bucket/orders/"/></label>
<label className="dynamic-field"><span>Format</span><select className="input" value={form.format} onChange={(event) => setForm({...form, format: event.target.value as TableForm['format']})} disabled={disabled}>
<option value="csv">CSV</option><option value="json">JSON lines</option><option value="parquet">Parquet</option>
</select></label>
{form.format === 'csv' && <>
<label className="dynamic-field"><span>Delimiter</span><input className="input mono" value={form.delimiter} maxLength={1} onChange={(event) => setForm({...form, delimiter: event.target.value})} disabled={disabled} required/></label>
<label className="dynamic-field catalog-check"><input type="checkbox" checked={form.headerRow} onChange={(event) => setForm({...form, headerRow: event.target.checked})} disabled={disabled}/><span>First line is a header</span></label>
</>}
</div>
<fieldset className="catalog-columns-editor" disabled={disabled}>
<legend>Columns</legend>
{form.columns.map((column, index) => <div className="catalog-column-row" key={index}>
<input className="input mono" aria-label={`Column ${index + 1} name`} value={column.name} onChange={(event) => setColumn(index, {name: event.target.value})} required pattern="[a-z_][a-z0-9_]*" title="Lowercase letters, numbers and underscores"/>
<select className="input" aria-label={`Column ${index + 1} type`} value={column.type} onChange={(event) => setColumn(index, {type: event.target.value})}>
{COLUMN_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
<button className="button compact" type="button" aria-label={`Remove column ${index + 1}`} disabled={form.columns.length === 1} onClick={() => setForm({...form, columns: form.columns.filter((_, i) => i !== index)})}><X size={14}/></button>
</div>)}
<button className="button compact" type="button" onClick={() => setForm({...form, columns: [...form.columns, {name: '', type: 'string'}]})}><Plus size={14}/>Add column</button>
</fieldset>
<p className="muted compact-text">List the columns in the order they appear in the files. For JSON, use the field names.</p>
<div><button className="button primary" type="submit" disabled={disabled}>{createTable.isPending ? 'Creating' : 'Create table'}</button></div>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
@@ -70,9 +70,6 @@ import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
import { FileSystemPanel } from "@/components/FileSystemPanel";
import { DataCatalogPanel } from "@/components/DataCatalogPanel";
import { QueryPanel } from "@/components/QueryPanel";
import { GraphqlPanel } from "@/components/GraphqlPanel";
import { CertificatePanel } from "@/components/CertificatePanel";
import { PipePanel } from "@/components/PipePanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
@@ -1006,15 +1003,6 @@ export function DynamicResourceView({
{service === "filesystems" && cloud === "aws" && (
<FileSystemPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "datacatalog" && cloud === "aws" && (
<DataCatalogPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "query" && cloud === "aws" && (
<QueryPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "graphql" && cloud === "aws" && (
<GraphqlPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
@@ -1,142 +0,0 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {GraphqlPanel} from './GraphqlPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
listCloudResources: vi.fn(),
updateGraphqlSchema: vi.fn(),
createGraphqlApiKey: vi.fn(),
deleteGraphqlApiKey: vi.fn(),
createGraphqlDataSource: vi.fn(),
deleteGraphqlDataSource: vi.fn(),
createGraphqlResolver: vi.fn(),
deleteGraphqlResolver: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const sdl = 'type Note { id: ID! text: String }\ntype Query { note(id: ID!): Note notes: [Note] }'
function graphqlApi(metadata: Record<string, unknown> = {}): CloudResource {
return {
id: 'a1', name: 'notes-api', cloud: 'aws', service: 'graphql', type: 'graphqlApi', region: 'us-east-1', createdAt: null,
metadata: {
apiId: 'a1', authenticationType: 'API_KEY', authenticationLabel: 'API key',
graphqlUrl: 'http://localhost:4566/v1/apis/a1/graphql', realtimeUrl: 'ws://localhost:4566/v1/apis/a1/graphql/realtime',
definition: sdl, schemaStatus: {status: 'SUCCESS', details: null},
types: [{name: 'Query', fields: ['note', 'notes']}, {name: 'Note', fields: ['id', 'text']}],
apiKeys: [{id: 'da2-key', description: 'web', expiresAt: '2026-11-01T22:00:00.000Z'}],
dataSources: [{name: 'notesTable', type: 'AMAZON_DYNAMODB', description: null, tableName: 'notes', functionArn: null, serviceRoleArn: 'arn:aws:iam::000000000000:role/appsync'}],
resolvers: [{typeName: 'Query', fieldName: 'note', dataSourceName: 'notesTable', kind: 'UNIT', runtime: 'APPSYNC_JS', code: ''}],
...metadata,
},
}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><GraphqlPanel resource={resource} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.listCloudResources.mockResolvedValue([{id: 'notes', name: 'notes', metadata: {}}])
for (const fn of [api.updateGraphqlSchema, api.createGraphqlApiKey, api.deleteGraphqlApiKey, api.createGraphqlDataSource, api.deleteGraphqlDataSource, api.createGraphqlResolver, api.deleteGraphqlResolver]) {
fn.mockResolvedValue(graphqlApi())
}
})
test('shows the endpoint, saved schema, keys, data sources and resolvers, and says resolvers do not run', async () => {
renderPanel(graphqlApi())
expect(await screen.findByText('http://localhost:4566/v1/apis/a1/graphql')).toBeInTheDocument()
expect(screen.getByLabelText('Schema definition')).toHaveValue(sdl)
expect(screen.getByText('da2-key')).toBeInTheDocument()
expect(screen.getByText('Query.note')).toBeInTheDocument()
expect(screen.getByText(/does not run resolvers yet/)).toBeInTheDocument()
})
test('offers a starter schema for a new API and saves an edited one', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi({definition: null, types: [], resolvers: [], schemaStatus: null}))
const schema = await screen.findByLabelText('Schema definition')
expect(await screen.findByText(/This starter schema is not saved/)).toBeInTheDocument()
await user.clear(schema)
await user.type(schema, 'type Query {{ hello: String }')
await user.click(screen.getByRole('button', {name: 'Save schema'}))
expect(api.updateGraphqlSchema).toHaveBeenCalledWith('aws', 'a1', 'type Query { hello: String }')
expect(await screen.findByText('Schema saved.')).toBeInTheDocument()
})
test('saves the starter schema as shown when it is not edited', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi({definition: null, types: [], resolvers: [], schemaStatus: null}))
await screen.findByText(/This starter schema is not saved/)
await user.click(screen.getByRole('button', {name: 'Save schema'}))
expect(api.updateGraphqlSchema).toHaveBeenCalledWith('aws', 'a1', expect.stringContaining('type Query {'))
})
test('creates an API key that lasts the chosen days', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi())
const form = await screen.findByRole('form', {name: 'Create API key'})
await user.type(within(form).getByLabelText('Description'), 'mobile')
const days = within(form).getByLabelText('Expires in (days)')
await user.clear(days)
await user.type(days, '30')
await user.click(within(form).getByRole('button', {name: 'Create API key'}))
expect(api.createGraphqlApiKey).toHaveBeenCalledWith('aws', 'a1', {description: 'mobile', expiresInDays: 30})
})
test('creates a DynamoDB data source from a chosen table and role', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi())
const form = await screen.findByRole('form', {name: 'Create data source'})
await user.type(within(form).getByLabelText('Name'), 'notesStore')
await user.selectOptions(within(form).getByLabelText('Type'), 'AMAZON_DYNAMODB')
await user.selectOptions(within(form).getByLabelText('Table'), await within(form).findByRole('option', {name: 'notes'}))
await user.type(within(form).getByLabelText('Service role ARN'), 'arn:aws:iam::000000000000:role/appsync')
await user.click(within(form).getByRole('button', {name: 'Create data source'}))
expect(api.createGraphqlDataSource).toHaveBeenCalledWith('aws', 'a1', {name: 'notesStore', type: 'AMAZON_DYNAMODB', tableName: 'notes', serviceRoleArn: 'arn:aws:iam::000000000000:role/appsync'})
})
test('attaches a resolver to a field without one, starting from the data source template', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi())
const form = await screen.findByRole('form', {name: 'Attach resolver'})
await user.selectOptions(within(form).getByLabelText('Type'), 'Query')
const field = within(form).getByLabelText('Field')
expect(within(field).queryByRole('option', {name: 'note'})).not.toBeInTheDocument()
await user.selectOptions(field, 'notes')
await user.selectOptions(within(form).getByLabelText('Data source'), 'notesTable')
expect((within(form).getByLabelText('Resolver code') as HTMLTextAreaElement).value).toContain("@aws-appsync/utils/dynamodb")
await user.click(within(form).getByRole('button', {name: 'Attach resolver'}))
expect(api.createGraphqlResolver).toHaveBeenCalledWith('aws', 'a1', expect.objectContaining({typeName: 'Query', fieldName: 'notes', dataSourceName: 'notesTable'}))
expect(await screen.findByText('Attached a resolver to Query.notes.')).toBeInTheDocument()
})
test('deletes a key and detaches a resolver after confirming', async () => {
const user = userEvent.setup()
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel(graphqlApi())
await user.click(await screen.findByRole('button', {name: 'Delete da2-key'}))
expect(api.deleteGraphqlApiKey).toHaveBeenCalledWith('aws', 'a1', 'da2-key')
await user.click(screen.getByRole('button', {name: 'Detach Query.note'}))
expect(api.deleteGraphqlResolver).toHaveBeenCalledWith('aws', 'a1', 'Query', 'note')
})
@@ -1,295 +0,0 @@
import {Copy, RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {
createGraphqlApiKey,
createGraphqlDataSource,
createGraphqlResolver,
deleteGraphqlApiKey,
deleteGraphqlDataSource,
deleteGraphqlResolver,
getCloudResource,
listCloudResources,
updateGraphqlSchema,
} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CloudResource, GraphqlDataSourceInput} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type ApiKey = {id: string; description: string | null; expiresAt: string | null}
type DataSource = {name: string; type: string | null; description: string | null; tableName: string | null; functionArn: string | null; serviceRoleArn: string | null}
type Resolver = {typeName: string; fieldName: string; dataSourceName: string | null; kind: string | null; runtime: string | null; code: string | null}
type GraphqlType = {name: string; fields: string[]}
type SourceForm = {name: string; type: GraphqlDataSourceInput['type']; tableName: string; functionArn: string; serviceRoleArn: string}
const STARTER_SCHEMA = `type Note {
id: ID!
text: String
}
type Query {
note(id: ID!): Note
}
type Mutation {
addNote(id: ID!, text: String!): Note
}
schema {
query: Query
mutation: Mutation
}
`
const SOURCE_LABELS: Record<string, string> = {NONE: 'None (local resolver)', AMAZON_DYNAMODB: 'DynamoDB table', AWS_LAMBDA: 'Lambda function'}
const EMPTY_SOURCE: SourceForm = {name: '', type: 'NONE', tableName: '', functionArn: '', serviceRoleArn: ''}
/** The resolver AppSync's console starts from for each kind of data source. */
function starterCode(sourceType: string | null | undefined): string {
if (sourceType === 'AMAZON_DYNAMODB') {
return `import * as ddb from '@aws-appsync/utils/dynamodb'
export function request(ctx) {
return ddb.get({ key: { id: ctx.args.id } })
}
export function response(ctx) {
return ctx.result
}
`
}
if (sourceType === 'AWS_LAMBDA') {
return `export function request(ctx) {
return { operation: 'Invoke', payload: { field: ctx.info.fieldName, arguments: ctx.args } }
}
export function response(ctx) {
return ctx.result
}
`
}
return `export function request(ctx) {
return { payload: ctx.args }
}
export function response(ctx) {
return ctx.result
}
`
}
export function GraphqlPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [definition, setDefinition] = useState<string | null>(null)
const [keyForm, setKeyForm] = useState({description: '', days: '7'})
const [sourceForm, setSourceForm] = useState<SourceForm>(EMPTY_SOURCE)
const [resolverForm, setResolverForm] = useState({typeName: '', fieldName: '', dataSourceName: '', code: ''})
const [notice, setNotice] = useState('')
const detailKey = ['appsync-api', accountId, resource?.id]
const detailQuery = useQuery({
queryKey: detailKey,
queryFn: ({signal}) => getCloudResource('aws', 'graphql', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const tablesQuery = useQuery({
queryKey: cloudQueryKeys.resources('aws', 'nosql'),
queryFn: ({signal}) => listCloudResources('aws', 'nosql', undefined, signal),
enabled: Boolean(resource) && runtimeReachable && sourceForm.type === 'AMAZON_DYNAMODB',
})
const functionsQuery = useQuery({
queryKey: cloudQueryKeys.resources('aws', 'serverless'),
queryFn: ({signal}) => listCloudResources('aws', 'serverless', undefined, signal),
enabled: Boolean(resource) && runtimeReachable && sourceForm.type === 'AWS_LAMBDA',
})
useEffect(() => {
setNotice('')
setDefinition(null)
setKeyForm({description: '', days: '7'})
setSourceForm(EMPTY_SOURCE)
setResolverForm({typeName: '', fieldName: '', dataSourceName: '', code: ''})
}, [resource?.id])
const id = resource?.id ?? ''
const settle = (updated: CloudResource, message: string) => {
queryClient.setQueryData(detailKey, updated)
setNotice(message)
}
const saveSchema = useMutation({
mutationFn: (text: string) => updateGraphqlSchema('aws', id, text),
onSuccess: (updated) => { setDefinition(null); settle(updated, 'Schema saved.') },
})
const addKey = useMutation({
mutationFn: () => createGraphqlApiKey('aws', id, {...(keyForm.description.trim() ? {description: keyForm.description.trim()} : {}), expiresInDays: Number(keyForm.days)}),
onSuccess: (updated) => { setKeyForm({description: '', days: '7'}); settle(updated, 'Created an API key.') },
})
const removeKey = useMutation({
mutationFn: (keyId: string) => deleteGraphqlApiKey('aws', id, keyId),
onSuccess: (updated, keyId) => settle(updated, `Deleted API key ${keyId}.`),
})
const addSource = useMutation({
mutationFn: () => createGraphqlDataSource('aws', id, {
name: sourceForm.name.trim(),
type: sourceForm.type,
...(sourceForm.type === 'AMAZON_DYNAMODB' ? {tableName: sourceForm.tableName} : {}),
...(sourceForm.type === 'AWS_LAMBDA' ? {functionArn: sourceForm.functionArn} : {}),
...(sourceForm.type !== 'NONE' ? {serviceRoleArn: sourceForm.serviceRoleArn.trim()} : {}),
}),
onSuccess: (updated) => { const name = sourceForm.name.trim(); setSourceForm(EMPTY_SOURCE); settle(updated, `Created data source ${name}.`) },
})
const removeSource = useMutation({
mutationFn: (name: string) => deleteGraphqlDataSource('aws', id, name),
onSuccess: (updated, name) => settle(updated, `Deleted data source ${name}.`),
})
const addResolver = useMutation({
mutationFn: () => createGraphqlResolver('aws', id, resolverForm),
onSuccess: (updated) => {
const field = `${resolverForm.typeName}.${resolverForm.fieldName}`
setResolverForm({typeName: '', fieldName: '', dataSourceName: '', code: ''})
settle(updated, `Attached a resolver to ${field}.`)
},
})
const removeResolver = useMutation({
mutationFn: (resolver: Resolver) => deleteGraphqlResolver('aws', id, resolver.typeName, resolver.fieldName),
onSuccess: (updated, resolver) => settle(updated, `Detached the resolver from ${resolver.typeName}.${resolver.fieldName}.`),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a GraphQL API</h3><p>Choose an API to edit its schema, keys, data sources and resolvers.</p></div></section>
}
const api = detailQuery.data ?? resource
const metadata = api.metadata
const savedDefinition = typeof metadata.definition === 'string' ? metadata.definition : ''
const schemaText = definition ?? (savedDefinition || (detailQuery.data ? STARTER_SCHEMA : ''))
const schemaStatus = metadata.schemaStatus as {status: string; details: string | null} | null | undefined
const apiKeys = Array.isArray(metadata.apiKeys) ? metadata.apiKeys as ApiKey[] : []
const dataSources = Array.isArray(metadata.dataSources) ? metadata.dataSources as DataSource[] : []
const resolvers = Array.isArray(metadata.resolvers) ? metadata.resolvers as Resolver[] : []
const types = Array.isArray(metadata.types) ? metadata.types as GraphqlType[] : []
const chosenType = types.find((type) => type.name === resolverForm.typeName)
const openFields = (chosenType?.fields ?? []).filter((field) => !resolvers.some((resolver) => resolver.typeName === chosenType?.name && resolver.fieldName === field))
const mutations = [saveSchema, addKey, removeKey, addSource, removeSource, addResolver, removeResolver]
const disabled = !runtimeReachable || mutations.some((mutation) => mutation.isPending)
const error = detailQuery.error ?? mutations.find((mutation) => mutation.error)?.error
const run = (action: () => void) => { setNotice(''); action() }
const copy = (value: string) => { void navigator.clipboard?.writeText(value) }
const chooseSource = (dataSourceName: string) => {
const source = dataSources.find((candidate) => candidate.name === dataSourceName)
const starterOfCurrent = starterCode(dataSources.find((candidate) => candidate.name === resolverForm.dataSourceName)?.type)
const keepCode = resolverForm.code && resolverForm.code !== starterOfCurrent
setResolverForm({...resolverForm, dataSourceName, code: keepCode ? resolverForm.code : starterCode(source?.type)})
}
return <section className="table-panel" style={{marginTop: 16}} aria-label={`GraphQL API ${api.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">AppSync GraphQL API</p><span className="muted">{String(metadata.authenticationLabel ?? '')} authorization</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
<dl className="graphql-endpoints">
{typeof metadata.graphqlUrl === 'string' && <><dt>GraphQL endpoint</dt><dd><code>{metadata.graphqlUrl}</code> <button className="button compact" type="button" aria-label="Copy GraphQL endpoint" onClick={() => copy(String(metadata.graphqlUrl))}><Copy size={12}/></button></dd></>}
{typeof metadata.realtimeUrl === 'string' && <><dt>Real-time endpoint</dt><dd><code>{metadata.realtimeUrl}</code></dd></>}
</dl>
<p className="muted compact-text graphql-note">TheCloud keeps the schema, keys, data sources and resolvers, and its endpoint checks each query against the schema and the caller&apos;s key. It does not run resolvers yet, so every field of a query resolves to null.</p>
<form className="graphql-section" aria-label="Schema" onSubmit={(event) => { event.preventDefault(); run(() => saveSchema.mutate(schemaText)) }}>
<div className="graphql-section-head"><p className="eyebrow">Schema</p>{schemaStatus && <code className={`query-state query-state--${schemaStatus.status === 'SUCCESS' ? 'succeeded' : schemaStatus.status === 'FAILED' ? 'failed' : 'running'}`}>{schemaStatus.status}</code>}</div>
<textarea className="textarea mono graphql-schema" aria-label="Schema definition" value={schemaText} spellCheck={false} rows={14} onChange={(event) => setDefinition(event.target.value)} disabled={disabled}/>
{!savedDefinition && detailQuery.data && <small className="muted">No schema yet. This starter schema is not saved until you save it.</small>}
<div><button className="button primary" type="submit" disabled={disabled || !schemaText.trim() || (definition === null && Boolean(savedDefinition))}>{saveSchema.isPending ? 'Saving' : 'Save schema'}</button></div>
</form>
<div className="input-row resource-table-bar"><div><p className="eyebrow">API keys</p><span className="muted">Clients send one in the x-api-key header{metadata.authenticationType === 'API_KEY' ? '.' : ' when API keys are an authorization mode.'}</span></div></div>
{apiKeys.length === 0 ? <p className="muted compact-text graphql-note">No API keys.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Key</th><th>Description</th><th>Expires</th><th aria-label="Actions"/></tr></thead><tbody>{apiKeys.map((key) => (
<tr key={key.id}>
<td className="mono">{key.id} <button className="button compact" type="button" aria-label={`Copy ${key.id}`} onClick={() => copy(key.id)}><Copy size={12}/></button></td>
<td>{key.description ?? '—'}</td>
<td>{formatDateTime(key.expiresAt) ?? '—'}</td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete ${key.id}`} onClick={() => run(() => {
if (window.confirm(`Delete API key ${key.id}? Clients using it are refused.`)) removeKey.mutate(key.id)
})}>Delete</button></td>
</tr>
))}</tbody></table></div>}
<form className="dynamic-form graphql-inline-form" aria-label="Create API key" onSubmit={(event) => { event.preventDefault(); run(() => addKey.mutate()) }}>
<label className="dynamic-field"><span>Description</span><input className="input" value={keyForm.description} onChange={(event) => setKeyForm({...keyForm, description: event.target.value})} disabled={disabled}/></label>
<label className="dynamic-field"><span>Expires in (days)</span><input className="input" type="number" min={1} max={365} value={keyForm.days} onChange={(event) => setKeyForm({...keyForm, days: event.target.value})} disabled={disabled} required/></label>
<div><button className="button" type="submit" disabled={disabled}>{addKey.isPending ? 'Creating' : 'Create API key'}</button></div>
</form>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Data sources</p><span className="muted">What resolvers read from and write to.</span></div></div>
{dataSources.length === 0 ? <p className="muted compact-text graphql-note">No data sources yet.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Type</th><th>Target</th><th aria-label="Actions"/></tr></thead><tbody>{dataSources.map((source) => (
<tr key={source.name}>
<td className="mono">{source.name}</td>
<td>{SOURCE_LABELS[source.type ?? ''] ?? source.type ?? '—'}</td>
<td className="mono graphql-target">{source.tableName ?? source.functionArn ?? '—'}</td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete data source ${source.name}`} onClick={() => run(() => {
if (window.confirm(`Delete data source ${source.name}?`)) removeSource.mutate(source.name)
})}>Delete</button></td>
</tr>
))}</tbody></table></div>}
<form className="dynamic-form graphql-source-form" aria-label="Create data source" onSubmit={(event) => { event.preventDefault(); run(() => addSource.mutate()) }}>
<label className="dynamic-field"><span>Name</span><input className="input mono" value={sourceForm.name} onChange={(event) => setSourceForm({...sourceForm, name: event.target.value})} disabled={disabled} required pattern="[_A-Za-z][_0-9A-Za-z]*" title="Letters, numbers and underscores, starting with a letter or underscore"/></label>
<label className="dynamic-field"><span>Type</span><select className="input" value={sourceForm.type} onChange={(event) => setSourceForm({...sourceForm, type: event.target.value as SourceForm['type']})} disabled={disabled}>
{Object.entries(SOURCE_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select></label>
{sourceForm.type === 'AMAZON_DYNAMODB' && <label className="dynamic-field"><span>Table</span><select className="input" value={sourceForm.tableName} onChange={(event) => setSourceForm({...sourceForm, tableName: event.target.value})} disabled={disabled} required>
<option value="">{tablesQuery.data?.length ? 'Choose a table' : 'No tables yet'}</option>
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.name}>{table.name}</option>)}
</select></label>}
{sourceForm.type === 'AWS_LAMBDA' && <label className="dynamic-field"><span>Function</span><select className="input" value={sourceForm.functionArn} onChange={(event) => setSourceForm({...sourceForm, functionArn: event.target.value})} disabled={disabled} required>
<option value="">{functionsQuery.data?.length ? 'Choose a function' : 'No functions yet'}</option>
{(functionsQuery.data ?? []).filter((fn) => typeof fn.metadata.arn === 'string').map((fn) => <option key={fn.id} value={String(fn.metadata.arn)}>{fn.name}</option>)}
</select></label>}
{sourceForm.type !== 'NONE' && <label className="dynamic-field graphql-span"><span>Service role ARN</span><input className="input mono" value={sourceForm.serviceRoleArn} placeholder="arn:aws:iam::000000000000:role/appsync-access" onChange={(event) => setSourceForm({...sourceForm, serviceRoleArn: event.target.value})} disabled={disabled} required pattern="arn:aws[a-z\-]*:iam::\d{12}:role/.+" title="The IAM role AppSync assumes, as AWS requires"/></label>}
<div><button className="button" type="submit" disabled={disabled}>{addSource.isPending ? 'Creating' : 'Create data source'}</button></div>
</form>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Resolvers</p><span className="muted">JavaScript (APPSYNC_JS) resolvers attached to fields of the schema.</span></div></div>
{resolvers.length === 0 ? <p className="muted compact-text graphql-note">No resolvers yet.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Field</th><th>Data source</th><th>Runtime</th><th aria-label="Actions"/></tr></thead><tbody>{resolvers.map((resolver) => (
<tr key={`${resolver.typeName}.${resolver.fieldName}`}>
<td className="mono">{resolver.typeName}.{resolver.fieldName}</td>
<td className="mono">{resolver.dataSourceName ?? '—'}</td>
<td>{resolver.runtime ?? '—'}</td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Detach ${resolver.typeName}.${resolver.fieldName}`} onClick={() => run(() => {
if (window.confirm(`Detach the resolver from ${resolver.typeName}.${resolver.fieldName}?`)) removeResolver.mutate(resolver)
})}>Detach</button></td>
</tr>
))}</tbody></table></div>}
{types.length === 0 ? <p className="muted compact-text graphql-note">Save a schema to attach resolvers to its fields.</p>
: dataSources.length === 0 ? <p className="muted compact-text graphql-note">Create a data source to attach resolvers.</p>
: <form className="graphql-section" aria-label="Attach resolver" onSubmit={(event) => { event.preventDefault(); run(() => addResolver.mutate()) }}>
<div className="dynamic-form graphql-source-form">
<label className="dynamic-field"><span>Type</span><select className="input" value={resolverForm.typeName} onChange={(event) => setResolverForm({...resolverForm, typeName: event.target.value, fieldName: ''})} disabled={disabled} required>
<option value="">Choose a type</option>
{types.map((type) => <option key={type.name} value={type.name}>{type.name}</option>)}
</select></label>
<label className="dynamic-field"><span>Field</span><select className="input" value={resolverForm.fieldName} onChange={(event) => setResolverForm({...resolverForm, fieldName: event.target.value})} disabled={disabled || !chosenType} required>
<option value="">{chosenType && openFields.length === 0 ? 'Every field has a resolver' : 'Choose a field'}</option>
{openFields.map((field) => <option key={field} value={field}>{field}</option>)}
</select></label>
<label className="dynamic-field"><span>Data source</span><select className="input" value={resolverForm.dataSourceName} onChange={(event) => chooseSource(event.target.value)} disabled={disabled} required>
<option value="">Choose a data source</option>
{dataSources.map((source) => <option key={source.name} value={source.name}>{source.name}</option>)}
</select></label>
</div>
<textarea className="textarea mono graphql-code" aria-label="Resolver code" value={resolverForm.code} spellCheck={false} rows={10} placeholder="Choose a data source to start from its template." onChange={(event) => setResolverForm({...resolverForm, code: event.target.value})} disabled={disabled} required/>
<div><button className="button" type="submit" disabled={disabled}>{addResolver.isPending ? 'Attaching' : 'Attach resolver'}</button></div>
</form>}
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
@@ -1,100 +0,0 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {MemoryRouter} from 'react-router-dom'
import {beforeEach, expect, test, vi} from 'vitest'
import {QueryPanel} from './QueryPanel'
import type {CloudResource, QueryExecutionSummary} from '@/types/resource'
const api = vi.hoisted(() => ({
describeQueryCatalog: vi.fn(),
getCloudResource: vi.fn(),
listQueries: vi.fn(),
startQuery: vi.fn(),
getQueryResult: vi.fn(),
stopQuery: vi.fn(),
storageObjectDownloadUrl: vi.fn((_cloud: string, bucket: string, key: string) => `/download/${bucket}/${key}`),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const workGroup: CloudResource = {id: 'analysts', name: 'analysts', cloud: 'aws', service: 'query', type: 'athenaWorkGroup', region: null, createdAt: null, status: 'ENABLED', metadata: {outputLocation: 's3://results/'}}
function execution(overrides: Partial<QueryExecutionSummary> = {}): QueryExecutionSummary {
return {
id: 'q1', sql: 'SELECT * FROM orders LIMIT 10', database: 'shop', workGroup: 'analysts', state: 'SUCCEEDED', stateReason: null, statementType: 'DML',
submittedAt: '2026-10-25T21:00:00.000Z', completedAt: null, runtimeMs: 42, dataScannedBytes: 0, outputLocation: 's3://results/q1.csv', ...overrides,
}
}
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<MemoryRouter><QueryClientProvider client={client}><QueryPanel resource={workGroup} runtimeReachable/></QueryClientProvider></MemoryRouter>)
}
beforeEach(() => {
vi.clearAllMocks()
api.getCloudResource.mockResolvedValue({...workGroup, metadata: {outputLocation: 's3://results/athena/'}})
api.describeQueryCatalog.mockResolvedValue({databases: ['logs', 'shop'], database: 'logs', tables: [{name: 'orders', columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}]}]})
api.listQueries.mockResolvedValue([])
api.startQuery.mockResolvedValue(execution({state: 'RUNNING'}))
api.getQueryResult.mockResolvedValue({execution: execution(), columns: [{name: 'id', type: 'integer'}, {name: 'amount', type: 'double'}], rows: [['1', '10.5'], ['2', null]], truncated: false})
})
test('starts from a preview of the first table and lists its columns', async () => {
renderPanel()
expect(await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')).toBeInTheDocument()
expect(screen.getByText('amount')).toBeInTheDocument()
expect(screen.getByLabelText('Database')).toHaveValue('logs')
expect(await screen.findByText('s3://results/athena/')).toBeInTheDocument()
})
test('runs the query in the workgroup and shows its rows', async () => {
const user = userEvent.setup()
renderPanel()
await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')
await user.click(screen.getByRole('button', {name: 'Run'}))
expect(api.startQuery).toHaveBeenCalledWith('aws', 'analysts', {sql: 'SELECT * FROM orders LIMIT 10', database: 'logs'})
expect(await screen.findByText('10.5')).toBeInTheDocument()
expect(screen.getByText('NULL')).toBeInTheDocument()
expect(screen.getByText('2 rows')).toBeInTheDocument()
expect(screen.getByRole('link', {name: 'Download CSV'})).toHaveAttribute('href', '/download/results/q1.csv')
})
test('shows why a query failed', async () => {
const user = userEvent.setup()
api.getQueryResult.mockResolvedValue({execution: execution({state: 'FAILED', stateReason: 'Table nope does not exist'}), columns: [], rows: [], truncated: false})
renderPanel()
const sql = await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')
await user.clear(sql)
await user.type(sql, 'SELECT * FROM nope')
await user.keyboard('{Control>}{Enter}{/Control}')
expect(api.startQuery).toHaveBeenCalledWith('aws', 'analysts', {sql: 'SELECT * FROM nope', database: 'logs'})
expect(await screen.findByText('Table nope does not exist')).toBeInTheDocument()
expect(screen.queryByRole('link', {name: 'Download CSV'})).not.toBeInTheDocument()
})
test('opens a query from the history', async () => {
const user = userEvent.setup()
api.listQueries.mockResolvedValue([execution({id: 'q0', sql: 'SELECT count(*) FROM orders', database: 'shop'})])
renderPanel()
await user.click(await screen.findByRole('button', {name: 'SELECT count(*) FROM orders'}))
expect(screen.getByLabelText('SQL')).toHaveValue('SELECT count(*) FROM orders')
expect(api.getQueryResult).toHaveBeenCalledWith('aws', 'analysts', 'q0', expect.anything())
})
test('points to the Glue Data Catalog when there is nothing to query', async () => {
api.describeQueryCatalog.mockResolvedValue({databases: [], database: null, tables: []})
renderPanel()
const link = await screen.findByRole('link', {name: 'Glue Data Catalog'})
expect(link).toHaveAttribute('href', '/cloud-explorer/aws/datacatalog')
expect(within(screen.getByRole('form', {name: 'Query'})).getByRole('button', {name: 'Run'})).toBeDisabled()
})
@@ -1,185 +0,0 @@
import {Download, Play, RefreshCw, Square} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useMemo, useState} from 'react'
import {Link} from 'react-router-dom'
import {describeQueryCatalog, getCloudResource, getQueryResult, listQueries, startQuery, stopQuery, storageObjectDownloadUrl} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CloudResource, QueryExecutionSummary} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
/** Athena reports a running query's state; it is re-read this often until it finishes. */
const RUNNING_POLL_MS = 1000
const FINISHED = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED'])
export function QueryPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const workGroup = resource?.id ?? ''
const [database, setDatabase] = useState('')
const [sql, setSql] = useState('')
const [activeQueryId, setActiveQueryId] = useState<string | null>(null)
const [seededFor, setSeededFor] = useState<string | null>(null)
const historyKey = useMemo(() => ['athena-history', accountId, workGroup], [accountId, workGroup])
const workGroupQuery = useQuery({
queryKey: ['athena-workgroup', accountId, workGroup],
queryFn: ({signal}) => getCloudResource('aws', 'query', workGroup, signal),
enabled: Boolean(resource) && runtimeReachable,
})
const catalogQuery = useQuery({
queryKey: ['athena-catalog', accountId, database],
queryFn: ({signal}) => describeQueryCatalog('aws', database || undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
const historyQuery = useQuery({
queryKey: historyKey,
queryFn: ({signal}) => listQueries('aws', workGroup, signal),
enabled: Boolean(resource) && runtimeReachable,
})
const resultQuery = useQuery({
queryKey: ['athena-result', accountId, workGroup, activeQueryId],
queryFn: ({signal}) => getQueryResult('aws', workGroup, activeQueryId ?? '', signal),
enabled: Boolean(activeQueryId) && runtimeReachable,
refetchInterval: (query) => query.state.data && !FINISHED.has(query.state.data.execution.state) ? RUNNING_POLL_MS : false,
})
const catalog = catalogQuery.data
const chosenDatabase = database || catalog?.database || ''
const firstTable = catalog?.tables[0]?.name
useEffect(() => {
setActiveQueryId(null)
setSql('')
}, [workGroup])
// Start each workgroup from a query that runs as-is, the way the Athena console opens on a table.
useEffect(() => {
if (!firstTable || seededFor === workGroup) return
setSeededFor(workGroup)
setSql((current) => current || `SELECT * FROM ${quote(firstTable)} LIMIT 10`)
}, [firstTable, seededFor, workGroup])
const execution = resultQuery.data?.execution
const finished = execution ? FINISHED.has(execution.state) : true
const executionState = execution?.state
useEffect(() => {
if (executionState && FINISHED.has(executionState)) void queryClient.invalidateQueries({queryKey: historyKey})
}, [executionState, historyKey, queryClient])
const run = useMutation({
mutationFn: () => startQuery('aws', workGroup, {sql, ...(chosenDatabase ? {database: chosenDatabase} : {})}),
onSuccess: (started) => {
setActiveQueryId(started.id)
return queryClient.invalidateQueries({queryKey: historyKey})
},
})
const cancel = useMutation({
mutationFn: () => stopQuery('aws', workGroup, activeQueryId ?? ''),
onSuccess: () => resultQuery.refetch(),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a workgroup</h3><p>Choose a workgroup to run SQL against the tables in the Glue Data Catalog.</p></div></section>
}
const outputLocation = (workGroupQuery.data ?? resource).metadata.outputLocation
const disabled = !runtimeReachable || run.isPending
const result = resultQuery.data
const error = run.error ?? cancel.error ?? resultQuery.error ?? catalogQuery.error
const history = historyQuery.data ?? []
const download = execution?.outputLocation ? s3Location(execution.outputLocation) : null
const submit = () => { if (sql.trim() && !disabled) run.mutate() }
const openHistory = (entry: QueryExecutionSummary) => {
setActiveQueryId(entry.id)
setSql(entry.sql)
if (entry.database) setDatabase(entry.database)
}
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Workgroup ${resource.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Athena query editor</p><span className="muted">Workgroup {resource.name}{outputLocation ? <> · results in <code>{String(outputLocation)}</code></> : null}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={!runtimeReachable || catalogQuery.isFetching} onClick={() => { void catalogQuery.refetch(); void historyQuery.refetch() }}><RefreshCw size={14}/>{catalogQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
<div className="query-layout">
<aside className="query-catalog" aria-label="Data catalog">
<label className="dynamic-field"><span>Database</span>
<select className="input" value={chosenDatabase} onChange={(event) => setDatabase(event.target.value)} disabled={!runtimeReachable || !catalog?.databases.length}>
{!catalog?.databases.length && <option value="">No databases</option>}
{catalog?.databases.map((name) => <option key={name} value={name}>{name}</option>)}
</select>
</label>
{catalog && catalog.databases.length === 0 && <p className="muted compact-text">Athena queries tables in the <Link to="/cloud-explorer/aws/datacatalog">Glue Data Catalog</Link>. Create a database and a table there first.</p>}
{catalog && catalog.tables.length === 0 && catalog.databases.length > 0 && <p className="muted compact-text">No tables in {chosenDatabase}. Add one in the <Link to="/cloud-explorer/aws/datacatalog">Glue Data Catalog</Link>.</p>}
<ul className="query-tables">{catalog?.tables.map((table) => (
<li key={table.name}>
<details>
<summary><span className="mono">{table.name}</span></summary>
<ul>{table.columns.map((column) => <li key={column.name}><code>{column.name}</code> <small className="muted">{column.type}</small></li>)}</ul>
</details>
<button className="button compact" type="button" aria-label={`Preview ${table.name}`} disabled={disabled} onClick={() => setSql(`SELECT * FROM ${quote(table.name)} LIMIT 10`)}>Preview</button>
</li>
))}</ul>
</aside>
<div className="query-editor">
<form aria-label="Query" onSubmit={(event) => { event.preventDefault(); submit() }}>
<label className="dynamic-field"><span>SQL</span>
<textarea className="textarea mono query-sql" value={sql} spellCheck={false} rows={7} placeholder="SELECT * FROM orders LIMIT 10" onChange={(event) => setSql(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); submit() } }}/>
</label>
<div className="query-actions">
<button className="button primary" type="submit" disabled={disabled || !sql.trim()}><Play size={14}/>{run.isPending ? 'Starting' : 'Run'}</button>
{execution && !finished && <button className="button" type="button" disabled={cancel.isPending} onClick={() => cancel.mutate()}><Square size={14}/>Cancel</button>}
<small className="muted">Ctrl+Enter runs the query. Unqualified table names resolve in {chosenDatabase || 'the chosen database'}.</small>
</div>
</form>
{execution && <div className="query-status" role="status">
<code className={`query-state query-state--${execution.state.toLowerCase()}`}>{execution.state}</code>
{execution.runtimeMs !== null && finished && <span className="muted">Run time {formatRuntime(execution.runtimeMs)}</span>}
{result && execution.state === 'SUCCEEDED' && <span className="muted">{result.rows.length === 1 ? '1 row' : `${result.rows.length} rows`}{result.truncated ? ' shown; the file has more' : ''}</span>}
{download && execution.state === 'SUCCEEDED' && <a className="button compact" href={storageObjectDownloadUrl('aws', download.bucket, download.key)} download><Download size={14}/>Download CSV</a>}
</div>}
{execution?.state === 'FAILED' && execution.stateReason && <pre className="query-error" role="alert">{execution.stateReason}</pre>}
{result && execution?.state === 'SUCCEEDED' && (result.columns.length === 0
? <p className="muted compact-text">The statement ran and returned no rows.</p>
: <div className="cognito-table query-results"><table className="table"><thead><tr>{result.columns.map((column, index) => <th key={`${column.name}-${index}`}>{column.name}</th>)}</tr></thead>
<tbody>{result.rows.map((row, rowIndex) => <tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex} className="mono">{cell ?? <span className="muted">NULL</span>}</td>)}</tr>)}</tbody></table></div>)}
</div>
</div>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Recent queries</p><span className="muted">The latest queries run in this workgroup.</span></div></div>
{history.length === 0 ? <p className="muted compact-text query-note">No queries yet.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Submitted</th><th>State</th><th>Query</th><th>Run time</th></tr></thead><tbody>{history.map((entry) => (
<tr key={entry.id} aria-current={entry.id === activeQueryId ? 'true' : undefined}>
<td>{formatDateTime(entry.submittedAt) ?? '—'}</td>
<td><code className={`query-state query-state--${entry.state.toLowerCase()}`}>{entry.state}</code></td>
<td><button className="link-button mono query-history-sql" type="button" onClick={() => openHistory(entry)} title="Open this query">{entry.sql}</button></td>
<td>{entry.runtimeMs !== null ? formatRuntime(entry.runtimeMs) : '—'}</td>
</tr>
))}</tbody></table></div>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
/** Quotes a table name only when Athena would need it, as the Athena console does. */
function quote(name: string): string {
return /^[a-z_][a-z0-9_]*$/.test(name) ? name : `"${name.replace(/"/g, '""')}"`
}
function formatRuntime(ms: number): string {
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`
}
function s3Location(uri: string): {bucket: string; key: string} | null {
const match = /^s3:\/\/([^/]+)\/(.+)$/.exec(uri)
return match ? {bucket: match[1], key: match[2]} : null
}
@@ -1,7 +1,6 @@
import {
Activity,
Boxes,
Braces,
Circle,
Clock,
Container,
@@ -19,7 +18,6 @@ import {
Network,
Radio,
Scaling,
SearchCode,
ScrollText,
Scale,
Server,
@@ -43,9 +41,6 @@ import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/R
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
import efsIcon from '../../../../aws-icons-svg/Storage/EFS.svg'
import glueIcon from '../../../../aws-icons-svg/Analytics/Glue.svg'
import athenaIcon from '../../../../aws-icons-svg/Analytics/Athena.svg'
import appSyncIcon from '../../../../aws-icons-svg/App-Integration/AppSync.svg'
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
@@ -109,9 +104,6 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
cdn: Earth,
autoscaling: Scaling,
filesystems: FolderTree,
datacatalog: Table2,
query: SearchCode,
graphql: Braces,
certificates: FileBadge,
monitoring: Activity,
loadbalancing: Scale,
@@ -163,9 +155,6 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
cdn: cloudFrontIcon,
autoscaling: autoScalingIcon,
filesystems: efsIcon,
datacatalog: glueIcon,
query: athenaIcon,
graphql: appSyncIcon,
certificates: certificateManagerIcon,
monitoring: cloudWatchIcon,
workflows: stepFunctionsIcon,
-55
View File
@@ -6636,58 +6636,3 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.efs-inline-form { grid-template-columns: minmax(180px, 320px) auto; align-items: end; padding: 4px 16px 12px; }
.efs-access-point-form { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); align-items: end; padding: 4px 16px 12px; }
.efs-row-actions { text-align: right; }
/* Glue Data Catalog: column chips in the tables list, a compact column editor. */
.catalog-note { padding: 0 16px 12px; margin: 0; }
.catalog-location { overflow-wrap: anywhere; }
.catalog-columns { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.catalog-columns > small { flex-basis: 100%; }
.catalog-form { display: grid; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border); }
.catalog-form p { margin: 0; }
.catalog-form-fields { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; }
.catalog-span { grid-column: span 2; }
.catalog-check { display: flex; align-items: center; gap: 8px; min-height: 30px; }
.catalog-check input { width: auto; }
.catalog-columns-editor { display: grid; gap: 6px; border: 0; padding: 0; margin: 0; min-width: 0; }
.catalog-columns-editor legend { color: var(--text-2); font-size: 11px; font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
.catalog-column-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto; gap: 6px; max-width: 560px; }
.catalog-column-row .input { width: 100%; min-width: 0; }
.catalog-columns-editor > .button { justify-self: start; }
@media (max-width: 640px) { .catalog-span { grid-column: auto; } }
/* Athena: catalog beside the editor, results below, finished/failed states coloured by their badge. */
.query-layout { display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); gap: 16px; padding: 12px 16px; border-top: 1px solid var(--border); }
.query-catalog { display: grid; gap: 10px; align-content: start; min-width: 0; }
.query-catalog p { margin: 0; }
.query-tables { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.query-tables > li { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; }
.query-tables details { min-width: 0; flex: 1; }
.query-tables summary { cursor: pointer; overflow-wrap: anywhere; }
.query-tables details ul { list-style: none; margin: 4px 0 0 14px; padding: 0; display: grid; gap: 2px; }
.query-editor { display: grid; gap: 10px; align-content: start; min-width: 0; }
.query-editor form { display: grid; gap: 8px; }
.query-sql { min-height: 140px; resize: vertical; }
.query-actions, .query-status { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.query-state--succeeded { color: var(--status-ok); }
.query-state--failed, .query-state--cancelled { color: var(--status-error); }
.query-error { margin: 0; padding: 8px 10px; border-radius: 4px; background: var(--raised); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
.query-results { max-height: 420px; overflow: auto; }
.query-note { padding: 0 16px 12px; margin: 0; }
.query-history-sql { text-align: left; max-width: 640px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
@media (max-width: 760px) { .query-layout { grid-template-columns: minmax(0, 1fr); } }
/* AppSync: endpoints as a definition list, schema and resolver code as tall editors. */
.graphql-endpoints { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 12px; margin: 0; padding: 12px 16px 4px; }
.graphql-endpoints dt { color: var(--text-2); font-size: 11px; font-weight: 600; text-transform: uppercase; align-self: center; }
.graphql-endpoints dd { margin: 0; overflow-wrap: anywhere; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.graphql-note { padding: 0 16px 12px; margin: 0; }
.graphql-section { display: grid; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
.graphql-section-head { display: flex; align-items: center; gap: 10px; }
.graphql-section-head p { margin: 0; }
.graphql-schema, .graphql-code { min-height: 180px; resize: vertical; }
.graphql-inline-form { grid-template-columns: repeat(auto-fit, minmax(160px, 260px)); align-items: end; padding: 4px 16px 12px; }
.graphql-source-form { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); align-items: end; padding: 4px 16px 12px; }
.graphql-section .graphql-source-form { padding: 0; }
.graphql-span { grid-column: span 2; }
.graphql-target { overflow-wrap: anywhere; }
@media (max-width: 640px) { .graphql-span { grid-column: auto; } .graphql-endpoints { grid-template-columns: minmax(0, 1fr); } }
-69
View File
@@ -362,75 +362,6 @@ export interface AutoScalingCapacityInput {
maxSize?: number
}
export interface GraphqlApiKeyInput {
description?: string
expiresInDays?: number
}
export interface GraphqlDataSourceInput {
name: string
description?: string
type: 'NONE' | 'AMAZON_DYNAMODB' | 'AWS_LAMBDA'
tableName?: string
functionArn?: string
serviceRoleArn?: string
}
export interface GraphqlResolverInput {
typeName: string
fieldName: string
dataSourceName: string
code: string
}
export interface QueryInput {
sql: string
database?: string
}
export interface QueryExecutionSummary {
id: string
sql: string
database: string | null
workGroup: string | null
state: string
stateReason: string | null
statementType: string | null
submittedAt: string | null
completedAt: string | null
runtimeMs: number | null
dataScannedBytes: number | null
outputLocation: string | null
}
export interface QueryResult {
execution: QueryExecutionSummary
columns: Array<{name: string; type: string | null}>
rows: Array<Array<string | null>>
truncated: boolean
}
export interface QueryCatalog {
databases: string[]
database: string | null
tables: Array<{name: string; columns: Array<{name: string; type: string}>}>
}
export interface CatalogColumnInput {
name: string
type: string
}
export interface CatalogTableInput {
name: string
description?: string
location: string
format: 'csv' | 'json' | 'parquet'
delimiter?: string
headerRow?: boolean
columns: CatalogColumnInput[]
}
export interface AccessPointInput {
name?: string
path: string
-6
View File
@@ -30,9 +30,6 @@ export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke'
export type GraphqlActionName = 'updateSchema' | 'createApiKey' | 'deleteApiKey' | 'createDataSource' | 'deleteDataSource' | 'createResolver' | 'deleteResolver'
export type QueryActionName = 'run' | 'results' | 'stop' | 'history' | 'catalog'
export type DataCatalogActionName = 'createTable' | 'deleteTable'
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
@@ -100,9 +97,6 @@ export interface ServiceSchema {
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
fileSystemActions?: Array<CapabilitySchema<FileSystemActionName>>
dataCatalogActions?: Array<CapabilitySchema<DataCatalogActionName>>
queryActions?: Array<CapabilitySchema<QueryActionName>>
graphqlActions?: Array<CapabilitySchema<GraphqlActionName>>
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
}
filters: FieldSchema[]
+119 -173
View File
@@ -22,12 +22,6 @@ importers:
'@aws-sdk/client-appconfig':
specifier: ^3.1076.0
version: 3.1130.0
'@aws-sdk/client-appsync':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-athena':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-auto-scaling':
specifier: ^3.1146.0
version: 3.1156.0
@@ -82,9 +76,6 @@ importers:
'@aws-sdk/client-eventbridge':
specifier: ^3.1076.0
version: 3.1121.0
'@aws-sdk/client-glue':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-iam':
specifier: ^3.1076.0
version: 3.1120.0
@@ -335,14 +326,6 @@ packages:
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-appsync@3.1156.0':
resolution: {integrity: sha512-I3orpZmtgqcxOdMg5Eq6roEbzGnrbR46JwByagLaTfezCakqcJFeE6n4oboQv8EDcAH3/ypfelCsCof6Tngrcg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-athena@3.1156.0':
resolution: {integrity: sha512-rqebnBvY6c9DuSKZz99dI0BS01HZl3iqXYTWTqbEbu02C1YCiYoho7tW7buAnNqIhZ1laB+EJh/JncO59pruJA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-auto-scaling@3.1156.0':
resolution: {integrity: sha512-iv4obhbLigmUcxWnUNd1CJ+jQ8QtCbPg9Ae0dSmv7D1hq/jw50eoMXW8UblNKnOl4FSwWeCtWs4aoUUQYTl7IQ==}
engines: {node: '>=20.0.0'}
@@ -415,10 +398,6 @@ packages:
resolution: {integrity: sha512-+zT6z91ZxHozsKGWf5wOJfxEebht3GmPBoXedlWoumHeAvzAueM1KwbWkzClLWjpi76HXuCIx7R3x4pvSVGqIw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-glue@3.1156.0':
resolution: {integrity: sha512-IkH03UtDnHWpbcdN11bWOr4XeS7F13veZ3/1ukRPznRwtrtXBVC3SivkQVJEeQ18stsKqgX3xMOK8UZH9imiiQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-iam@3.1120.0':
resolution: {integrity: sha512-w1UNruAHPJ2l6+fNsW3wBYa725ALR1FRQe+rLheeziNK3EmGK6wxTm39dgKyehPmeSXxEQYCg5gcrYq3Ew3CzQ==}
engines: {node: '>=20.0.0'}
@@ -2933,15 +2912,15 @@ snapshots:
'@aws-crypto/util@5.2.0':
dependencies:
'@aws-sdk/types': 3.975.0
'@aws-sdk/types': 3.974.5
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
'@aws-sdk/checksums@3.1000.13':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -2990,28 +2969,6 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/client-appsync@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-athena@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-auto-scaling@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
@@ -3221,17 +3178,6 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/client-glue@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-iam@3.1120.0':
dependencies:
'@aws-sdk/core': 3.977.9
@@ -3525,34 +3471,34 @@ snapshots:
'@aws-sdk/credential-provider-env@3.972.54':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.62':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.69':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.70':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.71':
@@ -3581,42 +3527,42 @@ snapshots:
'@aws-sdk/credential-provider-http@3.972.56':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.64':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.71':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.72':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.73':
@@ -3651,7 +3597,7 @@ snapshots:
'@aws-sdk/credential-provider-ini@3.972.61':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/credential-provider-env': 3.972.71
'@aws-sdk/credential-provider-http': 3.972.73
'@aws-sdk/credential-provider-login': 3.972.60
@@ -3659,15 +3605,15 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.973.15
'@aws-sdk/credential-provider-web-identity': 3.972.77
'@aws-sdk/nested-clients': 3.997.28
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.19.0
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.14':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/credential-provider-env': 3.972.71
'@aws-sdk/credential-provider-http': 3.972.73
'@aws-sdk/credential-provider-login': 3.972.76
@@ -3675,15 +3621,15 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.973.15
'@aws-sdk/credential-provider-web-identity': 3.972.77
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.19.0
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.15':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/credential-provider-env': 3.972.71
'@aws-sdk/credential-provider-http': 3.972.73
'@aws-sdk/credential-provider-login': 3.972.77
@@ -3691,10 +3637,10 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.973.15
'@aws-sdk/credential-provider-web-identity': 3.972.77
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.19.0
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.16':
@@ -3747,7 +3693,7 @@ snapshots:
'@aws-sdk/credential-provider-ini@3.973.7':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/credential-provider-env': 3.972.62
'@aws-sdk/credential-provider-http': 3.972.64
'@aws-sdk/credential-provider-login': 3.972.69
@@ -3755,10 +3701,10 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.973.6
'@aws-sdk/credential-provider-web-identity': 3.972.68
'@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/credential-provider-imds': 4.4.14
'@smithy/types': 4.19.0
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.60':
@@ -3938,34 +3884,34 @@ snapshots:
'@aws-sdk/credential-provider-process@3.972.54':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.62':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.69':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.70':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.71':
@@ -3994,32 +3940,32 @@ snapshots:
'@aws-sdk/credential-provider-sso@3.972.60':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.28
'@aws-sdk/token-providers': 3.1080.0
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.13':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/token-providers': 3.1111.0
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.14':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/token-providers': 3.1116.0
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.15':
@@ -4054,48 +4000,48 @@ snapshots:
'@aws-sdk/credential-provider-sso@3.973.6':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/token-providers': 3.1096.0
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.60':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.28
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.68':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.75':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.76':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.77':
@@ -4406,17 +4352,17 @@ snapshots:
'@aws-sdk/xml-builder@3.972.33':
dependencies:
'@smithy/types': 4.19.0
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.37':
dependencies:
'@smithy/types': 4.19.0
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.39':
dependencies:
'@smithy/types': 4.19.0
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.40':
@@ -4899,14 +4845,14 @@ snapshots:
'@smithy/credential-provider-imds@4.4.14':
dependencies:
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.6':
dependencies:
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.0':
@@ -4994,14 +4940,14 @@ snapshots:
'@smithy/signature-v4@5.6.10':
dependencies:
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/signature-v4@5.6.2':
dependencies:
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/signature-v4@5.7.0':