diff --git a/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx index 44c887be38..0f6dd1ad6d 100644 --- a/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx @@ -514,15 +514,10 @@ export const Laboratory = ( const pluginsApi = usePlugins(props); const testsApi = useTests(props); const tabsApi = useTabs(props); - const endpointApi = useEndpoint({ - ...props, - settingsApi, - }); const collectionsApi = useCollections({ ...props, tabsApi, }); - const operationsApi = useOperations({ ...props, collectionsApi, @@ -533,6 +528,13 @@ export const Laboratory = ( pluginsApi, checkPermissions, }); + const endpointApi = useEndpoint({ + ...props, + settingsApi, + operationsApi, + envApi, + pluginsApi, + }); const historyApi = useHistory(props); diff --git a/packages/libraries/laboratory/src/lib/endpoint.ts b/packages/libraries/laboratory/src/lib/endpoint.ts index 55848cd9e3..f9ff88c7f0 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'; @@ -24,11 +28,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,67 +56,109 @@ 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'); - } - - 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' - ) { - toast.error(error.message); - } else { - toast.error('Failed to fetch schema'); - } - - setIntrospection(null); - - throw error; - } - }, + const activeOperationHeadersRef = useRef( + props.operationsApi?.activeOperation?.headers, + ); + const envVariablesRef = useRef( + props.envApi?.env?.variables, + ); + const pluginsStateRef = useRef | undefined>(props.pluginsApi?.pluginsState); + + activeOperationHeadersRef.current = props.operationsApi?.activeOperation?.headers; + envVariablesRef.current = props.envApi?.env?.variables; + pluginsStateRef.current = props.pluginsApi?.pluginsState; + + const fetchSchema = useMemo( + () => + debounce( + async ( + signal?: AbortSignal, + options?: { + env?: LaboratoryEnv; + pluginsState?: Record; + }, + ) => { + if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) { + setIntrospection(props.defaultSchemaIntrospection); + return; + } + + if (!endpoint) { + setIntrospection(null); + return; + } + + try { + let parsedHeaders: Record = {}; + + try { + parsedHeaders = activeOperationHeadersRef.current + ? JSON.parse( + handleTemplate(activeOperationHeadersRef.current, { + ...(options?.env?.variables ?? envVariablesRef.current ?? {}), + plugins: options?.pluginsState ?? pluginsStateRef.current ?? {}, + }), + ) + : {}; + } 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, @@ -116,6 +167,12 @@ export const useEndpoint = (props: { ], ); + useEffect(() => { + return () => { + fetchSchema.cancel(); + }; + }, [fetchSchema]); + const shouldPollSchema = useMemo(() => { return endpoint !== props.defaultEndpoint || !props.defaultSchemaIntrospection; }, [endpoint, props.defaultEndpoint, props.defaultSchemaIntrospection]); @@ -132,7 +189,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 +197,7 @@ export const useEndpoint = (props: { ); return () => { - intervalController.abort('Polling schema aborted'); + intervalController.abort(new Error(EXPECTED_ERROR_REASON)); }; }, [shouldPollSchema, fetchSchema]); @@ -152,10 +209,39 @@ 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.operationsApi?.activeOperation?.headers, + props.envApi?.env?.variables, + props.pluginsApi?.pluginsState, + ]); + return { endpoint, setEndpoint,