fix issue where more than one api call was made on every route change

This commit is contained in:
Art Lowel
2023-09-22 10:23:07 +02:00
parent 404ccd9b0e
commit 5ad621b27e
7 changed files with 174 additions and 71 deletions

View File

@@ -638,4 +638,41 @@ describe('RequestService', () => {
expect(done$).toBeObservable(cold('-----(t|)', { t: true })); expect(done$).toBeObservable(cold('-----(t|)', { t: true }));
})); }));
}); });
describe('setStaleByHref', () => {
const uuid = 'c574a42c-4818-47ac-bbe1-6c3cd622c81f';
const href = 'https://rest.api/some/object';
const freshRE: any = {
request: { uuid, href },
state: RequestEntryState.Success
};
const staleRE: any = {
request: { uuid, href },
state: RequestEntryState.SuccessStale
};
it(`should call getByHref to retrieve the RequestEntry matching the href`, () => {
spyOn(service, 'getByHref').and.returnValue(observableOf(staleRE));
service.setStaleByHref(href);
expect(service.getByHref).toHaveBeenCalledWith(href);
});
it(`should dispatch a RequestStaleAction for the RequestEntry returned by getByHref`, (done: DoneFn) => {
spyOn(service, 'getByHref').and.returnValue(observableOf(staleRE));
spyOn(store, 'dispatch');
service.setStaleByHref(href).subscribe(() => {
expect(store.dispatch).toHaveBeenCalledWith(new RequestStaleAction(uuid));
done();
});
});
it(`should emit true when the request in the store is stale`, () => {
spyOn(service, 'getByHref').and.returnValue(cold('a-b', {
a: freshRE,
b: staleRE
}));
const result$ = service.setStaleByHref(href);
expect(result$).toBeObservable(cold('--(c|)', { c: true }));
});
});
}); });

View File

