ca3e67a803
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.
296 lines
21 KiB
TypeScript
296 lines
21 KiB
TypeScript
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>
|
|
}
|