diff --git a/package.json b/package.json index 563ea6a8..5a7590d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shyft", - "version": "0.5.4", + "version": "0.6.1", "description": "Model driven GraphQL API framework", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/engine/action/Action.js b/src/engine/action/Action.js new file mode 100644 index 00000000..2484a7dd --- /dev/null +++ b/src/engine/action/Action.js @@ -0,0 +1,208 @@ +import { passOrThrow, isMap, isFunction } from '../util'; + +import { + generatePermissionDescription, + processActionPermissions, +} from '../permission/Permission'; + +export const ACTION_TYPE_MUTATION = 'mutation'; +export const ACTION_TYPE_QUERY = 'query'; +export const actionTypes = [ ACTION_TYPE_MUTATION, ACTION_TYPE_QUERY ]; + +export class Action { + constructor(setup = {}) { + const { + name, + description, + input, + output, + resolve, + type, + permissions, + preProcessor, + postProcessor, + } = setup; + + passOrThrow(name, () => 'Missing action name'); + passOrThrow(description, () => `Missing description for action '${name}'`); + + passOrThrow( + !input || isMap(input) || isFunction(input), + () => `Action '${name}' has an invalid input definition`, + ); + + passOrThrow( + !output || isMap(output) || isFunction(output), + () => `Action '${name}' has an invalid output definition`, + ); + + passOrThrow( + isFunction(resolve), + () => `Action '${name}' needs a resolve function`, + ); + + passOrThrow( + !type || actionTypes.indexOf(type) >= 0, + () => + `Unknown action type '${type}' used in action '${name}', try one of these: '${actionTypes.join( + ', ', + )}'`, + ); + + if (preProcessor) { + passOrThrow( + isFunction(preProcessor), + () => `preProcessor of of action '${name}' needs to be a valid function`, + ); + + this.preProcessor = preProcessor; + } + + if (postProcessor) { + passOrThrow( + isFunction(postProcessor), + () => + `postProcessor of action '${name}' needs to be a valid function`, + ); + + this.postProcessor = postProcessor; + } + + this.name = name; + this.description = description; + this.input = input; + this.output = output; + this.resolve = resolve; + this.type = type || ACTION_TYPE_MUTATION; + this._permissions = permissions; + } + + getInput() { + if (!this.hasInput()) { + return null; + } + + if (this._input) { + return this._input; + } + + if (isFunction(this.input)) { + this.input = this.input(); + + passOrThrow( + isMap(this.input), + () => + `Input definition function for action '${ + this.name + }' does not return a map`, + ); + } + + passOrThrow( + this.input.type, + () => `Missing input type for action '${this.name}'`, + ); + + if (isFunction(this.input.type)) { + this.input.type = this.input.type({ + name: 'input', + description: this.input.description || this.description, + }); + } + + this._input = this.input; + + return this._input; + } + + hasInput() { + return !!this.input; + } + + getOutput() { + if (!this.hasOutput()) { + return null; + } + + if (this._output) { + return this._output; + } + + if (isFunction(this.output)) { + this.output = this.output(); + + passOrThrow( + isMap(this.output), + () => + `Output definition function for action '${ + this.name + }' does not return a map`, + ); + } + + passOrThrow( + this.output.type, + () => `Missing output type for action '${this.name}'`, + ); + + if (isFunction(this.output.type)) { + this.output.type = this.output.type({ + name: 'output', + description: this.output.description || this.description, + }); + } + + this._output = this.output; + + return this._output; + } + + hasOutput() { + return !!this.output; + } + + _processPermissions() { + if (this._permissions) { + const permissions = isFunction(this._permissions) + ? this._permissions() + : this._permissions; + + return processActionPermissions(this, permissions); + } + else if (this._defaultPermissions) { + return processActionPermissions(this, this._defaultPermissions); + } + + return null; + } + + _generatePermissionDescriptions() { + if (this.permissions) { + this.descriptionPermissions = generatePermissionDescription( + this.permissions, + ); + } + } + + _injectDefaultPermissionsBySchema(defaultPermissions) { + this._defaultPermissions = defaultPermissions; + } + + getPermissions() { + if ((!this._permissions && !this._defaultPermissions) || this.permissions) { + return this.permissions; + } + + this.permissions = this._processPermissions(); + this._generatePermissionDescriptions(); + return this.permissions; + } + + toString() { + return this.name; + } +} + +export const isAction = obj => { + return obj instanceof Action; +}; diff --git a/src/engine/action/Action.spec.ts b/src/engine/action/Action.spec.ts index c044e9d9..8af28a85 100644 --- a/src/engine/action/Action.spec.ts +++ b/src/engine/action/Action.spec.ts @@ -1,13 +1,16 @@ /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { Action, isAction } from './Action'; +import { Action, isAction, ACTION_TYPE_QUERY } from './Action'; import { Permission, isPermission } from '../permission/Permission'; -import { DataTypeString } from '../datatype/dataTypes'; +import { DataTypeString, DataTypeInteger } from '../datatype/dataTypes'; import { buildObjectDataType } from '../datatype/ObjectDataType'; import { passOrThrow } from '../util'; +import { generateTestSchema } from '../../graphqlProtocol/test-helper'; +import { generateGraphQLSchema } from '../../graphqlProtocol/generator'; +import { graphql } from 'graphql'; describe('Action', () => { it('should have a name', () => { @@ -338,6 +341,276 @@ describe('Action', () => { expect(fn).toThrowErrorMatchingSnapshot(); }); }); + + describe('preProcessor', () => { + it('should have a valid preProcessor function if defined', () => { + function fn() { + // eslint-disable-next-line no-new + new Action({ + name: 'example', + description: 'do something', + resolve() {}, + preProcessor: 'not-a-func', + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should pass through preProcessor if it is declared', async () => { + const setup = await generateTestSchema({ + actions: [ + new Action({ + name: 'SomeActionWithPreProcessor', + type: ACTION_TYPE_QUERY, + description: 'do something', + input: { + type: DataTypeInteger, + }, + output: { + type: buildObjectDataType({ + attributes: { + value: { + type: DataTypeInteger, + description: 'result value', + }, + }, + }), + }, + resolve(source, args) { + return { + value: args, + }; + }, + preProcessor: (action, source, payload) => { + if (payload === 13) { + throw new Error('13 brings bad luck'); + } + }, + }), + ], + }); + + const graphqlSchema = generateGraphQLSchema(setup.configuration); + + const query = ` + query SomeActionWithPreProcessor($number: Int!) { + someActionWithPreProcessor (input: { + data: $number + }) { + result { + value + } + } + } + + + `; + + const result1 = await graphql(graphqlSchema, query, null, null, { + number: 123, + }); + expect(result1).toMatchSnapshot(); + + const result2 = await graphql(graphqlSchema, query, null, null, { + number: 13, + }); + expect(result2).toMatchSnapshot(); + }); + }); + + describe('postProcessor', () => { + it('should have a valid postProcessor function if defined', () => { + function fn() { + // eslint-disable-next-line no-new + new Action({ + name: 'example', + description: 'do something', + resolve() {}, + postProcessor: 'not-a-func', + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should pass through postProcessor if it is declared', async () => { + const setup = await generateTestSchema({ + actions: [ + new Action({ + name: 'SomeActionWithPostProcessor', + type: ACTION_TYPE_QUERY, + description: 'do something', + input: { + type: DataTypeInteger, + }, + output: { + type: buildObjectDataType({ + attributes: { + value: { + type: DataTypeInteger, + description: 'result value', + }, + }, + }), + }, + resolve(source, args) { + return { + value: args, + }; + }, + postProcessor: (error, result, action, source, payload) => { + if (payload > 1000) { + result.value *= 2; + } + }, + }), + ], + }); + + const graphqlSchema = generateGraphQLSchema(setup.configuration); + + const query = ` + query SomeActionWithPostProcessor($number: Int!) { + someActionWithPostProcessor (input: { + data: $number + }) { + result { + value + } + } + } + + + `; + + const result1 = await graphql(graphqlSchema, query, null, null, { + number: 123, + }); + expect(result1).toMatchSnapshot(); + + const result2 = await graphql(graphqlSchema, query, null, null, { + number: 1234, + }); + expect(result2).toMatchSnapshot(); + }); + }); + + describe('defaultValue', () => { + it('should fill in provided default value', async () => { + const setup = await generateTestSchema({ + actions: [ + new Action({ + name: 'SomeAction', + type: ACTION_TYPE_QUERY, + description: 'do something', + input: { + type: DataTypeInteger, + description: 'just a number', + defaultValue: () => { + return 2000; + }, + }, + output: { + type: buildObjectDataType({ + attributes: { + value: { + type: DataTypeInteger, + description: 'result value', + }, + }, + }), + }, + resolve(source, args) { + return { + value: args, + }; + }, + }), + ], + }); + + const graphqlSchema = generateGraphQLSchema(setup.configuration); + + const query = ` + query SomeAction($number: Int) { + someAction (input: { + data: $number + }) { + result { + value + } + } + } + + + `; + + const result = await graphql(graphqlSchema, query, null, null, {}); + expect(result).toMatchSnapshot(); + }); + + it('should fill in provided default values in nested input objects', async () => { + const setup = await generateTestSchema({ + actions: [ + new Action({ + name: 'SomeAction', + type: ACTION_TYPE_QUERY, + description: 'do something', + input: { + type: buildObjectDataType({ + attributes: { + number: { + type: DataTypeInteger, + description: 'just a number', + defaultValue: () => { + return 2000; + }, + }, + }, + }), + }, + output: { + type: buildObjectDataType({ + attributes: { + value: { + type: DataTypeInteger, + description: 'result value', + }, + }, + }), + }, + resolve(source, args) { + return { + value: args.number, + }; + }, + }), + ], + }); + + const graphqlSchema = generateGraphQLSchema(setup.configuration); + + const query = ` + query SomeAction($number: Int) { + someAction (input: { + data: { + number: $number + } + }) { + result { + value + } + } + } + + + `; + + const result = await graphql(graphqlSchema, query, null, null, {}); + expect(result).toMatchSnapshot(); + }); + }); }); /* eslint-enable @typescript-eslint/no-empty-function */ diff --git a/src/engine/action/__snapshots__/Action.spec.ts.snap b/src/engine/action/__snapshots__/Action.spec.ts.snap index 8d97bd00..c0a540ab 100644 --- a/src/engine/action/__snapshots__/Action.spec.ts.snap +++ b/src/engine/action/__snapshots__/Action.spec.ts.snap @@ -1,9 +1,84 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Action defaultValue should fill in provided default value 1`] = ` +Object { + "data": Object { + "someAction": Object { + "result": Object { + "value": 2000, + }, + }, + }, +} +`; + +exports[`Action defaultValue should fill in provided default values in nested input objects 1`] = ` +Object { + "data": Object { + "someAction": Object { + "result": Object { + "value": 2000, + }, + }, + }, +} +`; + exports[`Action isAction should recognize non-Action objects 1`] = `"Not a Action object"`; exports[`Action permissions should throw if empty permissions are provided 1`] = `"Action 'example' has one or more empty permission definitions"`; +exports[`Action postProcessor should have a valid postProcessor function if defined 1`] = `"postProcessor of action 'example' needs to be a valid function"`; + +exports[`Action postProcessor should pass through postProcessor if it is declared 1`] = ` +Object { + "data": Object { + "someActionWithPostProcessor": Object { + "result": Object { + "value": 123, + }, + }, + }, +} +`; + +exports[`Action postProcessor should pass through postProcessor if it is declared 2`] = ` +Object { + "data": Object { + "someActionWithPostProcessor": Object { + "result": Object { + "value": 2468, + }, + }, + }, +} +`; + +exports[`Action preProcessor should have a valid preProcessor function if defined 1`] = `"preProcessor of of action 'example' needs to be a valid function"`; + +exports[`Action preProcessor should pass through preProcessor if it is declared 1`] = ` +Object { + "data": Object { + "someActionWithPreProcessor": Object { + "result": Object { + "value": 123, + }, + }, + }, +} +`; + +exports[`Action preProcessor should pass through preProcessor if it is declared 2`] = ` +Object { + "data": Object { + "someActionWithPreProcessor": null, + }, + "errors": Array [ + [GraphQLError: 13 brings bad luck], + ], +} +`; + exports[`Action should have a description 1`] = `"Missing description for action 'example'"`; exports[`Action should have a name 1`] = `"Missing action name"`; diff --git a/src/engine/entity/Entity.spec.ts b/src/engine/entity/Entity.spec.ts index 8d762813..352bceec 100644 --- a/src/engine/entity/Entity.spec.ts +++ b/src/engine/entity/Entity.spec.ts @@ -884,10 +884,9 @@ describe('Entity', () => { }); // create mutations to test processors ? - const setup = await generateTestSchema([ - SomeEntityWithPreprocess, - SomeEntityWithPostprocess, - ]); + const setup = await generateTestSchema({ + entities: [SomeEntityWithPreprocess, SomeEntityWithPostprocess], + }); const configuration = setup.configuration; graphqlSchema = generateGraphQLSchema(configuration); }); diff --git a/src/graphqlProtocol/action.ts b/src/graphqlProtocol/action.ts index 6781901b..c4dcc530 100644 --- a/src/graphqlProtocol/action.ts +++ b/src/graphqlProtocol/action.ts @@ -26,7 +26,7 @@ const fillSingleDefaultValues = async (param, payload, context) => { let ret = payload; if (typeof payload === 'undefined') { - if (param.required && param.defaultValue) { + if (param.defaultValue) { ret = param.defaultValue({}, context); } } @@ -97,7 +97,7 @@ export const handlePermission = async (context, action, input) => { userRoles, action, input, - context + context, ); if (!permissionWhere) { @@ -197,7 +197,7 @@ export const generateActions = (graphRegistry, actionTypeFilter) => { payload = args.input.data; clientMutationId = args.input.clientMutationId; - args.input.data = await fillDefaultValues(input, payload, context); + payload = await fillDefaultValues(input, payload, context); await validateActionPayload(input, payload, action, context); } @@ -210,6 +210,10 @@ export const generateActions = (graphRegistry, actionTypeFilter) => { await handlePermission(context, action, payload); try { + if (action.preProcessor) { + await action.preProcessor(action, source, payload, context, info); + } + const result = await action.resolve(source, payload, context, info); if (action.postProcessor) { @@ -228,8 +232,7 @@ export const generateActions = (graphRegistry, actionTypeFilter) => { result, clientMutationId, }; - } - catch (error) { + } catch (error) { if (action.postProcessor) { await action.postProcessor( error, diff --git a/src/graphqlProtocol/test-helper.ts b/src/graphqlProtocol/test-helper.ts index d38c9c5a..174fd551 100644 --- a/src/graphqlProtocol/test-helper.ts +++ b/src/graphqlProtocol/test-helper.ts @@ -10,6 +10,7 @@ import { Entity } from '../engine/entity/Entity'; import { Schema } from '../engine/schema/Schema'; import { StorageType } from '../engine/storage/StorageType'; import { StorageDataType } from '../engine/storage/StorageDataType'; +import { Action } from '..'; // import { StorageTypeMemory } from '../memory-connector/StorageTypeMemory'; // const { @@ -24,7 +25,16 @@ import { StorageDataType } from '../engine/storage/StorageDataType'; // context // ); -export const generateTestSchema = async entities => { +type GenerateTestSchemaSetup = { + entities?: Entity[]; + actions?: Action[]; +}; + +export const generateTestSchema = async ( + setup: GenerateTestSchemaSetup = {}, +) => { + const { entities, actions } = setup; + const testEntity = new Entity({ name: 'TestEntityName', description: 'Just some description', @@ -141,7 +151,7 @@ export const generateTestSchema = async entities => { // defaultStorageType: StorageTypeMemory, defaultActionPermissions: null, permissionsMap: null, - actions: [], + actions: actions || [], entities: entities ? [...entities, testEntity] : [testEntity], });