diff --git a/src/Controllers/ParseGraphQLController.js b/src/Controllers/ParseGraphQLController.js index 78a5bea53a..8feac06f20 100644 --- a/src/Controllers/ParseGraphQLController.js +++ b/src/Controllers/ParseGraphQLController.js @@ -24,7 +24,7 @@ class ParseGraphQLController { `ParseGraphQLController requires a "databaseController" to be instantiated.` ); this.cacheController = params.cacheController; - this.isMounted = !!params.mountGraphQL; + this.isMounted = !!params.mountGraphQL || !!params.mountSubscriptions; this.configCacheKey = GraphQLConfigKey; } @@ -145,7 +145,14 @@ class ParseGraphQLController { if (!isValidSimpleObject(classConfig)) { return 'it must be a valid object'; } else { - const { className, type = null, query = null, mutation = null, ...invalidKeys } = classConfig; + const { + className, + type = null, + query = null, + mutation = null, + subscription = null, + ...invalidKeys + } = classConfig; if (Object.keys(invalidKeys).length) { return `"invalidKeys" [${Object.keys(invalidKeys)}] should not be present`; } @@ -287,6 +294,20 @@ class ParseGraphQLController { return `"mutation" must be a valid object`; } } + if (subscription !== null) { + if (isValidSimpleObject(subscription)) { + const { enabled = null, alias = null, ...invalidKeys } = query; + if (Object.keys(invalidKeys).length) { + return `"subscription" contains invalid keys, [${Object.keys(invalidKeys)}]`; + } else if (enabled !== null && typeof enabled !== 'boolean') { + return `"subscription.enabled" must be a boolean`; + } else if (alias !== null && typeof alias !== 'string') { + return `"subscription.alias" must be a string`; + } + } else { + return `"subscription" must be a valid object`; + } + } } } } @@ -355,6 +376,11 @@ export interface ParseGraphQLClassConfig { updateAlias: ?String, destroyAlias: ?String, }; + /* The `subscription` object contains options for which class subscriptions are generated */ + subscription: ?{ + enabled: ?boolean, + alias: ?String, + }; } export default ParseGraphQLController; diff --git a/src/Controllers/index.js b/src/Controllers/index.js index 1e4765b666..6c8023219a 100644 --- a/src/Controllers/index.js +++ b/src/Controllers/index.js @@ -127,6 +127,7 @@ export function getParseGraphQLController( ): ParseGraphQLController { return new ParseGraphQLController({ mountGraphQL: options.mountGraphQL, + mountSubscriptions: options.mountSubscriptions, ...controllerDeps, }); } diff --git a/src/GraphQL/ParseGraphQLSchema.js b/src/GraphQL/ParseGraphQLSchema.js index 096266442d..8bbf60a958 100644 --- a/src/GraphQL/ParseGraphQLSchema.js +++ b/src/GraphQL/ParseGraphQLSchema.js @@ -7,6 +7,7 @@ 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 parseClassSubscriptions from './loaders/parseClassSubscriptions'; import * as defaultGraphQLQueries from './loaders/defaultGraphQLQueries'; import * as defaultGraphQLMutations from './loaders/defaultGraphQLMutations'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; @@ -58,6 +59,7 @@ const RESERVED_GRAPHQL_MUTATION_NAMES = [ 'updateClass', 'deleteClass', ]; +const RESERVED_GRAPHQL_SUBSCRIPTION_NAMES = []; class ParseGraphQLSchema { databaseController: DatabaseController; @@ -66,6 +68,7 @@ class ParseGraphQLSchema { log: any; appId: string; graphQLCustomTypeDefs: ?(string | GraphQLSchema | DocumentNode | GraphQLNamedType[]); + liveQueryClassNames: any; constructor( params: { @@ -74,6 +77,7 @@ class ParseGraphQLSchema { log: any, appId: string, graphQLCustomTypeDefs: ?(string | GraphQLSchema | DocumentNode | GraphQLNamedType[]), + liveQueryClassNames: any, } = {} ) { this.parseGraphQLController = @@ -85,6 +89,7 @@ class ParseGraphQLSchema { 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.liveQueryClassNames = params.liveQueryClassNames; } async load() { @@ -132,6 +137,9 @@ class ParseGraphQLSchema { parseClassTypes.load(this, parseClass, parseClassConfig); parseClassQueries.load(this, parseClass, parseClassConfig); parseClassMutations.load(this, parseClass, parseClassConfig); + if (this.liveQueryClassNames && this.liveQueryClassNames.includes(parseClass.className)) { + parseClassSubscriptions.load(this, parseClass, parseClassConfig); + } } ); @@ -342,6 +350,22 @@ class ParseGraphQLSchema { return field; } + addGraphQLSubscription(fieldName, field, throwError = false, ignoreReserved = false) { + if ( + (!ignoreReserved && RESERVED_GRAPHQL_SUBSCRIPTION_NAMES.includes(fieldName)) || + this.graphQLSubscriptions[fieldName] + ) { + const message = `Subscription ${fieldName} could not be added to the auto schema because it collided with an existing field.`; + if (throwError) { + throw new Error(message); + } + this.log.warn(message); + return undefined; + } + this.graphQLSubscriptions[fieldName] = field; + return field; + } + handleError(error) { if (error instanceof Parse.Error) { this.log.error('Parse error: ', error); diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index bb47ca2938..370a5f89c4 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -8,8 +8,10 @@ import { SubscriptionServer } from 'subscriptions-transport-ws'; import { handleParseErrors, handleParseHeaders } from '../middlewares'; import requiredParameter from '../requiredParameter'; import defaultLogger from '../logger'; +import { ParseLiveQueryServer } from '../LiveQuery/ParseLiveQueryServer'; import { ParseGraphQLSchema } from './ParseGraphQLSchema'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; +import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter'; class ParseGraphQLServer { parseGraphQLController: ParseGraphQLController; @@ -29,6 +31,8 @@ class ParseGraphQLServer { log: this.log, graphQLCustomTypeDefs: this.config.graphQLCustomTypeDefs, appId: this.parseServer.config.appId, + liveQueryClassNames: + this.parseServer.config.liveQuery && this.parseServer.config.liveQuery.classNames, }); } @@ -114,12 +118,132 @@ class ParseGraphQLServer { } createSubscriptions(server) { + const wssAdapter = new WSSAdapter(); + + new ParseLiveQueryServer( + undefined, + { + ...this.parseServer.config.liveQueryServerOptions, + wssAdapter, + }, + this.parseServer.config + ); + SubscriptionServer.create( { execute, subscribe, - onOperation: async (_message, params, webSocket) => - Object.assign({}, params, await this._getGraphQLOptions(webSocket.upgradeReq)), + onConnect: async connectionParams => { + const keyPairs = { + applicationId: connectionParams['X-Parse-Application-Id'], + sessionToken: connectionParams['X-Parse-Session-Token'], + masterKey: connectionParams['X-Parse-Master-Key'], + installationId: connectionParams['X-Parse-Installation-Id'], + clientKey: connectionParams['X-Parse-Client-Key'], + javascriptKey: connectionParams['X-Parse-Javascript-Key'], + windowsKey: connectionParams['X-Parse-Windows-Key'], + restAPIKey: connectionParams['X-Parse-REST-API-Key'], + }; + + const listeners = []; + + let connectResolve, connectReject; + let connectIsResolved = false; + const connectPromise = new Promise((resolve, reject) => { + connectResolve = resolve; + connectReject = reject; + }); + + const liveQuery = { + OPEN: 'OPEN', + readyState: 'OPEN', + on: () => {}, + ping: () => {}, + onmessage: () => {}, + onclose: () => {}, + send: message => { + message = JSON.parse(message); + if (message.op === 'connected') { + connectResolve(); + connectIsResolved = true; + return; + } else if (message.op === 'error' && !connectIsResolved) { + connectReject({ + code: message.code, + message: message.error, + }); + return; + } + const requestId = message && message.requestId; + if ( + requestId && + typeof requestId === 'number' && + requestId > 0 && + requestId <= listeners.length + ) { + const listener = listeners[requestId - 1]; + if (listener) { + listener(message); + } + } + }, + subscribe: async (query, sessionToken, listener) => { + await connectPromise; + listeners.push(listener); + liveQuery.onmessage( + JSON.stringify({ + op: 'subscribe', + requestId: listeners.length, + query, + sessionToken, + }) + ); + }, + unsubscribe: async listener => { + await connectPromise; + const index = listeners.indexOf(listener); + if (index > 0) { + liveQuery.onmessage( + JSON.stringify({ + op: 'unsubscribe', + requestId: index + 1, + }) + ); + listeners[index] = null; + } + }, + }; + + wssAdapter.onConnection(liveQuery); + + liveQuery.onmessage( + JSON.stringify({ + op: 'connect', + ...keyPairs, + }) + ); + + await connectPromise; + + return { liveQuery, keyPairs }; + }, + onDisconnect: (_webSocket, context) => { + const { liveQuery } = context; + + if (liveQuery) { + liveQuery.onclose(); + } + }, + onOperation: async (_message, params) => { + return { + ...params, + schema: await this.parseGraphQLSchema.load(), + formatError: error => { + // Allow to console.log here to debug + return error; + }, + }; + }, }, { server, diff --git a/src/GraphQL/loaders/defaultGraphQLTypes.js b/src/GraphQL/loaders/defaultGraphQLTypes.js index d1d092ef6f..bb441c78ce 100644 --- a/src/GraphQL/loaders/defaultGraphQLTypes.js +++ b/src/GraphQL/loaders/defaultGraphQLTypes.js @@ -1222,6 +1222,29 @@ const loadArrayResult = (parseGraphQLSchema, parseClasses) => { parseGraphQLSchema.graphQLTypes.push(ARRAY_RESULT); }; +const EVENT_KIND = new GraphQLEnumType({ + name: 'EventKind', + description: 'The EventKind enum type is used in subscriptions to identify listened events.', + values: { + create: { value: 'create' }, + enter: { value: 'enter' }, + update: { value: 'update' }, + leave: { value: 'leave' }, + delete: { value: 'delete' }, + all: { value: 'all' }, + }, +}); + +const EVENT_KIND_ATT = { + description: 'The event kind that was fired.', + type: new GraphQLNonNull(EVENT_KIND), +}; + +const EVENT_KINDS_ATT = { + description: 'The event kinds to be listened in the subscription.', + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(EVENT_KIND))), +}; + const load = parseGraphQLSchema => { parseGraphQLSchema.addGraphQLType(GraphQLUpload, true); parseGraphQLSchema.addGraphQLType(ANY, true); @@ -1266,6 +1289,7 @@ const load = parseGraphQLSchema => { parseGraphQLSchema.addGraphQLType(PUBLIC_ACL, true); parseGraphQLSchema.addGraphQLType(SUBQUERY_INPUT, true); parseGraphQLSchema.addGraphQLType(SELECT_INPUT, true); + parseGraphQLSchema.addGraphQLType(EVENT_KIND, true); }; export { @@ -1358,6 +1382,9 @@ export { USER_ACL, ROLE_ACL, PUBLIC_ACL, + EVENT_KIND, + EVENT_KIND_ATT, + EVENT_KINDS_ATT, load, loadArrayResult, }; diff --git a/src/GraphQL/loaders/parseClassSubscriptions.js b/src/GraphQL/loaders/parseClassSubscriptions.js new file mode 100644 index 0000000000..4cd1f9a009 --- /dev/null +++ b/src/GraphQL/loaders/parseClassSubscriptions.js @@ -0,0 +1,156 @@ +import { GraphQLNonNull } from 'graphql'; +import getFieldNames from 'graphql-list-fields'; +import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; +import { transformClassNameToGraphQL } from '../transformers/className'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import { extractKeysAndInclude } from '../parseGraphQLUtils'; +import { transformQueryInputToParse } from '../transformers/query'; + +const getParseClassSubscriptionConfig = function (parseClassConfig: ?ParseGraphQLClassConfig) { + return (parseClassConfig && parseClassConfig.subscription) || {}; +}; + +const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLClassConfig) { + const { + enabled: isSubscriptionEnabled = true, + alias: subscriptionAlias = '', + } = getParseClassSubscriptionConfig(parseClassConfig); + + if (isSubscriptionEnabled) { + const className = parseClass.className; + + const graphQLClassName = transformClassNameToGraphQL(className); + const lowerCaseClassName = graphQLClassName.charAt(0).toLowerCase() + graphQLClassName.slice(1); + const graphQLSubscriptionName = subscriptionAlias || lowerCaseClassName; + + const { + classGraphQLConstraintsType, + classGraphQLSubscriptionType, + } = parseGraphQLSchema.parseClassTypes[className]; + + parseGraphQLSchema.addGraphQLSubscription(graphQLSubscriptionName, { + description: `The ${graphQLSubscriptionName} subscription can be used to listen events on objects of the ${graphQLClassName} class under given conditions.`, + args: { + on: defaultGraphQLTypes.EVENT_KINDS_ATT, + where: { + description: + 'These are the conditions that the objects need to match in the subscription.', + type: classGraphQLConstraintsType, + }, + }, + type: new GraphQLNonNull(classGraphQLSubscriptionType), + subscribe(_source, args, context, queryInfo) { + let nextResolve; + let nextReject; + let nextPromise; + + const newNextPromise = () => { + nextPromise = new Promise((resolve, reject) => { + nextResolve = resolve; + nextReject = reject; + }); + }; + + newNextPromise(); + + const { on } = args; + const { liveQuery, keyPairs } = context; + + const listener = message => { + switch (message.op) { + case 'create': + case 'enter': + case 'update': + case 'leave': + case 'delete': + if (!on.includes('all') && !on.includes(message.op)) { + return; + } + + nextResolve({ + done: false, + value: { + [graphQLSubscriptionName]: { + event: message.op, + node: message.object, + originalNode: message.original, + }, + }, + }); + break; + case 'error': + nextReject({ + code: message.code, + message: message.error, + }); + return; + default: + return; + } + + newNextPromise(); + }; + + const unsubscribe = () => liveQuery.unsubscribe(listener); + + const selectedFields = getFieldNames(queryInfo); + const { keys: nodeKeys } = extractKeysAndInclude( + selectedFields + .filter(field => field.startsWith('node.')) + .map(field => field.replace('node.', '')) + ); + const { keys: originalNodeKeys } = extractKeysAndInclude( + selectedFields + .filter(field => field.startsWith('originalNode.')) + .map(field => field.replace('originalNode.', '')) + ); + + const fields = [...new Set(nodeKeys.split(',').concat(originalNodeKeys.split(',')))]; + + let { where } = args; + if (!where) { + where = {}; + } + transformQueryInputToParse(where, className, parseGraphQLSchema.parseClasses); + + liveQuery.subscribe( + { + className, + where, + fields, + }, + keyPairs.sessionToken, + listener + ); + + return { + [Symbol.asyncIterator]() { + return { + next() { + return nextPromise; + }, + return() { + unsubscribe(); + + return Promise.resolve({ + done: true, + value: undefined, + }); + }, + throw(error) { + unsubscribe(); + + return Promise.resolve({ + done: true, + value: error, + }); + }, + }; + }, + }; + }, + }); + } +}; + +export { load }; diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js index 22d90b52b7..1603ce2160 100644 --- a/src/GraphQL/loaders/parseClassTypes.js +++ b/src/GraphQL/loaders/parseClassTypes.js @@ -494,6 +494,24 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla classGraphQLFindResultType = connectionType; } + const classGraphQLSubscriptionTypeName = `${graphQLClassName}Subscription`; + let classGraphQLSubscriptionType = new GraphQLObjectType({ + name: classGraphQLSubscriptionTypeName, + description: `The ${classGraphQLSubscriptionTypeName} object type is used in subscriptions that involve objects of ${graphQLClassName} class.`, + fields: { + event: defaultGraphQLTypes.EVENT_KIND_ATT, + node: { + description: 'The node in which the event happened', + type: new GraphQLNonNull(classGraphQLOutputType || defaultGraphQLTypes.OBJECT), + }, + originalNode: { + description: 'The original node in which the event happened', + type: classGraphQLOutputType || defaultGraphQLTypes.OBJECT, + }, + }, + }); + classGraphQLSubscriptionType = parseGraphQLSchema.addGraphQLType(classGraphQLSubscriptionType); + parseGraphQLSchema.parseClassTypes[className] = { classGraphQLPointerType, classGraphQLRelationType, @@ -504,6 +522,7 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla classGraphQLFindArgs, classGraphQLOutputType, classGraphQLFindResultType, + classGraphQLSubscriptionType, config: { parseClassConfig, isCreateEnabled, diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js index 735788218b..f4a35dc659 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -242,6 +242,11 @@ function matchesKeyConstraints(object, key, constraints) { return false; } break; + case '$eq': + if (!equalObjects(object[key], compareTo)) { + return false; + } + break; case '$in': if (!contains(compareTo, object[key])) { return false; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index c67017a585..f3461cc8f8 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -283,6 +283,12 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, + mountSubscriptions: { + env: 'PARSE_SERVER_MOUNT_SUBSCRIPTIONS', + help: 'Mounts the GraphQL Subscriptions endpoint', + action: parsers.booleanParser, + default: false, + }, objectIdSize: { env: 'PARSE_SERVER_OBJECT_ID_SIZE', help: "Sets the number of characters in generated object id's, default 10", @@ -402,6 +408,11 @@ module.exports.ParseServerOptions = { help: 'Starts the liveQuery server', action: parsers.booleanParser, }, + subscriptionsPath: { + env: 'PARSE_SERVER_SUBSCRIPTIONS_PATH', + help: 'Mount path for the GraphQL Subscriptions endpoint, defaults to /subscriptions', + default: '/subscriptions', + }, userSensitiveFields: { env: 'PARSE_SERVER_USER_SENSITIVE_FIELDS', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index da90760389..a72605af49 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -53,6 +53,7 @@ * @property {Boolean} mountGraphQL Mounts the GraphQL endpoint * @property {String} mountPath Mount path for the server, defaults to /parse * @property {Boolean} mountPlayground Mounts the GraphQL Playground - never use this option in production + * @property {Boolean} mountSubscriptions Mounts the GraphQL Subscriptions endpoint * @property {Number} objectIdSize Sets the number of characters in generated object id's, default 10 * @property {PagesOptions} pages The options for pages such as password reset and email verification. Caution, this is an experimental feature that may not be appropriate for production. * @property {PasswordPolicyOptions} passwordPolicy Password policy for enforcing password related rules @@ -74,6 +75,7 @@ * @property {Number} sessionLength Session duration, in seconds, defaults to 1 year * @property {Boolean} silent Disables console output * @property {Boolean} startLiveQueryServer Starts the liveQuery server + * @property {String} subscriptionsPath Mount path for the GraphQL Subscriptions endpoint, defaults to /subscriptions * @property {String[]} userSensitiveFields Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields * @property {Boolean} verbose Set the logging to verbose * @property {Boolean} verifyUserEmails Enable (or disable) user email validation, defaults to false diff --git a/src/Options/index.js b/src/Options/index.js index e333b53694..a3f48ba54a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -215,6 +215,14 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_GRAPHQL_PATH :DEFAULT: /graphql */ graphQLPath: ?string; + /* Mounts the GraphQL Subscriptions endpoint + :ENV: PARSE_SERVER_MOUNT_SUBSCRIPTIONS + :DEFAULT: false */ + mountSubscriptions: ?boolean; + /* Mount path for the GraphQL Subscriptions endpoint, defaults to /subscriptions + :ENV: PARSE_SERVER_SUBSCRIPTIONS_PATH + :DEFAULT: /subscriptions */ + subscriptionsPath: ?string; /* Mounts the GraphQL Playground - never use this option in production :ENV: PARSE_SERVER_MOUNT_PLAYGROUND :DEFAULT: false */ diff --git a/src/ParseServer.js b/src/ParseServer.js index a81e97fc60..359b4e4efe 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -251,7 +251,12 @@ class ParseServer { app.use(options.mountPath, this.app); - if (options.mountGraphQL === true || options.mountPlayground === true) { + let parseGraphQLServer; + if ( + options.mountGraphQL === true || + options.mountSubscriptions === true || + options.mountPlayground === true + ) { let graphQLCustomTypeDefs = undefined; if (typeof options.graphQLSchema === 'string') { graphQLCustomTypeDefs = parse(fs.readFileSync(options.graphQLSchema, 'utf8')); @@ -262,8 +267,9 @@ class ParseServer { graphQLCustomTypeDefs = options.graphQLSchema; } - const parseGraphQLServer = new ParseGraphQLServer(this, { + parseGraphQLServer = new ParseGraphQLServer(this, { graphQLPath: options.graphQLPath, + subscriptionsPath: options.subscriptionsPath, playgroundPath: options.playgroundPath, graphQLCustomTypeDefs, }); @@ -280,6 +286,10 @@ class ParseServer { const server = app.listen(options.port, options.host, callback); this.server = server; + if (options.mountSubscriptions) { + parseGraphQLServer.createSubscriptions(server); + } + if (options.startLiveQueryServer || options.liveQueryServerOptions) { this.liveQueryServer = ParseServer.createLiveQueryServer( server, diff --git a/src/cli/parse-server.js b/src/cli/parse-server.js index 4ee9dc4c03..71cb4a6b86 100755 --- a/src/cli/parse-server.js +++ b/src/cli/parse-server.js @@ -91,6 +91,15 @@ runner({ options.graphQLPath ); } + if (options.mountSubscriptions) { + console.log( + '[' + + process.pid + + '] Subscriptions running on ws://localhost:' + + options.port + + options.subscriptionsPath + ); + } if (options.mountPlayground) { console.log( '[' +