@silexlabs/grapesjs-data-source
Version:
Grapesjs Data Source
466 lines (422 loc) • 16.1 kB
text/typescript
/*
* 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]
//}
}