@@ -16,7 +16,7 @@ import {
RequestExecuteAction, RequestExecuteAction,
RequestStaleAction RequestStaleAction
} from './request.actions'; } from './request.actions';
import { GetRequest} from './request.models'; import { GetRequest } from './request.models';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions'; import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { RestRequestMethod } from './rest-request-method'; import { RestRequestMethod } from './rest-request-method';
import { coreSelector } from '../core.selectors'; import { coreSelector } from '../core.selectors';
@@ -334,6 +334,28 @@ export class RequestService {
); );
} }
/**
* Mark a request as stale
* @param href the href of the request
* @return an Observable that will emit true once the Request becomes stale
*/
setStaleByHref(href: string): Observable<boolean> {
const requestEntry$ = this.getByHref(href);
requestEntry$.pipe(
map((re: RequestEntry) => re.request.uuid),
take(1),
).subscribe((uuid: string) => {
this.store.dispatch(new RequestStaleAction(uuid));
});
return requestEntry$.pipe(
map((request: RequestEntry) => isStale(request.state)),
filter((stale: boolean) => stale),
take(1)
);
}
/** /**
* Check if a GET request is in the cache or if it's still pending * Check if a GET request is in the cache or if it's still pending
* @param {GetRequest} request The request to check * @param {GetRequest} request The request to check

View File

@@ -1,16 +1,18 @@
import { RootDataService } from './root-data.service'; import { RootDataService } from './root-data.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import {
import { Observable, of } from 'rxjs'; createSuccessfulRemoteDataObject$,
createFailedRemoteDataObject$
} from '../../shared/remote-data.utils';
import { Observable } from 'rxjs';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { Root } from './root.model'; import { Root } from './root.model';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { cold } from 'jasmine-marbles'; import { cold } from 'jasmine-marbles';
describe('RootDataService', () => { describe('RootDataService', () => {
let service: RootDataService; let service: RootDataService;
let halService: HALEndpointService; let halService: HALEndpointService;
let restService; let requestService;
let rootEndpoint; let rootEndpoint;
let findByHrefSpy; let findByHrefSpy;
@@ -19,10 +21,10 @@ describe('RootDataService', () => {
halService = jasmine.createSpyObj('halService', { halService = jasmine.createSpyObj('halService', {
getRootHref: rootEndpoint, getRootHref: rootEndpoint,
}); });
restService = jasmine.createSpyObj('halService', { requestService = jasmine.createSpyObj('requestService', [
get: jasmine.createSpy('get'), 'setStaleByHref',
}); ]);
service = new RootDataService(null, null, null, halService, restService); service = new RootDataService(requestService, null, null, halService);
findByHrefSpy = spyOn(service as any, 'findByHref'); findByHrefSpy = spyOn(service as any, 'findByHref');
findByHrefSpy.and.returnValue(createSuccessfulRemoteDataObject$({})); findByHrefSpy.and.returnValue(createSuccessfulRemoteDataObject$({}));
@@ -47,12 +49,8 @@ describe('RootDataService', () => {
let result$: Observable<boolean>; let result$: Observable<boolean>;
it('should return observable of true when root endpoint is available', () => { it('should return observable of true when root endpoint is available', () => {
const mockResponse = { spyOn(service, 'findRoot').and.returnValue(createSuccessfulRemoteDataObject$<Root>({} as any));
statusCode: 200,
statusText: 'OK'
} as RawRestResponse;
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability(); result$ = service.checkServerAvailability();
expect(result$).toBeObservable(cold('(a|)', { expect(result$).toBeObservable(cold('(a|)', {
@@ -61,12 +59,8 @@ describe('RootDataService', () => {
}); });
it('should return observable of false when root endpoint is not available', () => { it('should return observable of false when root endpoint is not available', () => {
const mockResponse = { spyOn(service, 'findRoot').and.returnValue(createFailedRemoteDataObject$<Root>('500'));
statusCode: 500,
statusText: 'Internal Server Error'
} as RawRestResponse;
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability(); result$ = service.checkServerAvailability();
expect(result$).toBeObservable(cold('(a|)', { expect(result$).toBeObservable(cold('(a|)', {
@@ -75,4 +69,12 @@ describe('RootDataService', () => {
}); });
}); });
describe(`invalidateRootCache`, () => {
it(`should set the cached root request to stale`, () => {
service.invalidateRootCache();
expect(halService.getRootHref).toHaveBeenCalled();
expect(requestService.setStaleByHref).toHaveBeenCalledWith(rootEndpoint);
});
});
}); });

View File

@@ -7,12 +7,11 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { DspaceRestService } from '../dspace-rest/dspace-rest.service';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { BaseDataService } from './base/base-data.service'; import { BaseDataService } from './base/base-data.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { dataService } from './base/data-service.decorator'; import { dataService } from './base/data-service.decorator';
import { getFirstCompletedRemoteData } from '../shared/operators';
/** /**
* A service to retrieve the {@link Root} object from the REST API. * A service to retrieve the {@link Root} object from the REST API.
@@ -25,21 +24,21 @@ export class RootDataService extends BaseDataService<Root> {
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected restService: DspaceRestService,
) { ) {
super('', requestService, rdbService, objectCache, halService, 6 * 60 * 60 * 1000); super('', requestService, rdbService, objectCache, halService, 60 * 1000);
} }
/** /**
* Check if root endpoint is available * Check if root endpoint is available
*/ */
checkServerAvailability(): Observable<boolean> { checkServerAvailability(): Observable<boolean> {
return this.restService.get(this.halService.getRootHref()).pipe( return this.findRoot().pipe(
catchError((err ) => { catchError((err ) => {
console.error(err); console.error(err);
return observableOf(false); return observableOf(false);
}), }),
map((res: RawRestResponse) => res.statusCode === 200) getFirstCompletedRemoteData(),
map((rootRd: RemoteData<Root>) => rootRd.statusCode === 200)
); );
} }
@@ -60,6 +59,6 @@ export class RootDataService extends BaseDataService<Root> {
* Set to sale the root endpoint cache hit * Set to sale the root endpoint cache hit
*/ */
invalidateRootCache() { invalidateRootCache() {
this.requestService.setStaleByHrefSubstring(this.halService.getRootHref()); this.requestService.setStaleByHref(this.halService.getRootHref());
} }
} }

