From 71d92aed8d02c0b2dbad2a711d94dc8e4c57b679 Mon Sep 17 00:00:00 2001 From: Antonio Davi Macedo Coelho de Castro Date: Thu, 18 Jul 2019 12:43:49 -0700 Subject: [PATCH] GraphQL Custom Schema (#5821) This PR empowers the Parse GraphQL API with custom user-defined schema. The developers can now write their own types, queries, and mutations, which will merged with the ones that are automatically generated. The new types are resolved by the application's cloud code functions. Therefore, regarding https://github.com/parse-community/parse-server/issues/5777, this PR closes the cloud functions needs and also addresses the graphql customization topic. In my view, I think that this PR, together with https://github.com/parse-community/parse-server/pull/5782 and https://github.com/parse-community/parse-server/pull/5818, when merged, closes the issue. How it works: 1. When initializing ParseGraphQLServer, now the developer can pass a custom schema that will be merged to the auto-generated one: ``` parseGraphQLServer = new ParseGraphQLServer(parseServer, { graphQLPath: '/graphql', graphQLCustomTypeDefs: gql` extend type Query { custom: Custom @namespace } type Custom { hello: String @resolve hello2: String @resolve(to: "hello") userEcho(user: _UserFields!): _UserClass! @resolve } `, }); ``` Note: - This PR includes a @namespace directive that can be used to the top level field of the nested queries and mutations (it basically just returns an empty object); - This PR includes a @resolve directive that can be used to notify the Parse GraphQL Server to resolve that field using a cloud code function. The `to` argument specifies the function name. If the `to` argument is not passed, the Parse GraphQL Server will look for a function with the same name of the field; - This PR allows creating custom types using the auto-generated ones as in `userEcho(user: _UserFields!): _UserClass! @resolve`; - This PR allows to extend the auto-generated types, as in `extend type Query { ... }`. 2. Once the schema was set, you just need to write regular cloud code functions: ``` Parse.Cloud.define('hello', async () => { return 'Hello world!'; }); Parse.Cloud.define('userEcho', async req => { return req.params.user; }); ``` 3. Now you are ready to play with your new custom api: ``` query { custom { hello hello2 userEcho(user: { username: "somefolk" }) { username } } } ``` should return ``` { "data": { "custom": { "hello": "Hello world!", "hello2": "Hello world!", "userEcho": { "username": "somefolk" } } } } ``` --- package-lock.json | 45 +++------ package.json | 2 + spec/ParseGraphQLServer.spec.js | 119 +++++++++++++++++++++++- src/GraphQL/ParseGraphQLSchema.js | 57 +++++++++++- src/GraphQL/ParseGraphQLServer.js | 3 +- src/GraphQL/loaders/schemaDirectives.js | 51 ++++++++++ 6 files changed, 239 insertions(+), 38 deletions(-) create mode 100644 src/GraphQL/loaders/schemaDirectives.js diff --git a/package-lock.json b/package-lock.json index d74886e6..2c530350 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4750,8 +4750,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -4772,14 +4771,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4794,20 +4791,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -4924,8 +4918,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -4937,7 +4930,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4952,7 +4944,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -4960,14 +4951,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4986,7 +4975,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -5067,8 +5055,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -5080,7 +5067,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -5166,8 +5152,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -5203,7 +5188,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5223,7 +5207,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5267,14 +5250,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -7607,7 +7588,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -7616,8 +7596,7 @@ "yallist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", - "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", - "optional": true + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==" } } }, diff --git a/package.json b/package.json index 997c7902..2b1463c9 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "follow-redirects": "1.7.0", "graphql": "14.4.2", "graphql-list-fields": "2.0.2", + "graphql-tools": "^4.0.5", "graphql-upload": "8.0.7", "intersect": "1.0.1", "jsonwebtoken": "8.5.1", @@ -76,6 +77,7 @@ "flow-bin": "0.102.0", "form-data": "2.5.0", "gaze": "1.1.3", + "graphql-tag": "^2.10.1", "husky": "3.0.0", "jasmine": "3.4.0", "jsdoc": "3.6.3", diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index d46a5f76..83c9bc49 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -189,7 +189,9 @@ describe('ParseGraphQLServer', () => { }); }); - describe('API', () => { + describe('Auto API', () => { + let httpServer; + const headers = { 'X-Parse-Application-Id': 'test', 'X-Parse-Javascript-Key': 'test', @@ -340,7 +342,7 @@ describe('ParseGraphQLServer', () => { beforeAll(async () => { const expressApp = express(); - const httpServer = http.createServer(expressApp); + httpServer = http.createServer(expressApp); expressApp.use('/parse', parseServer.app); ParseServer.createLiveQueryServer(httpServer, { port: 1338, @@ -384,6 +386,10 @@ describe('ParseGraphQLServer', () => { }); }); + afterAll(async () => { + await httpServer.close(); + }); + describe('GraphQL', () => { it('should be healthy', async () => { const health = (await apolloClient.query({ @@ -5243,4 +5249,113 @@ describe('ParseGraphQLServer', () => { }); }); }); + + describe('Custom API', () => { + let httpServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + let apolloClient; + + beforeAll(async () => { + const expressApp = express(); + httpServer = http.createServer(expressApp); + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + graphQLCustomTypeDefs: gql` + extend type Query { + custom: Custom @namespace + } + + type Custom { + hello: String @resolve + hello2: String @resolve(to: "hello") + userEcho(user: _UserFields!): _UserClass! @resolve + } + `, + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve)); + const httpLink = createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + }); + + afterAll(async () => { + await httpServer.close(); + }); + + it('can resolve a custom query using default function name', async () => { + Parse.Cloud.define('hello', async () => { + return 'Hello world!'; + }); + + const result = await apolloClient.query({ + query: gql` + query Hello { + custom { + hello + } + } + `, + }); + + expect(result.data.custom.hello).toEqual('Hello world!'); + }); + + it('can resolve a custom query using function name set by "to" argument', async () => { + Parse.Cloud.define('hello', async () => { + return 'Hello world!'; + }); + + const result = await apolloClient.query({ + query: gql` + query Hello { + custom { + hello2 + } + } + `, + }); + + expect(result.data.custom.hello2).toEqual('Hello world!'); + }); + + it('should resolve auto types', async () => { + Parse.Cloud.define('userEcho', async req => { + return req.params.user; + }); + + const result = await apolloClient.query({ + query: gql` + query UserEcho($user: _UserFields!) { + custom { + userEcho(user: $user) { + username + } + } + } + `, + variables: { + user: { + username: 'somefolk', + }, + }, + }); + + expect(result.data.custom.userEcho.username).toEqual('somefolk'); + }); + }); }); diff --git a/src/GraphQL/ParseGraphQLSchema.js b/src/GraphQL/ParseGraphQLSchema.js index e298273b..641d7515 100644 --- a/src/GraphQL/ParseGraphQLSchema.js +++ b/src/GraphQL/ParseGraphQLSchema.js @@ -1,5 +1,6 @@ import Parse from 'parse/node'; import { GraphQLSchema, GraphQLObjectType } from 'graphql'; +import { mergeSchemas, SchemaDirectiveVisitor } from 'graphql-tools'; import requiredParameter from '../requiredParameter'; import * as defaultGraphQLTypes from './loaders/defaultGraphQLTypes'; import * as parseClassTypes from './loaders/parseClassTypes'; @@ -8,13 +9,15 @@ import * as parseClassMutations from './loaders/parseClassMutations'; import * as defaultGraphQLQueries from './loaders/defaultGraphQLQueries'; import * as defaultGraphQLMutations from './loaders/defaultGraphQLMutations'; import { toGraphQLError } from './parseGraphQLUtils'; +import * as schemaDirectives from './loaders/schemaDirectives'; class ParseGraphQLSchema { - constructor(databaseController, log) { + constructor(databaseController, log, graphQLCustomTypeDefs) { this.databaseController = databaseController || requiredParameter('You must provide a databaseController instance!'); this.log = log || requiredParameter('You must provide a log instance!'); + this.graphQLCustomTypeDefs = graphQLCustomTypeDefs; } async load() { @@ -37,6 +40,7 @@ class ParseGraphQLSchema { this.parseClassesString = parseClassesString; this.parseClassTypes = {}; this.meType = null; + this.graphQLAutoSchema = null; this.graphQLSchema = null; this.graphQLTypes = []; this.graphQLObjectsQueries = {}; @@ -44,6 +48,8 @@ class ParseGraphQLSchema { this.graphQLObjectsMutations = {}; this.graphQLMutations = {}; this.graphQLSubscriptions = {}; + this.graphQLSchemaDirectivesDefinitions = null; + this.graphQLSchemaDirectives = {}; defaultGraphQLTypes.load(this); @@ -89,13 +95,60 @@ class ParseGraphQLSchema { this.graphQLTypes.push(graphQLSubscription); } - this.graphQLSchema = new GraphQLSchema({ + this.graphQLAutoSchema = new GraphQLSchema({ types: this.graphQLTypes, query: graphQLQuery, mutation: graphQLMutation, subscription: graphQLSubscription, }); + if (this.graphQLCustomTypeDefs) { + schemaDirectives.load(this); + + this.graphQLSchema = mergeSchemas({ + schemas: [ + this.graphQLSchemaDirectivesDefinitions, + this.graphQLAutoSchema, + this.graphQLCustomTypeDefs, + ], + mergeDirectives: true, + }); + + const graphQLSchemaTypeMap = this.graphQLSchema.getTypeMap(); + Object.keys(graphQLSchemaTypeMap).forEach(graphQLSchemaTypeName => { + const graphQLSchemaType = graphQLSchemaTypeMap[graphQLSchemaTypeName]; + if (typeof graphQLSchemaType.getFields === 'function') { + const graphQLCustomTypeDef = this.graphQLCustomTypeDefs.definitions.find( + definition => definition.name.value === graphQLSchemaTypeName + ); + if (graphQLCustomTypeDef) { + const graphQLSchemaTypeFieldMap = graphQLSchemaType.getFields(); + Object.keys(graphQLSchemaTypeFieldMap).forEach( + graphQLSchemaTypeFieldName => { + const graphQLSchemaTypeField = + graphQLSchemaTypeFieldMap[graphQLSchemaTypeFieldName]; + if (!graphQLSchemaTypeField.astNode) { + const astNode = graphQLCustomTypeDef.fields.find( + field => field.name.value === graphQLSchemaTypeFieldName + ); + if (astNode) { + graphQLSchemaTypeField.astNode = astNode; + } + } + } + ); + } + } + }); + + SchemaDirectiveVisitor.visitSchemaDirectives( + this.graphQLSchema, + this.graphQLSchemaDirectives + ); + } else { + this.graphQLSchema = this.graphQLAutoSchema; + } + return this.graphQLSchema; } diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index d9ac8f41..537991eb 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -22,7 +22,8 @@ class ParseGraphQLServer { this.parseGraphQLSchema = new ParseGraphQLSchema( this.parseServer.config.databaseController, (this.parseServer.config && this.parseServer.config.loggerController) || - defaultLogger + defaultLogger, + this.config.graphQLCustomTypeDefs ); } diff --git a/src/GraphQL/loaders/schemaDirectives.js b/src/GraphQL/loaders/schemaDirectives.js new file mode 100644 index 00000000..e7e51493 --- /dev/null +++ b/src/GraphQL/loaders/schemaDirectives.js @@ -0,0 +1,51 @@ +import gql from 'graphql-tag'; +import { SchemaDirectiveVisitor } from 'graphql-tools'; +import { FunctionsRouter } from '../../Routers/FunctionsRouter'; + +export const definitions = gql` + directive @namespace on FIELD_DEFINITION + directive @resolve(to: String) on FIELD_DEFINITION +`; + +const load = parseGraphQLSchema => { + parseGraphQLSchema.graphQLSchemaDirectivesDefinitions = definitions; + + class NamespaceDirectiveVisitor extends SchemaDirectiveVisitor { + visitFieldDefinition(field) { + field.resolve = () => ({}); + } + } + + parseGraphQLSchema.graphQLSchemaDirectives.namespace = NamespaceDirectiveVisitor; + + class ResolveDirectiveVisitor extends SchemaDirectiveVisitor { + visitFieldDefinition(field) { + field.resolve = async (_source, args, context) => { + try { + const { config, auth, info } = context; + + let functionName = field.name; + if (this.args.to) { + functionName = this.args.to; + } + + return (await FunctionsRouter.handleCloudFunction({ + params: { + functionName, + }, + config, + auth, + info, + body: args, + })).response.result; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }; + } + } + + parseGraphQLSchema.graphQLSchemaDirectives.resolve = ResolveDirectiveVisitor; +}; + +export { load };