mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
AAE-51358 Cleaning up websocket service from old subscription protocol (#12212)
* AAE-51358 Cleaning up websocket service from old subscription protocol * AAE-51358 Fixing retry on error bug
This commit is contained in:
@@ -16,18 +16,39 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
import { Apollo, gql } from 'apollo-angular';
|
import { Apollo, gql } from 'apollo-angular';
|
||||||
import { lastValueFrom, of, Subject } from 'rxjs';
|
import { lastValueFrom, of, Subject } from 'rxjs';
|
||||||
import { WebSocketService } from './web-socket.service';
|
import { WebSocketService } from './web-socket.service';
|
||||||
import { SubscriptionOptions } from '@apollo/client/core';
|
import { ApolloLink, execute, FetchResult, Observable as ApolloObservable, SubscriptionOptions } from '@apollo/client/core';
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { AuthenticationService, AppConfigService } from '@alfresco/adf-core';
|
import { AuthenticationService, AppConfigService } from '@alfresco/adf-core';
|
||||||
|
import { Client, ClientOptions, Sink, SubscribePayload } from 'graphql-ws';
|
||||||
|
import { HttpLink } from 'apollo-angular/http';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
class TestWebSocketService extends WebSocketService {
|
||||||
|
public capturedOnError: (() => void) | undefined;
|
||||||
|
|
||||||
|
protected override createWsClient(clientOptions: ClientOptions): Client {
|
||||||
|
this.capturedOnError = clientOptions.on?.error as (() => void) | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
on: () => () => undefined,
|
||||||
|
subscribe: (_payload: SubscribePayload, _sink: Sink) => () => undefined,
|
||||||
|
async *iterate() {},
|
||||||
|
terminate: () => undefined,
|
||||||
|
dispose: () => undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('WebSocketService', () => {
|
describe('WebSocketService', () => {
|
||||||
let service: WebSocketService;
|
let service: TestWebSocketService;
|
||||||
const onLogoutSubject: Subject<void> = new Subject<void>();
|
const onLogoutSubject: Subject<void> = new Subject<void>();
|
||||||
|
|
||||||
const apolloMock = jasmine.createSpyObj('Apollo', ['use', 'createNamed']);
|
const apolloMock = jasmine.createSpyObj('Apollo', ['use', 'createNamed', 'removeClient']);
|
||||||
|
const httpLinkMock = jasmine.createSpyObj('HttpLink', ['create']);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -37,6 +58,14 @@ describe('WebSocketService', () => {
|
|||||||
provide: Apollo,
|
provide: Apollo,
|
||||||
useValue: apolloMock
|
useValue: apolloMock
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: WebSocketService,
|
||||||
|
useClass: TestWebSocketService
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: HttpLink,
|
||||||
|
useValue: httpLinkMock
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: AppConfigService,
|
provide: AppConfigService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -52,13 +81,15 @@ describe('WebSocketService', () => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
service = TestBed.inject(WebSocketService);
|
service = TestBed.inject(WebSocketService) as TestWebSocketService;
|
||||||
apolloMock.use.and.returnValues(undefined, { subscribe: () => of({}) });
|
apolloMock.use.and.returnValues(undefined, { subscribe: () => of({}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
apolloMock.use.calls.reset();
|
apolloMock.use.calls.reset();
|
||||||
apolloMock.createNamed.calls.reset();
|
apolloMock.createNamed.calls.reset();
|
||||||
|
apolloMock.removeClient.calls.reset();
|
||||||
|
httpLinkMock.create.calls.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not create a new Apollo client if it is already in use', async () => {
|
it('should not create a new Apollo client if it is already in use', async () => {
|
||||||
@@ -95,7 +126,7 @@ describe('WebSocketService', () => {
|
|||||||
const apolloClientName = 'testClient';
|
const apolloClientName = 'testClient';
|
||||||
const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
|
const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
|
||||||
const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions };
|
const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions };
|
||||||
apolloMock.createNamed.and.callFake((_, options) => {
|
apolloMock.createNamed.and.callFake((_: any, options: { headers: {} }) => {
|
||||||
headers = options.headers;
|
headers = options.headers;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,4 +136,74 @@ describe('WebSocketService', () => {
|
|||||||
expect(apolloMock.createNamed).toHaveBeenCalled();
|
expect(apolloMock.createNamed).toHaveBeenCalled();
|
||||||
expect(headers).toEqual(expectedHeaders);
|
expect(headers).toEqual(expectedHeaders);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should recreate the subscription client when the websocket connection errors', async () => {
|
||||||
|
const apolloClientName = 'testClient';
|
||||||
|
const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
|
||||||
|
const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions };
|
||||||
|
|
||||||
|
await lastValueFrom(service.getSubscription(wsOptions));
|
||||||
|
|
||||||
|
expect(apolloMock.createNamed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apolloMock.removeClient).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
if (!service.capturedOnError) {
|
||||||
|
fail('Expected websocket error handler to be registered');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
service.capturedOnError();
|
||||||
|
|
||||||
|
expect(apolloMock.removeClient).toHaveBeenCalledWith(apolloClientName);
|
||||||
|
expect(apolloMock.createNamed).toHaveBeenCalledTimes(2);
|
||||||
|
expect(apolloMock.createNamed).toHaveBeenCalledWith(apolloClientName, jasmine.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retry the operation when a GraphQL error is unauthenticated', async () => {
|
||||||
|
const apolloClientName = 'testClient';
|
||||||
|
const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
|
||||||
|
const wsOptions = { apolloClientName, wsUrl: 'testUrl', httpUrl: 'testHttpUrl', subscriptionOptions };
|
||||||
|
const expectedResult: FetchResult = { data: { retried: true } };
|
||||||
|
let createdLink: ApolloLink | undefined;
|
||||||
|
let requestCount = 0;
|
||||||
|
|
||||||
|
httpLinkMock.create.and.returnValue(
|
||||||
|
new ApolloLink(
|
||||||
|
() =>
|
||||||
|
new ApolloObservable<FetchResult>((observer) => {
|
||||||
|
requestCount++;
|
||||||
|
|
||||||
|
if (requestCount === 1) {
|
||||||
|
observer.next({
|
||||||
|
errors: [{ message: 'Unauthorized', extensions: { code: 'UNAUTHENTICATED' } }]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
observer.next(expectedResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.complete();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
apolloMock.createNamed.and.callFake((_clientName: any, options: { link: ApolloLink | undefined }) => {
|
||||||
|
createdLink = options.link;
|
||||||
|
});
|
||||||
|
|
||||||
|
await lastValueFrom(service.getSubscription(wsOptions));
|
||||||
|
|
||||||
|
if (!createdLink) {
|
||||||
|
fail('Expected Apollo link to be created');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await new Promise<FetchResult>((resolve, reject) => {
|
||||||
|
execute(createdLink!, { query: gql(`query { testQuery }`) }).subscribe({
|
||||||
|
next: resolve,
|
||||||
|
error: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requestCount).toBe(2);
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,10 +15,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient } from 'graphql-ws';
|
import { Client, ClientOptions, createClient } from 'graphql-ws';
|
||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
||||||
import { WebSocketLink } from '@apollo/client/link/ws';
|
|
||||||
import {
|
import {
|
||||||
DefaultContext,
|
DefaultContext,
|
||||||
FetchResult,
|
FetchResult,
|
||||||
@@ -56,9 +55,8 @@ export class WebSocketService {
|
|||||||
private readonly authService = inject(AuthenticationService);
|
private readonly authService = inject(AuthenticationService);
|
||||||
|
|
||||||
private readonly appConfigService = inject(AppConfigService);
|
private readonly appConfigService = inject(AppConfigService);
|
||||||
private readonly subscriptionProtocol: 'graphql-ws' | 'transport-ws' = 'graphql-ws';
|
private wsLink!: GraphQLWsLink;
|
||||||
private wsLink: GraphQLWsLink | WebSocketLink;
|
private httpLinkHandler: HttpLinkHandler | undefined;
|
||||||
private httpLinkHandler: HttpLinkHandler;
|
|
||||||
|
|
||||||
public getSubscription<T>(options: serviceOptions): Observable<FetchResult<T>> {
|
public getSubscription<T>(options: serviceOptions): Observable<FetchResult<T>> {
|
||||||
const { apolloClientName, subscriptionOptions } = options;
|
const { apolloClientName, subscriptionOptions } = options;
|
||||||
@@ -110,8 +108,7 @@ export class WebSocketService {
|
|||||||
operation.setContext(({ headers }: DefaultContext) => ({
|
operation.setContext(({ headers }: DefaultContext) => ({
|
||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
...(this.subscriptionProtocol === 'graphql-ws' && { Authorization: `Bearer ${this.authService.getToken()}` }),
|
Authorization: `Bearer ${this.authService.getToken()}`
|
||||||
...(this.subscriptionProtocol === 'transport-ws' && { 'X-Authorization': `Bearer ${this.authService.getToken()}` })
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
return forward(operation);
|
return forward(operation);
|
||||||
@@ -120,8 +117,8 @@ export class WebSocketService {
|
|||||||
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||||
if (graphQLErrors) {
|
if (graphQLErrors) {
|
||||||
for (const error of graphQLErrors) {
|
for (const error of graphQLErrors) {
|
||||||
if (error.extensions && error.extensions['code'] === 'UNAUTHENTICATED') {
|
if (error.extensions?.['code'] === 'UNAUTHENTICATED') {
|
||||||
authLink(operation, forward);
|
return authLink(operation, forward);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +126,8 @@ export class WebSocketService {
|
|||||||
if (networkError) {
|
if (networkError) {
|
||||||
console.error(`[Network error]: ${networkError}`);
|
console.error(`[Network error]: ${networkError}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const retryLink = new RetryLink({
|
const retryLink = new RetryLink({
|
||||||
@@ -145,8 +144,7 @@ export class WebSocketService {
|
|||||||
|
|
||||||
this.apollo.createNamed(options.apolloClientName, {
|
this.apollo.createNamed(options.apolloClientName, {
|
||||||
headers: {
|
headers: {
|
||||||
...(this.subscriptionProtocol === 'graphql-ws' && { Authorization: `Bearer ${this.authService.getToken()}` }),
|
Authorization: `Bearer ${this.authService.getToken()}`
|
||||||
...(this.subscriptionProtocol === 'transport-ws' && { 'X-Authorization': `Bearer ${this.authService.getToken()}` })
|
|
||||||
},
|
},
|
||||||
link: from([authLink, retryLink, errorLink, link]),
|
link: from([authLink, retryLink, errorLink, link]),
|
||||||
cache: new InMemoryCache({ merge: true } as InMemoryCacheConfig)
|
cache: new InMemoryCache({ merge: true } as InMemoryCacheConfig)
|
||||||
@@ -155,22 +153,28 @@ export class WebSocketService {
|
|||||||
|
|
||||||
private createGraphQLWsLink(options: serviceOptions): void {
|
private createGraphQLWsLink(options: serviceOptions): void {
|
||||||
this.wsLink = new GraphQLWsLink(
|
this.wsLink = new GraphQLWsLink(
|
||||||
createClient({
|
this.createWsClient({
|
||||||
url: this.createWsUrl(options.wsUrl) + '/v2/ws/graphql',
|
url: this.createWsUrl(options.wsUrl) + '/v2/ws/graphql',
|
||||||
connectionParams: () => ({
|
connectionParams: () => ({
|
||||||
Authorization: 'Bearer ' + this.authService.getToken()
|
Authorization: 'Bearer ' + this.authService.getToken()
|
||||||
}),
|
}),
|
||||||
on: {
|
on: {
|
||||||
error: () => {
|
error: () => this.reconnect(options)
|
||||||
this.apollo.removeClient(options.apolloClientName);
|
|
||||||
this.initSubscriptions(options);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
lazy: true
|
lazy: true
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected createWsClient(clientOptions: ClientOptions): Client {
|
||||||
|
return createClient(clientOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconnect(options: serviceOptions): void {
|
||||||
|
this.apollo.removeClient(options.apolloClientName);
|
||||||
|
this.initSubscriptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
private createHttpLinkHandler(options: serviceOptions): void {
|
private createHttpLinkHandler(options: serviceOptions): void {
|
||||||
this.httpLinkHandler = options.httpUrl
|
this.httpLinkHandler = options.httpUrl
|
||||||
? this.httpLink.create({
|
? this.httpLink.create({
|
||||||
|
|||||||
Reference in New Issue
Block a user