diff --git a/lib/process-services-cloud/.storybook/tsconfig.json b/lib/process-services-cloud/.storybook/tsconfig.json index 83c6a0ca455..30f9f5e443a 100644 --- a/lib/process-services-cloud/.storybook/tsconfig.json +++ b/lib/process-services-cloud/.storybook/tsconfig.json @@ -6,5 +6,5 @@ }, "exclude": ["../**/*.spec.ts" ], - "include": ["../src/**/*", "*.js"] + "include": ["../src/**/*", "*.js", "../../core/feature-flags"] } diff --git a/lib/process-services-cloud/src/lib/services/notification-cloud.service.spec.ts b/lib/process-services-cloud/src/lib/services/notification-cloud.service.spec.ts index 9374153402f..84b539eebba 100644 --- a/lib/process-services-cloud/src/lib/services/notification-cloud.service.spec.ts +++ b/lib/process-services-cloud/src/lib/services/notification-cloud.service.spec.ts @@ -16,19 +16,19 @@ */ import { TestBed } from '@angular/core/testing'; -import { ProcessServiceCloudTestingModule } from '../testing/process-service-cloud.testing.module'; import { NotificationCloudService } from './notification-cloud.service'; +import { provideMockFeatureFlags } from '@alfresco/adf-core/feature-flags'; +import { WebSocketService } from './web-socket.service'; import { Apollo } from 'apollo-angular'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { AuthenticationService } from '@alfresco/adf-core'; +import { Subject } from 'rxjs'; describe('NotificationCloudService', () => { let service: NotificationCloudService; - let apollo: Apollo; - let apolloCreateSpy: jasmine.Spy; - let apolloSubscribeSpy: jasmine.Spy; - - const useMock: any = { - subscribe: () => {} - }; + let wsService: WebSocketService; + const apolloMock = jasmine.createSpyObj('Apollo', ['use', 'createNamed']); + const onLogoutSubject: Subject = new Subject(); const queryMock = ` subscription { @@ -43,39 +43,39 @@ describe('NotificationCloudService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ProcessServiceCloudTestingModule] + imports: [HttpClientTestingModule], + providers: [ + WebSocketService, + provideMockFeatureFlags({ ['studio-ws-graphql-subprotocol']: false }), + { + provide: Apollo, + useValue: apolloMock + }, + { + provide: AuthenticationService, + useValue: { + getToken: () => 'testToken', + onLogout: onLogoutSubject.asObservable() + } + } + ] }); service = TestBed.inject(NotificationCloudService); - apollo = TestBed.inject(Apollo); - - service.appsListening = []; - apolloCreateSpy = spyOn(apollo, 'createNamed'); - apolloSubscribeSpy = spyOn(apollo, 'use').and.returnValue(useMock); + wsService = TestBed.inject(WebSocketService); }); - it('should not create more than one websocket per app if it was already created', () => { - service.makeGQLQuery('myAppName', queryMock); - expect(service.appsListening.length).toBe(1); - expect(service.appsListening[0]).toBe('myAppName'); - - service.makeGQLQuery('myAppName', queryMock); - expect(service.appsListening.length).toBe(1); - expect(service.appsListening[0]).toBe('myAppName'); - - expect(apolloCreateSpy).toHaveBeenCalledTimes(1); - expect(apolloSubscribeSpy).toHaveBeenCalledTimes(2); - }); + it('should call getSubscription with the correct parameters', () => { + const getSubscriptionSpy = spyOn(wsService, 'getSubscription').and.callThrough(); - it('should create new websocket if it is subscribing to new app', () => { service.makeGQLQuery('myAppName', queryMock); - expect(service.appsListening.length).toBe(1); - expect(service.appsListening[0]).toBe('myAppName'); - - service.makeGQLQuery('myOtherAppName', queryMock); - expect(service.appsListening.length).toBe(2); - expect(service.appsListening[1]).toBe('myOtherAppName'); - expect(apolloCreateSpy).toHaveBeenCalledTimes(2); - expect(apolloSubscribeSpy).toHaveBeenCalledTimes(2); + expect(getSubscriptionSpy).toHaveBeenCalledWith({ + apolloClientName: 'myAppName', + wsUrl: 'myAppName/notifications', + httpUrl: 'myAppName/notifications/graphql', + subscriptionOptions: { + query: jasmine.any(Object) + } + }); }); }); diff --git a/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts b/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts index afb1cd3bbef..3aa5fd2d652 100644 --- a/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts +++ b/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts @@ -15,101 +15,23 @@ * limitations under the License. */ -import { Apollo } from 'apollo-angular'; -import { HttpLink } from 'apollo-angular/http'; -import { split, gql, InMemoryCache, ApolloLink, InMemoryCacheConfig } from '@apollo/client/core'; -import { WebSocketLink } from '@apollo/client/link/ws'; -import { onError } from '@apollo/client/link/error'; -import { getMainDefinition } from '@apollo/client/utilities'; +import { gql } from '@apollo/client/core'; import { Injectable } from '@angular/core'; -import { AuthenticationService } from '@alfresco/adf-core'; -import { BaseCloudService } from './base-cloud.service'; -import { AdfHttpClient } from '@alfresco/adf-core/api'; - +import { WebSocketService } from './web-socket.service'; @Injectable({ providedIn: 'root' }) -export class NotificationCloudService extends BaseCloudService { - appsListening = []; - - constructor(public apollo: Apollo, private http: HttpLink, private authService: AuthenticationService, protected adfHttpClient: AdfHttpClient) { - super(adfHttpClient); - } - - private get webSocketHost() { - return this.contextRoot.split('://')[1]; - } - - private get protocol() { - return this.contextRoot.split('://')[0] === 'https' ? 'wss' : 'ws'; - } - - initNotificationsForApp(appName: string) { - if (!this.appsListening.includes(appName)) { - this.appsListening.push(appName); - const httpLink = this.http.create({ - uri: `${this.getBasePath(appName)}/notifications/graphql` - }); - - const webSocketLink = new WebSocketLink({ - uri: `${this.protocol}://${this.webSocketHost}/${appName}/notifications/ws/graphql`, - options: { - reconnect: true, - lazy: true, - connectionParams: { - kaInterval: 2000, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'X-Authorization': 'Bearer ' + this.authService.getToken() - } - } - }); - - const link = split( - ({ query }) => { - const definition = getMainDefinition(query); - return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'; - }, - webSocketLink, - httpLink - ); - - const errorLink = onError(({ graphQLErrors, operation, forward }) => { - if (graphQLErrors) { - for (const err of graphQLErrors) { - switch (err.extensions.code) { - case 'UNAUTHENTICATED': { - const oldHeaders = operation.getContext().headers; - operation.setContext({ - headers: { - ...oldHeaders, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'X-Authorization': 'Bearer ' + this.authService.getToken() - } - }); - forward(operation); - break; - } - default: - break; - } - } - } - }); - - this.apollo.createNamed(appName, { - link: ApolloLink.from([errorLink, link]), - cache: new InMemoryCache({ merge: true } as InMemoryCacheConfig), - defaultOptions: { - watchQuery: { - errorPolicy: 'all' - } - } - }); - } - } +export class NotificationCloudService { + constructor(private readonly webSocketService: WebSocketService) {} makeGQLQuery(appName: string, gqlQuery: string) { - this.initNotificationsForApp(appName); - return this.apollo.use(appName).subscribe({ query: gql(gqlQuery) }); + return this.webSocketService.getSubscription({ + apolloClientName: appName, + wsUrl: `${appName}/notifications`, + httpUrl: `${appName}/notifications/graphql`, + subscriptionOptions: { + query: gql(gqlQuery) + } + }); } } diff --git a/lib/process-services-cloud/src/lib/services/public-api.ts b/lib/process-services-cloud/src/lib/services/public-api.ts index 5da40b1da78..40684e03a66 100644 --- a/lib/process-services-cloud/src/lib/services/public-api.ts +++ b/lib/process-services-cloud/src/lib/services/public-api.ts @@ -24,3 +24,4 @@ export * from './form-fields.interfaces'; export * from './base-cloud.service'; export * from './task-list-cloud.service.interface'; export * from './variable-mapper.sevice'; +export * from './web-socket.service'; diff --git a/lib/process-services-cloud/src/lib/services/web-socket.service.spec.ts b/lib/process-services-cloud/src/lib/services/web-socket.service.spec.ts new file mode 100644 index 00000000000..1b28e7c4214 --- /dev/null +++ b/lib/process-services-cloud/src/lib/services/web-socket.service.spec.ts @@ -0,0 +1,136 @@ +/*! + * @license + * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { Apollo, gql } from 'apollo-angular'; +import { lastValueFrom, of, Subject } from 'rxjs'; +import { WebSocketService } from './web-socket.service'; +import { SubscriptionOptions } from '@apollo/client/core'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { AuthenticationService, AppConfigService } from '@alfresco/adf-core'; +import { FeaturesServiceToken } from '@alfresco/adf-core/feature-flags'; + +describe('WebSocketService', () => { + let service: WebSocketService; + const onLogoutSubject: Subject = new Subject(); + + const apolloMock = jasmine.createSpyObj('Apollo', ['use', 'createNamed']); + const isOnSpy = jasmine.createSpy('isOn$'); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { + provide: Apollo, + useValue: apolloMock + }, + { + provide: AppConfigService, + useValue: { + get: () => 'wss://testHost' + } + }, + { + provide: AuthenticationService, + useValue: { + getToken: () => 'testToken', + onLogout: onLogoutSubject.asObservable() + } + }, + { + provide: FeaturesServiceToken, + useValue: { + isOn$: isOnSpy + } + } + ] + }); + service = TestBed.inject(WebSocketService); + TestBed.inject(FeaturesServiceToken); + apolloMock.use.and.returnValues(undefined, { subscribe: () => of({}) }); + isOnSpy.and.returnValues(of(true)); + }); + + afterEach(() => { + apolloMock.use.calls.reset(); + apolloMock.createNamed.calls.reset(); + }); + + it('should not create a new Apollo client if it is already in use', async () => { + const apolloClientName = 'testClient'; + const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) }; + const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions }; + + apolloMock.use.and.returnValues(true, { subscribe: () => of({}) }); + + await lastValueFrom(service.getSubscription(wsOptions)); + + expect(apolloMock.use).toHaveBeenCalledTimes(2); + expect(apolloMock.use).toHaveBeenCalledWith(apolloClientName); + expect(apolloMock.createNamed).not.toHaveBeenCalled(); + }); + + it('should subscribe to Apollo client if not already in use', async () => { + const apolloClientName = 'testClient'; + const expectedApolloClientName = 'testClient'; + const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) }; + const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions }; + + await lastValueFrom(service.getSubscription(wsOptions)); + + expect(apolloMock.use).toHaveBeenCalledWith(expectedApolloClientName); + expect(apolloMock.use).toHaveBeenCalledTimes(2); + expect(apolloMock.createNamed).toHaveBeenCalledTimes(1); + expect(apolloMock.createNamed).toHaveBeenCalledWith(expectedApolloClientName, jasmine.any(Object)); + }); + + it('should create named client with the right authentication token when FF is on', async () => { + let headers = {}; + const expectedHeaders = { Authorization: 'Bearer testToken' }; + const apolloClientName = 'testClient'; + const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) }; + const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions }; + apolloMock.createNamed.and.callFake((_, options) => { + headers = options.headers; + }); + + await lastValueFrom(service.getSubscription(wsOptions)); + + expect(apolloMock.use).toHaveBeenCalledTimes(2); + expect(apolloMock.createNamed).toHaveBeenCalled(); + expect(headers).toEqual(expectedHeaders); + }); + + it('should create named client with the right authentication token when FF is off', async () => { + isOnSpy.and.returnValues(of(false)); + let headers = {}; + const expectedHeaders = { 'X-Authorization': 'Bearer testToken' }; + const apolloClientName = 'testClient'; + const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) }; + const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions }; + apolloMock.createNamed.and.callFake((_, options) => { + headers = options.headers; + }); + + await lastValueFrom(service.getSubscription(wsOptions)); + + expect(apolloMock.use).toHaveBeenCalledTimes(2); + expect(apolloMock.createNamed).toHaveBeenCalled(); + expect(headers).toEqual(expectedHeaders); + }); +}); diff --git a/lib/process-services-cloud/src/lib/services/web-socket.service.ts b/lib/process-services-cloud/src/lib/services/web-socket.service.ts new file mode 100644 index 00000000000..75fb71a7c29 --- /dev/null +++ b/lib/process-services-cloud/src/lib/services/web-socket.service.ts @@ -0,0 +1,217 @@ +/*! + * @license + * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createClient } from 'graphql-ws'; +import { inject, Inject, Injectable, Optional } from '@angular/core'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { WebSocketLink } from '@apollo/client/link/ws'; +import { + DefaultContext, + FetchResult, + from, + InMemoryCache, + InMemoryCacheConfig, + NextLink, + Operation, + split, + SubscriptionOptions +} from '@apollo/client/core'; +import { Observable } from 'rxjs'; +import { Apollo } from 'apollo-angular'; +import { HttpLink, HttpLinkHandler } from 'apollo-angular/http'; +import { Kind, OperationTypeNode } from 'graphql'; +import { onError } from '@apollo/client/link/error'; +import { RetryLink } from '@apollo/client/link/retry'; +import { getMainDefinition } from '@apollo/client/utilities'; +import { switchMap, take, tap } from 'rxjs/operators'; +import { AppConfigService, AuthenticationService } from '@alfresco/adf-core'; +import { FeaturesServiceToken, IFeaturesService } from '@alfresco/adf-core/feature-flags'; + +interface serviceOptions { + apolloClientName: string; + wsUrl: string; + httpUrl?: string; + subscriptionOptions: SubscriptionOptions; +} + +@Injectable({ + providedIn: 'root' +}) +export class WebSocketService { + private appConfigService = inject(AppConfigService); + private subscriptionProtocol: 'graphql-ws' | 'transport-ws' = 'transport-ws'; + private wsLink: GraphQLWsLink | WebSocketLink; + private httpLinkHandler: HttpLinkHandler; + + constructor( + private readonly apollo: Apollo, + private readonly httpLink: HttpLink, + private readonly authService: AuthenticationService, + @Optional() @Inject(FeaturesServiceToken) private featuresService: IFeaturesService + ) {} + + public getSubscription(options: serviceOptions): Observable> { + const { apolloClientName, subscriptionOptions } = options; + this.authService.onLogout.pipe(take(1)).subscribe(() => { + if (this.apollo.use(apolloClientName)) { + this.apollo.removeClient(apolloClientName); + } + }); + + return this.featuresService.isOn$('studio-ws-graphql-subprotocol').pipe( + tap((isOn) => { + if (isOn) { + this.subscriptionProtocol = 'graphql-ws'; + } + }), + switchMap(() => { + if (this.apollo.use(apolloClientName) === undefined) { + this.initSubscriptions(options); + } + return this.apollo.use(apolloClientName).subscribe({ errorPolicy: 'all', ...subscriptionOptions }); + }) + ); + } + + private get contextRoot() { + return this.appConfigService.get('bpmHost', ''); + } + + private createWsUrl(serviceUrl: string): string { + const url = new URL(serviceUrl, this.contextRoot); + const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.protocol = protocol; + + return url.href; + } + + private createHttpUrl(serviceUrl: string): string { + const url = new URL(serviceUrl, this.contextRoot); + + return url.href; + } + + private initSubscriptions(options: serviceOptions): void { + switch (this.subscriptionProtocol) { + case 'graphql-ws': + this.createGraphQLWsLink(options); + break; + case 'transport-ws': + this.createTransportWsLink(options); + break; + default: + throw new Error('Unknown subscription protocol'); + } + + this.createHttpLinkHandler(options); + + const link = split( + ({ query }) => { + const definition = getMainDefinition(query); + return definition.kind === Kind.OPERATION_DEFINITION && definition.operation === OperationTypeNode.SUBSCRIPTION; + }, + this.wsLink, + this.httpLinkHandler + ); + + const authLink = (operation: Operation, forward: NextLink) => { + operation.setContext(({ headers }: DefaultContext) => ({ + headers: { + ...headers, + ...(this.subscriptionProtocol === 'graphql-ws' && { Authorization: `Bearer ${this.authService.getToken()}` }), + ...(this.subscriptionProtocol === 'transport-ws' && { 'X-Authorization': `Bearer ${this.authService.getToken()}` }) + } + })); + return forward(operation); + }; + + const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => { + if (graphQLErrors) { + for (const error of graphQLErrors) { + if (error.extensions && error.extensions['code'] === 'UNAUTHENTICATED') { + authLink(operation, forward); + } + } + } + + if (networkError) { + console.error(`[Network error]: ${networkError}`); + } + }); + + const retryLink = new RetryLink({ + delay: { + initial: 300, + max: Number.POSITIVE_INFINITY, + jitter: true + }, + attempts: { + max: 5, + retryIf: (error) => !!error + } + }); + + this.apollo.createNamed(options.apolloClientName, { + headers: { + ...(this.subscriptionProtocol === 'graphql-ws' && { Authorization: `Bearer ${this.authService.getToken()}` }), + ...(this.subscriptionProtocol === 'transport-ws' && { 'X-Authorization': `Bearer ${this.authService.getToken()}` }) + }, + link: from([authLink, retryLink, errorLink, link]), + cache: new InMemoryCache({ merge: true } as InMemoryCacheConfig) + }); + } + + private createTransportWsLink(options: serviceOptions): void { + this.wsLink = new WebSocketLink({ + uri: this.createWsUrl(options.wsUrl) + '/ws/graphql', + options: { + reconnect: true, + lazy: true, + connectionParams: { + kaInterval: 2000, + 'X-Authorization': 'Bearer ' + this.authService.getToken() + } + } + }); + } + + private createGraphQLWsLink(options: serviceOptions): void { + this.wsLink = new GraphQLWsLink( + createClient({ + url: this.createWsUrl(options.wsUrl) + '/v2/ws/graphql', + connectionParams: { + Authorization: 'Bearer ' + this.authService.getToken() + }, + on: { + error: () => { + this.apollo.removeClient(options.apolloClientName); + this.initSubscriptions(options); + } + }, + lazy: true + }) + ); + } + + private createHttpLinkHandler(options: serviceOptions): void { + this.httpLinkHandler = options.httpUrl + ? this.httpLink.create({ + uri: this.createHttpUrl(options.httpUrl) + }) + : undefined; + } +} diff --git a/package-lock.json b/package-lock.json index 393fd2d89b6..3f231a22537 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "date-fns": "^2.30.0", "dotenv-expand": "^5.1.0", "event-emitter": "^0.3.5", + "graphql-ws": "^5.16.0", "material-icons": "^1.13.12", "minimatch-browser": "1.0.0", "ng2-charts": "^4.1.1", @@ -115,7 +116,7 @@ "eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-storybook": "^0.11.1", "eslint-plugin-unicorn": "^49.0.0", - "graphql": "^16.8.1", + "graphql": "^16.9.0", "husky": "^7.0.4", "jasmine-ajax": "4.0.0", "jasmine-core": "5.4.0", @@ -19221,7 +19222,8 @@ }, "node_modules/graphql": { "version": "16.9.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -19239,6 +19241,20 @@ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, + "node_modules/graphql-ws": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.16.0.tgz", + "integrity": "sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==", + "workspaces": [ + "website" + ], + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/guess-parser": { "version": "0.4.22", "dev": true, diff --git a/package.json b/package.json index 7ac89354d0c..5ae67dcdd2e 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "date-fns": "^2.30.0", "dotenv-expand": "^5.1.0", "event-emitter": "^0.3.5", + "graphql-ws": "^5.16.0", "material-icons": "^1.13.12", "minimatch-browser": "1.0.0", "ng2-charts": "^4.1.1", @@ -135,7 +136,7 @@ "eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-storybook": "^0.11.1", "eslint-plugin-unicorn": "^49.0.0", - "graphql": "^16.8.1", + "graphql": "^16.9.0", "husky": "^7.0.4", "jasmine-ajax": "4.0.0", "jasmine-core": "5.4.0",