Skip to content

Commit 1956e25

Browse files
authored
Merge pull request DSpace#3962 from 4Science/task/dspace-7_x/DURACOM-288
[Port dspace-7_x] Provide a setting to use a different REST url during SSR execution
2 parents 7fab963 + 2e28a51 commit 1956e25

18 files changed

Lines changed: 664 additions & 249 deletions

config/config.example.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ universal:
3535
# If set to true the component will be included in the HTML returned from the server side rendering.
3636
# If set to false the component will not be included in the HTML returned from the server side rendering.
3737
enableBrowseComponent: false
38+
# Enable state transfer from the server-side application to the client-side application.
39+
# Defaults to true.
40+
# Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it.
41+
# Disabling it ensures that dynamic state information is not inadvertently cached, which can improve security and
42+
# ensure that users always use the most up-to-date state.
43+
transferState: true
44+
# When a different REST base URL is used for the server-side application, the generated state contains references to
45+
# REST resources with the internal URL configured. By default, these internal URLs are replaced with public URLs.
46+
# Disable this setting to avoid URL replacement during SSR. In this the state is not transferred to avoid security issues.
47+
replaceRestUrl: true
3848

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

4962
# Caching settings
5063
cache:

server.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ let anonymousCache: LRU<string, any>;
7979
// extend environment with app config for server
8080
extendEnvironmentWithAppConfig(environment, appConfig);
8181

