499 lines
17 KiB
JavaScript
499 lines
17 KiB
JavaScript
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 };
|