diff --git a/package.json b/package.json index 9bd557c5..d20b6818 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "date-fns": "^2.1.0", "graphql": "^14.5.8", "graphql-relay": "^0.6.0", + "graphql-subscriptions": "^1.1.0", "graphql-type-json": "^0.3.0", "json-shaper": "^1.2.0", "lodash": "^4.17.10", diff --git a/src/engine/constants.ts b/src/engine/constants.ts index 9af3b582..48c43b19 100644 --- a/src/engine/constants.ts +++ b/src/engine/constants.ts @@ -47,6 +47,7 @@ export const entityPropertiesWhitelist: Array = [ 'includeUserTracking', 'indexes', 'mutations', + 'subscriptions', 'permissions', 'states', 'preProcessor', @@ -118,4 +119,3 @@ export const shadowEntityAttributePropertiesWhitelist: Array = [ 'primary', 'meta', ]; - diff --git a/src/engine/entity/Entity.ts b/src/engine/entity/Entity.ts index 6b3bef49..8c52712c 100644 --- a/src/engine/entity/Entity.ts +++ b/src/engine/entity/Entity.ts @@ -21,6 +21,12 @@ import { processEntityPermissions, } from '../permission/Permission'; +import { + Subscription, + defaultEntitySubscription, + processEntitySubscriptions, +} from '../subscription/Subscription'; + import { DataType, isDataType, DataTypeFunction } from '../datatype/DataType'; import { isStorageType } from '../storage/StorageType'; import { StorageTypeNull } from '../storage/StorageTypeNull'; @@ -66,6 +72,7 @@ export type EntitySetup = { // improve typings ? mutations?: any; permissions?: any; + subscriptions?: any; states?: any; preProcessor?: Function; postProcessor?: Function; @@ -84,6 +91,7 @@ export class Entity { indexes?: any; mutations?: any; permissions?: any; + subscriptions?: any; states?: any; preProcessor?: Function; postProcessor?: Function; @@ -95,6 +103,7 @@ export class Entity { private referencedByEntities: any; private _indexes: any; private _mutations: any; + private _subscriptions: any; private _states: any; private _permissions: any; private _defaultPermissions: any; @@ -122,6 +131,7 @@ export class Entity { indexes, mutations, permissions, + subscriptions, states, preProcessor, postProcessor, @@ -171,6 +181,7 @@ export class Entity { this.referencedByEntities = []; this._indexes = indexes; this._mutations = mutations; + this._subscriptions = subscriptions; this._states = states; this._permissions = permissions; this._preFilters = preFilters; @@ -285,7 +296,7 @@ export class Entity { return this.mutations; } - getMutationByName(name) { + getMutationByName(name: string) { const mutations = this.getMutations(); return mutations @@ -335,6 +346,62 @@ export class Entity { return null; } + _getDefaultSubscriptions() { + const nonSystemAttributeNames = []; + + mapOverProperties(this.getAttributes(), (attribute, attributeName) => { + if (!attribute.isSystemAttribute) { + nonSystemAttributeNames.push(attributeName); + } + }); + + const subscriptions = {}; + + defaultEntitySubscription.map(defaultSubscription => { + const key = `${defaultSubscription.name}Subscription`; + + subscriptions[key] = new Subscription({ + name: defaultSubscription.name, + type: defaultSubscription.type, + description: defaultSubscription.description(this.name), + attributes: nonSystemAttributeNames, + }); + }); + + return subscriptions; + } + + _processSubscriptions() { + let subscriptions; + + if (!this._subscriptions) { + subscriptions = Object.values(this._getDefaultSubscriptions()); + } else { + subscriptions = isFunction(this._subscriptions) + ? this._subscriptions(this._getDefaultSubscriptions()) + : this._subscriptions; + } + + return processEntitySubscriptions(this, subscriptions); + } + + getSubscriptions() { + if (this.subscriptions) { + return this.subscriptions; + } + + this.subscriptions = this._processSubscriptions(); + return this.subscriptions; + } + + getSubscriptionByName(name: string) { + const subscriptions = this.getSubscriptions(); + + return subscriptions + ? subscriptions.find(subscription => String(subscription) === name) + : null; + } + getStates() { if (!this._states || this.states) { return this.states; @@ -730,6 +797,22 @@ export class Entity { } }); } + + if (this.permissions.subscriptions && this.subscriptions) { + this.subscriptions.map(subscription => { + const subscriptionName = subscription.name; + const permission = this.permissions.subscriptions[subscriptionName]; + + if (permission) { + const descriptionPermissions = generatePermissionDescription( + permission, + ); + if (descriptionPermissions) { + subscription.description += descriptionPermissions; + } + } + }); + } } } @@ -758,6 +841,7 @@ export class Entity { } this.getMutations(); + this.getSubscriptions(); this.permissions = this._processPermissions(); this._generatePermissionDescriptions(); return this.permissions; diff --git a/src/engine/permission/Permission.spec.ts b/src/engine/permission/Permission.spec.ts index 5296d250..28ce8c1a 100644 --- a/src/engine/permission/Permission.spec.ts +++ b/src/engine/permission/Permission.spec.ts @@ -906,9 +906,13 @@ describe('Permission', () => { mutations: { update: new Permission().role('manager'), }, + subscriptions: { + onUpdate: new Permission().role('manager'), + }, }; - processEntityPermissions(entity, permissions); + const permissionMap = processEntityPermissions(entity, permissions); + expect(permissionMap).toMatchSnapshot(); }); it('should throw if provided with an invalid map of permissions', () => { @@ -933,6 +937,18 @@ describe('Permission', () => { expect(fn).toThrowErrorMatchingSnapshot(); }); + it('should throw if provided with an invalid map of subscription permissions', () => { + const permissions = { + subscriptions: ['bad'], + }; + + function fn() { + processEntityPermissions(entity, permissions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + it('should throw if provided with an invalid permissions', () => { const permissions1 = { read: ['bad'], @@ -1001,6 +1017,18 @@ describe('Permission', () => { } expect(fn3).toThrowErrorMatchingSnapshot(); + + const permissions4 = { + subscriptions: { + onUpdate: new Permission().userAttribute('notHere'), + }, + }; + + function fn4() { + processEntityPermissions(entity, permissions4); + } + + expect(fn4).toThrowErrorMatchingSnapshot(); }); it('should throw if permissions have invalid attributes defined', () => { @@ -1029,6 +1057,20 @@ describe('Permission', () => { expect(fn).toThrowErrorMatchingSnapshot(); }); + it('should throw if permissions are assigned to unknown subscriptions', () => { + const permissions = { + subscriptions: { + noSuchSubscription: new Permission().userAttribute('someAttribute'), + }, + }; + + function fn() { + processEntityPermissions(entity, permissions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + it('should throw if permission is used on a create type mutation and using data-bound permission types', () => { const permissions1 = { mutations: { diff --git a/src/engine/permission/Permission.ts b/src/engine/permission/Permission.ts index 7406f85d..85139387 100644 --- a/src/engine/permission/Permission.ts +++ b/src/engine/permission/Permission.ts @@ -10,6 +10,11 @@ import { isMutation, Mutation, } from '../mutation/Mutation'; +import { + SUBSCRIPTION_TYPE_CREATE, + // isSubscription, + Subscription, +} from '../subscription/Subscription'; import { isDataTypeState } from '../datatype/DataTypeState'; /* @@ -29,6 +34,7 @@ export type PermissionMap = { read?: Permission | Permission[]; find?: Permission | Permission[]; mutations?: {} | Permission | Permission[]; + subscriptions?: {} | Permission | Permission[]; }; export class Permission { @@ -973,6 +979,28 @@ const validatePermissionMutationTypes = ( } }; +const validatePermissionSubscriptionTypes = ( + entity: Entity, + permissions: Permission | Permission[], + subscription: Subscription, +): void => { + if (subscription.type === SUBSCRIPTION_TYPE_CREATE) { + const permissionsArray = isArray(permissions as Permission[]) + ? (permissions as Permission[]) + : ([permissions] as Permission[]); + + permissionsArray.map(permission => { + passOrThrow( + !permission.userAttributes.length && + !permission.states.length && + !permission.values.length, + () => + `Create type subscription permission '${subscription.name}' in '${entity.name}.permissions' can only be of type 'authenticated', 'everyone', 'role' or 'lookup'`, + ); + }); + } +}; + export const hasEmptyPermissions = ( _permissions: Permission | Permission[], ): boolean => { @@ -1075,6 +1103,41 @@ export const processEntityPermissions = ( } } + const entitySubscriptions = entity.getSubscriptions(); + + if (!permissions.subscriptions && defaultPermissions) { + permissions.subscriptions = {}; + } + + if (permissions.subscriptions) { + passOrThrow( + isMap(permissions.subscriptions), + () => + `Entity '${entity.name}' permissions definition for subscriptions needs to be a map of subscriptions and permissions`, + ); + + const subscriptionNames = Object.keys(permissions.subscriptions); + subscriptionNames.map((subscriptionName, idx) => { + passOrThrow( + isPermission(permissions.subscriptions[subscriptionName]) || + isPermissionsArray(permissions.subscriptions[subscriptionName]), + () => + `Invalid subscription permission definition for entity '${entity.name}' at position '${idx}'`, + ); + }); + + if (defaultPermissions) { + entitySubscriptions.map(({ name: subscriptionName }) => { + if (defaultPermissions.subscriptions) { + permissions.subscriptions[subscriptionName] = + permissions.subscriptions[subscriptionName] || + defaultPermissions.subscriptions[subscriptionName] || + defaultPermissions.subscriptions._default; + } + }); + } + } + if (permissions.find) { validatePermissionAttributesAndStates(entity, permissions.find, 'find'); } @@ -1107,6 +1170,36 @@ export const processEntityPermissions = ( }); } + if (permissions.subscriptions && entitySubscriptions) { + const permissionSubscriptionNames = Object.keys(permissions.subscriptions); + + const subscriptionNames = entitySubscriptions.map( + subscription => subscription.name, + ); + + permissionSubscriptionNames.map(permissionSubscriptionName => { + passOrThrow( + subscriptionNames.includes(permissionSubscriptionName), + () => + `Unknown subscription '${permissionSubscriptionName}' used for permissions in entity '${entity.name}'`, + ); + }); + + entitySubscriptions.map(subscription => { + const subscriptionName = subscription.name; + const permission = permissions.subscriptions[subscriptionName]; + if (permission) { + // not sure it's needed for subscription + validatePermissionSubscriptionTypes(entity, permission, subscription); + validatePermissionAttributesAndStates( + entity, + permission, + subscription.type, + ); + } + }); + } + const emptyPermissionsIn = findEmptyEntityPermissions(permissions); passOrThrow( diff --git a/src/engine/permission/__snapshots__/Permission.spec.ts.snap b/src/engine/permission/__snapshots__/Permission.spec.ts.snap index e0806bd9..37f56cb1 100644 --- a/src/engine/permission/__snapshots__/Permission.spec.ts.snap +++ b/src/engine/permission/__snapshots__/Permission.spec.ts.snap @@ -487,6 +487,63 @@ exports[`Permission processActionPermissions should throw if provided with inval exports[`Permission processActionPermissions should throw if provided with invalid permissions 2`] = `"Invalid permission definition for action 'SomeActionName'"`; +exports[`Permission processEntityPermissions should accept a correct permissions setup 1`] = ` +Object { + "mutations": Object { + "update": Permission { + "authenticatedCanAccess": false, + "everyoneCanAccess": false, + "isEmpty": false, + "lookups": Array [], + "roles": Array [ + "manager", + ], + "states": Array [], + "types": Object { + "role": true, + }, + "userAttributes": Array [], + "values": Array [], + }, + }, + "read": Permission { + "authenticatedCanAccess": false, + "everyoneCanAccess": false, + "isEmpty": false, + "lookups": Array [], + "roles": Array [], + "states": Array [], + "types": Object { + "value": true, + }, + "userAttributes": Array [], + "values": Array [ + Object { + "attributeName": "someAttribute", + "value": 123, + }, + ], + }, + "subscriptions": Object { + "onUpdate": Permission { + "authenticatedCanAccess": false, + "everyoneCanAccess": false, + "isEmpty": false, + "lookups": Array [], + "roles": Array [ + "manager", + ], + "states": Array [], + "types": Object { + "role": true, + }, + "userAttributes": Array [], + "values": Array [], + }, + }, +} +`; + exports[`Permission processEntityPermissions should throw if permission is used on a create type mutation and using data-bound permission types 1`] = `"Create type mutation permission 'create' in 'SomeEntityName.permissions' can only be of type 'authenticated', 'everyone', 'role' or 'lookup'"`; exports[`Permission processEntityPermissions should throw if permission is used on a create type mutation and using data-bound permission types 2`] = `"Create type mutation permission 'create' in 'SomeEntityName.permissions' can only be of type 'authenticated', 'everyone', 'role' or 'lookup'"`; @@ -495,6 +552,8 @@ exports[`Permission processEntityPermissions should throw if permission is used exports[`Permission processEntityPermissions should throw if permissions are assigned to unknown mutations 1`] = `"Unknown mutation 'noSuchMutation' used for permissions in entity 'SomeEntityName'"`; +exports[`Permission processEntityPermissions should throw if permissions are assigned to unknown subscriptions 1`] = `"Unknown subscription 'noSuchSubscription' used for permissions in entity 'SomeEntityName'"`; + exports[`Permission processEntityPermissions should throw if permissions have invalid attributes defined 1`] = `"Cannot use attribute 'someAttribute' in 'SomeEntityName.permissions' as 'userAttribute' as it is not a reference to the User entity"`; exports[`Permission processEntityPermissions should throw if permissions have unknown attributes defined 1`] = `"Cannot use attribute 'notHere' in 'SomeEntityName.permissions' for 'read' as it does not exist"`; @@ -503,10 +562,14 @@ exports[`Permission processEntityPermissions should throw if permissions have un exports[`Permission processEntityPermissions should throw if permissions have unknown attributes defined 3`] = `"Cannot use attribute 'notHere' in 'SomeEntityName.permissions' for 'update' as it does not exist"`; +exports[`Permission processEntityPermissions should throw if permissions have unknown attributes defined 4`] = `"Cannot use attribute 'notHere' in 'SomeEntityName.permissions' for 'onUpdate' as it does not exist"`; + exports[`Permission processEntityPermissions should throw if provided with an invalid map of mutation permissions 1`] = `"Entity 'SomeEntityName' permissions definition for mutations needs to be a map of mutations and permissions"`; exports[`Permission processEntityPermissions should throw if provided with an invalid map of permissions 1`] = `"Entity 'SomeEntityName' permissions definition needs to be an object"`; +exports[`Permission processEntityPermissions should throw if provided with an invalid map of subscription permissions 1`] = `"Entity 'SomeEntityName' permissions definition for subscriptions needs to be a map of subscriptions and permissions"`; + exports[`Permission processEntityPermissions should throw if provided with an invalid permissions 1`] = `"Invalid 'read' permission definition for entity 'SomeEntityName'"`; exports[`Permission processEntityPermissions should throw if provided with an invalid permissions 2`] = `"Invalid 'find' permission definition for entity 'SomeEntityName'"`; diff --git a/src/engine/schema/Schema.ts b/src/engine/schema/Schema.ts index 7740951a..155766fd 100644 --- a/src/engine/schema/Schema.ts +++ b/src/engine/schema/Schema.ts @@ -4,7 +4,11 @@ import { Entity, isEntity } from '../entity/Entity'; import { Action, isAction } from '../action/Action'; import { isDataTypeUser } from '../datatype/DataTypeUser'; import { StorageType, isStorageType } from '../storage/StorageType'; -import { isPermission, isPermissionsArray } from '../permission/Permission'; +import { + isPermission, + isPermissionsArray, + Permission, +} from '../permission/Permission'; import { isViewEntity, ViewEntity } from '../entity/ViewEntity'; import { isShadowEntity } from '../entity/ShadowEntity'; @@ -220,11 +224,14 @@ export class Schema { const entityDefaultPermissions = this.permissionsMap.entities[entity.name] || {}; entityDefaultPermissions.mutations = - entityDefaultPermissions.mutations || ({} as Entity); + entityDefaultPermissions.mutations || ({} as Permission); + entityDefaultPermissions.subscriptions = + entityDefaultPermissions.subscriptions || ({} as Permission); const defaultPermissions = this.permissionsMap.entities ._defaultPermissions; defaultPermissions.mutations = defaultPermissions.mutations || {}; + defaultPermissions.subscriptions = defaultPermissions.subscriptions || {}; const newDefaultPermissions = { read: entityDefaultPermissions.read || defaultPermissions.read, @@ -233,6 +240,10 @@ export class Schema { ...defaultPermissions.mutations, ...entityDefaultPermissions.mutations, }, + subscriptions: { + ...defaultPermissions.subscriptions, + ...entityDefaultPermissions.subscriptions, + }, }; if (isEntity(entity) || isViewEntity(entity)) { diff --git a/src/engine/subscription/Subscription.spec.ts b/src/engine/subscription/Subscription.spec.ts new file mode 100644 index 00000000..caf9ded8 --- /dev/null +++ b/src/engine/subscription/Subscription.spec.ts @@ -0,0 +1,575 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { + Subscription, + isSubscription, + SUBSCRIPTION_TYPE_CREATE, + SUBSCRIPTION_TYPE_UPDATE, + SUBSCRIPTION_TYPE_DELETE, + processEntitySubscriptions, + pubsub, +} from './Subscription'; +import { Entity } from '../entity/Entity'; +import { DataTypeString } from '../datatype/dataTypes'; +import { passOrThrow } from '../util'; +import { generateTestSchema } from '../../graphqlProtocol/test-helper'; +import { generateGraphQLSchema } from '../../graphqlProtocol/generator'; +import { subscribe, parse } from 'graphql'; + +describe('Subscription', () => { + const entity = new Entity({ + name: 'SomeEntityName', + description: 'Just some description', + attributes: { + someAttribute: { + type: DataTypeString, + description: 'Just some description', + }, + anotherAttribute: { + type: DataTypeString, + description: 'Just some description', + }, + }, + }); + + it('should have a name', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription(); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should have a type', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription({ + name: 'example', + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should have a valid type', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription({ + name: 'example', + type: 12346, + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should have a description', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should have a list of default attributes', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'subscribe the world', + }); + + processEntitySubscriptions(entity, [subscription]); + const defaultAttributes = subscription.attributes; + + const expectedAttributes = ['someAttribute', 'anotherAttribute']; + + expect(defaultAttributes).toEqual(expectedAttributes); + }); + + it('should have a list of valid attribute names', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'subscribe the world', + attributes: ['anything', { foo: 'bar' }], + }); + + function fn() { + processEntitySubscriptions(entity, [subscription]); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should allow an empty attributes list for UPDATE type subscriptions', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_UPDATE, + description: 'subscribe the world', + attributes: [], + }); + + processEntitySubscriptions(entity, [subscription]); + expect(subscription.attributes).toEqual([]); + }); + + it('should allow an empty attributes list for DELETE type subscriptions', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_DELETE, + description: 'subscribe the world', + attributes: [], + }); + + processEntitySubscriptions(entity, [subscription]); + expect(subscription.attributes).not.toBeDefined(); + }); + + it('should have a list of unique attribute names', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'subscribe the world', + attributes: ['anything', 'anything'], + }); + + function fn() { + processEntitySubscriptions(entity, [subscription]); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it("should return it's name", () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_UPDATE, + description: 'mutate the world', + attributes: ['anything'], + }); + + expect(subscription.name).toBe('example'); + expect(String(subscription)).toBe('example'); + }); + + it('should have a valid preProcessor function', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'mutate the world', + attributes: ['anything'], + preProcessor: 'not-a-function', + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should have a valid postProcessor function', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'mutate the world', + attributes: ['anything'], + postProcessor: 'not-a-function', + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + describe('isSubscription', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_UPDATE, + description: 'mutate the world', + attributes: ['anything'], + preProcessor() { + return null; + }, + postProcessor() { + return {}; + }, + }); + + it('should recognize objects of type Subscription', () => { + function fn() { + passOrThrow( + isSubscription(subscription), + () => 'This error will never happen', + ); + } + + expect(fn).not.toThrow(); + }); + + it('should recognize non-Subscription objects', () => { + function fn() { + passOrThrow( + isSubscription({}) || + isSubscription(function test() {}) || + isSubscription(Error), + () => 'Not a Subscription object', + ); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + }); + + describe('processEntitySubscriptions', () => { + it('should throw if provided with an invalid list of subscriptions', () => { + const subscriptions = { + foo: [{}], + }; + + function fn() { + processEntitySubscriptions(entity, subscriptions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if provided with an invalid subscription', () => { + const subscriptions = [{ foo: 'bar' }]; + + function fn() { + processEntitySubscriptions(entity, subscriptions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if required attribute is missing in CREATE type subscriptions', () => { + function fn() { + const otherEntity = new Entity({ + name: 'SomeEntityName', + description: 'Just some description', + attributes: { + someAttribute: { + type: DataTypeString, + description: 'Just some description', + }, + neededAttribute: { + type: DataTypeString, + description: 'This is important', + required: true, + }, + }, + subscriptions: [ + new Subscription({ + type: SUBSCRIPTION_TYPE_CREATE, + name: 'onBuild', + description: 'build item', + attributes: ['someAttribute'], + }), + ], + }); + + otherEntity.getSubscriptionByName('onBuild'); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should throw on duplicate subscription names', () => { + const subscriptions = [ + new Subscription({ + type: SUBSCRIPTION_TYPE_CREATE, + name: 'onBuild', + description: 'build item', + attributes: ['someAttribute'], + }), + new Subscription({ + type: SUBSCRIPTION_TYPE_DELETE, + name: 'onBuild', + description: 'build item', + attributes: ['someAttribute'], + }), + ]; + + function fn() { + processEntitySubscriptions(entity, subscriptions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if unknown attributes are used', () => { + const subscriptions = [ + new Subscription({ + type: SUBSCRIPTION_TYPE_CREATE, + name: 'onBuild', + description: 'build item', + attributes: ['doesNotExist'], + }), + ]; + + function fn() { + processEntitySubscriptions(entity, subscriptions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should allow for empty attribute lists on DELETE type subscriptions', () => { + const subscriptions = [ + new Subscription({ + type: SUBSCRIPTION_TYPE_DELETE, + name: 'onDrop', + description: 'drop item', + attributes: [], + }), + ]; + + processEntitySubscriptions(entity, subscriptions); + }); + }); + + describe('preProcessor', () => { + const testEntity = new Entity({ + name: 'SomeTestsEntityName', + description: 'Just some description', + attributes: { + someAttribute: { + type: DataTypeString, + description: 'Just some description', + required: false, + }, + anotherAttribute: { + type: DataTypeString, + description: 'Just some description', + }, + }, + subscriptions: [ + new Subscription({ + name: 'SomeSubWithPreProcessor', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'build item', + attributes: ['someAttribute'], + delimiter: '/', + // wildCard: '', + // pattern: '', + preProcessor: ( + _entity, + _id, + _source, + input, + typeName, + entitySubscription, + ) => { + if (entitySubscription.attributes && Object.keys(input).length) { + const delimiter = entitySubscription.delimiter; + const filled = entitySubscription.attributes + .map(attribute => input[attribute]) + .reduce((acc, curr) => `${acc + delimiter + curr}`, ''); + + const topic = `${entitySubscription.name}${_entity.name}${filled}`; + return topic; + } + + return null; + }, + }), + ], + }); + + const someAttribute = 'test'; + + it('should pass through preProcessor if it is declared', async () => { + const subscriptionByName = testEntity.getSubscriptionByName( + 'SomeSubWithPreProcessor', + ); + expect(subscriptionByName).toMatchSnapshot(); + + const setup = await generateTestSchema({ entities: [testEntity] }); + const graphqlSchema = generateGraphQLSchema(setup.configuration); + const subscriptionDoc = parse(` + subscription someSubWithPreProcessorSomeTestsEntityName($input: SomeSubWithPreProcessorSomeTestsEntityNameInput!) { + someSubWithPreProcessorSomeTestsEntityName(input: $input) { + someTestsEntityName { + # someAttribute + anotherAttribute + } + } + }`); + + // subscription topic will be automatically generated by shyft + let subscription = (await subscribe({ + schema: graphqlSchema, + document: subscriptionDoc, + variableValues: { + input: { + someTestsEntityName: {}, + }, + }, + contextValue: { pubsub }, + })) as AsyncIterableIterator; + + let pending = subscription.next(); + + await pubsub.publish('SomeSubWithPreProcessorSomeTestsEntityName', { + // someTestsEntityName: { + someAttribute, + anotherAttribute: 'world', + // }, + }); + + let result = await pending; + expect(result).toMatchSnapshot('withoutPreProcessorResult'); + expect(await subscription.return()).toMatchSnapshot( + 'withoutPreProcessorEnd', + ); + + // subscription topic will be generated in preProcessor + subscription = (await subscribe({ + schema: graphqlSchema, + document: subscriptionDoc, + variableValues: { + input: { + someTestsEntityName: { + someAttribute, + }, + }, + }, + contextValue: { pubsub }, + })) as AsyncIterableIterator; + + pending = subscription.next(); + + await pubsub.publish( + `SomeSubWithPreProcessorSomeTestsEntityName/${someAttribute}`, + { + someAttribute, + anotherAttribute: 'world', + }, + ); + + result = await pending; + expect(result).toMatchSnapshot('withPreProcessorResult'); + expect(await subscription.return()).toMatchSnapshot( + 'withPreProcessorEnd', + ); + }); + }); + + describe('postProcessor', () => { + const testEntity = new Entity({ + name: 'SomeTestsEntityName', + description: 'Just some description', + attributes: { + someAttribute: { + type: DataTypeString, + description: 'Just some description', + required: false, + }, + anotherAttribute: { + type: DataTypeString, + description: 'Just some description', + }, + }, + subscriptions: [ + new Subscription({ + name: 'SomeSubWithPostProcessor', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'build item', + attributes: ['someAttribute'], + delimiter: '/', + postProcessor: ( + _entity, + // _id, + _source, + input, + typeName, + entitySubscription, + context, + ) => { + if (context && context.changePayload) { + return { anotherAttribute: 'earth' }; + } + return null; + }, + }), + ], + }); + + const someAttribute = 'test'; + + it('should pass through postProcessor if it is declared', async () => { + const subscriptionByName = testEntity.getSubscriptionByName( + 'SomeSubWithPostProcessor', + ); + expect(subscriptionByName).toMatchSnapshot(); + + const setup = await generateTestSchema({ entities: [testEntity] }); + const graphqlSchema = generateGraphQLSchema(setup.configuration); + const subscriptionDoc = parse(` + subscription someSubWithPostProcessorSomeTestsEntityName($input: SomeSubWithPostProcessorSomeTestsEntityNameInput!) { + someSubWithPostProcessorSomeTestsEntityName(input: $input) { + someTestsEntityName { + # someAttribute + anotherAttribute + } + } + }`); + + let subscription = (await subscribe({ + schema: graphqlSchema, + document: subscriptionDoc, + variableValues: { + input: { + someTestsEntityName: {}, + }, + }, + contextValue: { pubsub }, + })) as AsyncIterableIterator; + + let pending = subscription.next(); + + await pubsub.publish('SomeSubWithPostProcessorSomeTestsEntityName', { + someAttribute, + anotherAttribute: 'world', + }); + + let result = await pending; + expect(result).toMatchSnapshot('withoutPostProcessorResult'); + expect(await subscription.return()).toMatchSnapshot( + 'withoutPostProcessorEnd', + ); + + subscription = (await subscribe({ + schema: graphqlSchema, + document: subscriptionDoc, + variableValues: { + input: { + someTestsEntityName: {}, + }, + }, + contextValue: { pubsub, changePayload: true }, + })) as AsyncIterableIterator; + + pending = subscription.next(); + + await pubsub.publish('SomeSubWithPostProcessorSomeTestsEntityName', { + someAttribute, + anotherAttribute: 'world', + }); + + result = await pending; + expect(result).toMatchSnapshot('withPostProcessorResult'); + expect(await subscription.return()).toMatchSnapshot( + 'withPostProcessorEnd', + ); + }); + }); +}); diff --git a/src/engine/subscription/Subscription.ts b/src/engine/subscription/Subscription.ts new file mode 100644 index 00000000..36aba830 --- /dev/null +++ b/src/engine/subscription/Subscription.ts @@ -0,0 +1,311 @@ +import { uniq } from 'lodash'; +import { PubSub } from 'graphql-subscriptions'; +import { passOrThrow, isArray, isFunction, mapOverProperties } from '../util'; + +import { Entity } from '../entity/Entity'; + +export const pubsub = new PubSub(); + +export const SUBSCRIPTION_TYPE_CREATE = 'onCreate'; +export const SUBSCRIPTION_TYPE_UPDATE = 'onUpdate'; +export const SUBSCRIPTION_TYPE_DELETE = 'onDelete'; + +export const subscriptionTypes = [ + SUBSCRIPTION_TYPE_CREATE, + SUBSCRIPTION_TYPE_UPDATE, + SUBSCRIPTION_TYPE_DELETE, +]; + +export const defaultEntitySubscription = [ + { + name: 'onCreate', + type: SUBSCRIPTION_TYPE_CREATE, + description: (typeName: string) => + `Watch a new **\`${typeName}\`** creation`, + hasAttributes: true, + }, + { + name: 'onUpdate', + type: SUBSCRIPTION_TYPE_UPDATE, + description: (typeName: string) => + `Watch a single **\`${typeName}\`** update using its node ID and a data patch`, + hasAttributes: true, + }, + { + name: 'onDelete', + description: (typeName: string) => + `Watch a single **\`${typeName}\`** deletion using its node ID`, + type: SUBSCRIPTION_TYPE_DELETE, + }, +]; + +export type SubscriptionSetup = { + name?: string; + type?: string; + description?: string; + attributes?: string[]; + delimiter?: string; + wildCard?: string; + pattern?: string; + preProcessor?: ( + entity?: Entity, + id?: string | number, + source?: any, + input?: any, + typeName?: string, + entitySubscription?: Subscription, + context?: any, + info?: any, + ) => Promise | string | null; + postProcessor?: ( + entity?: Entity, + // id, + source?: any, + input?: any, + typeName?: string, + entitySubscription?: Subscription, + context?: any, + info?: any, + ) => Promise | object | null; +}; + +export class Subscription { + name: string; + type: string; + description: string; + attributes: string[]; + delimiter?: string; + wildCard?: string; + pattern?: string; + + preProcessor: Function; + postProcessor: Function; + + isTypeCreate?: boolean; + isTypeDelete?: boolean; + needsInstance?: boolean; + ignoreRequired?: boolean; + isTypeUpdate?: boolean; + + constructor(setup: SubscriptionSetup = {} as SubscriptionSetup) { + const { + name, + type, + description, + attributes, + preProcessor, + postProcessor, + } = setup; + + passOrThrow(name, () => 'Missing subscription name'); + passOrThrow(type, () => `Missing type for subscription '${name}'`); + passOrThrow( + subscriptionTypes.indexOf(type) >= 0, + () => + `Unknown subscription type '${type}' used, try one of these: '${subscriptionTypes.join( + ', ', + )}'`, + ); + + passOrThrow( + description, + () => `Missing description for subscription '${name}'`, + ); + + this.name = name; + this.type = type; + this.description = description; + + if ( + this.type === SUBSCRIPTION_TYPE_CREATE || + this.type === SUBSCRIPTION_TYPE_UPDATE + ) { + this.attributes = attributes; + } + + if (this.type === SUBSCRIPTION_TYPE_CREATE) { + this.isTypeCreate = true; + } + + if (this.type === SUBSCRIPTION_TYPE_UPDATE) { + this.needsInstance = true; + this.ignoreRequired = true; + this.isTypeUpdate = true; + } + + if (this.type === SUBSCRIPTION_TYPE_DELETE) { + this.needsInstance = true; + this.isTypeDelete = true; + } + + if (preProcessor) { + passOrThrow( + isFunction(preProcessor), + () => + `preProcessor of subscription '${name}' needs to be a valid function`, + ); + + this.preProcessor = preProcessor; + } + + if (postProcessor) { + passOrThrow( + isFunction(postProcessor), + () => + `postProcessor of subscription '${name}' needs to be a valid function`, + ); + + this.postProcessor = postProcessor; + } + } + + toString() { + return this.name; + } +} + +export const isSubscription = (obj: any) => { + return obj instanceof Subscription; +}; + +export const processEntitySubscriptions = ( + entity: Entity, + subscriptions: Subscription[], +) => { + passOrThrow( + isArray(subscriptions), + () => + `Entity '${entity.name}' subscriptions definition needs to be an array of subscriptions`, + ); + + subscriptions.map((subscription, idx) => { + passOrThrow( + isSubscription(subscription), + () => + `Invalid subscription definition for entity '${entity.name}' at position '${idx}'`, + ); + }); + + const entityAttributes = entity.getAttributes(); + // const entityStates = entity.getStates(); + + const requiredAttributeNames = []; + + mapOverProperties(entityAttributes, (attribute, attributeName) => { + if (!attribute.isSystemAttribute) { + if (attribute.required && !attribute.defaultValue) { + requiredAttributeNames.push(attributeName); + } + } + }); + + const subscriptionNames = []; + + subscriptions.map(subscription => { + passOrThrow( + !subscriptionNames.includes(subscription.name), + () => + `Duplicate subscription name '${subscription.name}' found in '${entity.name}'`, + ); + + subscriptionNames.push(subscription.name); + + if (subscription.attributes) { + passOrThrow( + (isArray(subscription.attributes, true) && + subscription.type === SUBSCRIPTION_TYPE_CREATE) || + isArray(subscription.attributes, false), + () => + `Subscription '${entity.name}.${subscription.name}' needs to have a list of attributes`, + ); + + subscription.attributes.map(attribute => { + passOrThrow( + typeof attribute === 'string', + () => + `Subscription '${entity.name}.${subscription.name}' needs to have a list of attribute names`, + ); + }); + + passOrThrow( + subscription.attributes.length === uniq(subscription.attributes).length, + () => + `Subscription '${entity.name}.${subscription.name}' needs to have a list of unique attribute names`, + ); + + subscription.attributes.map(attributeName => { + passOrThrow( + entityAttributes[attributeName], + () => + `Cannot use attribute '${entity.name}.${attributeName}' in subscription '${entity.name}.${subscription.name}' as it does not exist`, + ); + }); + + if (subscription.type === SUBSCRIPTION_TYPE_CREATE) { + const missingAttributeNames = requiredAttributeNames.filter( + requiredAttributeName => { + return !subscription.attributes.includes(requiredAttributeName); + }, + ); + + passOrThrow( + missingAttributeNames.length === 0, + () => + `Missing required attributes in subscription '${entity.name}.${ + subscription.name + }' need to have a defaultValue() function: [ ${missingAttributeNames.join( + ', ', + )} ]`, + ); + } + } else if ( + subscription.type === SUBSCRIPTION_TYPE_CREATE || + subscription.type === SUBSCRIPTION_TYPE_UPDATE + ) { + const nonSystemAttributeNames = []; + + mapOverProperties(entityAttributes, (attribute, attributeName) => { + if (!attribute.isSystemAttribute) { + nonSystemAttributeNames.push(attributeName); + } + }); + + subscription.attributes = nonSystemAttributeNames; + } + + if (subscription.delimiter) { + passOrThrow( + typeof subscription.delimiter === 'string', + () => + `Subscription '${entity.name}.${subscription.name}' delimiter should be a string`, + ); + } else { + subscription.delimiter = '/'; + } + + if (subscription.wildCard) { + // todo make a list of valid wildCards (#, +, *) + passOrThrow( + typeof subscription.wildCard === 'string', + () => + `Subscription '${entity.name}.${subscription.name}' wildCard should be a string`, + ); + } + + if (subscription.pattern) { + // todo validate pattern based on entityAttributes + passOrThrow( + typeof subscription.pattern === 'string', + () => + `Subscription '${entity.name}.${subscription.name}' pattern should be a string`, + ); + } + // else { + // subscription.pattern = Object.keys(entityAttributes).join( + // subscription.delimiter, + // ); + // } + }); + + return subscriptions; +}; diff --git a/src/engine/subscription/__snapshots__/Subscription.spec.ts.snap b/src/engine/subscription/__snapshots__/Subscription.spec.ts.snap new file mode 100644 index 00000000..9df0e641 --- /dev/null +++ b/src/engine/subscription/__snapshots__/Subscription.spec.ts.snap @@ -0,0 +1,145 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Subscription isSubscription should recognize non-Subscription objects 1`] = `"Not a Subscription object"`; + +exports[`Subscription postProcessor should pass through postProcessor if it is declared 1`] = ` +Subscription { + "attributes": Array [ + "someAttribute", + ], + "delimiter": "/", + "description": "build item", + "isTypeCreate": true, + "name": "SomeSubWithPostProcessor", + "postProcessor": [Function], + "type": "onCreate", +} +`; + +exports[`Subscription postProcessor should pass through postProcessor if it is declared: withPostProcessorEnd 1`] = ` +Object { + "done": true, + "value": undefined, +} +`; + +exports[`Subscription postProcessor should pass through postProcessor if it is declared: withPostProcessorResult 1`] = ` +Object { + "done": false, + "value": Object { + "data": Object { + "someSubWithPostProcessorSomeTestsEntityName": Object { + "someTestsEntityName": Object { + "anotherAttribute": "earth", + }, + }, + }, + }, +} +`; + +exports[`Subscription postProcessor should pass through postProcessor if it is declared: withoutPostProcessorEnd 1`] = ` +Object { + "done": true, + "value": undefined, +} +`; + +exports[`Subscription postProcessor should pass through postProcessor if it is declared: withoutPostProcessorResult 1`] = ` +Object { + "done": false, + "value": Object { + "data": Object { + "someSubWithPostProcessorSomeTestsEntityName": Object { + "someTestsEntityName": Object { + "anotherAttribute": "world", + }, + }, + }, + }, +} +`; + +exports[`Subscription preProcessor should pass through preProcessor if it is declared 1`] = ` +Subscription { + "attributes": Array [ + "someAttribute", + ], + "delimiter": "/", + "description": "build item", + "isTypeCreate": true, + "name": "SomeSubWithPreProcessor", + "preProcessor": [Function], + "type": "onCreate", +} +`; + +exports[`Subscription preProcessor should pass through preProcessor if it is declared: withPreProcessorEnd 1`] = ` +Object { + "done": true, + "value": undefined, +} +`; + +exports[`Subscription preProcessor should pass through preProcessor if it is declared: withPreProcessorResult 1`] = ` +Object { + "done": false, + "value": Object { + "data": Object { + "someSubWithPreProcessorSomeTestsEntityName": Object { + "someTestsEntityName": Object { + "anotherAttribute": "world", + }, + }, + }, + }, +} +`; + +exports[`Subscription preProcessor should pass through preProcessor if it is declared: withoutPreProcessorEnd 1`] = ` +Object { + "done": true, + "value": undefined, +} +`; + +exports[`Subscription preProcessor should pass through preProcessor if it is declared: withoutPreProcessorResult 1`] = ` +Object { + "done": false, + "value": Object { + "data": Object { + "someSubWithPreProcessorSomeTestsEntityName": Object { + "someTestsEntityName": Object { + "anotherAttribute": "world", + }, + }, + }, + }, +} +`; + +exports[`Subscription processEntitySubscriptions should throw if provided with an invalid list of subscriptions 1`] = `"Entity 'SomeEntityName' subscriptions definition needs to be an array of subscriptions"`; + +exports[`Subscription processEntitySubscriptions should throw if provided with an invalid subscription 1`] = `"Invalid subscription definition for entity 'SomeEntityName' at position '0'"`; + +exports[`Subscription processEntitySubscriptions should throw if required attribute is missing in CREATE type subscriptions 1`] = `"Missing required attributes in subscription 'SomeEntityName.onBuild' need to have a defaultValue() function: [ neededAttribute ]"`; + +exports[`Subscription processEntitySubscriptions should throw if unknown attributes are used 1`] = `"Cannot use attribute 'SomeEntityName.doesNotExist' in subscription 'SomeEntityName.onBuild' as it does not exist"`; + +exports[`Subscription processEntitySubscriptions should throw on duplicate subscription names 1`] = `"Duplicate subscription name 'onBuild' found in 'SomeEntityName'"`; + +exports[`Subscription should have a description 1`] = `"Missing description for subscription 'example'"`; + +exports[`Subscription should have a list of unique attribute names 1`] = `"Subscription 'SomeEntityName.example' needs to have a list of unique attribute names"`; + +exports[`Subscription should have a list of valid attribute names 1`] = `"Subscription 'SomeEntityName.example' needs to have a list of attribute names"`; + +exports[`Subscription should have a name 1`] = `"Missing subscription name"`; + +exports[`Subscription should have a type 1`] = `"Missing type for subscription 'example'"`; + +exports[`Subscription should have a valid postProcessor function 1`] = `"postProcessor of subscription 'example' needs to be a valid function"`; + +exports[`Subscription should have a valid preProcessor function 1`] = `"preProcessor of subscription 'example' needs to be a valid function"`; + +exports[`Subscription should have a valid type 1`] = `"Unknown subscription type '12346' used, try one of these: 'onCreate, onUpdate, onDelete'"`; diff --git a/src/graphqlProtocol/ProtocolGraphQLConfiguration.ts b/src/graphqlProtocol/ProtocolGraphQLConfiguration.ts index 32d72a9d..5cbeea7f 100644 --- a/src/graphqlProtocol/ProtocolGraphQLConfiguration.ts +++ b/src/graphqlProtocol/ProtocolGraphQLConfiguration.ts @@ -93,6 +93,18 @@ export class ProtocolGraphQLConfiguration extends ProtocolConfiguration { ); } + generateSubscriptionI18nAttributeInputTypeName( + entity, + subscription, + attribute, + ) { + const typeName = this.generateEntityTypeName(entity); + const fieldName = this.generateFieldName(attribute); + return generateTypeNamePascalCase( + `${subscription.name}-${typeName}-${fieldName}-i18n-input`, + ); + } + generateListQueryTypeName(entity) { const typeNamePlural = this.generateEntityTypeNamePlural(entity); return generateTypeName(`all-${typeNamePlural}`); @@ -262,6 +274,71 @@ export class ProtocolGraphQLConfiguration extends ProtocolConfiguration { const typeName = this.generateEntityTypeName(entity); return generateTypeNamePascalCase(`${typeName}-connection`); } + + generateSubscriptionInstanceInputTypeName(entity, subscription) { + const typeName = this.generateEntityTypeName(entity); + return generateTypeNamePascalCase( + `${subscription.name}-${typeName}-instance-input`, + ); + } + + generateSubscriptionInputTypeName(entity, subscription) { + const typeName = this.generateEntityTypeName(entity); + return generateTypeNamePascalCase(`${subscription.name}-${typeName}-input`); + } + + generateSubscriptionByPrimaryAttributeInputTypeName( + entity, + subscription, + attribute, + ) { + const typeName = this.generateEntityTypeName(entity); + const fieldName = this.generateFieldName(attribute); + return generateTypeNamePascalCase( + `${subscription.name}-${typeName}-by-${fieldName}-input`, + ); + } + + generateSubscriptionInstanceNestedInputTypeName(entity, subscription) { + const typeName = this.generateEntityTypeName(entity); + return generateTypeNamePascalCase( + `${subscription.name}-${typeName}-instance-nested-input`, + ); + } + + generateSubscriptionNestedInputTypeName(entity, subscription) { + const typeName = this.generateEntityTypeName(entity); + return generateTypeNamePascalCase( + `${subscription.name}-${typeName}-nested-input`, + ); + } + + generateSubscriptionOutputTypeName(entity, subscription) { + const typeName = this.generateEntityTypeName(entity); + return generateTypeNamePascalCase( + `${subscription.name}-${typeName}-output`, + ); + } + + generateSubscriptionTypeName(entity, subscription) { + const typeName = this.generateEntityTypeName(entity); + return generateTypeName(`${subscription.name}-${typeName}`); + } + + generateSubscriptionNestedTypeName(entity, subscription) { + const typeName = this.generateEntityTypeName(entity); + return generateTypeName(`${subscription.name}-${typeName}-nested`); + } + + generateSubscriptionByPrimaryAttributeTypeName( + entity, + subscription, + attribute, + ) { + const typeName = this.generateEntityTypeName(entity); + const fieldName = this.generateFieldName(attribute); + return generateTypeName(`${subscription.name}-${typeName}-by-${fieldName}`); + } } export const isProtocolGraphQLConfiguration = (obj: any): boolean => { diff --git a/src/graphqlProtocol/__snapshots__/generator.spec.ts.snap b/src/graphqlProtocol/__snapshots__/generator.spec.ts.snap index 154b1ce4..351a0b03 100644 --- a/src/graphqlProtocol/__snapshots__/generator.spec.ts.snap +++ b/src/graphqlProtocol/__snapshots__/generator.spec.ts.snap @@ -17,7 +17,7 @@ GraphQLSchema { "_mutationType": "Mutation", "_possibleTypeMap": Object {}, "_queryType": "Query", - "_subscriptionType": undefined, + "_subscriptionType": "Subscription", "_typeMap": Object { "Boolean": "Boolean", "CreateTestEntityNameInput": "CreateTestEntityNameInput", @@ -33,9 +33,24 @@ GraphQLSchema { "Int": "Int", "Mutation": "Mutation", "Node": "Node", + "OnCreateTestEntityNameInput": "OnCreateTestEntityNameInput", + "OnCreateTestEntityNameInstanceInput": "OnCreateTestEntityNameInstanceInput", + "OnCreateTestEntityNameInstanceNestedInput": "OnCreateTestEntityNameInstanceNestedInput", + "OnCreateTestEntityNameNestedInput": "OnCreateTestEntityNameNestedInput", + "OnCreateTestEntityNameOutput": "OnCreateTestEntityNameOutput", + "OnDeleteTestEntityNameByIdInput": "OnDeleteTestEntityNameByIdInput", + "OnDeleteTestEntityNameInput": "OnDeleteTestEntityNameInput", + "OnDeleteTestEntityNameOutput": "OnDeleteTestEntityNameOutput", + "OnUpdateTestEntityNameByIdInput": "OnUpdateTestEntityNameByIdInput", + "OnUpdateTestEntityNameInput": "OnUpdateTestEntityNameInput", + "OnUpdateTestEntityNameInstanceInput": "OnUpdateTestEntityNameInstanceInput", + "OnUpdateTestEntityNameInstanceNestedInput": "OnUpdateTestEntityNameInstanceNestedInput", + "OnUpdateTestEntityNameNestedInput": "OnUpdateTestEntityNameNestedInput", + "OnUpdateTestEntityNameOutput": "OnUpdateTestEntityNameOutput", "PageInfo": "PageInfo", "Query": "Query", "String": "String", + "Subscription": "Subscription", "TestEntityName": "TestEntityName", "TestEntityNameConnection": "TestEntityNameConnection", "TestEntityNameEdge": "TestEntityNameEdge", diff --git a/src/graphqlProtocol/generator.ts b/src/graphqlProtocol/generator.ts index 8a87fac6..9a6fe5d2 100644 --- a/src/graphqlProtocol/generator.ts +++ b/src/graphqlProtocol/generator.ts @@ -23,6 +23,8 @@ import { generateMutations } from './mutation'; import { generateActions } from './action'; +import { generateSubscriptions } from './subscription'; + import { resolveByFindOne } from './resolver'; import { isConfiguration } from '../engine/configuration/Configuration'; import { isEntity } from '../engine/entity/Entity'; @@ -380,9 +382,27 @@ export const generateGraphQLSchema = configuration => { }, }); + const subscriptionType = new GraphQLObjectType({ + name: 'Subscription', + // root: 'The root subscription type', + + fields: () => { + const subscriptions = generateSubscriptions(graphRegistry); + // Object.keys(subscriptions).forEach(sub => { + // console.log('generate subscriptions', { + // name: sub, + // args: subscriptions[sub].args, + // }); + // }); + + return subscriptions; + }, + }); + // put it all together into a graphQL schema return new GraphQLSchema({ query: queryType, mutation: mutationType, + subscription: subscriptionType, }); }; diff --git a/src/graphqlProtocol/resolver.ts b/src/graphqlProtocol/resolver.ts index 6afd0120..ce323d4b 100644 --- a/src/graphqlProtocol/resolver.ts +++ b/src/graphqlProtocol/resolver.ts @@ -1,10 +1,10 @@ +import * as _ from 'lodash'; import { addRelayTypePromoterToList, addRelayTypePromoterToInstanceFn, translateList, translateInstanceFn, } from './util'; - import { ProtocolGraphQL } from './ProtocolGraphQL'; import { ProtocolGraphQLConfiguration } from './ProtocolGraphQLConfiguration'; @@ -16,8 +16,6 @@ import { import { transformFilterLevel } from './filter'; -import * as _ from 'lodash'; - import { addRelayTypePromoterToInstance, translateInstance } from './util'; import { @@ -30,6 +28,12 @@ import { MUTATION_TYPE_UPDATE, MUTATION_TYPE_DELETE, } from '../engine/mutation/Mutation'; +import { + SUBSCRIPTION_TYPE_CREATE, + SUBSCRIPTION_TYPE_UPDATE, + // SUBSCRIPTION_TYPE_DELETE, + pubsub, +} from '../engine/subscription/Subscription'; import { CustomError } from '../engine/CustomError'; import { fillSystemAttributesDefaultValues, @@ -453,3 +457,170 @@ export const getMutationResolver = ( } }; }; + +export const getSubscriptionResolver = ( + entity, + entitySubscription, + typeName, + nested, + idResolver, +) => { + const storageType = entity.storageType; + // const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + + const nestedPayloadResolver = getNestedPayloadResolver( + entity, + entitySubscription.attributes, + storageType, + ); + + return async ( + source, + args, + context, + info, + ): Promise> => { + checkRequiredI18nInputs( + entity, + entitySubscription, + args.input[typeName], + // context, + ); + + if (nested) { + args.input[typeName] = await nestedPayloadResolver( + source, + args.input[typeName], + context, + info, + ); + } + + const id = idResolver({ args }); + + if ( + entitySubscription.type === SUBSCRIPTION_TYPE_CREATE || + entitySubscription.type === SUBSCRIPTION_TYPE_UPDATE + ) { + args.input[typeName] = fillSystemAttributesDefaultValues( + entity, + entitySubscription, + args.input[typeName], + context, + ); + } + + // await validateSubscriptionPayload( + // entity, + // entitySubscription, + // args.input[typeName], + // context, + // ); + + // if (entitySubscription.type !== SUBSCRIPTION_TYPE_DELETE) { + // // + // // this function might be wrong when we look serializeValues args + // // unless we add typeName ? + // args.input[typeName] = serializeValues( + // entity, + // entitySubscription, + // args.input[typeName], + // typeName, + // context, + // ); + // } + + let topic; + const delimiter = entitySubscription.delimiter; + if (entitySubscription.pattern) { + // const delimiter = entitySubscription.delimiter; + // const filled = entitySubscription.attributes + // .map(attribute => input[attribute]) + // .reduce((acc, curr) => `${acc + delimiter + curr}`, ''); + + const params = entitySubscription.pattern + .split(delimiter) + .reduce((acc, curr) => (acc[curr] = args.input[typeName][curr]), {}); + console.log('getSubscriptionResolver', { params }); + + const filled = Object.values(params).join(delimiter); + + console.log('getSubscriptionResolver', { filled }); + + topic = `${entitySubscription.name}${entity.name}/${filled}${ + entitySubscription.wildCard + ? delimiter + entitySubscription.wildCard + : '' + }`; + } else if (entitySubscription.preProcessor) { + topic = await entitySubscription.preProcessor( + entity, + id, + source, + args.input[typeName], + typeName, + entitySubscription, + context, + info, + ); + } + if (!topic) { + topic = `${entitySubscription.name}${entity.name}${ + entitySubscription.wildCard + ? delimiter + entitySubscription.wildCard + : '' + }`; + } + + // console.log('getSubscriptionResolver', { topic }); + + return context.pubsub + ? context.pubsub.asyncIterator(topic) + : pubsub.asyncIterator(topic); + }; +}; + +export const getSubscriptionPayloadResolver = ( + entity, + entitySubscription, + typeName, +) => { + return async (source, args, context, info) => { + // let ret = { + // clientSubscriptionId: args.input.clientSubscriptionId, + // }; + + let ret = {}; + + let result; + if (entitySubscription.postProcessor) { + result = await entitySubscription.postProcessor( + entity, + // id, + source, + args.input[typeName], + typeName, + entitySubscription, + context, + info, + ); + } + + if (!result) { + result = source; + } + + if (entitySubscription.type === MUTATION_TYPE_DELETE) { + ret = { + ...ret, + ...result, + }; + } else { + ret[typeName] = result; + } + + // console.log('getSubscriptionPayloadResolver', JSON.stringify(ret, null, 2)); + + return ret; + }; +}; diff --git a/src/graphqlProtocol/subscription.ts b/src/graphqlProtocol/subscription.ts new file mode 100644 index 00000000..70a070a7 --- /dev/null +++ b/src/graphqlProtocol/subscription.ts @@ -0,0 +1,757 @@ +import { + GraphQLString, + GraphQLID, + GraphQLNonNull, + GraphQLInputObjectType, + GraphQLObjectType, + GraphQLInt, + GraphQLInputFieldConfigMap, + GraphQLFieldConfigMap, +} from 'graphql'; +import { fromGlobalId } from 'graphql-relay'; +import * as _ from 'lodash'; + +import { ProtocolGraphQL } from './ProtocolGraphQL'; +import { ProtocolGraphQLConfiguration } from './ProtocolGraphQLConfiguration'; +import { getEntityUniquenessAttributes } from './helper'; +import { + getSubscriptionResolver, + getSubscriptionPayloadResolver, +} from './resolver'; +import { isEntity } from '../engine/entity/Entity'; + +const i18nInputFieldTypesCache = {}; + +const generateI18nInputFieldType = (entity, entitySubscription, attribute) => { + const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + + const i18nFieldTypeName = protocolConfiguration.generateSubscriptionI18nAttributeInputTypeName( + entity, + entitySubscription, + attribute, + ); + + if (i18nInputFieldTypesCache[i18nFieldTypeName]) { + return i18nInputFieldTypesCache[i18nFieldTypeName]; + } + + const attributeType = attribute.type; + const typeNamePascalCase = entity.graphql.typeNamePascalCase; + const languages = protocolConfiguration + .getParentConfiguration() + .getLanguages(); + const fieldType = ProtocolGraphQL.convertToProtocolDataType( + attributeType, + entity.name, + true, + ); + + const i18nFieldType = new GraphQLInputObjectType({ + name: i18nFieldTypeName, + description: `**\`${entitySubscription.name}\`** subscription translations input type for **\`${typeNamePascalCase}.${attribute.gqlFieldName}\`**`, + + fields: () => { + const i18nFields = {}; + + languages.map((language, langIdx) => { + const type = + langIdx === 0 && + attribute.required && + !entitySubscription.ignoreRequired + ? new GraphQLNonNull(fieldType) + : fieldType; + + i18nFields[language] = { + type, + }; + }); + + return i18nFields; + }, + }); + + i18nInputFieldTypesCache[i18nFieldTypeName] = i18nFieldType; + + return i18nFieldType; +}; + +export const generateSubscriptionInstanceInput = ( + entity, + entitySubscription, +) => { + const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + + const typeNamePascalCase = entity.graphql.typeNamePascalCase; + + const entitySubscriptionInstanceInputType = new GraphQLInputObjectType({ + name: protocolConfiguration.generateSubscriptionInstanceInputTypeName( + entity, + entitySubscription, + ), + description: `**\`${entitySubscription.name}\`** subscription input type for **\`${typeNamePascalCase}\`**`, + + fields: () => { + const fields: GraphQLInputFieldConfigMap = {}; + + const entityAttributes = entity.getAttributes(); + + _.forEach(entitySubscription.attributes, attributeName => { + const attribute = entityAttributes[attributeName]; + + let attributeType = attribute.type; + + // it's a reference + if (isEntity(attributeType)) { + const targetEntity = attributeType; + const primaryAttribute = targetEntity.getPrimaryAttribute(); + attributeType = primaryAttribute.type; + } + + const fieldType = ProtocolGraphQL.convertToProtocolDataType( + attributeType, + entity.name, + true, + ); + + fields[attribute.gqlFieldName] = { + type: + attribute.required && + !entitySubscription.ignoreRequired && + !attribute.i18n && + !attribute.defaultValue + ? new GraphQLNonNull(fieldType) + : fieldType, + }; + + if (attribute.i18n) { + const i18nFieldType = generateI18nInputFieldType( + entity, + entitySubscription, + attribute, + ); + + fields[attribute.gqlFieldNameI18n] = { + type: i18nFieldType, + }; + } + }); + + return fields; + }, + }); + + return entitySubscriptionInstanceInputType; +}; + +export const generateSubscriptionInput = ( + entity, + typeName, + entitySubscription, + entitySubscriptionInstanceInputType, +) => { + const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + + const typeNamePascalCase = entity.graphql.typeNamePascalCase; + + const entitySubscriptionInputType = new GraphQLInputObjectType({ + name: protocolConfiguration.generateSubscriptionInputTypeName( + entity, + entitySubscription, + ), + description: `Subscription input type for **\`${typeNamePascalCase}\`**`, + + fields: () => { + const fields: GraphQLInputFieldConfigMap = { + clientSubscriptionId: { + type: GraphQLString, + }, + }; + + if (entitySubscription.needsInstance) { + fields.nodeId = { + type: new GraphQLNonNull(GraphQLID), + }; + } + + if (entitySubscriptionInstanceInputType) { + fields[typeName] = { + type: new GraphQLNonNull(entitySubscriptionInstanceInputType), + }; + } + + return fields; + }, + }); + + return entitySubscriptionInputType; +}; + +export const generateSubscriptionByPrimaryAttributeInput = ( + entity, + typeName, + entitySubscription, + entitySubscriptionInstanceInputType, + primaryAttribute, +) => { + const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + + const fieldName = primaryAttribute.gqlFieldName; + const fieldType = ProtocolGraphQL.convertToProtocolDataType( + primaryAttribute.type, + entity.name, + true, + ); + const typeNamePascalCase = entity.graphql.typeNamePascalCase; + + const entitySubscriptionInputType = new GraphQLInputObjectType({ + name: protocolConfiguration.generateMutationByPrimaryAttributeInputTypeName( + entity, + entitySubscription, + primaryAttribute, + ), + description: `Subscription input type for **\`${typeNamePascalCase}\`** using the **\`${fieldName}\`**`, + + fields: () => { + const fields: GraphQLInputFieldConfigMap = { + clientSubscriptionId: { + type: GraphQLString, + }, + }; + + if (entitySubscription.needsInstance) { + fields[fieldName] = { + type: new GraphQLNonNull(fieldType), + }; + } + + if (entitySubscriptionInstanceInputType) { + fields[typeName] = { + type: new GraphQLNonNull(entitySubscriptionInstanceInputType), + }; + } + + return fields; + }, + }); + + return entitySubscriptionInputType; +}; + +export const generateInstanceUniquenessInput = ( + entity, + uniquenessAttributes, + graphRegistry, +) => { + const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + + const typeNamePascalCase = entity.graphql.typeNamePascalCase; + + const entityInstanceInputType = new GraphQLInputObjectType({ + name: protocolConfiguration.generateInstanceUniquenessInputTypeName( + entity, + uniquenessAttributes.uniquenessName, + ), + description: `Input type for **\`${typeNamePascalCase}\`** using data uniqueness (${uniquenessAttributes.attributes}) to resolve the ID`, + + fields: () => { + const fields: GraphQLInputFieldConfigMap = {}; + + const entityAttributes = entity.getAttributes(); + + _.forEach(uniquenessAttributes.attributes, attributeName => { + const attribute = entityAttributes[attributeName]; + + let attributeType = attribute.type; + + if (isEntity(attributeType)) { + const targetEntity = attributeType; + const primaryAttribute = targetEntity.getPrimaryAttribute(); + const targetTypeName = targetEntity.graphql.typeName; + + attributeType = primaryAttribute.type; + const fieldType = ProtocolGraphQL.convertToProtocolDataType( + attributeType, + entity.name, + true, + ); + + const uniquenessAttributesList = getEntityUniquenessAttributes( + targetEntity, + ); + + if (uniquenessAttributesList.length === 0) { + fields[attribute.gqlFieldName] = { + type: attribute.required + ? new GraphQLNonNull(fieldType) + : fieldType, + }; + } else { + fields[attribute.gqlFieldName] = { + type: fieldType, + }; + + const registryType = graphRegistry.types[targetTypeName]; + registryType.instanceUniquenessInputs = + registryType.instanceUniquenessInputs || {}; + + uniquenessAttributesList.map(({ uniquenessName }) => { + const fieldName = protocolConfiguration.generateUniquenessAttributesFieldName( + entity, + attribute, + uniquenessName, + ); + fields[fieldName] = { + type: registryType.instanceUniquenessInputs[uniquenessName], + }; + }); + } + } else { + const fieldType = ProtocolGraphQL.convertToProtocolDataType( + attributeType, + entity.name, + true, + ); + + fields[attribute.gqlFieldName] = { + type: new GraphQLNonNull(fieldType), + }; + } + }); + + return fields; + }, + }); + + return entityInstanceInputType; +}; + +export const generateInstanceUniquenessInputs = graphRegistry => { + _.forEach(graphRegistry.types, ({ entity }, typeName) => { + const uniquenessAttributesList = getEntityUniquenessAttributes(entity); + + const registryType = graphRegistry.types[typeName]; + registryType.instanceUniquenessInputs = + registryType.instanceUniquenessInputs || {}; + + uniquenessAttributesList.map(uniquenessAttributes => { + const instanceUniquenessInput = generateInstanceUniquenessInput( + entity, + uniquenessAttributes, + graphRegistry, + ); + registryType.instanceUniquenessInputs[ + uniquenessAttributes.uniquenessName + ] = instanceUniquenessInput; + }); + }); +}; + +export const generateSubscriptionInstanceNestedInput = ( + entity, + entitySubscription, + graphRegistry, +) => { + const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + + const typeNamePascalCase = entity.graphql.typeNamePascalCase; + + const entitySubscriptionInstanceInputType = new GraphQLInputObjectType({ + name: protocolConfiguration.generateSubscriptionInstanceNestedInputTypeName( + entity, + entitySubscription, + ), + description: `**\`${entitySubscription.name}\`** subscription input type for **\`${typeNamePascalCase}\`** using data uniqueness to resolve references`, + + fields: () => { + const fields: GraphQLInputFieldConfigMap = {}; + + const entityAttributes = entity.getAttributes(); + + _.forEach(entitySubscription.attributes, attributeName => { + const attribute = entityAttributes[attributeName]; + + let attributeType = attribute.type; + + if (isEntity(attributeType)) { + const targetEntity = attributeType; + const primaryAttribute = targetEntity.getPrimaryAttribute(); + const targetTypeName = targetEntity.graphql.typeName; + + attributeType = primaryAttribute.type; + const fieldType = ProtocolGraphQL.convertToProtocolDataType( + attributeType, + entity.name, + true, + ); + + const uniquenessAttributesList = getEntityUniquenessAttributes( + targetEntity, + ); + + if (uniquenessAttributesList.length === 0) { + fields[attribute.gqlFieldName] = { + type: + attribute.required && + !entitySubscription.ignoreRequired && + !attribute.defaultValue + ? new GraphQLNonNull(fieldType) + : fieldType, + }; + } else { + fields[attribute.gqlFieldName] = { + type: fieldType, + }; + + const registryType = graphRegistry.types[targetTypeName]; + registryType.instanceUniquenessInputs = + registryType.instanceUniquenessInputs || {}; + + uniquenessAttributesList.map(({ uniquenessName }) => { + const fieldName = protocolConfiguration.generateUniquenessAttributesFieldName( + entity, + attribute, + uniquenessName, + ); + fields[fieldName] = { + type: registryType.instanceUniquenessInputs[uniquenessName], + }; + }); + } + } else { + const fieldType = ProtocolGraphQL.convertToProtocolDataType( + attributeType, + entity.name, + true, + ); + + fields[attribute.gqlFieldName] = { + type: + attribute.required && + !entitySubscription.ignoreRequired && + !attribute.i18n && + !attribute.defaultValue + ? new GraphQLNonNull(fieldType) + : fieldType, + }; + + if (attribute.i18n) { + const i18nFieldType = generateI18nInputFieldType( + entity, + entitySubscription, + attribute, + ); + + fields[attribute.gqlFieldNameI18n] = { + type: i18nFieldType, + }; + } + } + }); + + return fields; + }, + }); + + return entitySubscriptionInstanceInputType; +}; + +export const generateSubscriptionNestedInput = ( + entity, + typeName, + entitySubscription, + entitySubscriptionInstanceUniquenessInputType, +) => { + const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + + const typeNamePascalCase = entity.graphql.typeNamePascalCase; + + const entitySubscriptionInputType = new GraphQLInputObjectType({ + name: protocolConfiguration.generateSubscriptionNestedInputTypeName( + entity, + entitySubscription, + ), + description: `Mutation input type for **\`${typeNamePascalCase}\`** using data uniqueness to resolve references`, + + fields: () => { + const fields: GraphQLInputFieldConfigMap = { + clientSubscriptionId: { + type: GraphQLString, + }, + }; + + if (entitySubscription.needsInstance) { + fields.nodeId = { + type: new GraphQLNonNull(GraphQLID), + }; + } + + if (entitySubscriptionInstanceUniquenessInputType) { + fields[typeName] = { + type: new GraphQLNonNull( + entitySubscriptionInstanceUniquenessInputType, + ), + }; + } + + return fields; + }, + }); + + return entitySubscriptionInputType; +}; + +export const generateSubscriptionOutput = ( + entity, + typeName, + type, + entitySubscription, +) => { + const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + + const typeNamePascalCase = entity.graphql.typeNamePascalCase; + + const entitySubscriptionOutputType = new GraphQLObjectType({ + name: protocolConfiguration.generateSubscriptionOutputTypeName( + entity, + entitySubscription, + ), + description: `Subscription output type for **\`${typeNamePascalCase}\`**`, + + fields: () => { + const fields: GraphQLFieldConfigMap = { + clientSubscriptionId: { + type: GraphQLString, + }, + }; + + if (entitySubscription.isTypeDelete) { + fields.deleteRowCount = { + type: new GraphQLNonNull(GraphQLInt), + description: 'Number of deleted rows', + }; + + const primaryAttribute = entity.getPrimaryAttribute(); + + if (primaryAttribute) { + const fieldName = primaryAttribute.gqlFieldName; + const fieldType = ProtocolGraphQL.convertToProtocolDataType( + primaryAttribute.type, + entity.name, + false, + ); + + fields[fieldName] = { + type: new GraphQLNonNull(fieldType), + description: primaryAttribute.description, + }; + } + } else { + fields[typeName] = { + type: new GraphQLNonNull(type), + }; + } + + return fields; + }, + }); + + return entitySubscriptionOutputType; +}; + +const extractIdFromNodeId = (graphRegistry, sourceEntityName, nodeId) => { + let instanceId; + + if (nodeId) { + const { type, id } = fromGlobalId(nodeId); + + instanceId = id; + + const entity = graphRegistry.types[type] + ? graphRegistry.types[type].entity + : null; + + if (!entity || entity.name !== sourceEntityName) { + throw new Error('Incompatible nodeId used with this mutation'); + } + } + + return instanceId; +}; + +export const generateSubscriptions = graphRegistry => { + const protocolConfiguration = ProtocolGraphQL.getProtocolConfiguration() as ProtocolGraphQLConfiguration; + const subscriptions = {}; + + generateInstanceUniquenessInputs(graphRegistry); + + _.forEach(graphRegistry.types, ({ type, entity }, typeName) => { + if (!entity.getSubscriptions) { + return; + } + + // console.log('generateSubscriptions', { type, typeName }); + + const entitySubscriptions = entity.getSubscriptions(); + + if (!entitySubscriptions || entitySubscriptions.length < 1) { + return; + } + + entitySubscriptions.map(entitySubscription => { + const subscriptionName = protocolConfiguration.generateSubscriptionTypeName( + entity, + entitySubscription, + ); + + let entitySubscriptionInstanceInputType; + + if ( + entitySubscription.attributes && + entitySubscription.attributes.length + ) { + entitySubscriptionInstanceInputType = generateSubscriptionInstanceInput( + entity, + entitySubscription, + ); + } + + const subscriptionInputType = generateSubscriptionInput( + entity, + typeName, + entitySubscription, + entitySubscriptionInstanceInputType, + ); + const subscriptionOutputType = generateSubscriptionOutput( + entity, + typeName, + type, + entitySubscription, + ); + + subscriptions[subscriptionName] = { + type: subscriptionOutputType, + description: entitySubscription.description, + args: { + input: { + description: 'Input argument for this subscription', + type: new GraphQLNonNull(subscriptionInputType), + }, + }, + subscribe: getSubscriptionResolver( + entity, + entitySubscription, + typeName, + false, + ({ args }) => + extractIdFromNodeId(graphRegistry, entity.name, args.input.nodeId), + ), + resolve: getSubscriptionPayloadResolver( + entity, + entitySubscription, + typeName, + ), + }; + + if (entitySubscription.isTypeCreate || entitySubscription.isTypeUpdate) { + const subscriptionNestedName = protocolConfiguration.generateSubscriptionNestedTypeName( + entity, + entitySubscription, + ); + + let entitySubscriptionInstanceNestedInputType; + + if ( + entitySubscription.attributes && + entitySubscription.attributes.length + ) { + entitySubscriptionInstanceNestedInputType = generateSubscriptionInstanceNestedInput( + entity, + entitySubscription, + graphRegistry, + ); + } + + const subscriptionInputNestedType = generateSubscriptionNestedInput( + entity, + typeName, + entitySubscription, + entitySubscriptionInstanceNestedInputType, + ); + subscriptions[subscriptionNestedName] = { + type: subscriptionOutputType, + description: entitySubscription.description, + args: { + input: { + description: 'Input argument for this subscription', + type: new GraphQLNonNull(subscriptionInputNestedType), + }, + }, + subscribe: getSubscriptionResolver( + entity, + entitySubscription, + typeName, + true, + ({ args }) => + extractIdFromNodeId( + graphRegistry, + entity.name, + args.input.nodeId, + ), + ), + resolve: getSubscriptionPayloadResolver( + entity, + entitySubscription, + typeName, + ), + }; + } + + if (entitySubscription.needsInstance) { + const primaryAttribute = entity.getPrimaryAttribute(); + + if (primaryAttribute) { + const fieldName = primaryAttribute.gqlFieldName; + const subscriptionByPrimaryAttributeInputType = generateSubscriptionByPrimaryAttributeInput( + entity, + typeName, + entitySubscription, + entitySubscriptionInstanceInputType, + primaryAttribute, + ); + const subscriptionByPrimaryAttributeName = protocolConfiguration.generateSubscriptionByPrimaryAttributeTypeName( + entity, + entitySubscription, + primaryAttribute, + ); + + subscriptions[subscriptionByPrimaryAttributeName] = { + type: subscriptionOutputType, + description: entitySubscription.description, + args: { + input: { + description: 'Input argument for this subscription', + type: new GraphQLNonNull( + subscriptionByPrimaryAttributeInputType, + ), + }, + }, + subscribe: getSubscriptionResolver( + entity, + entitySubscription, + typeName, + false, + ({ args }) => args.input[fieldName], + ), + resolve: getSubscriptionPayloadResolver( + entity, + entitySubscription, + typeName, + ), + }; + } + } + }); + }); + + return subscriptions; +}; diff --git a/src/index.ts b/src/index.ts index ae5a9449..e0a6b1c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,13 @@ import { MUTATION_TYPE_UPDATE, MUTATION_TYPE_DELETE, } from './engine/mutation/Mutation'; +import { + Subscription, + SUBSCRIPTION_TYPE_CREATE, + SUBSCRIPTION_TYPE_UPDATE, + SUBSCRIPTION_TYPE_DELETE, + pubsub, +} from './engine/subscription/Subscription'; import { Permission, checkPermissionSimple, @@ -217,6 +224,11 @@ export { MUTATION_TYPE_CREATE, MUTATION_TYPE_UPDATE, MUTATION_TYPE_DELETE, + Subscription, + SUBSCRIPTION_TYPE_CREATE, + SUBSCRIPTION_TYPE_UPDATE, + SUBSCRIPTION_TYPE_DELETE, + pubsub, Permission, checkPermissionSimple, buildPermissionFilter, diff --git a/yarn.lock b/yarn.lock index 7a2e693a..0d8ed580 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2861,6 +2861,13 @@ graphql-relay@^0.6.0: dependencies: prettier "^1.16.0" +graphql-subscriptions@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-1.1.0.tgz#5f2fa4233eda44cf7570526adfcf3c16937aef11" + integrity sha512-6WzlBFC0lWmXJbIVE8OgFgXIP4RJi3OQgTPa0DVMsDXdpRDjTsM1K9wfl5HSYX7R87QAGlvcv2Y4BIZa/ItonA== + dependencies: + iterall "^1.2.1" + graphql-type-json@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/graphql-type-json/-/graphql-type-json-0.3.0.tgz#bb32e1b74bf52ebc690f9df12b4067bc061f818a" @@ -3404,6 +3411,11 @@ istanbul-reports@^3.0.0: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +iterall@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" + integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== + iterall@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7"