/** * GraphQL Schema Introspection for URL State Management * * Fetches GraphQL schema via introspection query at runtime (works behind auth). * Caches schema in localStorage + memory for performance. */ import { getIntrospectionQuery, buildClientSchema, type IntrospectionQuery, type GraphQLSchema, type GraphQLInputType, isInputObjectType, } from 'graphql'; import type { JSType } from '../../utils/search-params'; import type { VariableDefinition } from './graphql-parser'; /** * Schema cache structure stored in localStorage */ interface SchemaCache { schema: IntrospectionQuery; timestamp: number; version: string; } /** * GraphQL Introspection Manager * * Singleton class that handles schema introspection with caching. * Fetches schema once per session and caches for 24 hours. */ export class GraphQLIntrospector { private schema: GraphQLSchema | null = null; private cacheKey = 'graphql-schema-cache-v1'; private cacheDuration = 24 * 60 * 60 * 1000; // 24 hours private schemaVersion = '1.0.0'; /** * Fetch GraphQL schema via introspection query * * @param endpoint - GraphQL endpoint URL * @param headers - HTTP headers (e.g., authentication) * @param skipCache - Force fresh fetch, ignore cache * * @example * await introspector.fetchSchema( * 'http://localhost/api/graphql', * { 'Authorization': 'Bearer token123' } * ) */ async fetchSchema(endpoint: string, headers: Record = {}, skipCache = false): Promise { // Check cache first (unless skipped) if (!skipCache) { const cached = this.loadFromCache(); if (cached && Date.now() - cached.timestamp < this.cacheDuration) { try { this.schema = buildClientSchema(cached.schema); return; } catch (error) { console.warn('[Introspector] Failed to build schema from cache:', error); // Continue to fetch fresh schema } } } // Fetch schema via introspection query try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers, }, body: JSON.stringify({ query: getIntrospectionQuery(), }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Standard GraphQL execution result. `data` carries the introspection // payload `buildClientSchema` expects; `errors`, when present, is only // ever stringified for the thrown message. const { data, errors } = (await response.json()) as { data?: IntrospectionQuery; errors?: readonly unknown[]; }; if (errors) { throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`); } if (!data) { throw new Error('No data returned from introspection query'); } // Build and cache schema this.schema = buildClientSchema(data); this.saveToCache(data); } catch (error) { console.error('[Introspector] Failed to fetch schema:', error); throw error; } } /** * Get fields from a GraphQL input object type * * @param typeName - Name of the input type (e.g., "LogFilterInput") * @returns Record of field definitions * * @example * const fields = introspector.getInputTypeFields('LogFilterInput') * // { * // severity: { name: 'severity', type: 'array', ... }, * // toolType: { name: 'toolType', type: 'array', ... } * // } */ getInputTypeFields(typeName: string): Record { if (!this.schema) { return {}; } const type = this.schema.getType(typeName); if (!type || !isInputObjectType(type)) { return {}; } const fields: Record = {}; const typeFields = type.getFields(); for (const [fieldName, field] of Object.entries(typeFields)) { const fieldType = this.unwrapType(field.type); fields[fieldName] = { name: fieldName, type: this.mapGraphQLTypeToJS(fieldType.typeName), required: fieldType.isNonNull, isArray: fieldType.isList, graphqlTypeName: fieldType.typeName, }; } return fields; } /** * Check if a type exists in the schema */ hasType(typeName: string): boolean { if (!this.schema) return false; return this.schema.getType(typeName) !== undefined; } /** * Get the schema (if loaded) */ getSchema(): GraphQLSchema | null { return this.schema; } /** * Check if schema is loaded */ isLoaded(): boolean { return this.schema !== null; } /** * Clear cached schema */ clearCache(): void { try { localStorage.removeItem(this.cacheKey); this.schema = null; } catch { // Ignore storage errors } } /** * Unwrap GraphQL type to get base type info * Handles NonNull and List wrappers */ private unwrapType(type: GraphQLInputType): { typeName: string; isNonNull: boolean; isList: boolean; } { let typeName = ''; let isNonNull = false; let isList = false; let current: GraphQLInputType = type; // Unwrap NonNull wrapper if (current.toString().endsWith('!')) { isNonNull = true; current = 'ofType' in current ? current.ofType : current; } // Check for List wrapper if (current.toString().startsWith('[')) { isList = true; current = 'ofType' in current ? current.ofType : current; } // Handle inner NonNull (e.g., [String!]) if (current.toString().endsWith('!')) { current = 'ofType' in current ? current.ofType : current; } // Get base type name // Only NAMED types carry `name`; list/non-null wrappers fall back to // stripping the wrapper punctuation off their printed form. const named = 'name' in current ? current.name : undefined; typeName = named || current.toString().replace(/[[\]!]/g, ''); return { typeName, isNonNull, isList }; } /** * Map GraphQL types to JavaScript types for URL handling */ private mapGraphQLTypeToJS(graphqlType: string): JSType { const typeMap: Record = { String: 'string', Int: 'number', Float: 'number', Boolean: 'boolean', ID: 'string', }; return typeMap[graphqlType] || 'object'; } /** * Load schema from localStorage cache */ private loadFromCache(): SchemaCache | null { try { const cached = localStorage.getItem(this.cacheKey); if (!cached) return null; const parsed = JSON.parse(cached) as SchemaCache; // Validate cache version if (parsed.version !== this.schemaVersion) { return null; } return parsed; } catch (error) { console.warn('[Introspector] Failed to load cache:', error); return null; } } /** * Save schema to localStorage cache */ private saveToCache(schema: IntrospectionQuery): void { try { const cache: SchemaCache = { schema, timestamp: Date.now(), version: this.schemaVersion, }; localStorage.setItem(this.cacheKey, JSON.stringify(cache)); } catch (error) { console.warn('[Introspector] Failed to save cache:', error); // Ignore storage errors (e.g., quota exceeded) } } } /** * Singleton introspector instance * * Use this instance throughout the application to share schema cache * * @example * import { introspector } from '@flamingo/ui-kit/hooks/state' * * // Initialize after auth * await introspector.fetchSchema(endpoint, { Authorization: token }) * * // Get input type fields * const fields = introspector.getInputTypeFields('LogFilterInput') */ export const introspector = new GraphQLIntrospector();