/*
* 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 .
*/
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 '../types'
import { buildArgs } from '../model/token'
/**
* @fileoverview GraphQL DataSource implementation
*/
/**
* Backend type for GraphQL datasources
* Determines default type selection behavior
*/
export type GraphQLBackendType = 'gitlab' | 'wordpress' | 'strapi' | 'supabase' | 'generic'
/**
* Lightweight query to fetch type names and kinds during datasource creation
* Also fetches queryType name to know which type is the root query type
* Does NOT fetch fields, interfaces, enums, inputs, or possibleTypes
*/
export const lightweightTypeNamesQuery = `
query TypeNamesQuery {
__schema {
queryType {
name
}
types {
name
kind
}
}
}
`
/**
* Simplified fragment for selective introspection query
* Fetches type name, field names, field types, and field arguments
* This keeps the query lightweight and avoids GitLab complexity limits
*/
const selectiveIntrospectionFragment = `
fragment SelectiveType on __Type {
name
kind
fields(includeDeprecated: false) {
name
args {
name
type {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
defaultValue
}
type {
kind
name
possibleTypes {
kind
name
}
ofType {
kind
name
possibleTypes {
kind
name
}
ofType {
kind
name
possibleTypes {
kind
name
}
ofType {
kind
name
possibleTypes {
kind
name
}
}
}
}
}
}
}
`
/**
* Number of types to fetch per batch request
* Balances between number of HTTP requests and query complexity
* Default is 100, but GitLab requires smaller batches (5) due to query complexity limits
*/
export const DEFAULT_BATCH_SIZE = 100
export const GITLAB_BATCH_SIZE = 5
export function getBatchSize(backendType: GraphQLBackendType): number {
return backendType === 'gitlab' ? GITLAB_BATCH_SIZE : DEFAULT_BATCH_SIZE
}
/**
* Build a selective introspection query for a batch of types
* Uses __type(name: "...") for each type
* @param typeNames - Array of type names to fetch
* @returns GraphQL query string
*/
export function buildBatchTypeQuery(typeNames: string[]): string {
const typeQueries = typeNames
.map(name => {
const alias = `type_${name.replace(/[^a-zA-Z0-9_]/g, '_')}`
return ` ${alias}: __type(name: "${name}") { ...SelectiveType }`
})
.join('\n')
return `
query BatchTypeIntrospection {
${typeQueries}
}
${selectiveIntrospectionFragment}
`
}
/**
* Result type for lightweight type query
*/
export interface LightweightType {
name: string
kind: 'SCALAR' | 'OBJECT' | 'INTERFACE' | 'UNION' | 'ENUM' | 'INPUT_OBJECT' | 'LIST' | 'NON_NULL'
}
/**
* Result type for fetchTypeNames method
*/
export interface LightweightTypesResult {
types: LightweightType[]
queryTypeName: string
}
/**
* GraphQL Data source options
*/
interface GraphQLQueryOptions {
url: string
headers: Record
method: 'GET' | 'POST'
queryable?: TypeId[]
readonly?: boolean
}
/**
* GraphQL Data source options with server to server options
*/
export interface GraphQLOptions extends GraphQLQueryOptions, IDataSourceOptions {
serverToServer?: GraphQLQueryOptions
hidden?: boolean
/**
* Backend type for this datasource (gitlab, wordpress, generic)
* Determines default type selection behavior
*/
backendType?: GraphQLBackendType
/**
* List of disabled type names for this datasource
* Types in this list will be filtered out during introspection
* If undefined or empty, all types are enabled
* Using disabledTypes (instead of enabledTypes) ensures new types added to the schema are visible by default
*/
disabledTypes?: string[]
}
// 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
* Simple JS object that implements IDataSource interface
*/
export default class GraphQL implements IDataSource {
id: string
label: string
url: string
type = 'graphql' as const
method: 'GET' | 'POST' = 'POST'
headers: Record = {}
queryable?: TypeId[]
readonly?: boolean
hidden?: boolean
backendType: GraphQLBackendType = 'generic'
disabledTypes?: string[]
protected types: Type[] = []
protected queryables: Field[] = []
protected queryType: string = ''
protected ready = false
private eventListeners: Record void)[]> = {}
constructor(options: GraphQLOptions) {
this.id = options.id.toString()
this.label = options.label
this.url = options.url
this.type = options.type
this.method = options.method || 'POST'
this.headers = options.headers || {}
this.queryable = options.queryable
this.readonly = options.readonly
this.hidden = options.hidden
this.backendType = options.backendType || 'generic'
this.disabledTypes = options.disabledTypes
}
// Simple event handling
on(event: string, callback: (...args: unknown[]) => void): void {
if (!this.eventListeners[event]) {
this.eventListeners[event] = []
}
this.eventListeners[event].push(callback)
}
off(event: string, callback?: (...args: unknown[]) => void): void {
if (!this.eventListeners[event]) return
if (callback) {
this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback)
} else {
this.eventListeners[event] = []
}
}
trigger(event: string, ...args: unknown[]): void {
if (!this.eventListeners[event]) return
this.eventListeners[event].forEach(callback => callback(...args))
}
/**
* Fetch only type names and kinds from the GraphQL endpoint
* This is a lightweight query used during datasource creation
* Returns an object with types array and queryTypeName
*/
async fetchTypeNames(): Promise {
try {
const result = await this.call(lightweightTypeNamesQuery) as {data: {__schema: {queryType: {name: string}, types: LightweightType[]}}}
if (!result.data?.__schema?.types) {
throw new Error(`Invalid response: ${JSON.stringify(result)}`)
}
const queryTypeName = result.data.__schema.queryType?.name || 'Query'
const types = result.data.__schema.types
// Filter out introspection types (starting with __)
.filter((type: LightweightType) => !type.name.startsWith('__'))
return { types, queryTypeName }
} catch (e) {
console.error('[GraphQL] Failed to fetch type names:', (e as Error).message)
throw new Error(`Failed to fetch type names: ${(e as Error).message}`)
}
}
/**
* Get default enabled types based on backend type
* @param backendType - The backend type (gitlab, wordpress, generic)
* @param allTypes - Array of all available types from the schema with their kinds
* @param queryTypeName - The name of the query type from the schema (e.g., 'Query', 'RootQuery')
* @returns Array of type names that should be enabled by default
*/
static getDefaultEnabledTypes(backendType: GraphQLBackendType, allTypes: LightweightType[], queryTypeName: string = 'Query'): string[] {
// Get all type names for filtering
const allTypeNames = allTypes.map(t => t.name)
// Automatically detect all SCALAR types from the schema
// This includes String, Boolean, Int, Float, ID, and any custom scalars like DateTime, JSON, etc.
const scalarTypeNames = allTypes
.filter(t => t.kind === 'SCALAR')
.map(t => t.name)
switch (backendType) {
case 'gitlab': {
// GitLab: Essential types + connections for listing
const gitlabCoreTypes = [
'Project',
'Namespace',
'User',
'Group',
'Repository',
'Commit',
'Branch',
'MergeRequest',
'Issue',
'Pipeline',
'Job',
'Release',
'Milestone',
'Label',
'Note',
'Discussion',
'Snippet',
'Board',
'Epic',
'Vulnerability',
'Package',
'ContainerRepository',
'Environment',
'Deployment',
'CiRunner',
'Topic',
'PageInfo',
]
const gitlabDefaults = [
...scalarTypeNames,
queryTypeName,
...gitlabCoreTypes,
// Include connection types for pagination (e.g., GroupConnection, ProjectConnection)
...allTypeNames.filter(name => {
// Exclude input/enum types
if (name.endsWith('Input') || name.endsWith('Enum')) return false
// Include connections and edges for core types
return (
name.endsWith('Connection') ||
name.endsWith('Edge') ||
// Include core type variations
gitlabCoreTypes.some(core => name.startsWith(core))
)
}),
]
return allTypeNames.filter(name => gitlabDefaults.includes(name))
}
case 'wordpress': {
// WordPress/WPGraphQL: Blacklist approach for headless CMS
// All types are enabled by default, except those explicitly blacklisted
// This ensures CPTs (like ACF custom post types) are included automatically
// Get all INPUT_OBJECT types (used for mutations, not queries)
const inputTypes = allTypes
.filter(t => t.kind === 'INPUT_OBJECT')
.map(t => t.name)
// Types to blacklist (not useful for static site generation)
const blacklistedTypes = [
// Mutation root
'RootMutation',
// Settings (admin only)
'Settings',
'DiscussionSettings',
'GeneralSettings',
'ReadingSettings',
'WritingSettings',
// Admin-only types
'Plugin',
'Theme',
'UserRole',
'Avatar',
// Content templates (WP internal, not CPTs)
'ContentTemplate',
'DefaultTemplate',
]
// Patterns to blacklist
const blacklistPatterns = [
// All mutation inputs and payloads
/^Create.+Input$/,
/^Update.+Input$/,
/^Delete.+Input$/,
/^Register.+Input$/,
/^Reset.+Input$/,
/^Restore.+Input$/,
/^Send.+Input$/,
/Payload$/,
// Admin connections
/^RootQueryToPlugin/,
/^RootQueryToTheme/,
/^RootQueryToUserRole/,
// Plugin/Theme/UserRole related
/Plugin(?:Connection|Edge|PageInfo|Status)/,
/Theme(?:Connection|Edge|PageInfo)/,
/UserRole(?:Connection|Edge|PageInfo)/,
// Enqueued assets (internal WP)
/Enqueued/,
/Script(?!$)/, // Exclude Script-related but not if it's just "Script"
/Stylesheet/,
// Revisions (internal WP)
/Revision/,
// Comments (usually not needed for static sites)
/Comment/,
/Commenter/,
// Post formats (rarely used)
/PostFormat/,
// Generic content types (not needed if querying specific collections)
/^RootQueryToContentNode/,
]
const isBlacklisted = (name: string): boolean => {
// Blacklist all INPUT_OBJECT types (mutation inputs)
if (inputTypes.includes(name)) return true
// Blacklist explicitly named types
if (blacklistedTypes.includes(name)) return true
// Blacklist by pattern
return blacklistPatterns.some(pattern => pattern.test(name))
}
// Return all types except blacklisted ones
return allTypeNames.filter(name => !isBlacklisted(name))
}
case 'strapi': {
// Strapi v4/v5 GraphQL: Blacklist approach for headless CMS
// Strapi generates types for content types, media, and internal admin types
// Get all INPUT_OBJECT types (used for mutations, not queries)
const inputTypes = allTypes
.filter(t => t.kind === 'INPUT_OBJECT')
.map(t => t.name)
// Types to blacklist (not useful for static site generation)
const blacklistedTypes = [
// Mutation root
'Mutation',
// Generic internal type
'GenericMorph',
]
// Patterns to blacklist
const blacklistPatterns = [
// Mutation payloads
/Payload$/,
// Users & Permissions plugin (admin)
/^UsersPermissions/,
// Upload folder management (admin)
/^UploadFolder/,
// i18n internal types
/^I18N/,
// Entity wrappers (Strapi v4 internal)
/EntityResponse$/,
/EntityResponseCollection$/,
// Content type metadata (admin)
/^ContentType/,
// Admin panel types
/^Admin/,
]
const isBlacklisted = (name: string): boolean => {
// Blacklist all INPUT_OBJECT types (mutation inputs)
if (inputTypes.includes(name)) return true
// Blacklist explicitly named types
if (blacklistedTypes.includes(name)) return true
// Blacklist by pattern
return blacklistPatterns.some(pattern => pattern.test(name))
}
// Return all types except blacklisted ones
return allTypeNames.filter(name => !isBlacklisted(name))
}
case 'supabase': {
// Supabase (pg_graphql): Blacklist approach
// pg_graphql generates types from PostgreSQL tables
// Get all INPUT_OBJECT types (used for mutations, not queries)
const inputTypes = allTypes
.filter(t => t.kind === 'INPUT_OBJECT')
.map(t => t.name)
// Types to blacklist (not useful for static site generation)
const blacklistedTypes = [
// Mutation root
'Mutation',
// Cursor type (internal pagination)
'Cursor',
]
// Patterns to blacklist
const blacklistPatterns = [
// Mutation-related types
/InsertInput$/,
/UpdateInput$/,
/DeleteInput$/,
/InsertResponse$/,
/UpdateResponse$/,
/DeleteResponse$/,
// Ordering types (query params, not data)
/OrderBy$/,
/OrderByDirection$/,
// Filter types (query params, not data)
/Filter$/,
/FilterInput$/,
// Edge types (keep Connection but not Edge for simpler queries)
/Edge$/,
]
const isBlacklisted = (name: string): boolean => {
// Blacklist all INPUT_OBJECT types (mutation inputs)
if (inputTypes.includes(name)) return true
// Blacklist explicitly named types
if (blacklistedTypes.includes(name)) return true
// Blacklist by pattern
return blacklistPatterns.some(pattern => pattern.test(name))
}
// Return all types except blacklisted ones
return allTypeNames.filter(name => !isBlacklisted(name))
}
case 'generic':
default:
// Generic: All types checked by default
return [...allTypeNames]
}
}
/**
* @throws Error
*/
protected triggerError(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 {
let schemaTypes: GQLType[]
let queryTypeName: string
// If disabledTypes is set, compute enabled types and use selective introspection
if (this.disabledTypes && this.disabledTypes.length > 0) {
// Fetch all type names (lightweight query)
const lightweightResult = await this.call(lightweightTypeNamesQuery) as {data: {__schema: {queryType: {name: string}, types: LightweightType[]}}}
if (!lightweightResult.data?.__schema?.types) {
return this.triggerError(`Invalid lightweight response: ${JSON.stringify(lightweightResult)}`)
}
const allTypeNames = lightweightResult.data.__schema.types
.map(t => t.name)
.filter(name => !name.startsWith('__'))
// Get the queryType name from the lightweight query
const lightweightQueryTypeName = lightweightResult.data.__schema.queryType?.name || 'Query'
// Compute enabled types by excluding disabled ones (blacklist logic)
// Always include queryType and SCALAR types - they cannot be blacklisted
const scalarTypeNames = lightweightResult.data.__schema.types
.filter(t => t.kind === 'SCALAR' && !t.name.startsWith('__'))
.map(t => t.name)
const enabledTypeNames = allTypeNames.filter(name => !this.disabledTypes!.includes(name))
// Ensure queryType is always included (e.g., Query, RootQuery, etc.)
if (!enabledTypeNames.includes(lightweightQueryTypeName) && allTypeNames.includes(lightweightQueryTypeName)) {
enabledTypeNames.push(lightweightQueryTypeName)
}
// Ensure all SCALAR types are always included
for (const scalarName of scalarTypeNames) {
if (!enabledTypeNames.includes(scalarName)) {
enabledTypeNames.push(scalarName)
}
}
// Split types into batches (smaller batches for GitLab due to query complexity limits)
const batchSize = getBatchSize(this.backendType)
const batches: string[][] = []
for (let i = 0; i < enabledTypeNames.length; i += batchSize) {
batches.push(enabledTypeNames.slice(i, i + batchSize))
}
// Fetch batches in parallel
const batchResults = await Promise.all(
batches.map(async (batch) => {
try {
const query = buildBatchTypeQuery(batch)
const result = await this.call(query) as {data: {[key: string]: GQLType | null}}
// Extract types from aliased responses
const types: GQLType[] = []
for (const [key, value] of Object.entries(result.data || {})) {
if (key.startsWith('type_') && value !== null) {
types.push(value)
}
}
return types
} catch (e) {
console.warn('[GraphQL] Failed to fetch batch:', (e as Error).message)
return []
}
})
)
// Flatten batch results
schemaTypes = batchResults.flat()
queryTypeName = lightweightQueryTypeName
} else {
// No blacklist - use full introspection query
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)}`)
}
schemaTypes = result.data.__schema.types
queryTypeName = result.data.__schema.queryType?.name
}
if (!queryTypeName) {
return this.triggerError('Invalid response, queryType not found')
}
const allTypes = schemaTypes.map((type: GQLType) => type.name)
.concat(builtinTypeIds)
const query: GQLType | undefined = schemaTypes.find((type: GQLType) => type.name === queryTypeName)
if (!query) {
return this.triggerError(`Query type "${queryTypeName}" not found in schema. Make sure to enable it.`)
}
// Get non-queryable types
const nonQueryables = schemaTypes
// Filter out introspection types
.filter((type: GQLType) => !type.name.startsWith('__'))
// 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)
// Get queryable types
const queryableTypes = (query.fields || [])
// Map to GQLType, keeping kind for later
.map((field: GQLField) => ({
type: {
...schemaTypes.find((type: GQLType) => type.name === this.getOfTypeProp('name', field.type, field.name)),
name: field.name,
} as GQLType,
kind: this.ofKindToKind(field.type),
}))
// Filter out types that were excluded
.filter(({type}) => type.fields !== undefined)
// Map to Type
.map(({type, kind}) => this.graphQLToType(allTypes, type, kind, true))
// Get all queryables as fields
const queryableFields = (query.fields || [])
// Filter out fields whose types were not fetched
.filter((field: GQLField) => {
const typeName = this.getOfTypeProp('name', field.type, field.name)
// Include if the type exists in schemaTypes or is a builtin type
return schemaTypes.some(t => t.name === typeName) || builtinTypeIds.includes(typeName)
})
// Map to Field
.map((field: GQLField) => this.graphQLToField(field))
// Return all types, queryables and non-queryables
return [queryableTypes.concat(nonQueryables), queryableFields, queryTypeName]
} 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.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('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(prop: string, type: GQLOfType, defaultValue?: T): T {
const result = this.getOfTypePropRecursive(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(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(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)
}
const typeName = this.getOfTypeProp('name', field.type, field.name)
return [typeName]
}
/**
* 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.queryable
const result = {
id: type.name,
dataSourceId: this.id,
label: type.name,
fields: type.fields
// Do not include fields whose type is not in the schema (blacklisted types)
// 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('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 {
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.ready = true
this.trigger(DATA_SOURCE_READY, this)
}
} 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[] {
if (!this.ready) {
console.error('DataSource is not ready. Attempted to get types before ready status was achieved.')
throw new Error('DataSource is not ready. Ensure it is connected and ready before querying.')
}
if (this.types.length === 0) {
console.error('No types available. It seems the data source may not be connected or the schema is incomplete.', this.ready)
throw new Error('No types found. The data source may not be connected or there might be an issue with the schema.')
}
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 {
// Retrieve the URL for the GraphQL endpoint
const url = this.url
if (!url) return this.triggerError('Missing GraphQL URL') // Ensure the URL is provided
// Retrieve the headers for the GraphQL request
const headers = this.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.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.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 fetchValues(query: string): Promise {
const result = await this.call(query) as { data: unknown[] }
return result.data
}
}