diff --git a/spec/ParseGraphQLSchema.spec.js b/spec/ParseGraphQLSchema.spec.js index 56683d69c3..6d99dce68e 100644 --- a/spec/ParseGraphQLSchema.spec.js +++ b/spec/ParseGraphQLSchema.spec.js @@ -7,6 +7,7 @@ describe('ParseGraphQLSchema', () => { let databaseController; let parseGraphQLController; let parseGraphQLSchema; + const appId = 'test'; beforeAll(async () => { parseServer = await global.reconfigureServer({ @@ -18,11 +19,12 @@ describe('ParseGraphQLSchema', () => { databaseController, parseGraphQLController, log: defaultLogger, + appId, }); }); describe('constructor', () => { - it('should require a parseGraphQLController, databaseController and a log instance', () => { + it('should require a parseGraphQLController, databaseController, a log instance, and the appId', () => { expect(() => new ParseGraphQLSchema()).toThrow( 'You must provide a parseGraphQLController instance!' ); @@ -36,6 +38,14 @@ describe('ParseGraphQLSchema', () => { databaseController: {}, }) ).toThrow('You must provide a log instance!'); + expect( + () => + new ParseGraphQLSchema({ + parseGraphQLController: {}, + databaseController: {}, + log: {}, + }) + ).toThrow('You must provide the appId!'); }); }); @@ -88,6 +98,7 @@ describe('ParseGraphQLSchema', () => { databaseController, parseGraphQLController, log: defaultLogger, + appId, }); await parseGraphQLSchema.load(); const parseClasses = parseGraphQLSchema.parseClasses; @@ -134,6 +145,7 @@ describe('ParseGraphQLSchema', () => { ); }, }, + appId, }); await parseGraphQLSchema.load(); const type = new GraphQLObjectType({ name: 'SomeClass' }); @@ -156,6 +168,7 @@ describe('ParseGraphQLSchema', () => { fail('Should not warn'); }, }, + appId, }); await parseGraphQLSchema.load(); const type = new GraphQLObjectType({ name: 'SomeClass' }); @@ -184,6 +197,7 @@ describe('ParseGraphQLSchema', () => { ); }, }, + appId, }); await parseGraphQLSchema.load(); expect( @@ -203,6 +217,7 @@ describe('ParseGraphQLSchema', () => { fail('Should not warn'); }, }, + appId, }); await parseGraphQLSchema.load(); const type = new GraphQLObjectType({ name: 'String' }); @@ -225,6 +240,7 @@ describe('ParseGraphQLSchema', () => { ); }, }, + appId, }); await parseGraphQLSchema.load(); const field = {}; @@ -247,6 +263,7 @@ describe('ParseGraphQLSchema', () => { fail('Should not warn'); }, }, + appId, }); await parseGraphQLSchema.load(); const field = {}; @@ -274,6 +291,7 @@ describe('ParseGraphQLSchema', () => { ); }, }, + appId, }); await parseGraphQLSchema.load(); expect(parseGraphQLSchema.addGraphQLQuery('viewer', {})).toBeUndefined(); @@ -289,6 +307,7 @@ describe('ParseGraphQLSchema', () => { fail('Should not warn'); }, }, + appId, }); await parseGraphQLSchema.load(); delete parseGraphQLSchema.graphQLQueries.viewer; @@ -314,6 +333,7 @@ describe('ParseGraphQLSchema', () => { ); }, }, + appId, }); await parseGraphQLSchema.load(); const field = {}; @@ -338,6 +358,7 @@ describe('ParseGraphQLSchema', () => { fail('Should not warn'); }, }, + appId, }); await parseGraphQLSchema.load(); const field = {}; @@ -367,6 +388,7 @@ describe('ParseGraphQLSchema', () => { ); }, }, + appId, }); await parseGraphQLSchema.load(); expect( @@ -384,6 +406,7 @@ describe('ParseGraphQLSchema', () => { fail('Should not warn'); }, }, + appId, }); await parseGraphQLSchema.load(); delete parseGraphQLSchema.graphQLMutations.signUp; @@ -405,6 +428,7 @@ describe('ParseGraphQLSchema', () => { fail('Should not warn'); }, }, + appId, }); expect( parseGraphQLSchema @@ -443,6 +467,7 @@ describe('ParseGraphQLSchema', () => { databaseController, parseGraphQLController, log: defaultLogger, + appId, }); await parseGraphQLSchema.databaseController.schemaCache.clear(); const schema1 = await parseGraphQLSchema.load(); @@ -476,6 +501,7 @@ describe('ParseGraphQLSchema', () => { databaseController, parseGraphQLController, log: defaultLogger, + appId, }); const car1 = new Parse.Object('Car'); await car1.save(); @@ -511,6 +537,7 @@ describe('ParseGraphQLSchema', () => { databaseController, parseGraphQLController, log: defaultLogger, + appId, }); const car = new Parse.Object('Car'); await car.save(); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 7718ad184b..62f1f2f3e2 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -5177,19 +5177,23 @@ describe('ParseGraphQLServer', () => { describe('Functions Mutations', () => { it('can be called', async () => { - Parse.Cloud.define('hello', async () => { - return 'Hello world!'; - }); + try { + Parse.Cloud.define('hello', async () => { + return 'Hello world!'; + }); - const result = await apolloClient.mutate({ - mutation: gql` - mutation CallFunction { - callCloudCode(functionName: "hello") - } - `, - }); + const result = await apolloClient.mutate({ + mutation: gql` + mutation CallFunction { + callCloudCode(functionName: hello) + } + `, + }); - expect(result.data.callCloudCode).toEqual('Hello world!'); + expect(result.data.callCloudCode).toEqual('Hello world!'); + } catch (e) { + handleError(e); + } }); it('can throw errors', async () => { @@ -5201,7 +5205,7 @@ describe('ParseGraphQLServer', () => { await apolloClient.mutate({ mutation: gql` mutation CallFunction { - callCloudCode(functionName: "hello") + callCloudCode(functionName: hello) } `, }); @@ -5302,7 +5306,7 @@ describe('ParseGraphQLServer', () => { apolloClient.mutate({ mutation: gql` mutation CallFunction($params: Object) { - callCloudCode(functionName: "hello", params: $params) + callCloudCode(functionName: hello, params: $params) } `, variables: { @@ -5310,6 +5314,94 @@ describe('ParseGraphQLServer', () => { }, }); }); + + it('should list all functions in the enum type', async () => { + try { + Parse.Cloud.define('a', async () => { + return 'hello a'; + }); + + Parse.Cloud.define('b', async () => { + return 'hello b'; + }); + + Parse.Cloud.define('_underscored', async () => { + return 'hello _underscored'; + }); + + Parse.Cloud.define('contains1Number', async () => { + return 'hello contains1Number'; + }); + + const functionEnum = (await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "CloudCodeFunction") { + kind + enumValues { + name + } + } + } + `, + })).data['__type']; + expect(functionEnum.kind).toEqual('ENUM'); + expect( + functionEnum.enumValues.map(value => value.name).sort() + ).toEqual(['_underscored', 'a', 'b', 'contains1Number']); + } catch (e) { + handleError(e); + } + }); + + it('should warn functions not matching GraphQL allowed names', async () => { + try { + spyOn( + parseGraphQLServer.parseGraphQLSchema.log, + 'warn' + ).and.callThrough(); + + Parse.Cloud.define('a', async () => { + return 'hello a'; + }); + + Parse.Cloud.define('double-barrelled', async () => { + return 'hello b'; + }); + + Parse.Cloud.define('1NumberInTheBeggning', async () => { + return 'hello contains1Number'; + }); + + const functionEnum = (await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "CloudCodeFunction") { + kind + enumValues { + name + } + } + } + `, + })).data['__type']; + expect(functionEnum.kind).toEqual('ENUM'); + expect( + functionEnum.enumValues.map(value => value.name).sort() + ).toEqual(['a']); + expect( + parseGraphQLServer.parseGraphQLSchema.log.warn.calls + .all() + .map(call => call.args[0]) + .sort() + ).toEqual([ + 'Function 1NumberInTheBeggning could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.', + 'Function double-barrelled could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.', + ]); + } catch (e) { + handleError(e); + } + }); }); describe('Data Types', () => { diff --git a/src/GraphQL/ParseGraphQLSchema.js b/src/GraphQL/ParseGraphQLSchema.js index 59efe9290d..5a12189f65 100644 --- a/src/GraphQL/ParseGraphQLSchema.js +++ b/src/GraphQL/ParseGraphQLSchema.js @@ -15,6 +15,7 @@ import DatabaseController from '../Controllers/DatabaseController'; import { toGraphQLError } from './parseGraphQLUtils'; import * as schemaDirectives from './loaders/schemaDirectives'; import * as schemaTypes from './loaders/schemaTypes'; +import { getFunctionNames } from '../triggers'; const RESERVED_GRAPHQL_TYPE_NAMES = [ 'String', @@ -29,6 +30,7 @@ const RESERVED_GRAPHQL_TYPE_NAMES = [ 'Viewer', 'SignUpFieldsInput', 'LogInFieldsInput', + 'CloudCodeFunction', ]; const RESERVED_GRAPHQL_QUERY_NAMES = ['health', 'viewer', 'class', 'classes']; const RESERVED_GRAPHQL_MUTATION_NAMES = [ @@ -53,6 +55,7 @@ class ParseGraphQLSchema { databaseController: DatabaseController, parseGraphQLController: ParseGraphQLController, log: any, + appId: string, } = {} ) { this.parseGraphQLController = @@ -64,13 +67,16 @@ 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!'); } async load() { const { parseGraphQLConfig } = await this._initializeSchemaAndConfig(); - const parseClasses = await this._getClassesForSchema(parseGraphQLConfig); const parseClassesString = JSON.stringify(parseClasses); + const functionNames = await this._getFunctionNames(); + const functionNamesString = JSON.stringify(functionNames); if ( this.graphQLSchema && @@ -78,6 +84,7 @@ class ParseGraphQLSchema { parseClasses, parseClassesString, parseGraphQLConfig, + functionNamesString, }) ) { return this.graphQLSchema; @@ -86,6 +93,8 @@ class ParseGraphQLSchema { this.parseClasses = parseClasses; this.parseClassesString = parseClassesString; this.parseGraphQLConfig = parseGraphQLConfig; + this.functionNames = functionNames; + this.functionNamesString = functionNamesString; this.parseClassTypes = {}; this.viewerType = null; this.graphQLAutoSchema = null; @@ -360,6 +369,19 @@ class ParseGraphQLSchema { }); } + async _getFunctionNames() { + return await getFunctionNames(this.appId).filter(functionName => { + if (/^[_a-zA-Z][_a-zA-Z0-9]*$/.test(functionName)) { + return true; + } else { + this.log.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 @@ -370,12 +392,19 @@ class ParseGraphQLSchema { parseClasses: any, parseClassesString: string, parseGraphQLConfig: ?ParseGraphQLConfig, + functionNamesString: string, }): boolean { - const { parseClasses, parseClassesString, parseGraphQLConfig } = params; + const { + parseClasses, + parseClassesString, + parseGraphQLConfig, + functionNamesString, + } = params; if ( JSON.stringify(this.parseGraphQLConfig) === - JSON.stringify(parseGraphQLConfig) + JSON.stringify(parseGraphQLConfig) && + this.functionNamesString === functionNamesString ) { if (this.parseClasses === parseClasses) { return false; diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 3c06699f7f..534fba5d74 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -33,6 +33,7 @@ class ParseGraphQLServer { databaseController: this.parseServer.config.databaseController, log: this.log, graphQLCustomTypeDefs: this.config.graphQLCustomTypeDefs, + appId: this.parseServer.config.appId, }); } diff --git a/src/GraphQL/loaders/functionsMutations.js b/src/GraphQL/loaders/functionsMutations.js index ac7d1fde89..5c3f5d0659 100644 --- a/src/GraphQL/loaders/functionsMutations.js +++ b/src/GraphQL/loaders/functionsMutations.js @@ -1,46 +1,65 @@ -import { GraphQLNonNull, GraphQLString } from 'graphql'; +import { GraphQLNonNull, GraphQLEnumType } from 'graphql'; import { FunctionsRouter } from '../../Routers/FunctionsRouter'; import * as defaultGraphQLTypes from './defaultGraphQLTypes'; const load = parseGraphQLSchema => { - parseGraphQLSchema.addGraphQLMutation( - 'callCloudCode', - { - description: - 'The call mutation can be used to invoke a cloud code function.', - args: { - functionName: { - description: 'This is the name of the function to be called.', - type: new GraphQLNonNull(GraphQLString), - }, - params: { - description: 'These are the params to be passed to the function.', - type: defaultGraphQLTypes.OBJECT, + if (parseGraphQLSchema.functionNames.length > 0) { + const cloudCodeFunctionEnum = parseGraphQLSchema.addGraphQLType( + new GraphQLEnumType({ + name: 'CloudCodeFunction', + description: + 'The CloudCodeFunction enum type contains a list of all available cloud code functions.', + values: parseGraphQLSchema.functionNames.reduce( + (values, functionName) => ({ + ...values, + [functionName]: { value: functionName }, + }), + {} + ), + }), + true, + true + ); + + parseGraphQLSchema.addGraphQLMutation( + 'callCloudCode', + { + description: + 'The call mutation can be used to invoke a cloud code function.', + args: { + functionName: { + description: 'This is the function to be called.', + type: new GraphQLNonNull(cloudCodeFunctionEnum), + }, + params: { + description: 'These are the params to be passed to the function.', + type: defaultGraphQLTypes.OBJECT, + }, }, - }, - type: defaultGraphQLTypes.ANY, - async resolve(_source, args, context) { - try { - const { functionName, params } = args; - const { config, auth, info } = context; + type: defaultGraphQLTypes.ANY, + async resolve(_source, args, context) { + try { + const { functionName, params } = args; + const { config, auth, info } = context; - return (await FunctionsRouter.handleCloudFunction({ - params: { - functionName, - }, - config, - auth, - info, - body: params, - })).response.result; - } catch (e) { - parseGraphQLSchema.handleError(e); - } + return (await FunctionsRouter.handleCloudFunction({ + params: { + functionName, + }, + config, + auth, + info, + body: params, + })).response.result; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, }, - }, - true, - true - ); + true, + true + ); + } }; export { load }; diff --git a/src/triggers.js b/src/triggers.js index f2917ce6fd..07ef6dc1fd 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -148,6 +148,29 @@ export function getFunction(functionName, applicationId) { return get(Category.Functions, functionName, applicationId); } +export function getFunctionNames(applicationId) { + const store = + (_triggerStore[applicationId] && + _triggerStore[applicationId][Category.Functions]) || + {}; + const functionNames = []; + const extractFunctionNames = (namespace, store) => { + Object.keys(store).forEach(name => { + const value = store[name]; + if (namespace) { + name = `${namespace}.${name}`; + } + if (typeof value === 'function') { + functionNames.push(name); + } else { + extractFunctionNames(name, value); + } + }); + }; + extractFunctionNames(null, store); + return functionNames; +} + export function getJob(jobName, applicationId) { return get(Category.Jobs, jobName, applicationId); }