UNPKG

@silexlabs/grapesjs-data-source

Version:
466 lines (422 loc) 16.1 kB
/* * Silex website builder, free/libre no-code tool for makers. * Copyright (c) 2023 lexoyo and Silex Labs foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import Backbone from 'backbone' import { IDataSourceOptions, Type, Field, Tree, TypeId, IDataSource, DATA_SOURCE_ERROR, builtinTypeIds, builtinTypes, FieldKind, DATA_SOURCE_READY, DATA_SOURCE_CHANGED } from '../types' import graphqlIntrospectionQuery from './graphql-introspection-query' import dedent from 'dedent-js' import { FIXED_TOKEN_ID } from '../utils' import { buildArgs } from '../model/token' /** * @fileoverview GraphQL DataSource implementation */ /** * GraphQL Data source options */ interface GraphQLQueryOptions { url: string headers: Record<string, string> method: 'GET' | 'POST' queryable?: TypeId[] readonly?: boolean } /** * GraphQL Data source options with server to server options */ export interface GraphQLOptions extends GraphQLQueryOptions, IDataSourceOptions { serverToServer?: GraphQLQueryOptions } // GraphQL specific types // Exported for unit tests export type GQLKind = 'SCALAR' | 'OBJECT' | 'LIST' | 'NON_NULL' | 'UNION' export interface GQLOfType { name?: string, kind: GQLKind, ofType?: GQLOfType, possibleTypes?: {name: string, kind: GQLKind}[], } export interface GQLField { name: string, type: GQLOfType, args?: { name: string, type: GQLOfType, defaultValue?: string, }[], } export interface GQLType { name: string, fields: GQLField[], } /** * GraphQL DataSource implementation * This is a Backbone model used in the DataSourceManager collection */ export default class GraphQL extends Backbone.Model<GraphQLOptions> implements IDataSource { id: string protected types: Type[] = [] protected queryables: Field[] = [] protected queryType: string = '' protected ready = false constructor(options: GraphQLOptions) { super(options) this.id = options.id.toString() this.set('id', options.id) this.set('label', options.label) this.set('url', options.url) this.set('headers', options.headers) this.set('queryable', options.queryable) this.set('readonly', options.readonly) } /** * @throws Error */ protected triggerError<T>(message: string): T { console.error('GraphQL error:', message) this.trigger(DATA_SOURCE_ERROR, message, this) throw new Error(message) } protected async loadData(): Promise<[Type[], Field[], string]> { try { const result = await this.call(graphqlIntrospectionQuery) as {data: {__schema: {types: GQLType[], queryType: {name: string}}}} if (!result.data?.__schema?.types) return this.triggerError(`Invalid response: ${JSON.stringify(result)}`) const allTypes = result.data.__schema.types.map((type: GQLType) => type.name) .concat(builtinTypeIds) const queryType: string = result.data.__schema.queryType?.name if(!queryType) return this.triggerError(`Invalid response, queryType not found: ${JSON.stringify(result)}`) const query: GQLType | undefined = result.data.__schema.types.find((type: GQLType) => type.name === queryType) if(!query) return this.triggerError(`Invalid response, query not found: ${JSON.stringify(result)}`) // Get non-queryable types const nonQueryables = result.data.__schema.types // Filter out Query, Mutation, Subscription //.filter((type: GQLType) => !['Query', 'Mutation', 'Subscription'].includes(type.name)) // Filter out introspection types .filter((type: GQLType) => !type.name.startsWith('__')) // Filter out types that are not in Query //.map((type: GQLType) => query?.fields.find((field: GQLField) => field.name === type.name) ?? type) // Filter out types that are in Query (the queryables are handled separately) .filter((type: GQLType) => !query?.fields.find((field: GQLField) => field.name === type.name)) // Map to Type .map((type: GQLType) => this.graphQLToType(allTypes, type, 'SCALAR', false)) // Add builtin types .concat(builtinTypes) if(!query) { return this.triggerError('Query type not found in GraphQL schema') } // Get queryable types const queryableTypes = query.fields // Map to GQLType, keeping kind for later .map((field: GQLField) => ({ type: { ...result.data.__schema.types.find((type: GQLType) => type.name === this.getOfTypeProp<string>('name', field.type, field.name)), name: field.name, } as GQLType, kind: this.ofKindToKind(field.type), })) // Map to Type .map(({type, kind}) => this.graphQLToType(allTypes, type, kind, true)) // Get all queryables as fields const queryableFields = query.fields // Map to Field .map((field: GQLField) => this.graphQLToField(field)) // Return all types, queryables and non-queryables return [queryableTypes.concat(nonQueryables), queryableFields, queryType] } catch (e) { return this.triggerError(`GraphQL introspection failed: ${(e as Error).message}`) } } protected graphQLToField(field: GQLField): Field { const kind = this.ofKindToKind(field.type) return { id: field.name, dataSourceId: this.get('id')!, label: field.name, typeIds: this.graphQLToTypes(field), kind: kind ? this.graphQLToKind(kind) : 'unknown', arguments: field.args?.map(arg => ({ name: arg.name, typeId: this.getOfTypeProp<string>('name', arg.type, arg.name), defaultValue: arg.defaultValue, })), } } /** * Recursively search for a property on a GraphQL type * Check the deepest values in ofType first */ protected getOfTypeProp<T>(prop: string, type: GQLOfType, defaultValue?: T): T { const result = this.getOfTypePropRecursive<T>(prop, type) if(result) return result if(defaultValue) return defaultValue throw new Error(`Type ${JSON.stringify(type)} has no property ${prop} and no default was provided`) } protected getOfTypePropRecursive<T>(prop: string, type: GQLOfType): T | undefined { if(!type) { console.error('Invalid type', type) throw new Error('Invalid type') } if(type.ofType) { const ofTypeResult = this.getOfTypePropRecursive<T>(prop, type.ofType) if(ofTypeResult) return ofTypeResult } return type[prop as keyof GQLOfType] as T } /** * Recursively search for a property on a GraphQL type * Handles Union types with possibleTypes * Handles list and object and non-null types with ofType */ protected graphQLToTypes(field: GQLField): TypeId[] { const possibleTypes = this.getOfTypeProp<{name: string, kind: GQLKind}[]>('possibleTypes', field.type, []) if(possibleTypes.length > 0) return possibleTypes.map(type => type.name) return [this.getOfTypeProp<string>('name', field.type, field.name)] } /** * Convert GraphQL kind to FieldKind * @throws Error if kind is not valid or is NON_NULL */ protected graphQLToKind(kind: GQLKind): FieldKind { switch(kind) { case 'LIST': return 'list' case 'OBJECT': return 'object' case 'SCALAR': return 'scalar' case 'UNION': case 'NON_NULL': default: throw new Error(`Unable to find a valid kind for ${kind}`) } } /** * Check if a GraphQL kind has a valid FieldKind equivalent */ protected validKind(kind: GQLKind): boolean { return ['LIST', 'OBJECT', 'SCALAR'].includes(kind) } /** * Recursively search for a GraphQL kind of type list, object or scalar */ protected ofKindToKind(ofKind: GQLOfType): GQLKind | null { if(ofKind.possibleTypes) { const foundKind = ofKind.possibleTypes .reduce((prev: GQLKind | null, type: {kind: GQLKind, name: string}) => { if(!prev) return type.kind as GQLKind if(prev !== type.kind) { throw new Error(`Unable to find a valid kind for ${ofKind.kind}. Union types with different kind is not supported`) } return prev as GQLKind }, null) if(!foundKind) { console.error('Unable to find a valid kind (1)', ofKind) return null } return foundKind } if(this.validKind(ofKind.kind)) return ofKind.kind if(ofKind.ofType) return this.ofKindToKind(ofKind.ofType) // This happens when the type is missing // Remove the warning because it happens with directus and polutes the logs // console.error('Unable to find a valid kind (2)', ofKind) return null } /** * Convert a GraphQL type to a Type */ protected graphQLToType(allTypes: TypeId[], type: GQLType, kind: GQLKind | null, queryable: boolean): Type { const queryableOverride = this.get('queryable') const result = { id: type.name, dataSourceId: this.get('id')!, label: type.name, fields: type.fields // Do not include fields that are not in the schema // FIXME: somehow this happens with fields of type datetime_functions for directus //?.filter((field: {name: string, type: any}) => allTypes.includes(field.name)) ?.filter((field) => allTypes.includes(this.getOfTypeProp<string>('name', field.type, field.name))) ?.map(field => this.graphQLToField(field)) ?? [], queryable: queryable && (!queryableOverride || queryableOverride!.includes(type.name)), } return result } /** * Connect to the GraphQL endpoint and load the schema * This has to be implemented as it is a DataSource method */ async connect(): Promise<void> { try { // const result = await this.call(` // query { // __typename // } // `) as any // if (!result?.data?.__typename) return this.triggerError(`Invalid response: ${JSON.stringify(result)}`) const [types, fields, queryType] = await this.loadData() if(types.length === 0) return this.triggerError('No types found in GraphQL schema') if(fields.length === 0) return this.triggerError('No fields found in GraphQL schema') if(!queryType) return this.triggerError('No query type found in GraphQL schema') this.types = types this.queryables = fields this.queryType = queryType if (this.ready) { this.trigger(DATA_SOURCE_CHANGED, this) } else { this.trigger(DATA_SOURCE_READY, this) } this.ready = true } catch (e) { return this.triggerError(`GraphQL connection failed: ${(e as Error).message}`) } } /** * Check if the DataSource is ready * This has to be implemented as it is a DataSource method */ isConnected(): boolean { return this.ready } /** * Get all types * This has to be implemented as it is a DataSource method */ getTypes(): Type[] { return this.types } /** * Get all queryable fields * This has to be implemented as it is a DataSource method */ getQueryables(): Field[] { return this.queryables } /** * Call the GraphQL endpoint */ protected async call(query: string): Promise<unknown> { // Retrieve the URL for the GraphQL endpoint const url = this.get('url') if (!url) return this.triggerError('Missing GraphQL URL') // Ensure the URL is provided // Retrieve the headers for the GraphQL request const headers = this.get('headers') if (!headers) return this.triggerError('Missing GraphQL headers') // Ensure headers are provided // Ensure the Content-Type header is set to 'application/json', normalizing the case const key = Object.keys(headers).find(name => name.toLowerCase() === 'content-type') headers[key || 'Content-Type'] = headers[key || 'Content-Type'] || 'application/json' // Retrieve the HTTP method (defaulting to 'POST' for GraphQL queries) const method = this.get('method') ?? 'POST' // Make the HTTP request to the GraphQL endpoint const response = await fetch(url, { method, headers, // Include a body only for POST requests ...(method === 'POST' ? { body: JSON.stringify({ query }), } : {}), }) // Handle non-OK responses with detailed error logging if (!response?.ok) { console.error('GraphQL call failed', response?.status, response?.statusText, query) return this.triggerError(`GraphQL call failed with \`${response?.statusText}\` and status ${response?.status}`) } // Return the parsed JSON response return response.json() } /** * Build a GraphQL query from a tree */ getQuery(children: Tree[]): string { return this.getQueryRecursive({ // Add the main query object which is the root of the tree token: { dataSourceId: this.get('id'), fieldId: 'query', kind: 'object', typeIds: [this.queryType], }, children, } as Tree) } protected getQueryRecursive(tree: Tree, indent = '', fragment = ''): string { // Check if the tree is a fragment const typeOrFragment = fragment ? `...on ${fragment}` : `${tree.token.fieldId}${buildArgs(tree.token.options)}` // Build the value switch(tree.token.kind) { case 'scalar': if(tree.token.fieldId === FIXED_TOKEN_ID) return '' return indent + typeOrFragment case 'object': case 'list': { const types = this.getTypes().filter(t => tree.token.typeIds?.includes(t.id)) if(types.length === 0) { throw new Error(`Type not found for ${tree.token.fieldId} (${tree.token.typeIds})`) } else if(types.length > 1) throw new Error(`Multiple types found for ${tree.token.fieldId}`) const type = types[0] as Type const fieldTypes = tree.children .map(child => { const fieldType = type.fields.find(f => f.id === child.token.fieldId) if(!fieldType) { // Not a queryable type return null } return { fieldType, child, } }) // Remove non-queryable types .filter(fieldType => fieldType !== null) as {fieldType: Field, child: Tree}[] // Handle fragments const fragments = fieldTypes .filter(({fieldType}) => fieldType.typeIds.length > 1) .map(({child}) => { return { query: this.getQueryRecursive(child, indent + ' ', child.token.typeIds[0]), child, } }) const fragmentsQuery = fragments .map(({query, child}) => dedent` ${indent}${child.token.fieldId} { ${query} } `) .join('\n') // Handle simple case, no fragment const childQuery = fieldTypes .filter(({fieldType}) => fieldType.typeIds.length === 1) .map(({child}) => { return this.getQueryRecursive(child, indent + ' ') }) .join('\n') return dedent`${indent}${typeOrFragment} { ${indent} __typename ${childQuery} ${fragmentsQuery} ${indent}}` } default: console.error('Unable to build GraphQL query', tree) throw new Error(`Unable to build GraphQL query: unable to build tree ${JSON.stringify(tree)}`) } } //async getData(query: Query): Promise<any[]> { // const result = await this.call(` // query { // ${this.buildQuery(query)} // } // `) as any // return result.data.Query[query.name] //} }