import Parse from 'parse/node'; import { GraphQLSchema, GraphQLObjectType, DocumentNode, GraphQLNamedType } from 'graphql'; import { mergeSchemas } from '@graphql-tools/schema'; import { mergeTypeDefs } from '@graphql-tools/merge'; import { isDeepStrictEqual } from 'util'; import requiredParameter from '../requiredParameter'; import * as defaultGraphQLTypes from './loaders/defaultGraphQLTypes'; import * as parseClassTypes from './loaders/parseClassTypes'; import * as parseClassQueries from './loaders/parseClassQueries'; import * as parseClassMutations from './loaders/parseClassMutations'; import * as defaultGraphQLQueries from './loaders/defaultGraphQLQueries'; import * as defaultGraphQLMutations from './loaders/defaultGraphQLMutations'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; import DatabaseController from '../Controllers/DatabaseController'; import SchemaCache from '../Adapters/Cache/SchemaCache'; import { toGraphQLError } from './parseGraphQLUtils'; import * as schemaDirectives from './loaders/schemaDirectives'; import * as schemaTypes from './loaders/schemaTypes'; import { getFunctionNames } from '../triggers'; import * as defaultRelaySchema from './loaders/defaultRelaySchema'; const RESERVED_GRAPHQL_TYPE_NAMES = [ 'String', 'Boolean', 'Int', 'Float', 'ID', 'ArrayResult', 'Query', 'Mutation', 'Subscription', 'CreateFileInput', 'CreateFilePayload', 'Viewer', 'SignUpInput', 'SignUpPayload', 'LogInInput', 'LogInPayload', 'LogOutInput', 'LogOutPayload', 'CloudCodeFunction', 'CallCloudCodeInput', 'CallCloudCodePayload', 'CreateClassInput', 'CreateClassPayload', 'UpdateClassInput', 'UpdateClassPayload', 'DeleteClassInput', 'DeleteClassPayload', 'PageInfo', ]; const RESERVED_GRAPHQL_QUERY_NAMES = ['health', 'viewer', 'class', 'classes']; const RESERVED_GRAPHQL_MUTATION_NAMES = [ 'signUp', 'logIn', 'logOut', 'createFile', 'callCloudCode', 'createClass', 'updateClass', 'deleteClass', ]; class ParseGraphQLSchema { databaseController: DatabaseController; parseGraphQLController: ParseGraphQLController; parseGraphQLConfig: ParseGraphQLConfig; log: any; appId: string; graphQLCustomTypeDefs: ?(string | GraphQLSchema | DocumentNode | GraphQLNamedType[]); schemaCache: any; constructor( params: { databaseController: DatabaseController, parseGraphQLController: ParseGraphQLController, log: any, appId: string, graphQLCustomTypeDefs: ?(string | GraphQLSchema | DocumentNode | GraphQLNamedType[]), } = {} ) { this.parseGraphQLController = params.parseGraphQLController || requiredParameter('You must provide a parseGraphQLController instance!'); this.databaseController = params.databaseController || requiredParameter('You must provide a databaseController instance!'); this.log = params.log || requiredParameter('You must provide a log instance!'); this.graphQLCustomTypeDefs = params.graphQLCustomTypeDefs; this.appId = params.appId || requiredParameter('You must provide the appId!'); this.schemaCache = SchemaCache; this.logCache = {}; } async load() { const { parseGraphQLConfig } = await this._initializeSchemaAndConfig(); const parseClassesArray = await this._getClassesForSchema(parseGraphQLConfig); const functionNames = await this._getFunctionNames(); const functionNamesString = functionNames.join(); const parseClasses = parseClassesArray.reduce((acc, clazz) => { acc[clazz.className] = clazz; return acc; }, {}); if ( !this._hasSchemaInputChanged({ parseClasses, parseGraphQLConfig, functionNamesString, }) ) { return this.graphQLSchema; } this.parseClasses = parseClasses; this.parseGraphQLConfig = parseGraphQLConfig; this.functionNames = functionNames; this.functionNamesString = functionNamesString; this.parseClassTypes = {}; this.viewerType = null; this.graphQLAutoSchema = null; this.graphQLSchema = null; this.graphQLTypes = []; this.graphQLQueries = {}; this.graphQLMutations = {}; this.graphQLSubscriptions = {}; this.graphQLSchemaDirectivesDefinitions = null; this.graphQLSchemaDirectives = {}; this.relayNodeInterface = null; defaultGraphQLTypes.load(this); defaultRelaySchema.load(this); schemaTypes.load(this); this._getParseClassesWithConfig(parseClassesArray, parseGraphQLConfig).forEach( ([parseClass, parseClassConfig]) => { // Some times schema return the _auth_data_ field // it will lead to unstable graphql generation order if (parseClass.className === '_User') { Object.keys(parseClass.fields).forEach(fieldName => { if (fieldName.startsWith('_auth_data_')) { delete parseClass.fields[fieldName]; } }); } // Fields order inside the schema seems to not be consistent across // restart so we need to ensure an alphabetical order // also it's better for the playground documentation const orderedFields = {}; Object.keys(parseClass.fields) .sort() .forEach(fieldName => { orderedFields[fieldName] = parseClass.fields[fieldName]; }); parseClass.fields = orderedFields; parseClassTypes.load(this, parseClass, parseClassConfig); parseClassQueries.load(this, parseClass, parseClassConfig); parseClassMutations.load(this, parseClass, parseClassConfig); } ); defaultGraphQLTypes.loadArrayResult(this, parseClassesArray); defaultGraphQLQueries.load(this); defaultGraphQLMutations.load(this); let graphQLQuery = undefined; if (Object.keys(this.graphQLQueries).length > 0) { graphQLQuery = new GraphQLObjectType({ name: 'Query', description: 'Query is the top level type for queries.', fields: this.graphQLQueries, }); this.addGraphQLType(graphQLQuery, true, true); } let graphQLMutation = undefined; if (Object.keys(this.graphQLMutations).length > 0) { graphQLMutation = new GraphQLObjectType({ name: 'Mutation', description: 'Mutation is the top level type for mutations.', fields: this.graphQLMutations, }); this.addGraphQLType(graphQLMutation, true, true); } let graphQLSubscription = undefined; if (Object.keys(this.graphQLSubscriptions).length > 0) { graphQLSubscription = new GraphQLObjectType({ name: 'Subscription', description: 'Subscription is the top level type for subscriptions.', fields: this.graphQLSubscriptions, }); this.addGraphQLType(graphQLSubscription, true, true); } this.graphQLAutoSchema = new GraphQLSchema({ types: this.graphQLTypes, query: graphQLQuery, mutation: graphQLMutation, subscription: graphQLSubscription, }); if (this.graphQLCustomTypeDefs) { schemaDirectives.load(this); if (typeof this.graphQLCustomTypeDefs.getTypeMap === 'function') { // In following code we use underscore attr to keep the direct variable reference const customGraphQLSchemaTypeMap = this.graphQLCustomTypeDefs._typeMap; const findAndReplaceLastType = (parent, key) => { if (parent[key].name) { if ( this.graphQLAutoSchema._typeMap[parent[key].name] && this.graphQLAutoSchema._typeMap[parent[key].name] !== parent[key] ) { // To avoid unresolved field on overloaded schema // replace the final type with the auto schema one parent[key] = this.graphQLAutoSchema._typeMap[parent[key].name]; } } else { if (parent[key].ofType) { findAndReplaceLastType(parent[key], 'ofType'); } } }; // Add non shared types from custom schema to auto schema // note: some non shared types can use some shared types // so this code need to be ran before the shared types addition // we use sort to ensure schema consistency over restarts Object.keys(customGraphQLSchemaTypeMap) .sort() .forEach(customGraphQLSchemaTypeKey => { const customGraphQLSchemaType = customGraphQLSchemaTypeMap[customGraphQLSchemaTypeKey]; if ( !customGraphQLSchemaType || !customGraphQLSchemaType.name || customGraphQLSchemaType.name.startsWith('__') ) { return; } const autoGraphQLSchemaType = this.graphQLAutoSchema._typeMap[ customGraphQLSchemaType.name ]; if (!autoGraphQLSchemaType) { this.graphQLAutoSchema._typeMap[ customGraphQLSchemaType.name ] = customGraphQLSchemaType; } }); // Handle shared types // We pass through each type and ensure that all sub field types are replaced // we use sort to ensure schema consistency over restarts Object.keys(customGraphQLSchemaTypeMap) .sort() .forEach(customGraphQLSchemaTypeKey => { const customGraphQLSchemaType = customGraphQLSchemaTypeMap[customGraphQLSchemaTypeKey]; if ( !customGraphQLSchemaType || !customGraphQLSchemaType.name || customGraphQLSchemaType.name.startsWith('__') ) { return; } const autoGraphQLSchemaType = this.graphQLAutoSchema._typeMap[ customGraphQLSchemaType.name ]; if (autoGraphQLSchemaType && typeof customGraphQLSchemaType.getFields === 'function') { Object.keys(customGraphQLSchemaType._fields) .sort() .forEach(fieldKey => { const field = customGraphQLSchemaType._fields[fieldKey]; findAndReplaceLastType(field, 'type'); autoGraphQLSchemaType._fields[field.name] = field; }); } }); this.graphQLSchema = this.graphQLAutoSchema; } else if (typeof this.graphQLCustomTypeDefs === 'function') { this.graphQLSchema = await this.graphQLCustomTypeDefs({ directivesDefinitionsSchema: this.graphQLSchemaDirectivesDefinitions, autoSchema: this.graphQLAutoSchema, graphQLSchemaDirectives: this.graphQLSchemaDirectives, }); } else { this.graphQLSchema = mergeSchemas({ schemas: [this.graphQLAutoSchema], typeDefs: mergeTypeDefs([ this.graphQLCustomTypeDefs, this.graphQLSchemaDirectivesDefinitions, ]), }); this.graphQLSchema = this.graphQLSchemaDirectives(this.graphQLSchema); } } else { this.graphQLSchema = this.graphQLAutoSchema; } return this.graphQLSchema; } _logOnce(severity, message) { if (this.logCache[message]) { return; } this.log[severity](message); this.logCache[message] = true; } addGraphQLType(type, throwError = false, ignoreReserved = false, ignoreConnection = false) { if ( (!ignoreReserved && RESERVED_GRAPHQL_TYPE_NAMES.includes(type.name)) || this.graphQLTypes.find(existingType => existingType.name === type.name) || (!ignoreConnection && type.name.endsWith('Connection')) ) { const message = `Type ${type.name} could not be added to the auto schema because it collided with an existing type.`; if (throwError) { throw new Error(message); } this._logOnce('warn', message); return undefined; } this.graphQLTypes.push(type); return type; } addGraphQLQuery(fieldName, field, throwError = false, ignoreReserved = false) { if ( (!ignoreReserved && RESERVED_GRAPHQL_QUERY_NAMES.includes(fieldName)) || this.graphQLQueries[fieldName] ) { const message = `Query ${fieldName} could not be added to the auto schema because it collided with an existing field.`; if (throwError) { throw new Error(message); } this._logOnce('warn', message); return undefined; } this.graphQLQueries[fieldName] = field; return field; } addGraphQLMutation(fieldName, field, throwError = false, ignoreReserved = false) { if ( (!ignoreReserved && RESERVED_GRAPHQL_MUTATION_NAMES.includes(fieldName)) || this.graphQLMutations[fieldName] ) { const message = `Mutation ${fieldName} could not be added to the auto schema because it collided with an existing field.`; if (throwError) { throw new Error(message); } this._logOnce('warn', message); return undefined; } this.graphQLMutations[fieldName] = field; return field; } handleError(error) { if (error instanceof Parse.Error) { this.log.error('Parse error: ', error); } else { this.log.error('Uncaught internal server error.', error, error.stack); } throw toGraphQLError(error); } async _initializeSchemaAndConfig() { const [schemaController, parseGraphQLConfig] = await Promise.all([ this.databaseController.loadSchema(), this.parseGraphQLController.getGraphQLConfig(), ]); this.schemaController = schemaController; return { parseGraphQLConfig, }; } /** * Gets all classes found by the `schemaController` * minus those filtered out by the app's parseGraphQLConfig. */ async _getClassesForSchema(parseGraphQLConfig: ParseGraphQLConfig) { const { enabledForClasses, disabledForClasses } = parseGraphQLConfig; const allClasses = await this.schemaController.getAllClasses(); if (Array.isArray(enabledForClasses) || Array.isArray(disabledForClasses)) { let includedClasses = allClasses; if (enabledForClasses) { includedClasses = allClasses.filter(clazz => { return enabledForClasses.includes(clazz.className); }); } if (disabledForClasses) { // Classes included in `enabledForClasses` that // are also present in `disabledForClasses` will // still be filtered out includedClasses = includedClasses.filter(clazz => { return !disabledForClasses.includes(clazz.className); }); } this.isUsersClassDisabled = !includedClasses.some(clazz => { return clazz.className === '_User'; }); return includedClasses; } else { return allClasses; } } /** * This method returns a list of tuples * that provide the parseClass along with * its parseClassConfig where provided. */ _getParseClassesWithConfig(parseClasses, parseGraphQLConfig: ParseGraphQLConfig) { const { classConfigs } = parseGraphQLConfig; // Make sures that the default classes and classes that // starts with capitalized letter will be generated first. const sortClasses = (a, b) => { a = a.className; b = b.className; if (a[0] === '_') { if (b[0] !== '_') { return -1; } } if (b[0] === '_') { if (a[0] !== '_') { return 1; } } if (a === b) { return 0; } else if (a < b) { return -1; } else { return 1; } }; return parseClasses.sort(sortClasses).map(parseClass => { let parseClassConfig; if (classConfigs) { parseClassConfig = classConfigs.find(c => c.className === parseClass.className); } return [parseClass, parseClassConfig]; }); } async _getFunctionNames() { return await getFunctionNames(this.appId).filter(functionName => { if (/^[_a-zA-Z][_a-zA-Z0-9]*$/.test(functionName)) { return true; } else { this._logOnce( 'warn', `Function ${functionName} could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.` ); return false; } }); } /** * Checks for changes to the parseClasses * objects (i.e. database schema) or to * the parseGraphQLConfig object. If no * changes are found, return true; */ _hasSchemaInputChanged(params: { parseClasses: any, parseGraphQLConfig: ?ParseGraphQLConfig, functionNamesString: string, }): boolean { const { parseClasses, parseGraphQLConfig, functionNamesString } = params; // First init if (!this.graphQLSchema) { return true; } if ( isDeepStrictEqual(this.parseGraphQLConfig, parseGraphQLConfig) && this.functionNamesString === functionNamesString && isDeepStrictEqual(this.parseClasses, parseClasses) ) { return false; } return true; } } export { ParseGraphQLSchema };