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 = {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(null) const [keyForm, setKeyForm] = useState({description: '', days: '7'}) const [sourceForm, setSourceForm] = useState(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

Select a GraphQL API

Choose an API to edit its schema, keys, data sources and resolvers.

} 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

AppSync GraphQL API

{String(metadata.authenticationLabel ?? '')} authorization
{typeof metadata.graphqlUrl === 'string' && <>
GraphQL endpoint
{metadata.graphqlUrl}
} {typeof metadata.realtimeUrl === 'string' && <>
Real-time endpoint
{metadata.realtimeUrl}
}

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.

{ event.preventDefault(); run(() => saveSchema.mutate(schemaText)) }}>

Schema

{schemaStatus && {schemaStatus.status}}