View File

@@ -1,68 +1,78 @@
import { ServerCheckGuard } from './server-check.guard'; import { ServerCheckGuard } from './server-check.guard';
import { Router } from '@angular/router'; import { Router, NavigationStart, UrlTree, NavigationEnd, RouterEvent } from '@angular/router';
import { of } from 'rxjs'; import { of, ReplaySubject } from 'rxjs';
import { take } from 'rxjs/operators';
import { getPageInternalServerErrorRoute } from '../../app-routing-paths';
import { RootDataService } from '../data/root-data.service'; import { RootDataService } from '../data/root-data.service';
import { TestScheduler } from 'rxjs/testing';
import SpyObj = jasmine.SpyObj; import SpyObj = jasmine.SpyObj;
describe('ServerCheckGuard', () => { describe('ServerCheckGuard', () => {
let guard: ServerCheckGuard; let guard: ServerCheckGuard;
let router: SpyObj<Router>; let router: Router;
const eventSubject = new ReplaySubject<RouterEvent>(1);
let rootDataServiceStub: SpyObj<RootDataService>; let rootDataServiceStub: SpyObj<RootDataService>;
let testScheduler: TestScheduler;
let redirectUrlTree: UrlTree;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
rootDataServiceStub = jasmine.createSpyObj('RootDataService', { rootDataServiceStub = jasmine.createSpyObj('RootDataService', {
checkServerAvailability: jasmine.createSpy('checkServerAvailability'), checkServerAvailability: jasmine.createSpy('checkServerAvailability'),
invalidateRootCache: jasmine.createSpy('invalidateRootCache') invalidateRootCache: jasmine.createSpy('invalidateRootCache')
}); });
router = jasmine.createSpyObj('Router', { redirectUrlTree = new UrlTree();
navigateByUrl: jasmine.createSpy('navigateByUrl') router = {
}); events: eventSubject.asObservable(),
navigateByUrl: jasmine.createSpy('navigateByUrl'),
beforeEach(() => { parseUrl: jasmine.createSpy('parseUrl').and.returnValue(redirectUrlTree)
} as any;
guard = new ServerCheckGuard(router, rootDataServiceStub); guard = new ServerCheckGuard(router, rootDataServiceStub);
}); });
afterEach(() => {
router.navigateByUrl.calls.reset();
rootDataServiceStub.invalidateRootCache.calls.reset();
});
it('should be created', () => { it('should be created', () => {
expect(guard).toBeTruthy(); expect(guard).toBeTruthy();
}); });
describe('when root endpoint has succeeded', () => { describe('when root endpoint request has succeeded', () => {
beforeEach(() => { beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(true)); rootDataServiceStub.checkServerAvailability.and.returnValue(of(true));
}); });
it('should not redirect to error page', () => { it('should return true', () => {
guard.canActivateChild({} as any, {} as any).pipe( testScheduler.run(({ expectObservable }) => {
take(1) const result$ = guard.canActivateChild({} as any, {} as any);
).subscribe((canActivate: boolean) => { expectObservable(result$).toBe('(a|)', { a: true });
expect(canActivate).toEqual(true);
expect(rootDataServiceStub.invalidateRootCache).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
}); });
}); });
}); });
describe('when root endpoint has not succeeded', () => { describe('when root endpoint request has not succeeded', () => {
beforeEach(() => { beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(false)); rootDataServiceStub.checkServerAvailability.and.returnValue(of(false));
}); });
it('should redirect to error page', () => { it('should return a UrlTree with the route to the 500 error page', () => {
guard.canActivateChild({} as any, {} as any).pipe( testScheduler.run(({ expectObservable }) => {
take(1) const result$ = guard.canActivateChild({} as any, {} as any);
).subscribe((canActivate: boolean) => { expectObservable(result$).toBe('(b|)', { b: redirectUrlTree });
expect(canActivate).toEqual(false);
expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith(getPageInternalServerErrorRoute());
}); });
expect(router.parseUrl).toHaveBeenCalledWith('/500');
});
});
describe(`listenForRouteChanges`, () => {
it(`should invalidate the root cache on every NavigationStart event`, () => {
testScheduler.run(() => {
guard.listenForRouteChanges();
eventSubject.next(new NavigationStart(1,''));
eventSubject.next(new NavigationEnd(1,'', ''));
eventSubject.next(new NavigationStart(2,''));
eventSubject.next(new NavigationEnd(2,'', ''));
eventSubject.next(new NavigationStart(3,''));
});
expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalledTimes(3);
}); });
}); });
}); });

