AAE-20808 using new GraphQL library (#10454)

* AAE-20808 using new GraphQL library

* AAE-20808 Code refactoring

* AAE-20808 Improving unit tests

* AAE-20808 unit test improvement

* AAE-20808 Fixed process services storybook build
This commit is contained in:
Ehsan Rezaei
2024-12-06 15:03:13 +01:00
committed by GitHub
parent 06f16996a4
commit 44321b01c5
8 changed files with 422 additions and 129 deletions

View File

@@ -6,5 +6,5 @@
},
"exclude": ["../**/*.spec.ts" ],
"include": ["../src/**/*", "*.js"]
"include": ["../src/**/*", "*.js", "../../core/feature-flags"]
}

View File

@@ -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<void> = new Subject<void>();
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');
it('should call getSubscription with the correct parameters', () => {
const getSubscriptionSpy = spyOn(wsService, 'getSubscription').and.callThrough();
service.makeGQLQuery('myAppName', queryMock);
expect(service.appsListening.length).toBe(1);
expect(service.appsListening[0]).toBe('myAppName');
expect(apolloCreateSpy).toHaveBeenCalledTimes(1);
expect(apolloSubscribeSpy).toHaveBeenCalledTimes(2);
expect(getSubscriptionSpy).toHaveBeenCalledWith({
apolloClientName: 'myAppName',
wsUrl: 'myAppName/notifications',
httpUrl: 'myAppName/notifications/graphql',
subscriptionOptions: {
query: jasmine.any(Object)
}
});
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);
});
});

View File

@@ -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)
}
});
}
}

View File

@@ -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';

View File

@@ -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<void> = new Subject<void>();
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);
});
});

View File

@@ -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<T>(options: serviceOptions): Observable<FetchResult<T>> {
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<T>({ 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;
}
}

20
package-lock.json generated
View File

@@ -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,

View File

@@ -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",