feat(api): add AppSync GraphQL APIs to the cloud explorer
CI / checks (pull_request) Successful in 28m38s
CI / checks (pull_request) Successful in 28m38s
Create GraphQL APIs with API-key (the first key is made for you), IAM or Cognito user pool authorization, then edit the schema (a starter schema is offered, and a rejected one is explained), create and delete API keys, create data sources for a DynamoDB table, a Lambda function or none, and attach JavaScript resolvers to fields of the schema from a template for the data source. TheCloud's endpoint checks queries against the schema and the API key but does not run resolvers yet, so the page says so and offers no query runner.
This commit is contained in:
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user