View File

@@ -1,8 +1,15 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'; import {
ActivatedRouteSnapshot,
CanActivateChild,
Router,
RouterStateSnapshot,
UrlTree,
NavigationStart
} from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { take, tap } from 'rxjs/operators'; import { take, map, filter } from 'rxjs/operators';
import { RootDataService } from '../data/root-data.service'; import { RootDataService } from '../data/root-data.service';
import { getPageInternalServerErrorRoute } from '../../app-routing-paths'; import { getPageInternalServerErrorRoute } from '../../app-routing-paths';
@@ -23,17 +30,32 @@ export class ServerCheckGuard implements CanActivateChild {
*/ */
canActivateChild( canActivateChild(
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> { state: RouterStateSnapshot
): Observable<boolean | UrlTree> {
return this.rootDataService.checkServerAvailability().pipe( return this.rootDataService.checkServerAvailability().pipe(
take(1), take(1),
tap((isAvailable: boolean) => { map((isAvailable: boolean) => {
if (!isAvailable) { if (!isAvailable) {
this.rootDataService.invalidateRootCache(); return this.router.parseUrl(getPageInternalServerErrorRoute());
this.router.navigateByUrl(getPageInternalServerErrorRoute()); } else {
return true;
} }
}) })
); );
}
/**
* Listen to all router events. Every time a new navigation starts, invalidate the cache
* for the root endpoint. That way we retrieve it once per routing operation to ensure the
* backend is not down. But if the guard is called multiple times during the same routing
* operation, the cached version is used.
*/
listenForRouteChanges(): void {
this.router.events.pipe(
filter(event => event instanceof NavigationStart),
).subscribe(() => {
this.rootDataService.invalidateRootCache();
});
} }
} }

View File

@@ -32,6 +32,7 @@ import { logStartupMessage } from '../../../startup-message';
import { MenuService } from '../../app/shared/menu/menu.service'; import { MenuService } from '../../app/shared/menu/menu.service';
import { RootDataService } from '../../app/core/data/root-data.service'; import { RootDataService } from '../../app/core/data/root-data.service';
import { firstValueFrom, Subscription } from 'rxjs'; import { firstValueFrom, Subscription } from 'rxjs';
import { ServerCheckGuard } from '../../app/core/server-check/server-check.guard';
/** /**
* Performs client-side initialization. * Performs client-side initialization.
@@ -56,7 +57,8 @@ export class BrowserInitService extends InitService {
protected authService: AuthService, protected authService: AuthService,
protected themeService: ThemeService, protected themeService: ThemeService,
protected menuService: MenuService, protected menuService: MenuService,
private rootDataService: RootDataService private rootDataService: RootDataService,
protected serverCheckGuard: ServerCheckGuard,
) { ) {
super( super(
store, store,
@@ -172,4 +174,13 @@ export class BrowserInitService extends InitService {
}); });
} }
/**
* Start route-listening subscriptions
* @protected
*/
protected initRouteListeners(): void {
super.initRouteListeners();
this.serverCheckGuard.listenForRouteChanges();
}
} }