diff --git a/packages/ra-data-graphql-simple/README.md b/packages/ra-data-graphql-simple/README.md index bbbaefd6e15..3871f51ddbb 100644 --- a/packages/ra-data-graphql-simple/README.md +++ b/packages/ra-data-graphql-simple/README.md @@ -253,6 +253,63 @@ This can increase efficiency, optimize client performance, improve security and Your GraphQL backend may not allow multiple deletions or updates in a single query. This provider defaults to simply making multiple requests to handle those. This is obviously not ideal but can be alleviated by supplying your own `ApolloClient` which could use the [apollo-link-batch-http](https://www.apollographql.com/docs/link/links/batch-http.html) link if your GraphQL backend support query batching. +## Data Provider Extensions + +Your GraphQL backend may support functionality that extends beyond the default Data Provider methods. One such example of this would be implementing GraphQL subscriptions and integrating [ra-realtime](https://marmelab.com/ra-enterprise/modules/ra-realtime) to power realtime updates in your React-Admin application. The extensions pattern allows you to expand the Data Provider methods to power this additional functionality. A Data Provider Extention is defined by the following type: + +```js +type DataProviderExtension = { + methodFactory: ( + dataProvider: DataProvider, + ...args: any[] + ) => { [k: string]: DataProviderMethod }; + factoryArgs?: any[]; + introspectionOperationNames?: IntrospectionOptions['operationNames']; +} +``` + +The `methodFactory` is a required function attribute that generates the additional Data Provider methods. It always receives the dataProvider as it's first argument. Arguments defined in the factoryArgs optional attribute will also be passed into `methodFactory`. `introspectionOperationNames` is an optional object attribute that allows you to inform React-Admin hooks and UI components of how these methods map to the GraphQL schema. + +### Realtime Extension + +`ra-data-graphql-simple` comes with a Realtime Data Provider Extension out of the box. If your app uses [ra-realtime](https://marmelab.com/ra-enterprise/modules/ra-realtime), you can drop in the Realtime Extension and light up realtime events in no time. Here is an example integration: + +```js +// in App.js +import React from 'react'; +import { Component } from 'react'; +import buildGraphQLProvider, { defaultOptions, DataProviderExtensions } from 'ra-data-graphql-simple'; +import { Admin, Resource } from 'react-admin'; + +import { PostCreate, PostEdit, PostList } from './posts'; + +const dPOptions = { + clientOptions: { uri: 'http://localhost:4000' }, + extensions: [DataProviderExtensions.Realtime] +} + +const App = () => { + + const [dataProvider, setDataProvider] = React.useState(null); + React.useEffect(() => { + buildGraphQLProvider(dPOptions) + .then(graphQlDataProvider => setDataProvider(() => graphQlDataProvider)); + }, []); + + if (!dataProvider) { + return
Loading < /div>; + } + + return ( + + + + ); +} + +export default App; +``` + ## Contributing Run the tests with this command: diff --git a/packages/ra-data-graphql-simple/src/extensions/index.ts b/packages/ra-data-graphql-simple/src/extensions/index.ts new file mode 100644 index 00000000000..7bbb6df8203 --- /dev/null +++ b/packages/ra-data-graphql-simple/src/extensions/index.ts @@ -0,0 +1,19 @@ +import { DataProvider } from 'ra-core'; +import { IntrospectionOptions } from 'ra-data-graphql'; + +import { RealtimeExtension } from './realtime'; + +type DataProviderMethod = (...args: any[]) => Promise<{ data: any }>; + +export type DataProviderExtension = { + methodFactory: ( + dataProvider: DataProvider, + ...args: any[] + ) => { [k: string]: DataProviderMethod }; + factoryArgs?: any[]; + introspectionOperationNames?: IntrospectionOptions['operationNames']; +}; + +export class DataProviderExtensions { + static Realtime = RealtimeExtension; +} diff --git a/packages/ra-data-graphql-simple/src/extensions/realtime.test.ts b/packages/ra-data-graphql-simple/src/extensions/realtime.test.ts new file mode 100644 index 00000000000..85726ab74d9 --- /dev/null +++ b/packages/ra-data-graphql-simple/src/extensions/realtime.test.ts @@ -0,0 +1,170 @@ +import { print } from 'graphql'; + +import buildDataProvider from '../'; +import { DataProviderExtensions } from './index'; +import { topicToGQLSubscribe, SUBSCRIBE_LIST, SUBSCRIBE_ONE } from './realtime'; + +describe('Realtime Data Provider Extension', () => { + const resource: any = { name: 'Command' }; + + it('correctly defines methodFactory', async () => { + const dataProvider = await buildDataProvider({ + clientOptions: { uri: 'http://localhost:4000' }, + }); + expect( + DataProviderExtensions.Realtime.methodFactory(dataProvider) + .subscribe + ).toBeDefined(); + expect( + DataProviderExtensions.Realtime.methodFactory(dataProvider) + .unsubscribe + ).toBeDefined(); + }); + + it('correctly defines introspectionOperationNames', async () => { + expect( + DataProviderExtensions.Realtime.introspectionOperationNames?.[ + SUBSCRIBE_LIST + ] + ).toBeDefined(); + expect( + DataProviderExtensions.Realtime.introspectionOperationNames?.[ + SUBSCRIBE_ONE + ] + ).toBeDefined(); + + expect( + DataProviderExtensions.Realtime.introspectionOperationNames?.[ + SUBSCRIBE_LIST + ](resource) + ).toBe('allCommands'); + expect( + DataProviderExtensions.Realtime.introspectionOperationNames?.[ + SUBSCRIBE_ONE + ](resource) + ).toBe('Command'); + }); + + describe('topicToGQLSubscribe', () => { + it('correctly converts a subscribe list topic to a GQL subscribe query', async () => { + const { query, variables, queryName } = topicToGQLSubscribe( + 'resource/Command' + ); + expect(variables).toEqual({}); + expect(queryName).toBe('allCommands'); + expect(print(query)).toEqual( + `subscription allCommands { + allCommands { + topic + event + } +} +` + ); + }); + + it('correctly converts a subscribe one topic to a GQL subscribe query', async () => { + const { query, variables, queryName } = topicToGQLSubscribe( + 'resource/Command/1' + ); + expect(variables).toEqual({ id: '1' }); + expect(queryName).toBe('Command'); + expect(print(query)).toEqual( + `subscription Command($id: ID!) { + Command(id: $id) { + topic + event + } +} +` + ); + }); + }); + + describe('subscription management', () => { + it('correctly subscribes to multiple topics', async () => { + const subscriptionStore: any[] = []; + const dataProvider = await buildDataProvider({ + clientOptions: { uri: 'http://localhost:4000' }, + extensions: [ + { + ...DataProviderExtensions.Realtime, + factoryArgs: [subscriptionStore], + }, + ], + }); + + const topic1 = 'resource/Command'; + const callback1 = () => {}; + const topic2 = 'resource/Command/1'; + const callback2 = () => {}; + + await dataProvider.subscribe(topic1, callback1); + await dataProvider.subscribe(topic2, callback2); + + expect(subscriptionStore).toHaveLength(2); + + expect(subscriptionStore[0].topic).toBe(topic1); + expect(subscriptionStore[0].subscription).toBeDefined(); + expect(subscriptionStore[0].subscriptionCallback).toBe(callback1); + + expect(subscriptionStore[1].topic).toBe(topic2); + expect(subscriptionStore[1].subscription).toBeDefined(); + expect(subscriptionStore[1].subscriptionCallback).toBe(callback2); + }); + + it('correctly unsubscribes from a topic', async () => { + const subscriptionStore: any[] = []; + const dataProvider = await buildDataProvider({ + clientOptions: { uri: 'http://localhost:4000' }, + extensions: [ + { + ...DataProviderExtensions.Realtime, + factoryArgs: [subscriptionStore], + }, + ], + }); + + const callback1 = () => {}; + const callback2 = () => {}; + const callback3 = () => {}; + const callback4 = () => {}; + + const subscriptions = [ + ['resource/Command', callback1], + ['resource/Command/1', callback2], + ['resource/Command/1', callback3], + ['resource/Command/2', callback4], + ]; + + subscriptions.forEach( + async s => await dataProvider.subscribe(...s) + ); + + expect(subscriptionStore).toHaveLength(4); + + while (subscriptions.length) { + const randomI = Math.floor( + Math.random() * subscriptions.length + ); + const subscription = subscriptions.splice(randomI, 1)[0]; + + await dataProvider.unsubscribe(...subscription); + + expect(subscriptionStore).toHaveLength(subscriptions.length); + + subscriptions.forEach(([topic, callback]) => { + const subscription = subscriptionStore.find( + s => + s.topic === topic && + s.subscriptionCallback === callback + ); + + expect(subscription).toBeDefined(); + expect(subscription.topic).toBe(topic); + expect(subscription.subscriptionCallback).toBe(callback); + }); + } + }); + }); +}); diff --git a/packages/ra-data-graphql-simple/src/extensions/realtime.ts b/packages/ra-data-graphql-simple/src/extensions/realtime.ts new file mode 100644 index 00000000000..e3eaec5c906 --- /dev/null +++ b/packages/ra-data-graphql-simple/src/extensions/realtime.ts @@ -0,0 +1,126 @@ +import { OperationVariables, SubscriptionOptions, gql } from '@apollo/client'; + +import pluralize from 'pluralize'; + +import { DataProviderExtension } from '.'; +import { DataProvider } from 'ra-core'; + +// Below is based off @react-admin/ra-realtime expectations + +export const SUBSCRIBE_LIST = 'SUBSCRIBE_LIST'; +export const SUBSCRIBE_ONE = 'SUBSCRIBE_ONE'; + +const resourceToSubscribeList = (resource: string) => + `all${pluralize(resource)}`; +const resourceToSubscribeOne = (resource: string) => resource; + +const introspectionOperationNames = { + [SUBSCRIBE_LIST]: resource => resourceToSubscribeList(resource.name), + [SUBSCRIBE_ONE]: resource => resourceToSubscribeOne(resource.name), +}; + +export const topicToGQLSubscribe = ( + topic: string +): SubscriptionOptions & { queryName: string } => { + // Two possible topic patterns (from react admin) + // 1. resource/${resource} + // 2. resource/${resource}/${id} + + let raCRUDTopic = topic.startsWith('resource/') ? topic.split('/') : null; + + // TODO handle non crud topics + if (!raCRUDTopic) return { query: gql``, queryName: '', variables: {} }; + + let query; + let variables = {}; + let queryName; + const resource = raCRUDTopic[1]; + + if (raCRUDTopic.length === 2) { + // list subscription + + queryName = resourceToSubscribeList(resource); + + query = gql` + subscription ${queryName} { + ${queryName}{ + topic + event + } + } + `; + } else { + // single resource subscription + queryName = resourceToSubscribeOne(resource); + + query = gql` + subscription ${queryName}($id: ID!) { + ${queryName}(id: $id){ + topic + event + } + } + `; + + variables = { id: raCRUDTopic[2] }; + } + + return { + query, + variables, + queryName, + }; +}; + +type Subscription = { + topic: string; + subscription: any; + subscriptionCallback: any; +}; + +const methodFactory = ( + dataProvider: DataProvider, + subscriptionStore: Subscription[] = [] +) => { + return { + subscribe: async (topic: string, subscriptionCallback: any) => { + const { queryName, ...subscribeOptions } = topicToGQLSubscribe( + topic + ); + const subscription = dataProvider.client + .subscribe(subscribeOptions) + .subscribe(data => + subscriptionCallback(data.data[queryName].event) + ); + + subscriptionStore.push({ + topic, + subscription, + subscriptionCallback, + }); + + return { data: subscriptionStore }; + }, + + unsubscribe: async (topic: string, subscriptionCallback: any) => { + const indexOfSubscription = subscriptionStore.findIndex( + s => + s.topic === topic && + s.subscriptionCallback === subscriptionCallback + ); + const { subscription } = subscriptionStore.splice( + indexOfSubscription, + 1 + )[0]; + + if (subscription) subscription.unsubscribe(); + + return { data: subscriptionStore }; + }, + }; +}; + +export const RealtimeExtension: DataProviderExtension = { + methodFactory, + introspectionOperationNames, +}; diff --git a/packages/ra-data-graphql-simple/src/index.test.ts b/packages/ra-data-graphql-simple/src/index.test.ts new file mode 100644 index 00000000000..d0843388780 --- /dev/null +++ b/packages/ra-data-graphql-simple/src/index.test.ts @@ -0,0 +1,14 @@ +import buildDataProvider from './index'; +import { DataProviderExtensions } from './index'; + +describe('GraphQL data provider', () => { + it('supports Data Provider Extensions', async () => { + const dataProvider = await buildDataProvider({ + clientOptions: { uri: 'http://localhost:4000' }, + extensions: [DataProviderExtensions.Realtime], + }); + + expect(dataProvider.subscribe).toBeDefined(); + expect(dataProvider.unsubscribe).toBeDefined(); + }); +}); diff --git a/packages/ra-data-graphql-simple/src/index.ts b/packages/ra-data-graphql-simple/src/index.ts index e80bd02d0ca..5d648a043e6 100644 --- a/packages/ra-data-graphql-simple/src/index.ts +++ b/packages/ra-data-graphql-simple/src/index.ts @@ -8,6 +8,7 @@ import { DELETE_MANY, DataProvider, Identifier, UPDATE_MANY } from 'ra-core'; import pluralize from 'pluralize'; import defaultBuildQuery from './buildQuery'; +import { DataProviderExtension, DataProviderExtensions } from './extensions'; export const buildQuery = defaultBuildQuery; export { buildQueryFactory } from './buildQuery'; @@ -20,6 +21,8 @@ const defaultOptions = { buildQuery: defaultBuildQuery, }; +export { defaultOptions, DataProviderExtensions }; + const bulkActionOperationNames = { [DELETE_MANY]: resource => `delete${pluralize(resource.name)}`, [UPDATE_MANY]: resource => `update${pluralize(resource.name)}`, @@ -29,13 +32,12 @@ export default ( options: Omit & { buildQuery?: BuildQueryFactory; bulkActionsEnabled?: boolean; + extensions?: DataProviderExtension[]; } ): Promise => { - const { bulkActionsEnabled = false, ...dPOptions } = merge( - {}, - defaultOptions, - options - ); + const { bulkActionsEnabled = false, extensions = [], ...rest } = options; + + const dPOptions = merge({}, defaultOptions, rest); if (bulkActionsEnabled && dPOptions.introspection?.operationNames) dPOptions.introspection.operationNames = merge( @@ -43,6 +45,18 @@ export default ( bulkActionOperationNames ); + extensions + .filter( + ({ introspectionOperationNames }) => !!introspectionOperationNames + ) + .forEach(({ introspectionOperationNames }) => { + if (dPOptions.introspection?.operationNames) + dPOptions.introspection.operationNames = merge( + dPOptions.introspection.operationNames, + introspectionOperationNames + ); + }); + return buildDataProvider(dPOptions).then(defaultDataProvider => { return { ...defaultDataProvider, @@ -92,6 +106,13 @@ export default ( }); }, }), + ...extensions.reduce( + (acc, { methodFactory, factoryArgs = [] }) => ({ + ...acc, + ...methodFactory(defaultDataProvider, ...factoryArgs), + }), + {} + ), }; }); }; diff --git a/packages/ra-data-graphql/src/index.test.ts b/packages/ra-data-graphql/src/index.test.ts index 9d7d0b72a97..fb28a777e68 100644 --- a/packages/ra-data-graphql/src/index.test.ts +++ b/packages/ra-data-graphql/src/index.test.ts @@ -5,28 +5,29 @@ import gql from 'graphql-tag'; import buildDataProvider, { BuildQueryFactory } from './index'; describe('GraphQL data provider', () => { + const mockClient = { + mutate: async () => { + throw new ApolloError({ + graphQLErrors: [new GraphQLError('some error')], + }); + }, + }; + const mockBuildQueryFactory = () => { + return () => ({ + query: gql` + mutation { + updateMyResource { + result + } + } + `, + parseResponse: () => ({}), + }); + }; + describe('mutate', () => { describe('with error', () => { it('sets ApolloError in body', async () => { - const mockClient = { - mutate: async () => { - throw new ApolloError({ - graphQLErrors: [new GraphQLError('some error')], - }); - }, - }; - const mockBuildQueryFactory = () => { - return () => ({ - query: gql` - mutation { - updateMyResource { - result - } - } - `, - parseResponse: () => ({}), - }); - }; const dataProvider = await buildDataProvider({ client: (mockClient as unknown) as ApolloClient, introspection: false, @@ -48,4 +49,15 @@ describe('GraphQL data provider', () => { }); }); }); + + it('makes client available', async () => { + const dataProvider = await buildDataProvider({ + client: (mockClient as unknown) as ApolloClient, + introspection: false, + buildQuery: (mockBuildQueryFactory as unknown) as BuildQueryFactory, + }); + + expect(dataProvider.client).toBeDefined(); + expect(dataProvider.client.mutate).toBeDefined(); + }); }); diff --git a/packages/ra-data-graphql/src/index.ts b/packages/ra-data-graphql/src/index.ts index c83676c2182..ebab8b4d4ac 100644 --- a/packages/ra-data-graphql/src/index.ts +++ b/packages/ra-data-graphql/src/index.ts @@ -154,6 +154,8 @@ export default async (options: Options): Promise => { if (typeof name === 'symbol' || name === 'then') { return; } + if (name === 'client') return client; // make client accessible to the proxy + const raFetchMethod = RaFetchMethodMap[name]; return async (resource, params) => { if (introspection) {