Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b9ed1dea | |||
| 09b7eba9c4 | |||
| 62faf162de | |||
| 1c0ef3091e | |||
| a2a9c19ff6 | |||
| ca3e67a803 | |||
| 5eee0b7e9b |
@@ -81,6 +81,7 @@ 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,6 +92,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
||||
| 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 |
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"@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",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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'})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,268 @@
|
||||
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
|
||||
}
|
||||
@@ -39,6 +39,8 @@ 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";
|
||||
@@ -109,6 +111,8 @@ export type AwsClients = {
|
||||
pipes: PipesClient;
|
||||
efs: EFSClient;
|
||||
glue: GlueClient;
|
||||
athena: AthenaClient;
|
||||
appsync: AppSyncClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -163,6 +167,8 @@ function buildClients(accountId: string): AwsClients {
|
||||
pipes: new PipesClient(base),
|
||||
efs: new EFSClient(base),
|
||||
glue: new GlueClient(base),
|
||||
athena: new AthenaClient(base),
|
||||
appsync: new AppSyncClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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
|
||||
}
|
||||
@@ -229,8 +229,22 @@ export const SERVICE_CATALOG = {
|
||||
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'},
|
||||
|
||||
@@ -188,6 +188,8 @@ 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'
|
||||
@@ -253,6 +255,8 @@ export interface ServiceSchema {
|
||||
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>[]
|
||||
@@ -560,6 +564,69 @@ 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
|
||||
@@ -1255,6 +1322,20 @@ 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>
|
||||
|
||||
@@ -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, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DataCatalogActionName, DistributionActionName, FileSystemActionName, GraphqlActionName, HttpApiActionName, QueryActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Guards the schema-to-implementation contract for every registered adapter.
|
||||
@@ -81,6 +81,24 @@ 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',
|
||||
@@ -231,6 +249,24 @@ 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]
|
||||
@@ -274,6 +310,8 @@ describe('registered adapters honour their schema', () => {
|
||||
...(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 ?? []),
|
||||
|
||||
@@ -55,6 +55,8 @@ 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'
|
||||
@@ -113,6 +115,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
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(),
|
||||
|
||||
@@ -21,6 +21,8 @@ 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'
|
||||
@@ -877,6 +879,58 @@ 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: {}}
|
||||
|
||||
@@ -5,6 +5,10 @@ import type {
|
||||
AutoScalingCapacityInput,
|
||||
AccessPointInput,
|
||||
CatalogTableInput,
|
||||
QueryInput,
|
||||
GraphqlApiKeyInput,
|
||||
GraphqlDataSourceInput,
|
||||
GraphqlResolverInput,
|
||||
FileSystemAttachmentInput,
|
||||
CertificateRevocationReason,
|
||||
CloudProvider,
|
||||
@@ -557,6 +561,96 @@ 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)
|
||||
|
||||
@@ -96,6 +96,13 @@ import type {
|
||||
AutoScalingCapacityInput,
|
||||
AccessPointInput,
|
||||
CatalogTableInput,
|
||||
QueryCatalog,
|
||||
GraphqlApiKeyInput,
|
||||
GraphqlDataSourceInput,
|
||||
GraphqlResolverInput,
|
||||
QueryExecutionSummary,
|
||||
QueryInput,
|
||||
QueryResult,
|
||||
FileSystemAttachmentInput,
|
||||
CertificatePem,
|
||||
CertificateRevocationReason,
|
||||
@@ -967,6 +974,78 @@ 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`)
|
||||
|
||||
@@ -155,6 +155,22 @@ 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",
|
||||
@@ -968,6 +984,90 @@ 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,
|
||||
{
|
||||
|
||||
@@ -36,6 +36,13 @@ import type {
|
||||
AutoScalingCapacityInput,
|
||||
AccessPointInput,
|
||||
CatalogTableInput,
|
||||
QueryCatalog,
|
||||
GraphqlApiKeyInput,
|
||||
GraphqlDataSourceInput,
|
||||
GraphqlResolverInput,
|
||||
QueryExecutionSummary,
|
||||
QueryInput,
|
||||
QueryResult,
|
||||
FileSystemAttachmentInput,
|
||||
CertificatePem,
|
||||
CertificateRevocationReason,
|
||||
@@ -1724,6 +1731,163 @@ 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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -24,7 +25,7 @@ function database(tables: unknown[] = [{
|
||||
function renderPanel(resource: CloudResource) {
|
||||
api.getCloudResource.mockResolvedValue(resource)
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><DataCatalogPanel resource={resource} runtimeReachable/></QueryClientProvider>)
|
||||
return render(<MemoryRouter><QueryClientProvider client={client}><DataCatalogPanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,6 +41,7 @@ test('lists tables with their format, location and columns', async () => {
|
||||
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 () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -90,6 +91,7 @@ export function DataCatalogPanel({resource, runtimeReachable}: Props) {
|
||||
<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>
|
||||
|
||||
@@ -71,6 +71,8 @@ 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";
|
||||
@@ -1007,6 +1009,12 @@ export function DynamicResourceView({
|
||||
{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} />
|
||||
)}
|
||||
|
||||
@@ -102,3 +102,22 @@ test('cannot attach until the file system has a mount target', async () => {
|
||||
expect(await screen.findByText(/No mount targets yet/)).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('form', {name: 'Attach to a function'})).getByRole('button', {name: 'Attach'})).toBeDisabled()
|
||||
})
|
||||
|
||||
test('shows how to mount the file system on an EC2 instance once a mount target is available', async () => {
|
||||
renderPanel(fileSystem())
|
||||
|
||||
const commands = within(await screen.findByLabelText('Mount on an EC2 instance'))
|
||||
expect(commands.getByText('fs-1.efs.us-east-1.amazonaws.com')).toBeInTheDocument()
|
||||
expect(commands.getByText('sudo mount -t nfs4 -o nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport fs-1.efs.us-east-1.amazonaws.com:/ efs')).toBeInTheDocument()
|
||||
expect(commands.getByText('sudo mount -t efs fs-1:/ efs')).toBeInTheDocument()
|
||||
expect(commands.getByText('Mount by IP address in us-east-1a')).toBeInTheDocument()
|
||||
expect(commands.getByText(/10\.0\.0\.10:\/ efs$/)).toBeInTheDocument()
|
||||
expect(commands.getByRole('button', {name: 'Copy mount with the nfs client command'})).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('holds the mount commands back while the mount target is still creating', async () => {
|
||||
renderPanel(fileSystem({mountTargets: [{id: 'fsmt-1', subnetId: 'subnet-a', availabilityZone: 'us-east-1a', ipAddress: '10.0.0.10', state: 'creating'}]}))
|
||||
|
||||
expect(await screen.findByText('The mount commands appear once a mount target is available.')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Mount on an EC2 instance')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import {RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {
|
||||
attachFileSystemToFunction,
|
||||
createAccessPoint,
|
||||
@@ -26,6 +27,7 @@ type AccessPoint = {id: string; arn: string | null; name: string | null; path: s
|
||||
type MountedFunction = {name: string; accessPointId: string | null; mountPath: string | null}
|
||||
|
||||
const EMPTY_ACCESS_POINT = {name: '', path: '/', uid: '1000', gid: '1000', permissions: '755'}
|
||||
const NFS_OPTIONS = 'nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport'
|
||||
|
||||
export function FileSystemPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
@@ -40,6 +42,11 @@ export function FileSystemPanel({resource, runtimeReachable}: Props) {
|
||||
queryKey: detailKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'filesystems', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
// A new mount target is creating until its NFS server answers, a few seconds.
|
||||
refetchInterval: (query) => {
|
||||
const targets = (query.state.data?.metadata.mountTargets ?? []) as MountTarget[]
|
||||
return targets.some((mountTarget) => mountTarget.state === 'creating') ? 2_000 : false
|
||||
},
|
||||
})
|
||||
const functionsQuery = useQuery({
|
||||
queryKey: cloudQueryKeys.resources('aws', 'serverless'),
|
||||
@@ -185,7 +192,36 @@ export function FileSystemPanel({resource, runtimeReachable}: Props) {
|
||||
</form>}
|
||||
{accessPoints.length > 0 && <p className="muted compact-text efs-note">Lambda needs a VPC to mount a file system. A function not yet in one is put in this file system's mount target subnets with the default security group.</p>}
|
||||
|
||||
<Ec2MountInstructions fileSystemId={String(metadata.fileSystemId ?? fileSystem.id)} region={fileSystem.region ?? 'us-east-1'} mountTargets={mountTargets}/>
|
||||
|
||||
{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>
|
||||
}
|
||||
|
||||
function Ec2MountInstructions({fileSystemId, region, mountTargets}: {fileSystemId: string; region: string; mountTargets: MountTarget[]}) {
|
||||
const available = mountTargets.filter((mountTarget) => mountTarget.state === 'available' && mountTarget.ipAddress)
|
||||
const dnsName = `${fileSystemId}.efs.${region}.amazonaws.com`
|
||||
const command = (label: string, value: string) => <div className="efs-command" key={label}>
|
||||
<span className="muted">{label}</span>
|
||||
<code>{value}</code>
|
||||
<CopyValueButton value={value} label={`${label.toLowerCase()} command`}/>
|
||||
</div>
|
||||
|
||||
return <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">EC2 instances</p><span className="muted">An instance in this file system's VPC mounts it over NFS through a mount target, as on AWS.</span></div></div>
|
||||
{available.length === 0
|
||||
? <p className="muted compact-text efs-note">{mountTargets.length === 0 ? 'Add a mount target to mount the file system on an instance.' : 'The mount commands appear once a mount target is available.'}</p>
|
||||
: <div className="efs-commands" aria-label="Mount on an EC2 instance">
|
||||
<p className="muted compact-text">DNS name <code>{dnsName}</code></p>
|
||||
{command('Install the NFS client on Amazon Linux', 'sudo dnf install -y nfs-utils')}
|
||||
{command('Install the NFS client on Debian or Ubuntu', 'sudo apt-get install -y nfs-common')}
|
||||
{command('Make a mount point', 'sudo mkdir -p efs')}
|
||||
{command('Mount with the NFS client', `sudo mount -t nfs4 -o ${NFS_OPTIONS} ${dnsName}:/ efs`)}
|
||||
{command('Mount with the EFS mount helper', `sudo mount -t efs ${fileSystemId}:/ efs`)}
|
||||
{available.map((mountTarget) => command(`Mount by IP address in ${mountTarget.availabilityZone ?? mountTarget.id}`,
|
||||
`sudo mount -t nfs4 -o ${NFS_OPTIONS} ${mountTarget.ipAddress}:/ efs`))}
|
||||
<p className="muted compact-text">The EFS mount helper is the <code>amazon-efs-utils</code> package. TheCloud serves plain NFS, so its <code>tls</code> and <code>accesspoint</code> options are not supported. A function's files are in its access point's root directory, such as <code>efs/my-function</code>.</p>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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')
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
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'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>
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
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,6 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
Braces,
|
||||
Circle,
|
||||
Clock,
|
||||
Container,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Network,
|
||||
Radio,
|
||||
Scaling,
|
||||
SearchCode,
|
||||
ScrollText,
|
||||
Scale,
|
||||
Server,
|
||||
@@ -42,6 +44,8 @@ import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Deliver
|
||||
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'
|
||||
@@ -106,6 +110,8 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
autoscaling: Scaling,
|
||||
filesystems: FolderTree,
|
||||
datacatalog: Table2,
|
||||
query: SearchCode,
|
||||
graphql: Braces,
|
||||
certificates: FileBadge,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
@@ -158,6 +164,8 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
autoscaling: autoScalingIcon,
|
||||
filesystems: efsIcon,
|
||||
datacatalog: glueIcon,
|
||||
query: athenaIcon,
|
||||
graphql: appSyncIcon,
|
||||
certificates: certificateManagerIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
|
||||
@@ -6654,3 +6654,45 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
.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); } }
|
||||
.efs-commands { display: grid; gap: 8px; padding: 0 16px 12px; }
|
||||
.efs-commands p { margin: 0; }
|
||||
.efs-command { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 8px; row-gap: 2px; align-items: center; }
|
||||
.efs-command > span { grid-column: 1 / -1; font-size: 12px; }
|
||||
.efs-command > code { overflow-x: auto; white-space: nowrap; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--raised); font-size: 12px; }
|
||||
|
||||
@@ -362,6 +362,60 @@ 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
|
||||
|
||||
@@ -30,6 +30,8 @@ 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'
|
||||
@@ -99,6 +101,8 @@ export interface ServiceSchema {
|
||||
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[]
|
||||
|
||||
Generated
+155
-119
@@ -22,6 +22,12 @@ 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
|
||||
@@ -329,6 +335,14 @@ 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'}
|
||||
@@ -2919,15 +2933,15 @@ snapshots:
|
||||
|
||||
'@aws-crypto/util@5.2.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/checksums@3.1000.13':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -2976,6 +2990,28 @@ 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
|
||||
@@ -3489,34 +3525,34 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.54':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.62':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.69':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.70':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.71':
|
||||
@@ -3545,42 +3581,42 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.56':
|
||||
dependencies:
|
||||
'@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
|
||||
'@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
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.64':
|
||||
dependencies:
|
||||
'@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
|
||||
'@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
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.71':
|
||||
dependencies:
|
||||
'@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
|
||||
'@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
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.72':
|
||||
dependencies:
|
||||
'@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
|
||||
'@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
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.73':
|
||||
@@ -3615,7 +3651,7 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.972.61':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-env': 3.972.71
|
||||
'@aws-sdk/credential-provider-http': 3.972.73
|
||||
'@aws-sdk/credential-provider-login': 3.972.60
|
||||
@@ -3623,15 +3659,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.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/credential-provider-imds': 4.5.0
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.14':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-env': 3.972.71
|
||||
'@aws-sdk/credential-provider-http': 3.972.73
|
||||
'@aws-sdk/credential-provider-login': 3.972.76
|
||||
@@ -3639,15 +3675,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.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/credential-provider-imds': 4.5.0
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.15':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-env': 3.972.71
|
||||
'@aws-sdk/credential-provider-http': 3.972.73
|
||||
'@aws-sdk/credential-provider-login': 3.972.77
|
||||
@@ -3655,10 +3691,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.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/credential-provider-imds': 4.5.0
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.16':
|
||||
@@ -3711,7 +3747,7 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.7':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-env': 3.972.62
|
||||
'@aws-sdk/credential-provider-http': 3.972.64
|
||||
'@aws-sdk/credential-provider-login': 3.972.69
|
||||
@@ -3719,10 +3755,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.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/credential-provider-imds': 4.4.14
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.60':
|
||||
@@ -3902,34 +3938,34 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.54':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.62':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.69':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.70':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.71':
|
||||
@@ -3958,32 +3994,32 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.972.60':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.28
|
||||
'@aws-sdk/token-providers': 3.1080.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.13':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.43
|
||||
'@aws-sdk/token-providers': 3.1111.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.14':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/token-providers': 3.1116.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.15':
|
||||
@@ -4018,48 +4054,48 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.6':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.36
|
||||
'@aws-sdk/token-providers': 3.1096.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.60':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.28
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.68':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.36
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.75':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.43
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.77':
|
||||
@@ -4370,17 +4406,17 @@ snapshots:
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.33':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.37':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.39':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.40':
|
||||
@@ -4863,14 +4899,14 @@ snapshots:
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.14':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.6':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.5.0':
|
||||
@@ -4958,14 +4994,14 @@ snapshots:
|
||||
|
||||
'@smithy/signature-v4@5.6.10':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.6.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.7.0':
|
||||
|
||||
Reference in New Issue
Block a user