Skip to content

Commit

Permalink
Merge pull request DSpace#3358 from 4Science/task/main/DURACOM-288
Browse files Browse the repository at this point in the history
Provide a setting to use a different REST url during SSR execution
  • Loading branch information
tdonohue authored Feb 6, 2025
2 parents 542e2db + 7acf793 commit 3d6af1f
Show file tree
Hide file tree
Showing 18 changed files with 695 additions and 266 deletions.
16 changes: 16 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ ui:

# Angular Server Side Rendering (SSR) settings
ssr:
# Enable request performance profiling data collection and printing the results in the server console.
# Defaults to false. Enabling in production is NOT recommended
enablePerformanceProfiler: false
# Whether to tell Angular to inline "critical" styles into the server-side rendered HTML.
# Determining which styles are critical is a relatively expensive operation; this option is
# disabled (false) by default to boost server performance at the expense of loading smoothness.
Expand All @@ -35,6 +38,16 @@ ssr:
# If set to true the component will be included in the HTML returned from the server side rendering.
# If set to false the component will not be included in the HTML returned from the server side rendering.
enableBrowseComponent: false
# Enable state transfer from the server-side application to the client-side application.
# Defaults to true.
# Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it.
# Disabling it ensures that dynamic state information is not inadvertently cached, which can improve security and
# ensure that users always use the most up-to-date state.
transferState: true
# When a different REST base URL is used for the server-side application, the generated state contains references to
# REST resources with the internal URL configured. By default, these internal URLs are replaced with public URLs.
# Disable this setting to avoid URL replacement during SSR. In this the state is not transferred to avoid security issues.
replaceRestUrl: true

# The REST API server settings
# NOTE: these settings define which (publicly available) REST API to use. They are usually
Expand All @@ -45,6 +58,9 @@ rest:
port: 443
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: /server
# Provide a different REST url to be used during SSR execution. It must contain the whole url including protocol, server port and
# server namespace (uncomment to use it).
#ssrBaseUrl: http://localhost:8080/server

# Caching settings
cache:
Expand Down
14 changes: 11 additions & 3 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,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() {

Expand Down Expand Up @@ -156,7 +159,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,
}));
Expand All @@ -165,7 +168,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,
}));
Expand Down Expand Up @@ -266,6 +269,11 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) {
})
.then((html) => {
if (hasValue(html)) {
// Replace REST URL with UI URL
if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) {
html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl);
}

// save server side rendered page to cache (if any are enabled)
saveToCache(req, html);
if (sendToUser) {
Expand Down Expand Up @@ -623,7 +631,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);
Expand Down
6 changes: 6 additions & 0 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
} from './app-routes';
import { BROWSE_BY_DECORATOR_MAP } from './browse-by/browse-by-switcher/browse-by-decorator';
import { AuthInterceptor } from './core/auth/auth.interceptor';
import { DspaceRestInterceptor } from './core/dspace-rest/dspace-rest.interceptor';
import { LocaleInterceptor } from './core/locale/locale.interceptor';
import { LogInterceptor } from './core/log/log.interceptor';
import {
Expand Down Expand Up @@ -148,6 +149,11 @@ export const commonAppConfig: ApplicationConfig = {
useClass: LogInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: DspaceRestInterceptor,
multi: true,
},
// register the dynamic matcher used by form. MUST be provided by the app module
...DYNAMIC_MATCHER_PROVIDERS,
provideCore(),
Expand Down
194 changes: 194 additions & 0 deletions src/app/core/dspace-rest/dspace-rest.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
});
52 changes: 52 additions & 0 deletions src/app/core/dspace-rest/dspace-rest.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
22 changes: 21 additions & 1 deletion src/app/core/services/server-hard-redirect.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { TestBed } from '@angular/core/testing';

import { environment } from '../../../environments/environment.test';
import { ServerHardRedirectService } from './server-hard-redirect.service';

describe('ServerHardRedirectService', () => {

const mockRequest = jasmine.createSpyObj(['get']);
const mockResponse = jasmine.createSpyObj(['redirect', 'end']);

const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse);
let service: ServerHardRedirectService = new ServerHardRedirectService(environment, mockRequest, mockResponse);
const origin = 'https://test-host.com:4000';

beforeEach(() => {
Expand Down Expand Up @@ -68,4 +69,23 @@ describe('ServerHardRedirectService', () => {
});
});

describe('when SSR base url is set', () => {
const redirect = 'https://private-url:4000/server/api/bitstreams/uuid';
const replacedUrl = 'https://public-url/server/api/bitstreams/uuid';
const environmentWithSSRUrl: any = { ...environment, ...{ ...environment.rest, rest: {
ssrBaseUrl: 'https://private-url:4000/server',
baseUrl: 'https://public-url/server',
} } };
service = new ServerHardRedirectService(environmentWithSSRUrl, mockRequest, mockResponse);

beforeEach(() => {
service.redirect(redirect);
});

it('should perform a 302 redirect', () => {
expect(mockResponse.redirect).toHaveBeenCalledWith(302, replacedUrl);
expect(mockResponse.end).toHaveBeenCalled();
});
});

});
Loading

0 comments on commit 3d6af1f

Please sign in to comment.