Merge pull request #1498 from 4Science/CST-4880_Show-an-error-page-if-the-REST-API-is-not-available

[CST-4880] Add ServerCheckGuard in order to show internal server error page when rest server is not available
This commit is contained in:
Tim Donohue
2022-01-25 11:52:37 -06:00
committed by GitHub
15 changed files with 290 additions and 21 deletions

View File

@@ -89,6 +89,12 @@ export function getPageNotFoundRoute() {
return `/${PAGE_NOT_FOUND_PATH}`;
}
export const INTERNAL_SERVER_ERROR = '500';
export function getPageInternalServerErrorRoute() {
return `/${INTERNAL_SERVER_ERROR}`;
}
export const INFO_MODULE_PATH = 'info';
export function getInfoModulePath() {
return `/${INFO_MODULE_PATH}`;

View File

@@ -11,10 +11,12 @@ import {
FORBIDDEN_PATH,
FORGOT_PASSWORD_PATH,
INFO_MODULE_PATH,
INTERNAL_SERVER_ERROR,
LEGACY_BITSTREAM_MODULE_PATH,
PROFILE_MODULE_PATH,
REGISTER_PATH,
REQUEST_COPY_MODULE_PATH,
WORKFLOW_ITEM_MODULE_PATH,
LEGACY_BITSTREAM_MODULE_PATH, REQUEST_COPY_MODULE_PATH,
} from './app-routing-paths';
import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths';
import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths';
@@ -26,14 +28,25 @@ import { SiteRegisterGuard } from './core/data/feature-authorization/feature-aut
import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component';
import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component';
import { GroupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component';
import { ServerCheckGuard } from './core/server-check/server-check.guard';
@NgModule({
imports: [
RouterModule.forRoot([{
path: '', canActivate: [AuthBlockingGuard],
RouterModule.forRoot([
{ path: INTERNAL_SERVER_ERROR, component: ThemedPageInternalServerErrorComponent },
{
path: '',
canActivate: [AuthBlockingGuard],
canActivateChild: [ServerCheckGuard],
children: [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'reload/:rnd', component: ThemedPageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] },
{
path: 'reload/:rnd',
component: ThemedPageNotFoundComponent,
pathMatch: 'full',
canActivate: [ReloadGuard]
},
{
path: 'home',
loadChildren: () => import('./home-page/home-page.module')
@@ -89,7 +102,8 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu
.then((m) => m.ItemPageModule),
canActivate: [EndUserAgreementCurrentUserGuard]
},
{ path: 'entities/:entity-type',
{
path: 'entities/:entity-type',
loadChildren: () => import('./item-page/item-page.module')
.then((m) => m.ItemPageModule),
canActivate: [EndUserAgreementCurrentUserGuard]
@@ -133,12 +147,12 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu
{
path: 'login',
loadChildren: () => import('./login-page/login-page.module')
.then((m) => m.LoginPageModule),
.then((m) => m.LoginPageModule)
},
{
path: 'logout',
loadChildren: () => import('./logout-page/logout-page.module')
.then((m) => m.LogoutPageModule),
.then((m) => m.LogoutPageModule)
},
{
path: 'submit',
@@ -178,7 +192,7 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu
},
{
path: INFO_MODULE_PATH,
loadChildren: () => import('./info/info.module').then((m) => m.InfoModule),
loadChildren: () => import('./info/info.module').then((m) => m.InfoModule)
},
{
path: REQUEST_COPY_MODULE_PATH,
@@ -192,7 +206,7 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu
{
path: 'statistics',
loadChildren: () => import('./statistics-page/statistics-page-routing.module')
.then((m) => m.StatisticsPageRoutingModule),
.then((m) => m.StatisticsPageRoutingModule)
},
{
path: ACCESS_CONTROL_MODULE_PATH,
@@ -200,9 +214,10 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu
canActivate: [GroupAdministratorGuard],
},
{ path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent },
]}
],{
onSameUrlNavigation: 'reload',
]
}
], {
onSameUrlNavigation: 'reload',
})
],
exports: [RouterModule],

View File

@@ -54,8 +54,10 @@ import { ThemedFooterComponent } from './footer/themed-footer.component';
import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component';
import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component';
import { PageInternalServerErrorComponent } from './page-internal-server-error/page-internal-server-error.component';
import { AppConfig, APP_CONFIG } from '../config/app-config.interface';
import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
export function getConfig() {
return environment;
@@ -181,7 +183,9 @@ const DECLARATIONS = [
ThemedBreadcrumbsComponent,
ForbiddenComponent,
ThemedForbiddenComponent,
IdleModalComponent
IdleModalComponent,
ThemedPageInternalServerErrorComponent,
PageInternalServerErrorComponent
];
const EXPORTS = [

View File

@@ -1,13 +1,16 @@
import { RootDataService } from './root-data.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { RemoteData } from './remote-data';
import { Root } from './root.model';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { cold } from 'jasmine-marbles';
describe('RootDataService', () => {
let service: RootDataService;
let halService: HALEndpointService;
let restService;
let rootEndpoint;
beforeEach(() => {
@@ -15,7 +18,10 @@ describe('RootDataService', () => {
halService = jasmine.createSpyObj('halService', {
getRootHref: rootEndpoint
});
service = new RootDataService(null, null, null, null, halService, null, null, null);
restService = jasmine.createSpyObj('halService', {
get: jasmine.createSpy('get')
});
service = new RootDataService(null, null, null, null, halService, null, null, null, restService);
(service as any).dataService = jasmine.createSpyObj('dataService', {
findByHref: createSuccessfulRemoteDataObject$({})
});
@@ -35,4 +41,37 @@ describe('RootDataService', () => {
});
});
});
describe('checkServerAvailability', () => {
let result$: Observable<boolean>;
it('should return observable of true when root endpoint is available', () => {
const mockResponse = {
statusCode: 200,
statusText: 'OK'
} as RawRestResponse;
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability();
expect(result$).toBeObservable(cold('(a|)', {
a: true
}));
});
it('should return observable of false when root endpoint is not available', () => {
const mockResponse = {
statusCode: 500,
statusText: 'Internal Server Error'
} as RawRestResponse;
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability();
expect(result$).toBeObservable(cold('(a|)', {
a: false
}));
});
});
});

View File

@@ -17,6 +17,10 @@ import { RemoteData } from './remote-data';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { FindListOptions } from './request.models';
import { PaginatedList } from './paginated-list.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 { of } from 'rxjs/internal/observable/of';
/* tslint:disable:max-classes-per-file */
@@ -59,10 +63,24 @@ export class RootDataService {
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<Root>) {
protected comparator: DefaultChangeAnalyzer<Root>,
protected restService: DspaceRestService) {
this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator);
}
/**
* Check if root endpoint is available
*/
checkServerAvailability(): Observable<boolean> {
return this.restService.get(this.halService.getRootHref()).pipe(
catchError((err ) => {
console.error(err);
return of(false);
}),
map((res: RawRestResponse) => res.statusCode === 200)
);
}
/**
* Find the {@link Root} object of the REST API
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
@@ -106,5 +124,12 @@ export class RootDataService {
findAllByHref(href: string | Observable<string>, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Root>[]): Observable<RemoteData<PaginatedList<Root>>> {
return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Set to sale the root endpoint cache hit
*/
invalidateRootCache() {
this.requestService.setStaleByHrefSubstring(this.halService.getRootHref());
}
}
/* tslint:enable:max-classes-per-file */

View File

@@ -0,0 +1,68 @@
import { ServerCheckGuard } from './server-check.guard';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { take } from 'rxjs/operators';
import { getPageInternalServerErrorRoute } from '../../app-routing-paths';
import { RootDataService } from '../data/root-data.service';
import SpyObj = jasmine.SpyObj;
describe('ServerCheckGuard', () => {
let guard: ServerCheckGuard;
let router: SpyObj<Router>;
let rootDataServiceStub: SpyObj<RootDataService>;
rootDataServiceStub = jasmine.createSpyObj('RootDataService', {
checkServerAvailability: jasmine.createSpy('checkServerAvailability'),
invalidateRootCache: jasmine.createSpy('invalidateRootCache')
});
router = jasmine.createSpyObj('Router', {
navigateByUrl: jasmine.createSpy('navigateByUrl')
});
beforeEach(() => {
guard = new ServerCheckGuard(router, rootDataServiceStub);
});
afterEach(() => {
router.navigateByUrl.calls.reset();
rootDataServiceStub.invalidateRootCache.calls.reset();
});
it('should be created', () => {
expect(guard).toBeTruthy();
});
describe('when root endpoint has succeeded', () => {
beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(true));
});
it('should not redirect to error page', () => {
guard.canActivateChild({} as any, {} as any).pipe(
take(1)
).subscribe((canActivate: boolean) => {
expect(canActivate).toEqual(true);
expect(rootDataServiceStub.invalidateRootCache).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});
});
describe('when root endpoint has not succeeded', () => {
beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(false));
});
it('should redirect to error page', () => {
guard.canActivateChild({} as any, {} as any).pipe(
take(1)
).subscribe((canActivate: boolean) => {
expect(canActivate).toEqual(false);
expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith(getPageInternalServerErrorRoute());
});
});
});
});

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { take, tap } from 'rxjs/operators';
import { RootDataService } from '../data/root-data.service';
import { getPageInternalServerErrorRoute } from '../../app-routing-paths';
@Injectable({
providedIn: 'root'
})
/**
* A guard that checks if root api endpoint is reachable.
* If not redirect to 500 error page
*/
export class ServerCheckGuard implements CanActivateChild {
constructor(private router: Router, private rootDataService: RootDataService) {
}
/**
* True when root api endpoint is reachable.
*/
canActivateChild(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> {
return this.rootDataService.checkServerAvailability().pipe(
take(1),
tap((isAvailable: boolean) => {
if (!isAvailable) {
this.rootDataService.invalidateRootCache();
this.router.navigateByUrl(getPageInternalServerErrorRoute());
}
})
);
}
}

View File

@@ -31,4 +31,8 @@ export class ServerResponseService {
setNotFound(message = 'Not found'): this {
return this.setStatus(404, message);
}
setInternalServerError(message = 'Internal Server Error'): this {
return this.setStatus(500, message);
}
}

View File

@@ -0,0 +1,10 @@
<div class="page-internal-server-error container">
<h1>500</h1>
<h2><small>{{"500.page-internal-server-error" | translate}}</small></h2>
<br/>
<p>{{"500.help" | translate}}</p>
<br/>
<p class="text-center">
<a href="/home" class="btn btn-primary">{{"500.link.home-page" | translate}}</a>
</p>
</div>

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ServerResponseService } from '../core/services/server-response.service';
/**
* This component representing the `PageInternalServer` DSpace page.
*/
@Component({
selector: 'ds-page-internal-server-error',
styleUrls: ['./page-internal-server-error.component.scss'],
templateUrl: './page-internal-server-error.component.html',
changeDetection: ChangeDetectionStrategy.Default
})
export class PageInternalServerErrorComponent {
/**
* Initialize instance variables
*
* @param {ServerResponseService} responseService
*/
constructor(private responseService: ServerResponseService) {
this.responseService.setInternalServerError();
}
}

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../shared/theme-support/themed.component';
import { PageInternalServerErrorComponent } from './page-internal-server-error.component';
/**
* Themed wrapper for PageInternalServerErrorComponent
*/
@Component({
selector: 'ds-themed-search-page',
styleUrls: [],
templateUrl: '../shared/theme-support/themed.component.html',
})
export class ThemedPageInternalServerErrorComponent extends ThemedComponent<PageInternalServerErrorComponent> {
protected getComponentName(): string {
return 'PageInternalServerErrorComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../themes/${themeName}/app/page-internal-server-error/page-internal-server-error.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./page-internal-server-error.component`);
}
}

View File

@@ -1,5 +1,5 @@
import { map } from 'rxjs/operators';
import { Component, Inject, OnInit, Input } from '@angular/core';
import { Component, Inject, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
@@ -19,6 +19,7 @@ import { ThemeConfig } from '../../config/theme.model';
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
import { environment } from '../../environments/environment';
import { slideSidebarPadding } from '../shared/animations/slide';
import { getPageInternalServerErrorRoute } from '../app-routing-paths';
@Component({
selector: 'ds-root',
@@ -68,9 +69,13 @@ export class RootComponent implements OnInit {
this.totalSidebarWidth = this.cssService.getVariable('totalSidebarWidth');
const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN);
this.slideSidebarOver = combineLatestObservable(sidebarCollapsed, this.windowService.isXsOrSm())
this.slideSidebarOver = combineLatestObservable([sidebarCollapsed, this.windowService.isXsOrSm()])
.pipe(
map(([collapsed, mobile]) => collapsed || mobile)
);
if (this.router.url === getPageInternalServerErrorRoute()) {
this.shouldShowRouteLoader = false;
}
}
}

View File

@@ -145,8 +145,8 @@ export class MenuComponent implements OnInit, OnDestroy {
* Get statistics route dso data
*/
getObjectUrl(data) {
const object = data.site ? data.site : data.dso.payload;
return object._links.self.href;
const object = data.site ? data.site : data.dso?.payload;
return object?._links?.self?.href;
}
/**

View File

@@ -14,6 +14,11 @@
"403.forbidden": "forbidden",
"500.page-internal-server-error": "Service Unavailable",
"500.help": "The server is temporarily unable to service your request due to maintenance downtime or capacity problems. Please try again later.",
"500.link.home-page": "Take me to the home page",
"404.help": "We can't find the page you're looking for. The page may have been moved or deleted. You can use the button below to get back to the home page. ",