82+
// The REST server base URL
83+
const REST_BASE_URL = environment.rest.ssrBaseUrl || environment.rest.baseUrl;
84+
8285
// The Express app is exported so that it can be used by serverless Functions.
8386
export function app() {
8487

@@ -176,7 +179,7 @@ export function app() {
176179
* Proxy the sitemaps
177180
*/
178181
router.use('/sitemap**', createProxyMiddleware({
179-
target: `${environment.rest.baseUrl}/sitemaps`,
182+
target: `${REST_BASE_URL}/sitemaps`,
180183
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
181184
changeOrigin: true
182185
}));
@@ -185,7 +188,7 @@ export function app() {
185188
* Proxy the linksets
186189
*/
187190
router.use('/signposting**', createProxyMiddleware({
188-
target: `${environment.rest.baseUrl}`,
191+
target: `${REST_BASE_URL}`,
189192
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
190193
changeOrigin: true
191194
}));
@@ -269,6 +272,11 @@ function serverSideRender(req, res, sendToUser: boolean = true) {
269272
requestUrl: req.originalUrl,
270273
}, (err, data) => {
271274
if (hasNoValue(err) && hasValue(data)) {
275+
// Replace REST URL with UI URL
276+
if (environment.universal.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) {
277+
data = data.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl);
278+
}
279+
272280
// save server side rendered page to cache (if any are enabled)
273281
saveToCache(req, data);
274282
if (sendToUser) {
@@ -621,7 +629,7 @@ function start() {
621629
* The callback function to serve health check requests
622630
*/
623631
function healthCheck(req, res) {
624-
const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`;
632+
const baseUrl = `${REST_BASE_URL}${environment.actuators.endpointPath}`;
625633
axios.get(baseUrl)
626634
.then((response) => {
627635
res.status(response.status).send(response.data);

src/app/app.module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { EagerThemesModule } from '../themes/eager-themes.module';
3030
import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
3131
import { StoreDevModules } from '../config/store/devtools';
3232
import { RootModule } from './root.module';
33+
import { DspaceRestInterceptor } from './core/dspace-rest/dspace-rest.interceptor';
3334

3435
export function getConfig() {
3536
return environment;
@@ -103,6 +104,12 @@ const PROVIDERS = [
103104
useClass: LogInterceptor,
104105
multi: true
105106
},
107+
// register DspaceRestInterceptor as HttpInterceptor
108+
{
109+
provide: HTTP_INTERCEPTORS,
110+
useClass: DspaceRestInterceptor,
111+
multi: true
112+
},
106113
// register the dynamic matcher used by form. MUST be provided by the app module
107114
...DYNAMIC_MATCHER_PROVIDERS,
108115
];
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
HTTP_INTERCEPTORS,
3+
HttpClient,
4+
} from '@angular/common/http';
5+
import {
6+
HttpClientTestingModule,
7+
HttpTestingController,
8+
} from '@angular/common/http/testing';
9+
import { PLATFORM_ID } from '@angular/core';
10+
import { TestBed } from '@angular/core/testing';
11+
12+
import {
13+
APP_CONFIG,
14+
AppConfig,
15+
} from '../../../config/app-config.interface';
16+
import { DspaceRestInterceptor } from './dspace-rest.interceptor';
17+
import { DspaceRestService } from './dspace-rest.service';
18+
19+
describe('DspaceRestInterceptor', () => {
20+
let httpMock: HttpTestingController;
21+
let httpClient: HttpClient;
22+
const appConfig: Partial<AppConfig> = {
23+
rest: {
24+
ssl: false,
25+
host: 'localhost',
26+
port: 8080,
27+
nameSpace: '/server',
28+
baseUrl: 'http://api.example.com/server',
29+
},
30+
};
31+
const appConfigWithSSR: Partial<AppConfig> = {
32+
rest: {
33+
ssl: false,
34+
host: 'localhost',
35+
port: 8080,
36+
nameSpace: '/server',
37+
baseUrl: 'http://api.example.com/server',
38+
ssrBaseUrl: 'http://ssr.example.com/server',
39+
},
40+
};
41+
42+
describe('When SSR base URL is not set ', () => {
43+
describe('and it\'s in the browser', () => {
44+
beforeEach(() => {
45+
TestBed.configureTestingModule({
46+
imports: [HttpClientTestingModule],
47+
providers: [
48+
DspaceRestService,
49+
{
50+
provide: HTTP_INTERCEPTORS,
51+
useClass: DspaceRestInterceptor,
52+
multi: true,
53+
},
54+
{ provide: APP_CONFIG, useValue: appConfig },
55+
{ provide: PLATFORM_ID, useValue: 'browser' },
56+
],
57+
});
58+
59+
httpMock = TestBed.inject(HttpTestingController);
60+
httpClient = TestBed.inject(HttpClient);
61+
});
62+
63+
it('should not modify the request', () => {
64+
const url = 'http://api.example.com/server/items';
65+
httpClient.get(url).subscribe((response) => {
66+
expect(response).toBeTruthy();
67+
});
68+
69+
const req = httpMock.expectOne(url);
70+
expect(req.request.url).toBe(url);
71+
req.flush({});
72+
httpMock.verify();
73+
});
74+
});
75+
76+
describe('and it\'s in SSR mode', () => {
77+
beforeEach(() => {
78+
TestBed.configureTestingModule({
79+
imports: [HttpClientTestingModule],
80+
providers: [
81+
DspaceRestService,
82+
{
83+
provide: HTTP_INTERCEPTORS,
84+
useClass: DspaceRestInterceptor,
85+
multi: true,
86+
},
87+
{ provide: APP_CONFIG, useValue: appConfig },
88+
{ provide: PLATFORM_ID, useValue: 'server' },
89+
],
90+
});
91+
92+
httpMock = TestBed.inject(HttpTestingController);
93+
httpClient = TestBed.inject(HttpClient);
94+
});
95+
96+
it('should not replace the base URL', () => {
97+
const url = 'http://api.example.com/server/items';
98+
99+
httpClient.get(url).subscribe((response) => {
100+
expect(response).toBeTruthy();
101+
});
102+
103+
const req = httpMock.expectOne(url);
104+
expect(req.request.url).toBe(url);
105+
req.flush({});
106+
httpMock.verify();
107+
});
108+
});
109+
});
110+
111+
describe('When SSR base URL is set ', () => {
112+
describe('and it\'s in the browser', () => {
113+
beforeEach(() => {
114+
TestBed.configureTestingModule({
115+
imports: [HttpClientTestingModule],
116+
providers: [
117+
DspaceRestService,
118+
{
119+
provide: HTTP_INTERCEPTORS,
120+
useClass: DspaceRestInterceptor,
121+
multi: true,
122+
},
123+
{ provide: APP_CONFIG, useValue: appConfigWithSSR },
124+
{ provide: PLATFORM_ID, useValue: 'browser' },
125+
],
126+
});
127+
128+
httpMock = TestBed.inject(HttpTestingController);
129+
httpClient = TestBed.inject(HttpClient);
130+
});
131+
132+
it('should not modify the request', () => {
133+
const url = 'http://api.example.com/server/items';
134+
httpClient.get(url).subscribe((response) => {
135+
expect(response).toBeTruthy();
136+
});
137+
138+
const req = httpMock.expectOne(url);
139+
expect(req.request.url).toBe(url);
140+
req.flush({});
141+
httpMock.verify();
142+
});
143+
});
144+
145+
describe('and it\'s in SSR mode', () => {
146+
beforeEach(() => {
147+
TestBed.configureTestingModule({
148+
imports: [HttpClientTestingModule],
149+
providers: [
150+
DspaceRestService,
151+
{
152+
provide: HTTP_INTERCEPTORS,
153+
useClass: DspaceRestInterceptor,
154+
multi: true,
155+
},
156+
{ provide: APP_CONFIG, useValue: appConfigWithSSR },
157+
{ provide: PLATFORM_ID, useValue: 'server' },
158+
],
159+
});
160+
161+
httpMock = TestBed.inject(HttpTestingController);
162+
httpClient = TestBed.inject(HttpClient);
163+
});
164+
165+
it('should replace the base URL', () => {
166+
const url = 'http://api.example.com/server/items';
167+
const ssrBaseUrl = appConfigWithSSR.rest.ssrBaseUrl;
168+
169+
httpClient.get(url).subscribe((response) => {
170+
expect(response).toBeTruthy();
171+
});
172+
173+
const req = httpMock.expectOne(ssrBaseUrl + '/items');
174+
expect(req.request.url).toBe(ssrBaseUrl + '/items');
175+
req.flush({});
176+
httpMock.verify();
177+
});
178+
179+
it('should not replace any query param containing the base URL', () => {
180+
const url = 'http://api.example.com/server/items?url=http://api.example.com/server/item/1';
181+
const ssrBaseUrl = appConfigWithSSR.rest.ssrBaseUrl;
182+
183+
httpClient.get(url).subscribe((response) => {
184+
expect(response).toBeTruthy();
185+
});
186+
187+
const req = httpMock.expectOne(ssrBaseUrl + '/items?url=http://api.example.com/server/item/1');
188+
expect(req.request.url).toBe(ssrBaseUrl + '/items?url=http://api.example.com/server/item/1');
189+
req.flush({});
190+
httpMock.verify();
191+
});
192+
});
193+
});
194+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { isPlatformBrowser } from '@angular/common';
2+
import {
3+
HttpEvent,
4+
HttpHandler,
5+
HttpInterceptor,
6+
HttpRequest,
7+
} from '@angular/common/http';
8+
import {
9+
Inject,
10+
Injectable,
11+
PLATFORM_ID,
12+
} from '@angular/core';
13+
import { Observable } from 'rxjs';
14+
15+
import {
16+
APP_CONFIG,
17+
AppConfig,
18+
} from '../../../config/app-config.interface';
19+
import { isEmpty } from '../../shared/empty.util';
20+
21+
@Injectable()
22+
/**
23+
* This Interceptor is used to use the configured base URL for the request made during SSR execution
24+
*/
25+
export class DspaceRestInterceptor implements HttpInterceptor {
26+
27+
/**
28+
* Contains the configured application base URL
29+
* @protected
30+
*/
31+
protected baseUrl: string;
32+
protected ssrBaseUrl: string;
33+
34+
constructor(
35+
@Inject(APP_CONFIG) protected appConfig: AppConfig,
36+
@Inject(PLATFORM_ID) private platformId: string,
37+
) {
38+
this.baseUrl = this.appConfig.rest.baseUrl;
39+
this.ssrBaseUrl = this.appConfig.rest.ssrBaseUrl;
40+
}
41+
42+
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
43+
if (isPlatformBrowser(this.platformId) || isEmpty(this.ssrBaseUrl) || this.baseUrl === this.ssrBaseUrl) {
44+
return next.handle(request);
45+
}
46+
47+
// Different SSR Base URL specified so replace it in the current request url
48+
const url = request.url.replace(this.baseUrl, this.ssrBaseUrl);
49+
const newRequest: HttpRequest<any> = request.clone({ url });
50+
return next.handle(newRequest);
51+
}
52+
}

src/app/core/services/server-hard-redirect.service.spec.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { TestBed } from '@angular/core/testing';
2+
3+
import { environment } from '../../../environments/environment.test';
24
import { ServerHardRedirectService } from './server-hard-redirect.service';
35

46
describe('ServerHardRedirectService', () => {
57

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

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

1214
beforeEach(() => {
@@ -67,4 +69,23 @@ describe('ServerHardRedirectService', () => {
6769
});
6870
});
6971

72+
describe('when SSR base url is set', () => {
73+
const redirect = 'https://private-url:4000/server/api/bitstreams/uuid';
74+
const replacedUrl = 'https://public-url/server/api/bitstreams/uuid';
75+
const environmentWithSSRUrl: any = { ...environment, ...{ ...environment.rest, rest: {
76+
ssrBaseUrl: 'https://private-url:4000/server',
77+
baseUrl: 'https://public-url/server',
78+
} } };
79+
service = new ServerHardRedirectService(environmentWithSSRUrl, mockRequest, mockResponse);
80+
81+
beforeEach(() => {
82+
service.redirect(redirect);
83+
});
84+
85+
it('should perform a 302 redirect', () => {
86+
expect(mockResponse.redirect).toHaveBeenCalledWith(302, replacedUrl);
87+
expect(mockResponse.end).toHaveBeenCalled();
88+
});
89+
});
90+
7091
});

0 commit comments

Comments
 (0)