diff --git a/.changeset/ripe-loops-drum.md b/.changeset/ripe-loops-drum.md new file mode 100644 index 0000000000..9bca28d131 --- /dev/null +++ b/.changeset/ripe-loops-drum.md @@ -0,0 +1,6 @@ +--- +'@graphql-hive/laboratory': patch +'@graphql-hive/render-laboratory': patch +--- + +Hive laboratory introspection query to include active tab headers diff --git a/packages/libraries/laboratory/src/components/laboratory/builder.tsx b/packages/libraries/laboratory/src/components/laboratory/builder.tsx index 395802c17c..129f615c3b 100644 --- a/packages/libraries/laboratory/src/components/laboratory/builder.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/builder.tsx @@ -19,6 +19,7 @@ import { ListTreeIcon, RotateCcwIcon, SearchIcon, + SettingsIcon, TextAlignStartIcon, } from 'lucide-react'; import { toast } from 'sonner'; @@ -759,7 +760,17 @@ export const Builder = (props: { operationName?: string | null; isReadOnly?: boolean; }) => { - const { schema, activeOperation, endpoint, setEndpoint, defaultEndpoint } = useLaboratory(); + const { + schema, + activeOperation, + endpoint, + setEndpoint, + defaultEndpoint, + tabs, + addTab, + setActiveTab, + shouldPollSchema, + } = useLaboratory(); const [endpointValue, setEndpointValue] = useState(endpoint ?? ''); const [searchValue, setSearchValue] = useState(''); @@ -845,23 +856,45 @@ export const Builder = (props: { return (
-
+
Builder -
- - - - - Collapse all - +
+ {shouldPollSchema && ( + + )} +
+ + + + + Collapse all + +
diff --git a/packages/libraries/laboratory/src/components/laboratory/editor.tsx b/packages/libraries/laboratory/src/components/laboratory/editor.tsx index 1944fa47a8..19a55ef972 100644 --- a/packages/libraries/laboratory/src/components/laboratory/editor.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/editor.tsx @@ -12,6 +12,7 @@ import { OperationDefinitionNode, parse } from 'graphql'; import * as monaco from 'monaco-editor'; import { MonacoGraphQLAPI } from 'monaco-graphql/esm/api.js'; import { initializeMode } from 'monaco-graphql/initializeMode'; +import { cn } from '@/lib/utils'; import MonacoEditor, { loader } from '@monaco-editor/react'; import { useLaboratory } from './context'; @@ -84,7 +85,7 @@ const darkTheme: monaco.editor.IStandaloneThemeData = { ], colors: { 'editor.foreground': '#f6f8fa', - 'editor.background': '#0f1214', + 'editor.background': '#0f121400', 'editor.selectionBackground': '#2A2F34', 'editor.inactiveSelectionBackground': '#2A2F34', 'editor.lineHighlightBackground': '#2A2F34', @@ -354,10 +355,10 @@ const EditorInner = forwardRef((props, ref) => { } return ( -
+
{ validators: { onSubmit: settingsFormSchema, }, - onSubmit: ({ value }) => { - setSettings(value as typeof settings); - }, }); + useEffect(() => { + form.store.subscribe(state => { + setSettings(state.currentVal.values); + }); + }, [setSettings]); + return (
-
+ Fetch @@ -220,6 +222,43 @@ export const Settings = () => { ); }} + + {field => { + return ( + + Headers + + + ); + }} + + + {field => { + return ( + + +
+ + Include active operation headers + + + Active operation (tab) headers will be included in the introspection query + +
+
+ ); + }} +
diff --git a/packages/libraries/laboratory/src/index.css b/packages/libraries/laboratory/src/index.css index 7bf2bdff06..e9f44c44df 100644 --- a/packages/libraries/laboratory/src/index.css +++ b/packages/libraries/laboratory/src/index.css @@ -93,6 +93,10 @@ --destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1)); --ring: var(--hive-laboratory-ring, var(--color-ring)); + + & .monaco-editor { + --vscode-focusBorder: transparent !important; + } } .hive-laboratory.dark { diff --git a/packages/libraries/laboratory/src/lib/endpoint.ts b/packages/libraries/laboratory/src/lib/endpoint.ts index 55848cd9e3..689147e799 100644 --- a/packages/libraries/laboratory/src/lib/endpoint.ts +++ b/packages/libraries/laboratory/src/lib/endpoint.ts @@ -1,12 +1,16 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildClientSchema, GraphQLSchema, introspectionFromSchema, type IntrospectionQuery, } from 'graphql'; +import { debounce } from 'lodash'; import { toast } from 'sonner'; -// import z from 'zod'; +import { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/lib/env'; +import { LaboratoryOperationsActions, LaboratoryOperationsState } from '@/lib/operations'; +import { handleTemplate } from '@/lib/operations.utils'; +import { LaboratoryPluginsActions, LaboratoryPluginsState } from '@/lib/plugins'; import { asyncInterval } from '@/lib/utils'; import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader'; import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings'; @@ -16,6 +20,7 @@ export interface LaboratoryEndpointState { schema: GraphQLSchema | null; introspection: IntrospectionQuery | null; defaultEndpoint: string | null; + shouldPollSchema: boolean; } export interface LaboratoryEndpointActions { @@ -24,11 +29,16 @@ export interface LaboratoryEndpointActions { restoreDefaultEndpoint: () => void; } +export const EXPECTED_ERROR_REASON = 'Expected error reason'; + export const useEndpoint = (props: { defaultEndpoint?: string | null; onEndpointChange?: (endpoint: string | null) => void; defaultSchemaIntrospection?: IntrospectionQuery | null; settingsApi?: LaboratorySettingsState & LaboratorySettingsActions; + operationsApi?: LaboratoryOperationsState & LaboratoryOperationsActions; + envApi?: LaboratoryEnvState & LaboratoryEnvActions; + pluginsApi?: LaboratoryPluginsState & LaboratoryPluginsActions; }): LaboratoryEndpointState & LaboratoryEndpointActions => { const [endpoint, _setEndpoint] = useState(props.defaultEndpoint ?? null); const [introspection, setIntrospection] = useState(null); @@ -47,75 +57,141 @@ export const useEndpoint = (props: { const loader = useMemo(() => new UrlLoader(), []); - const fetchSchema = useCallback( - async (signal?: AbortSignal) => { - if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) { - setIntrospection(props.defaultSchemaIntrospection); - return; - } - - if (!endpoint) { - setIntrospection(null); - return; - } - - try { - const result = await loader.load(endpoint, { - subscriptionsEndpoint: endpoint, - subscriptionsProtocol: - (props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ?? - SubscriptionProtocol.GRAPHQL_SSE, - credentials: props.settingsApi?.settings.fetch.credentials, - specifiedByUrl: true, - directiveIsRepeatable: true, - inputValueDeprecation: true, - retry: props.settingsApi?.settings.fetch.retry, - timeout: props.settingsApi?.settings.fetch.timeout, - useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries, - exposeHTTPDetailsInExtensions: true, - descriptions: props.settingsApi?.settings.introspection.schemaDescription ?? false, - method: props.settingsApi?.settings.introspection.method ?? 'POST', - fetch: (input: string | URL | Request, init?: RequestInit) => - fetch(input, { - ...init, - signal, - }), - }); - - if (result.length === 0) { - throw new Error('Failed to fetch schema'); - } + const activeOperationHeadersRef = useRef( + props.operationsApi?.activeOperation?.headers, + ); + const envVariablesRef = useRef( + props.envApi?.env?.variables, + ); + const pluginsStateRef = useRef | undefined>(props.pluginsApi?.pluginsState); - if (!result[0].schema) { - throw new Error('Failed to fetch schema'); - } + activeOperationHeadersRef.current = props.operationsApi?.activeOperation?.headers; + envVariablesRef.current = props.envApi?.env?.variables; + pluginsStateRef.current = props.pluginsApi?.pluginsState; - setIntrospection(introspectionFromSchema(result[0].schema)); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'message' in error && - typeof error.message === 'string' - ) { - toast.error(error.message); - } else { - toast.error('Failed to fetch schema'); - } + const fetchSchema = useMemo( + () => + debounce( + async ( + signal?: AbortSignal, + options?: { + env?: LaboratoryEnv; + pluginsState?: Record; + }, + ) => { + if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) { + setIntrospection(props.defaultSchemaIntrospection); + return; + } - setIntrospection(null); + if (!endpoint) { + setIntrospection(null); + return; + } - throw error; - } - }, + try { + let parsedHeaders: Record = {}; + + if (props.settingsApi?.settings.introspection.headers) { + try { + parsedHeaders = JSON.parse(props.settingsApi?.settings.introspection.headers); + } catch (error: unknown) { + toast.error('Failed to parse headers'); + parsedHeaders = {}; + } + } + + if (props.settingsApi?.settings.introspection.includeActiveOperationHeaders) { + try { + const activeOperationHeaders = activeOperationHeadersRef.current + ? JSON.parse( + handleTemplate(activeOperationHeadersRef.current, { + ...(options?.env?.variables ?? envVariablesRef.current ?? {}), + plugins: options?.pluginsState ?? pluginsStateRef.current ?? {}, + }), + ) + : {}; + + parsedHeaders = { + ...parsedHeaders, + ...activeOperationHeaders, + }; + } catch (error: unknown) { + toast.error('Failed to parse headers'); + } + } + + const result = await loader.load(endpoint, { + subscriptionsEndpoint: endpoint, + subscriptionsProtocol: + (props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ?? + SubscriptionProtocol.GRAPHQL_SSE, + headers: parsedHeaders, + credentials: props.settingsApi?.settings.fetch.credentials, + specifiedByUrl: true, + directiveIsRepeatable: true, + inputValueDeprecation: true, + retry: props.settingsApi?.settings.fetch.retry, + timeout: props.settingsApi?.settings.fetch.timeout, + useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries, + exposeHTTPDetailsInExtensions: true, + descriptions: props.settingsApi?.settings.introspection.schemaDescription ?? false, + method: props.settingsApi?.settings.introspection.method ?? 'POST', + fetch: (input: string | URL | Request, init?: RequestInit) => + fetch(input, { + ...init, + signal, + }), + }); + + if (result.length === 0) { + throw new Error('Failed to fetch schema'); + } + + if (!result[0].schema) { + throw new Error('Failed to fetch schema'); + } + + setIntrospection(introspectionFromSchema(result[0].schema)); + } catch (error: unknown) { + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof error.message === 'string' + ) { + if (error.message === EXPECTED_ERROR_REASON) { + return; + } + + toast.error(error.message); + } else { + toast.error('Failed to fetch schema'); + } + + setIntrospection(null); + + throw error; + } + }, + 500, + ), [ endpoint, props.settingsApi?.settings.fetch.timeout, props.settingsApi?.settings.introspection.method, props.settingsApi?.settings.introspection.schemaDescription, + props.settingsApi?.settings.introspection.headers, + props.settingsApi?.settings.introspection.includeActiveOperationHeaders, ], ); + useEffect(() => { + return () => { + fetchSchema.cancel(); + }; + }, [fetchSchema]); + const shouldPollSchema = useMemo(() => { return endpoint !== props.defaultEndpoint || !props.defaultSchemaIntrospection; }, [endpoint, props.defaultEndpoint, props.defaultSchemaIntrospection]); @@ -132,7 +208,7 @@ export const useEndpoint = (props: { try { await fetchSchema(intervalController.signal); } catch { - intervalController.abort('Polling schema failed'); + intervalController.abort(new Error('Aborted because of schema polling error')); } }, 5000, @@ -140,7 +216,7 @@ export const useEndpoint = (props: { ); return () => { - intervalController.abort('Polling schema aborted'); + intervalController.abort(new Error(EXPECTED_ERROR_REASON)); }; }, [shouldPollSchema, fetchSchema]); @@ -152,10 +228,44 @@ export const useEndpoint = (props: { useEffect(() => { if (endpoint && !shouldPollSchema) { - void fetchSchema(); + const abortController = new AbortController(); + + void fetchSchema(abortController.signal); + + return () => { + abortController.abort(new Error(EXPECTED_ERROR_REASON)); + }; } }, [endpoint, fetchSchema, shouldPollSchema]); + useEffect(() => { + if (!endpoint || !shouldPollSchema) { + return; + } + + const abortController = new AbortController(); + void fetchSchema(abortController.signal, { + env: props.envApi?.env ?? undefined, + pluginsState: props.pluginsApi?.pluginsState, + }); + + return () => { + abortController.abort(new Error(EXPECTED_ERROR_REASON)); + }; + }, [ + endpoint, + shouldPollSchema, + fetchSchema, + props.settingsApi?.settings.introspection.headers, + props.settingsApi?.settings.introspection.includeActiveOperationHeaders, + props.settingsApi?.settings.introspection.includeActiveOperationHeaders && + props.operationsApi?.activeOperation?.headers, + props.settingsApi?.settings.introspection.includeActiveOperationHeaders && + props.envApi?.env?.variables, + props.settingsApi?.settings.introspection.includeActiveOperationHeaders && + props.pluginsApi?.pluginsState, + ]); + return { endpoint, setEndpoint, @@ -164,5 +274,6 @@ export const useEndpoint = (props: { fetchSchema, restoreDefaultEndpoint, defaultEndpoint: props.defaultEndpoint ?? null, + shouldPollSchema, }; }; diff --git a/packages/libraries/laboratory/src/lib/settings.ts b/packages/libraries/laboratory/src/lib/settings.ts index 20edcfe701..25df85ae7f 100644 --- a/packages/libraries/laboratory/src/lib/settings.ts +++ b/packages/libraries/laboratory/src/lib/settings.ts @@ -13,6 +13,8 @@ export type LaboratorySettings = { introspection: { method?: 'GET' | 'POST'; schemaDescription?: boolean; + headers?: string; + includeActiveOperationHeaders?: boolean; }; }; @@ -29,6 +31,8 @@ export const defaultLaboratorySettings: LaboratorySettings = { introspection: { method: 'POST', schemaDescription: false, + headers: '', + includeActiveOperationHeaders: false, }, }; @@ -50,6 +54,10 @@ export const normalizeLaboratorySettings = ( schemaDescription: settings?.introspection?.schemaDescription ?? defaultLaboratorySettings.introspection.schemaDescription, + headers: settings?.introspection?.headers ?? defaultLaboratorySettings.introspection.headers, + includeActiveOperationHeaders: + settings?.introspection?.includeActiveOperationHeaders ?? + defaultLaboratorySettings.introspection.includeActiveOperationHeaders, }, });