More control over GraphQL introspection

This commit is contained in:
Gregory Schier
2024-11-16 14:27:13 -08:00
parent c9c48c77e4
commit ff9abab547
3 changed files with 153 additions and 71 deletions
+89 -22
View File
@@ -2,12 +2,15 @@ import type { HttpRequest } from '@yaakapp-internal/models';
import { updateSchema } from 'cm6-graphql'; import { updateSchema } from 'cm6-graphql';
import type { EditorView } from 'codemirror'; import type { EditorView } from 'codemirror';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL'; import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL';
import { tryFormatJson } from '../lib/formatters'; import { tryFormatJson } from '../lib/formatters';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Dropdown } from './core/Dropdown';
import type { EditorProps } from './core/Editor'; import type { EditorProps } from './core/Editor';
import { Editor, formatGraphQL } from './core/Editor'; import { Editor, formatGraphQL } from './core/Editor';
import { FormattedError } from './core/FormattedError'; import { FormattedError } from './core/FormattedError';
import { Icon } from './core/Icon';
import { Separator } from './core/Separator'; import { Separator } from './core/Separator';
import { useDialog } from './DialogContext'; import { useDialog } from './DialogContext';
@@ -19,8 +22,14 @@ type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> &
export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps }: Props) { export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps }: Props) {
const editorViewRef = useRef<EditorView>(null); const editorViewRef = useRef<EditorView>(null);
const { schema, isLoading, error, refetch } = useIntrospectGraphQL(baseRequest); const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
const [currentBody, setCurrentBody] = useState<{ query: string; variables: string | undefined }>(() => { Record<string, boolean>
>('graphQLAutoIntrospectDisabled', {});
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
disabled: autoIntrospectDisabled?.[baseRequest.id],
});
const [currentBody, setCurrentBody] = useState<{ query: string; variables: string | undefined }>(
() => {
// Migrate text bodies to GraphQL format // Migrate text bodies to GraphQL format
// NOTE: This is how GraphQL used to be stored // NOTE: This is how GraphQL used to be stored
if ('text' in body) { if ('text' in body) {
@@ -30,7 +39,8 @@ export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps
} }
return { query: body.query ?? '', variables: body.variables ?? '' }; return { query: body.query ?? '', variables: body.variables ?? '' };
}); },
);
const handleChangeQuery = (query: string) => { const handleChangeQuery = (query: string) => {
const newBody = { query, variables: currentBody.variables || undefined }; const newBody = { query, variables: currentBody.variables || undefined };
@@ -52,32 +62,79 @@ export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps
const dialog = useDialog(); const dialog = useDialog();
const actions = useMemo<EditorProps['actions']>(() => { const actions = useMemo<EditorProps['actions']>(
const isValid = error || isLoading; () => [
if (!isValid) {
return [];
}
const actions: EditorProps['actions'] = [
<div key="introspection" className="!opacity-100"> <div key="introspection" className="!opacity-100">
{isLoading ? (
<Button size="sm" variant="border" onClick={refetch} isLoading>
{isLoading ? 'Introspecting' : 'Schema'}
</Button>
) : !error ? (
<Dropdown
items={[
{
key: 'refresh',
label: 'Refetch',
leftSlot: <Icon icon="refresh" />,
onSelect: refetch,
},
{
key: 'clear',
label: 'Clear',
onSelect: clear,
hidden: !schema,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
{type: 'separator', label: 'Setting'},
{
key: 'auto_fetch',
label: 'Automatic Introspection',
onSelect: () => {
setAutoIntrospectDisabled({
...autoIntrospectDisabled,
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
});
},
leftSlot: (
<Icon
icon={
autoIntrospectDisabled?.[baseRequest.id]
? 'check_square_unchecked'
: 'check_square_checked'
}
/>
),
},
]}
>
<Button <Button
key="introspection" size="sm"
size="xs" variant="border"
color={error ? 'danger' : 'secondary'} title="Refetch Schema"
color={schema ? 'default' : 'warning'}
>
{schema ? 'Schema' : 'No Schema'}
</Button>
</Dropdown>
) : (
<Button
size="sm"
color="danger"
isLoading={isLoading} isLoading={isLoading}
onClick={() => { onClick={() => {
dialog.show({ dialog.show({
title: 'Introspection Failed', title: 'Introspection Failed',
size: 'dynamic', size: 'dynamic',
id: 'introspection-failed', id: 'introspection-failed',
render: () => ( render: ({ hide }) => (
<> <>
<FormattedError>{error ?? 'unknown'}</FormattedError> <FormattedError>{error ?? 'unknown'}</FormattedError>
<div className="w-full my-4"> <div className="w-full my-4">
<Button <Button
onClick={() => { onClick={async () => {
dialog.hide('introspection-failed'); hide();
refetch(); await refetch();
}} }}
className="ml-auto" className="ml-auto"
color="primary" color="primary"
@@ -91,13 +148,23 @@ export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps
}); });
}} }}
> >
{error ? 'Introspection Failed' : 'Introspecting'} Introspection Failed
</Button> </Button>
)}
</div>, </div>,
]; ],
[
return actions; isLoading,
}, [dialog, error, isLoading, refetch]); refetch,
error,
autoIntrospectDisabled,
baseRequest.id,
clear,
schema,
setAutoIntrospectDisabled,
dialog,
],
);
return ( return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto]"> <div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto]">
+3
View File
@@ -22,6 +22,8 @@ const icons = {
cake: lucide.CakeIcon, cake: lucide.CakeIcon,
chat: lucide.MessageSquare, chat: lucide.MessageSquare,
check: lucide.CheckIcon, check: lucide.CheckIcon,
check_square_checked: lucide.SquareCheckIcon,
check_square_unchecked: lucide.SquareIcon,
check_circle: lucide.CheckCircleIcon, check_circle: lucide.CheckCircleIcon,
chevron_down: lucide.ChevronDownIcon, chevron_down: lucide.ChevronDownIcon,
chevron_right: lucide.ChevronRightIcon, chevron_right: lucide.ChevronRightIcon,
@@ -56,6 +58,7 @@ const icons = {
left_panel_visible: lucide.PanelLeftCloseIcon, left_panel_visible: lucide.PanelLeftCloseIcon,
magic_wand: lucide.Wand2Icon, magic_wand: lucide.Wand2Icon,
minus: lucide.MinusIcon, minus: lucide.MinusIcon,
minus_circle: lucide.MinusCircleIcon,
moon: lucide.MoonIcon, moon: lucide.MoonIcon,
more_vertical: lucide.MoreVerticalIcon, more_vertical: lucide.MoreVerticalIcon,
paste: lucide.ClipboardPasteIcon, paste: lucide.ClipboardPasteIcon,
+28 -16
View File
@@ -14,12 +14,14 @@ const introspectionRequestBody = JSON.stringify({
operationName: 'IntrospectionQuery', operationName: 'IntrospectionQuery',
}); });
export function useIntrospectGraphQL(baseRequest: HttpRequest) { export function useIntrospectGraphQL(
baseRequest: HttpRequest,
options: { disabled?: boolean } = {},
) {
// Debounce the request because it can change rapidly and we don't // Debounce the request because it can change rapidly and we don't
// want to send so too many requests. // want to send so too many requests.
const request = useDebouncedValue(baseRequest); const request = useDebouncedValue(baseRequest);
const [activeEnvironment] = useActiveEnvironment(); const [activeEnvironment] = useActiveEnvironment();
const [refetchKey, setRefetchKey] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -29,10 +31,11 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
namespace: 'global', namespace: 'global',
}); });
useEffect(() => { const refetch = useCallback(async () => {
const fetchIntrospection = async () => { try {
setIsLoading(true); setIsLoading(true);
setError(undefined); setError(undefined);
const args = { const args = {
...baseRequest, ...baseRequest,
bodyType: 'application/json', bodyType: 'application/json',
@@ -44,33 +47,42 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
); );
if (response.error) { if (response.error) {
throw new Error(response.error); return setError(response.error);
} }
const bodyText = await getResponseBodyText(response); const bodyText = await getResponseBodyText(response);
if (response.status < 200 || response.status >= 300) { if (response.status < 200 || response.status >= 300) {
throw new Error(`Request failed with status ${response.status}.\n\n${bodyText}`); return setError(`Request failed with status ${response.status}.\n\n${bodyText}`);
} }
if (bodyText === null) { if (bodyText === null) {
throw new Error('Empty body returned in response'); return setError('Empty body returned in response');
} }
const { data } = JSON.parse(bodyText); const { data } = JSON.parse(bodyText);
console.log(`Got introspection response for ${baseRequest.url}`, data); console.log(`Got introspection response for ${baseRequest.url}`, data);
await setIntrospection(data); await setIntrospection(data);
}; } catch (err) {
setError(String(err));
} finally {
setIsLoading(false);
}
}, [activeEnvironment?.id, baseRequest, setIntrospection]);
fetchIntrospection() useEffect(() => {
.catch((e) => setError(e.message)) // Skip introspection if automatic is disabled and we already have one
.finally(() => setIsLoading(false)); if (options.disabled) {
return;
}
refetch().catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [request.id, request.url, request.method, refetchKey, activeEnvironment?.id]); }, [request.id, request.url, request.method, activeEnvironment?.id]);
const refetch = useCallback(() => { const clear = useCallback(async () => {
setRefetchKey((k) => k + 1); await setIntrospection(null);
}, []); }, [setIntrospection]);
const schema = useMemo(() => { const schema = useMemo(() => {
try { try {
@@ -81,5 +93,5 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
} }
}, [introspection]); }, [introspection]);
return { schema, isLoading, error, refetch }; return { schema, isLoading, error, refetch, clear };
} }