Vulcan/packages/vulcan-lib/lib/modules/graphql.js
2019-01-18 14:55:23 +09:00

523 lines
16 KiB
JavaScript

// TODO: this should not be loaded on the client?
/*
Utilities to generate the app's GraphQL schema
*/
import deepmerge from 'deepmerge';
import GraphQLJSON from 'graphql-type-json';
import GraphQLDate from 'graphql-date';
import Vulcan from './config.js'; // used for global export
import {Utils} from './utils.js';
import {disableFragmentWarnings} from 'graphql-tag';
import {isIntlField} from './intl.js';
import {
selectorInputTemplate,
mainTypeTemplate,
createInputTemplate,
createDataInputTemplate,
updateInputTemplate,
updateDataInputTemplate,
orderByInputTemplate,
selectorUniqueInputTemplate,
deleteInputTemplate,
upsertInputTemplate,
singleInputTemplate,
multiInputTemplate,
multiOutputTemplate,
singleOutputTemplate,
mutationOutputTemplate,
singleQueryTemplate,
multiQueryTemplate,
createMutationTemplate,
updateMutationTemplate,
upsertMutationTemplate,
deleteMutationTemplate,
} from './graphql_templates.js';
disableFragmentWarnings();
// get GraphQL type for a given schema and field name
const getGraphQLType = (schema, fieldName, isInput = false) => {
const field = schema[fieldName];
const type = field.type.singleType;
const typeName =
typeof type === 'object'
? 'Object'
: typeof type === 'function'
? type.name
: type;
if (field.isIntlData) {
return isInput ? '[IntlValueInput]' : '[IntlValue]';
}
switch (typeName) {
case 'String':
return 'String';
case 'Boolean':
return 'Boolean';
case 'Number':
return 'Float';
case 'SimpleSchema.Integer':
return 'Int';
// for arrays, look for type of associated schema field or default to [String]
case 'Array':
const arrayItemFieldName = `${fieldName}.$`;
// note: make sure field has an associated array
if (schema[arrayItemFieldName]) {
// try to get array type from associated array
const arrayItemType = getGraphQLType(schema, arrayItemFieldName);
return arrayItemType ? `[${arrayItemType}]` : null;
}
return null;
case 'Object':
return 'JSON';
case 'Date':
return 'Date';
default:
return null;
}
};
export const GraphQLSchema = {
// collections used to auto-generate schemas
collections: [],
addCollection(collection) {
this.collections.push(collection);
},
// generate GraphQL schemas for all registered collections
getCollectionsSchemas() {
const collectionsSchemas = this.collections
.map(collection => {
return this.generateSchema(collection);
})
.join('');
return collectionsSchemas;
},
// additional schemas
schemas: [],
addSchema(schema) {
this.schemas.push(schema);
},
// get extra schemas defined manually
getAdditionalSchemas() {
const additionalSchemas = this.schemas.join('\n');
return additionalSchemas;
},
// queries
queries: [],
addQuery(query, description) {
this.queries.push({query, description});
},
// mutations
mutations: [],
addMutation(mutation, description) {
this.mutations.push({mutation, description});
},
// add resolvers
resolvers: {
JSON: GraphQLJSON,
Date: GraphQLDate,
},
addResolvers(resolvers) {
this.resolvers = deepmerge(this.resolvers, resolvers);
},
removeResolver(typeName, resolverName) {
delete this.resolvers[typeName][resolverName];
},
// add objects to context
context: {},
addToContext(object) {
this.context = deepmerge(this.context, object);
},
directives: {},
addDirective(directive) {
this.directives = deepmerge(this.directives, directive);
},
// for a given schema, return main type fields, selector fields,
// unique selector fields, orderBy fields, creatable fields, and updatable fields
getFields(schema, typeName) {
const fields = {
mainType: [],
create: [],
update: [],
selector: [],
selectorUnique: [],
orderBy: [],
};
Object.keys(schema).forEach(fieldName => {
const field = schema[fieldName];
const fieldType = getGraphQLType(schema, fieldName);
const inputFieldType = getGraphQLType(schema, fieldName, true);
// only include fields that are viewable/insertable/editable and don't contain "$" in their name
// note: insertable/editable fields must be included in main schema in case they're returned by a mutation
// OpenCRUD backwards compatibility
if (
(field.canRead ||
field.canCreate ||
field.canUpdate ||
field.viewableBy ||
field.insertableBy ||
field.editableBy) &&
fieldName.indexOf('$') === -1
) {
const fieldDescription = field.description;
const fieldDirective = isIntlField(field) ? '@intl' : '';
const fieldArguments = isIntlField(field)
? [{name: 'locale', type: 'String'}]
: [];
// if field has a resolveAs, push it to schema
if (field.resolveAs) {
// get resolver name from resolveAs object, or else default to field name
const resolverName = field.resolveAs.fieldName || fieldName;
// use specified GraphQL type or else convert schema type
const fieldGraphQLType = field.resolveAs.type || fieldType;
// if resolveAs is an object, first push its type definition
// include arguments if there are any
// note: resolved fields are not internationalized
fields.mainType.push({
description: field.resolveAs.description,
name: resolverName,
args: field.resolveAs.arguments,
type: fieldGraphQLType,
});
// then build actual resolver object and pass it to addGraphQLResolvers
const resolver = {
[typeName]: {
[resolverName]: (document, args, context, info) => {
const {Users, currentUser} = context;
// check that current user has permission to access the original non-resolved field
const canReadField = Users.canReadField(
currentUser,
field,
document
);
return canReadField
? field.resolveAs.resolver(document, args, context, info)
: null;
},
},
};
addGraphQLResolvers(resolver);
// if addOriginalField option is enabled, also add original field to schema
if (field.resolveAs.addOriginalField && fieldType) {
fields.mainType.push({
description: fieldDescription,
name: fieldName,
args: fieldArguments,
type: fieldType,
directive: fieldDirective,
});
}
} else {
// try to guess GraphQL type
if (fieldType) {
fields.mainType.push({
description: fieldDescription,
name: fieldName,
args: fieldArguments,
type: fieldType,
directive: fieldDirective,
});
}
}
// OpenCRUD backwards compatibility
if (field.canCreate || field.insertableBy) {
fields.create.push({
name: fieldName,
type: inputFieldType,
required: !field.optional,
});
}
// OpenCRUD backwards compatibility
if (field.canUpdate || field.editableBy) {
fields.update.push({
name: fieldName,
type: inputFieldType,
});
}
// if field is i18nized, add foo_intl field containing all languages
if (isIntlField(field)) {
// fields.mainType.push({
// name: `${fieldName}_intl`,
// type: '[IntlValue]',
// });
fields.create.push({
name: `${fieldName}_intl`,
type: '[IntlValueInput]',
});
fields.update.push({
name: `${fieldName}_intl`,
type: '[IntlValueInput]',
});
}
if (field.selectable) {
fields.selector.push({
name: fieldName,
type: inputFieldType,
});
}
if (field.selectable && field.unique) {
fields.selectorUnique.push({
name: fieldName,
type: inputFieldType,
});
}
if (field.orderable) {
fields.orderBy.push(fieldName);
}
}
});
return fields;
},
// generate a GraphQL schema corresponding to a given collection
generateSchema(collection) {
let graphQLSchema = '';
const schemaFragments = [];
const collectionName = collection.options.collectionName;
const typeName = collection.typeName
? collection.typeName
: Utils.camelToSpaces(_.initial(collectionName).join('')); // default to posts -> Post
const schema = collection.simpleSchema()._schema;
const fields = this.getFields(schema, typeName);
const {interfaces = [], resolvers, mutations} = collection.options;
const description = collection.options.description
? collection.options.description
: `Type for ${collectionName}`;
const {
mainType,
create,
update,
selector,
selectorUnique,
orderBy,
} = fields;
if (mainType.length) {
schemaFragments.push(
mainTypeTemplate({typeName, description, interfaces, fields: mainType})
);
schemaFragments.push(deleteInputTemplate({typeName}));
schemaFragments.push(singleInputTemplate({typeName}));
schemaFragments.push(multiInputTemplate({typeName}));
schemaFragments.push(singleOutputTemplate({typeName}));
schemaFragments.push(multiOutputTemplate({typeName}));
schemaFragments.push(mutationOutputTemplate({typeName}));
if (create.length) {
schemaFragments.push(createInputTemplate({typeName}));
schemaFragments.push(
createDataInputTemplate({typeName, fields: create})
);
}
if (update.length) {
schemaFragments.push(updateInputTemplate({typeName}));
schemaFragments.push(upsertInputTemplate({typeName}));
schemaFragments.push(
updateDataInputTemplate({typeName, fields: update})
);
}
schemaFragments.push(selectorInputTemplate({typeName, fields: selector}));
schemaFragments.push(
selectorUniqueInputTemplate({typeName, fields: selectorUnique})
);
schemaFragments.push(orderByInputTemplate({typeName, fields: orderBy}));
if (!_.isEmpty(resolvers)) {
const queryResolvers = {};
// single
if (resolvers.single) {
addGraphQLQuery(
singleQueryTemplate({typeName}),
resolvers.single.description
);
queryResolvers[
Utils.camelCaseify(typeName)
] = resolvers.single.resolver.bind(resolvers.single);
}
// multi
if (resolvers.multi) {
addGraphQLQuery(
multiQueryTemplate({typeName}),
resolvers.multi.description
);
queryResolvers[
Utils.camelCaseify(Utils.pluralize(typeName))
] = resolvers.multi.resolver.bind(resolvers.multi);
}
addGraphQLResolvers({Query: {...queryResolvers}});
}
if (!_.isEmpty(mutations)) {
const mutationResolvers = {};
// create
if (mutations.create) {
// e.g. "createMovie(input: CreateMovieInput) : Movie"
if (create.length === 0) {
// eslint-disable-next-line no-console
console.log(
`// Warning: you defined a "create" mutation for collection ${collectionName}, but it doesn't have any mutable fields, so no corresponding mutation types can be generated. Remove the "create" mutation or define a "canCreate" property on a field to disable this warning`
);
} else {
addGraphQLMutation(
createMutationTemplate({typeName}),
mutations.create.description
);
mutationResolvers[
`create${typeName}`
] = mutations.create.mutation.bind(mutations.create);
}
}
// update
if (mutations.update) {
// e.g. "updateMovie(input: UpdateMovieInput) : Movie"
if (update.length === 0) {
// eslint-disable-next-line no-console
console.log(
`// Warning: you defined an "update" mutation for collection ${collectionName}, but it doesn't have any mutable fields, so no corresponding mutation types can be generated. Remove the "update" mutation or define a "canUpdate" property on a field to disable this warning`
);
} else {
addGraphQLMutation(
updateMutationTemplate({typeName}),
mutations.update.description
);
mutationResolvers[
`update${typeName}`
] = mutations.update.mutation.bind(mutations.update);
}
}
// upsert
if (mutations.upsert) {
// e.g. "upsertMovie(input: UpsertMovieInput) : Movie"
if (update.length === 0) {
// eslint-disable-next-line no-console
console.log(
`// Warning: you defined an "upsert" mutation for collection ${collectionName}, but it doesn't have any mutable fields, so no corresponding mutation types can be generated. Remove the "upsert" mutation or define a "canUpdate" property on a field to disable this warning`
);
} else {
addGraphQLMutation(
upsertMutationTemplate({typeName}),
mutations.upsert.description
);
mutationResolvers[
`upsert${typeName}`
] = mutations.upsert.mutation.bind(mutations.upsert);
}
}
// delete
if (mutations.delete) {
// e.g. "deleteMovie(input: DeleteMovieInput) : Movie"
addGraphQLMutation(
deleteMutationTemplate({typeName}),
mutations.delete.description
);
mutationResolvers[
`delete${typeName}`
] = mutations.delete.mutation.bind(mutations.delete);
}
addGraphQLResolvers({Mutation: {...mutationResolvers}});
}
graphQLSchema = schemaFragments.join('\n\n') + '\n\n\n';
} else {
// eslint-disable-next-line no-console
console.log(
`// Warning: collection ${collectionName} doesn't have any GraphQL-enabled fields, so no corresponding type can be generated. Pass generateGraphQLSchema = false to createCollection() to disable this warning`
);
}
return graphQLSchema;
},
// getters
getSchema() {
if (!(this.finalSchema && this.finalSchema.length)) {
throw new Error(
'Warning: trying to access schema before it has been created by the server.'
);
}
return this.finalSchema[0];
},
getExecutableSchema() {
if (!this.executableSchema) {
throw new Error(
'Warning: trying to access executable schema before it has been created by the server.'
);
}
return this.executableSchema;
},
};
Vulcan.getGraphQLSchema = () => {
if (!GraphQLSchema.finalSchema) {
throw new Error(
'Warning: trying to access graphQL schema before it has been created by the server.'
);
}
const schema = GraphQLSchema.finalSchema[0];
// eslint-disable-next-line no-console
console.log(schema);
return schema;
};
export const addGraphQLCollection = GraphQLSchema.addCollection.bind(
GraphQLSchema
);
export const addGraphQLSchema = GraphQLSchema.addSchema.bind(GraphQLSchema);
export const addGraphQLQuery = GraphQLSchema.addQuery.bind(GraphQLSchema);
export const addGraphQLMutation = GraphQLSchema.addMutation.bind(GraphQLSchema);
export const addGraphQLResolvers = GraphQLSchema.addResolvers.bind(
GraphQLSchema
);
export const removeGraphQLResolver = GraphQLSchema.removeResolver.bind(
GraphQLSchema
);
export const addToGraphQLContext = GraphQLSchema.addToContext.bind(
GraphQLSchema
);
export const addGraphQLDirective = GraphQLSchema.addDirective.bind(
GraphQLSchema
);