[DURACOM-288] Provide a setting to use a different REST url during SSR execution

# Conflicts:
#	src/app/app.config.ts
#	src/app/core/services/server-hard-redirect.service.spec.ts
#	src/app/core/services/server-hard-redirect.service.ts
#	src/app/thumbnail/thumbnail.component.ts
#	src/modules/app/browser-init.service.ts
#	src/modules/app/server-init.service.ts
This commit is contained in:
Giuseppe Digilio
2024-07-30 20:19:18 +02:00
parent 95064122d0
commit 8ca668159e
10 changed files with 315 additions and 27 deletions

View File

@@ -79,6 +79,9 @@ let anonymousCache: LRU<string, any>;
// extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig);
// The REST server base URL
const REST_BASE_URL = environment.rest.ssrBaseUrl || environment.rest.baseUrl;
// The Express app is exported so that it can be used by serverless Functions.
export function app() {
@@ -176,7 +179,7 @@ export function app() {
* Proxy the sitemaps
*/
router.use('/sitemap**', createProxyMiddleware({
target: `${environment.rest.baseUrl}/sitemaps`,
target: `${REST_BASE_URL}/sitemaps`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true
}));
@@ -185,7 +188,7 @@ export function app() {
* Proxy the linksets
*/
router.use('/signposting**', createProxyMiddleware({
target: `${environment.rest.baseUrl}`,
target: `${REST_BASE_URL}`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true
}));
@@ -621,7 +624,7 @@ function start() {
* The callback function to serve health check requests
*/
function healthCheck(req, res) {
const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`;
const baseUrl = `${REST_BASE_URL}${environment.actuators.endpointPath}`;
axios.get(baseUrl)
.then((response) => {
res.status(response.status).send(response.data);

View File

@@ -30,6 +30,7 @@ import { EagerThemesModule } from '../themes/eager-themes.module';
import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
import { StoreDevModules } from '../config/store/devtools';
import { RootModule } from './root.module';
import { DspaceRestInterceptor } from './core/dspace-rest/dspace-rest.interceptor';
export function getConfig() {
return environment;
@@ -103,6 +104,12 @@ const PROVIDERS = [
useClass: LogInterceptor,
multi: true
},
// register DspaceRestInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,
useClass: DspaceRestInterceptor,
multi: true
},
// register the dynamic matcher used by form. MUST be provided by the app module
...DYNAMIC_MATCHER_PROVIDERS,
];

View File

@@ -0,0 +1,194 @@
import {
HTTP_INTERCEPTORS,
HttpClient,
} from '@angular/common/http';
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { PLATFORM_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
APP_CONFIG,
AppConfig,
} from '../../../config/app-config.interface';
import { DspaceRestInterceptor } from './dspace-rest.interceptor';
import { DspaceRestService } from './dspace-rest.service';
describe('DspaceRestInterceptor', () => {
let httpMock: HttpTestingController;
let httpClient: HttpClient;
const appConfig: Partial<AppConfig> = {
rest: {
ssl: false,
host: 'localhost',
port: 8080,
nameSpace: '/server',
baseUrl: 'http://api.example.com/server',
},
};
const appConfigWithSSR: Partial<AppConfig> = {
rest: {
ssl: false,
host: 'localhost',
port: 8080,
nameSpace: '/server',
baseUrl: 'http://api.example.com/server',
ssrBaseUrl: 'http://ssr.example.com/server',
},
};
describe('When SSR base URL is not set ', () => {
describe('and it\'s in the browser', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
DspaceRestService,
{
provide: HTTP_INTERCEPTORS,
useClass: DspaceRestInterceptor,
multi: true,
},
{ provide: APP_CONFIG, useValue: appConfig },
{ provide: PLATFORM_ID, useValue: 'browser' },
],
});
httpMock = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
});
it('should not modify the request', () => {
const url = 'http://api.example.com/server/items';
httpClient.get(url).subscribe((response) => {
expect(response).toBeTruthy();
});
const req = httpMock.expectOne(url);
expect(req.request.url).toBe(url);
req.flush({});
httpMock.verify();
});
});
describe('and it\'s in SSR mode', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
DspaceRestService,
{
provide: HTTP_INTERCEPTORS,
useClass: DspaceRestInterceptor,
multi: true,
},
{ provide: APP_CONFIG, useValue: appConfig },
{ provide: PLATFORM_ID, useValue: 'server' },
],
});
httpMock = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
});
it('should not replace the base URL', () => {
const url = 'http://api.example.com/server/items';
httpClient.get(url).subscribe((response) => {
expect(response).toBeTruthy();
});
const req = httpMock.expectOne(url);
expect(req.request.url).toBe(url);
req.flush({});
httpMock.verify();
});
});
});
describe('When SSR base URL is set ', () => {
describe('and it\'s in the browser', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
DspaceRestService,
{
provide: HTTP_INTERCEPTORS,
useClass: DspaceRestInterceptor,
multi: true,
},
{ provide: APP_CONFIG, useValue: appConfigWithSSR },
{ provide: PLATFORM_ID, useValue: 'browser' },
],
});
httpMock = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
});
it('should not modify the request', () => {
const url = 'http://api.example.com/server/items';
httpClient.get(url).subscribe((response) => {
expect(response).toBeTruthy();
});
const req = httpMock.expectOne(url);
expect(req.request.url).toBe(url);
req.flush({});
httpMock.verify();
});
});
describe('and it\'s in SSR mode', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
DspaceRestService,
{
provide: HTTP_INTERCEPTORS,
useClass: DspaceRestInterceptor,
multi: true,
},
{ provide: APP_CONFIG, useValue: appConfigWithSSR },
{ provide: PLATFORM_ID, useValue: 'server' },
],
});
httpMock = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
});
it('should replace the base URL', () => {
const url = 'http://api.example.com/server/items';
const ssrBaseUrl = appConfigWithSSR.rest.ssrBaseUrl;
httpClient.get(url).subscribe((response) => {
expect(response).toBeTruthy();
});
const req = httpMock.expectOne(ssrBaseUrl + '/items');
expect(req.request.url).toBe(ssrBaseUrl + '/items');
req.flush({});
httpMock.verify();
});
it('should not replace any query param containing the base URL', () => {
const url = 'http://api.example.com/server/items?url=http://api.example.com/server/item/1';
const ssrBaseUrl = appConfigWithSSR.rest.ssrBaseUrl;
httpClient.get(url).subscribe((response) => {
expect(response).toBeTruthy();
});
const req = httpMock.expectOne(ssrBaseUrl + '/items?url=http://api.example.com/server/item/1');
expect(req.request.url).toBe(ssrBaseUrl + '/items?url=http://api.example.com/server/item/1');
req.flush({});
httpMock.verify();
});
});
});
});

View File

@@ -0,0 +1,52 @@
import { isPlatformBrowser } from '@angular/common';
import {
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import {
Inject,
Injectable,
PLATFORM_ID,
} from '@angular/core';
import { Observable } from 'rxjs';
import {
APP_CONFIG,
AppConfig,
} from '../../../config/app-config.interface';
import { isEmpty } from '../../shared/empty.util';
@Injectable()
/**
* This Interceptor is used to use the configured base URL for the request made during SSR execution
*/
export class DspaceRestInterceptor implements HttpInterceptor {
/**
* Contains the configured application base URL
* @protected
*/
protected baseUrl: string;
protected ssrBaseUrl: string;
constructor(
@Inject(APP_CONFIG) protected appConfig: AppConfig,
@Inject(PLATFORM_ID) private platformId: string,
) {
this.baseUrl = this.appConfig.rest.baseUrl;
this.ssrBaseUrl = this.appConfig.rest.ssrBaseUrl;
}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (isPlatformBrowser(this.platformId) || isEmpty(this.ssrBaseUrl) || this.baseUrl === this.ssrBaseUrl) {
return next.handle(request);
}
// Different SSR Base URL specified so replace it in the current request url
const url = request.url.replace(this.baseUrl, this.ssrBaseUrl);
const newRequest: HttpRequest<any> = request.clone({ url });
return next.handle(newRequest);
}
}

View File

@@ -1,4 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment.test';
import { ServerHardRedirectService } from './server-hard-redirect.service';
describe('ServerHardRedirectService', () => {
@@ -6,7 +8,7 @@ describe('ServerHardRedirectService', () => {
const mockRequest = jasmine.createSpyObj(['get']);
const mockResponse = jasmine.createSpyObj(['redirect', 'end']);
const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse);
const service: ServerHardRedirectService = new ServerHardRedirectService(environment, mockRequest, mockResponse);
const origin = 'https://test-host.com:4000';
beforeEach(() => {

View File

@@ -2,6 +2,8 @@ import { Inject, Injectable } from '@angular/core';
import { Request, Response } from 'express';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { HardRedirectService } from './hard-redirect.service';
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
import { isNotEmpty } from '../../shared/empty.util';
/**
* Service for performing hard redirects within the server app module
@@ -10,6 +12,7 @@ import { HardRedirectService } from './hard-redirect.service';
export class ServerHardRedirectService extends HardRedirectService {
constructor(
@Inject(APP_CONFIG) protected appConfig: AppConfig,
@Inject(REQUEST) protected req: Request,
@Inject(RESPONSE) protected res: Response,
) {
@@ -25,17 +28,22 @@ export class ServerHardRedirectService extends HardRedirectService {
* optional HTTP status code to use for redirect (default = 302, which is a temporary redirect)
*/
redirect(url: string, statusCode?: number) {
if (url === this.req.url) {
return;
}
let redirectUrl = url;
// If redirect url contains SSR base url then replace with public base url
if (isNotEmpty(this.appConfig.rest.ssrBaseUrl) && this.appConfig.rest.baseUrl !== this.appConfig.rest.ssrBaseUrl) {
redirectUrl = url.replace(this.appConfig.rest.ssrBaseUrl, this.appConfig.rest.baseUrl);
}
if (this.res.finished) {
const req: any = this.req;
req._r_count = (req._r_count || 0) + 1;
console.warn('Attempted to redirect on a finished response. From',
this.req.url, 'to', url);
this.req.url, 'to', redirectUrl);
if (req._r_count > 10) {
console.error('Detected a redirection loop. killing the nodejs process');
@@ -49,9 +57,9 @@ export class ServerHardRedirectService extends HardRedirectService {
status = 302;
}
console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`);
console.info(`Redirecting from ${this.req.url} to ${redirectUrl} with ${status}`);
this.res.redirect(status, url);
this.res.redirect(status, redirectUrl);
this.res.end();
// I haven't found a way to correctly stop Angular rendering.
// So we just let it end its work, though we have already closed

View File

@@ -1,4 +1,4 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Component, Inject, Input, OnChanges, PLATFORM_ID, SimpleChanges } from '@angular/core';
import { Bitstream } from '../core/shared/bitstream.model';
import { hasNoValue, hasValue } from '../shared/empty.util';
import { RemoteData } from '../core/data/remote-data';
@@ -8,6 +8,7 @@ import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { AuthService } from '../core/auth/auth.service';
import { FileService } from '../core/shared/file.service';
import { isPlatformBrowser } from '@angular/common';
/**
* This component renders a given Bitstream as a thumbnail.
@@ -60,6 +61,7 @@ export class ThumbnailComponent implements OnChanges {
isLoading = true;
constructor(
@Inject(PLATFORM_ID) private platformID: any,
protected auth: AuthService,
protected authorizationService: AuthorizationDataService,
protected fileService: FileService,
@@ -71,16 +73,18 @@ export class ThumbnailComponent implements OnChanges {
* Use a default image if no actual image is available.
*/
ngOnChanges(changes: SimpleChanges): void {
if (hasNoValue(this.thumbnail)) {
this.setSrc(this.defaultImage);
return;
}
if (isPlatformBrowser(this.platformID)) {
if (hasNoValue(this.thumbnail)) {
this.setSrc(this.defaultImage);
return;
}
const src = this.contentHref;
if (hasValue(src)) {
this.setSrc(src);
} else {
this.setSrc(this.defaultImage);
const src = this.contentHref;
if (hasValue(src)) {
this.setSrc(src);
} else {
this.setSrc(this.defaultImage);
}
}
}

View File

@@ -6,4 +6,6 @@ export class ServerConfig implements Config {
public port: number;
public nameSpace: string;
public baseUrl?: string;
public ssrBaseUrl?: string;
public hasSsrBaseUrl?: boolean;
}

View File

@@ -32,7 +32,7 @@ import { logStartupMessage } from '../../../startup-message';
import { MenuService } from '../../app/shared/menu/menu.service';
import { RequestService } from '../../app/core/data/request.service';
import { RootDataService } from '../../app/core/data/root-data.service';
import { firstValueFrom, Subscription } from 'rxjs';
import { firstValueFrom, lastValueFrom, Subscription } from 'rxjs';
import { ServerCheckGuard } from '../../app/core/server-check/server-check.guard';
import { HALEndpointService } from '../../app/core/shared/hal-endpoint.service';
@@ -121,13 +121,20 @@ export class BrowserInitService extends InitService {
* @private
*/
private async loadAppState(): Promise<boolean> {
const state = this.transferState.get<any>(InitService.NGRX_STATE, null);
this.transferState.remove(InitService.NGRX_STATE);
this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state));
return this.store.select(coreSelector).pipe(
find((core: any) => isNotEmpty(core)),
map(() => true)
).toPromise();
// The app state can be transferred only when SSR and CSR are using the same base url for the REST API
if (!this.appConfig.rest.hasSsrBaseUrl) {
const state = this.transferState.get<any>(InitService.NGRX_STATE, null);
this.transferState.remove(InitService.NGRX_STATE);
this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state));
return lastValueFrom(
this.store.select(coreSelector).pipe(
find((core: any) => isNotEmpty(core)),
map(() => true),
),
);
} else {
return Promise.resolve(true);
}
}
private trackAuthTokenExpiration(): void {

View File

@@ -21,6 +21,7 @@ import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service';
import { ThemeService } from '../../app/shared/theme-support/theme.service';
import { take } from 'rxjs/operators';
import { MenuService } from '../../app/shared/menu/menu.service';
import { isNotEmpty } from '../../app/shared/empty.util';
/**
* Performs server-side initialization.
@@ -91,6 +92,14 @@ export class ServerInitService extends InitService {
}
private saveAppConfigForCSR(): void {
this.transferState.set<AppConfig>(APP_CONFIG_STATE, environment as AppConfig);
if (isNotEmpty(environment.rest.ssrBaseUrl) && environment.rest.baseUrl !== environment.rest.ssrBaseUrl) {
// Avoid to transfer ssrBaseUrl in order to prevent security issues
const config: AppConfig = Object.assign({}, environment as AppConfig, {
rest: Object.assign({}, environment.rest, { ssrBaseUrl: '', hasSsrBaseUrl: true }),
});
this.transferState.set<AppConfig>(APP_CONFIG_STATE, config);
} else {
this.transferState.set<AppConfig>(APP_CONFIG_STATE, environment as AppConfig);
}
}
}