diff --git a/server.ts b/server.ts index 3e10677a8b..d64b80b4ab 100644 --- a/server.ts +++ b/server.ts @@ -26,7 +26,6 @@ import * as ejs from 'ejs'; import * as compression from 'compression'; import * as expressStaticGzip from 'express-static-gzip'; /* eslint-enable import/no-namespace */ - import axios from 'axios'; import LRU from 'lru-cache'; import isbot from 'isbot'; @@ -34,7 +33,7 @@ import { createCertificate } from 'pem'; import { createServer } from 'https'; import { json } from 'body-parser'; -import { existsSync, readFileSync } from 'fs'; +import { readFileSync } from 'fs'; import { join } from 'path'; import { enableProdMode } from '@angular/core'; @@ -180,6 +179,15 @@ export function app() { changeOrigin: true })); + /** + * Proxy the linksets + */ + router.use('/signposting**', createProxyMiddleware({ + target: `${environment.rest.baseUrl}`, + pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), + changeOrigin: true + })); + /** * Checks if the rateLimiter property is present * When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled. diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts index e84b254eae..59261e56d2 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts @@ -11,6 +11,9 @@ import { ActivatedRoute, Router } from '@angular/router'; import { getForbiddenRoute } from '../../app-routing-paths'; import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { PLATFORM_ID } from '@angular/core'; describe('BitstreamDownloadPageComponent', () => { let component: BitstreamDownloadPageComponent; @@ -24,6 +27,20 @@ describe('BitstreamDownloadPageComponent', () => { let router; let bitstream: Bitstream; + let serverResponseService: jasmine.SpyObj; + let signpostingDataService: jasmine.SpyObj; + + const mocklink = { + href: 'http://test.org', + rel: 'test', + type: 'test' + }; + + const mocklink2 = { + href: 'http://test2.org', + rel: 'test', + type: 'test' + }; function init() { authService = jasmine.createSpyObj('authService', { @@ -44,8 +61,8 @@ describe('BitstreamDownloadPageComponent', () => { bitstream = Object.assign(new Bitstream(), { uuid: 'bitstreamUuid', _links: { - content: {href: 'bitstream-content-link'}, - self: {href: 'bitstream-self-link'}, + content: { href: 'bitstream-content-link' }, + self: { href: 'bitstream-self-link' }, } }); @@ -54,10 +71,21 @@ describe('BitstreamDownloadPageComponent', () => { bitstream: createSuccessfulRemoteDataObject( bitstream ) + }), + params: observableOf({ + id: 'testid' }) }; router = jasmine.createSpyObj('router', ['navigateByUrl']); + + serverResponseService = jasmine.createSpyObj('ServerResponseService', { + setHeader: jasmine.createSpy('setHeader'), + }); + + signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { + getLinks: observableOf([mocklink, mocklink2]) + }); } function initTestbed() { @@ -65,12 +93,15 @@ describe('BitstreamDownloadPageComponent', () => { imports: [CommonModule, TranslateModule.forRoot()], declarations: [BitstreamDownloadPageComponent], providers: [ - {provide: ActivatedRoute, useValue: activatedRoute}, - {provide: Router, useValue: router}, - {provide: AuthorizationDataService, useValue: authorizationService}, - {provide: AuthService, useValue: authService}, - {provide: FileService, useValue: fileService}, - {provide: HardRedirectService, useValue: hardRedirectService}, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: router }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: AuthService, useValue: authService }, + { provide: FileService, useValue: fileService }, + { provide: HardRedirectService, useValue: hardRedirectService }, + { provide: ServerResponseService, useValue: serverResponseService }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: PLATFORM_ID, useValue: 'server' } ] }) .compileComponents(); @@ -107,6 +138,9 @@ describe('BitstreamDownloadPageComponent', () => { it('should redirect to the content link', () => { expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link'); }); + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + }); }); describe('when the user is authorized and logged in', () => { beforeEach(waitForAsync(() => { @@ -134,7 +168,7 @@ describe('BitstreamDownloadPageComponent', () => { fixture.detectChanges(); }); it('should navigate to the forbidden route', () => { - expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), {skipLocationChange: true}); + expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: true }); }); }); describe('when the user is not authorized and not logged in', () => { diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts index bf1a565daa..cf8d8e7767 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts @@ -1,8 +1,8 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, OnInit, PLATFORM_ID } from '@angular/core'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { getRemoteDataPayload} from '../../core/shared/operators'; +import { getRemoteDataPayload } from '../../core/shared/operators'; import { Bitstream } from '../../core/shared/bitstream.model'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; @@ -13,8 +13,11 @@ import { HardRedirectService } from '../../core/services/hard-redirect.service'; import { getForbiddenRoute } from '../../app-routing-paths'; import { RemoteData } from '../../core/data/remote-data'; import { redirectOn4xx } from '../../core/shared/authorized.operators'; -import { Location } from '@angular/common'; +import { isPlatformServer, Location } from '@angular/common'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingLink } from '../../core/data/signposting-links.model'; @Component({ selector: 'ds-bitstream-download-page', @@ -28,7 +31,6 @@ export class BitstreamDownloadPageComponent implements OnInit { bitstream$: Observable; bitstreamRD$: Observable>; - constructor( private route: ActivatedRoute, protected router: Router, @@ -38,8 +40,11 @@ export class BitstreamDownloadPageComponent implements OnInit { private hardRedirectService: HardRedirectService, private location: Location, public dsoNameService: DSONameService, + private signpostingDataService: SignpostingDataService, + private responseService: ServerResponseService, + @Inject(PLATFORM_ID) protected platformId: string ) { - + this.initPageLinks(); } back(): void { @@ -89,4 +94,26 @@ export class BitstreamDownloadPageComponent implements OnInit { } }); } + + /** + * Create page links if any are retrieved by signposting endpoint + * + * @private + */ + private initPageLinks(): void { + if (isPlatformServer(this.platformId)) { + this.route.params.subscribe(params => { + this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => { + let links = ''; + + signpostingLinks.forEach((link: SignpostingLink) => { + links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' '); + links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}" ; type="${link.type}" `; + }); + + this.responseService.setHeader('Link', links); + }); + }); + } + } } diff --git a/src/app/core/data/signposting-data.service.spec.ts b/src/app/core/data/signposting-data.service.spec.ts new file mode 100644 index 0000000000..c76899221e --- /dev/null +++ b/src/app/core/data/signposting-data.service.spec.ts @@ -0,0 +1,97 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { SignpostingDataService } from './signposting-data.service'; +import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { of } from 'rxjs'; +import { SignpostingLink } from './signposting-links.model'; + +describe('SignpostingDataService', () => { + let service: SignpostingDataService; + let restServiceSpy: jasmine.SpyObj; + let halServiceSpy: jasmine.SpyObj; + const mocklink = { + href: 'http://test.org', + rel: 'test', + type: 'test' + }; + + const mocklink2 = { + href: 'http://test2.org', + rel: 'test', + type: 'test' + }; + + const mockResponse: any = { + statusCode: 200, + payload: [mocklink, mocklink2] + }; + + const mockErrResponse: any = { + statusCode: 500 + }; + + beforeEach(() => { + const restSpy = jasmine.createSpyObj('DspaceRestService', ['get', 'getWithHeaders']); + const halSpy = jasmine.createSpyObj('HALEndpointService', ['getRootHref']); + + TestBed.configureTestingModule({ + providers: [ + SignpostingDataService, + { provide: DspaceRestService, useValue: restSpy }, + { provide: HALEndpointService, useValue: halSpy } + ] + }); + + service = TestBed.inject(SignpostingDataService); + restServiceSpy = TestBed.inject(DspaceRestService) as jasmine.SpyObj; + halServiceSpy = TestBed.inject(HALEndpointService) as jasmine.SpyObj; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return signposting links', fakeAsync(() => { + const uuid = '123'; + const baseUrl = 'http://localhost:8080'; + + halServiceSpy.getRootHref.and.returnValue(`${baseUrl}/api`); + + restServiceSpy.get.and.returnValue(of(mockResponse)); + + let result: SignpostingLink[]; + + const expectedResult: SignpostingLink[] = [mocklink, mocklink2]; + + service.getLinks(uuid).subscribe((links) => { + result = links; + }); + + tick(); + + expect(result).toEqual(expectedResult); + expect(halServiceSpy.getRootHref).toHaveBeenCalled(); + expect(restServiceSpy.get).toHaveBeenCalledWith(`${baseUrl}/signposting/links/${uuid}`); + })); + + it('should handle error and return an empty array', fakeAsync(() => { + const uuid = '123'; + const baseUrl = 'http://localhost:8080'; + + halServiceSpy.getRootHref.and.returnValue(`${baseUrl}/api`); + + restServiceSpy.get.and.returnValue(of(mockErrResponse)); + + let result: any; + + service.getLinks(uuid).subscribe((data) => { + result = data; + }); + + tick(); + + expect(result).toEqual([]); + expect(halServiceSpy.getRootHref).toHaveBeenCalled(); + expect(restServiceSpy.get).toHaveBeenCalledWith(`${baseUrl}/signposting/links/${uuid}`); + })); +}); diff --git a/src/app/core/data/signposting-data.service.ts b/src/app/core/data/signposting-data.service.ts new file mode 100644 index 0000000000..d051ecf8db --- /dev/null +++ b/src/app/core/data/signposting-data.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; + +import { catchError, map } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; + +import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { SignpostingLink } from './signposting-links.model'; + +/** + * Service responsible for handling requests related to the Signposting endpoint + */ +@Injectable({ + providedIn: 'root' +}) +export class SignpostingDataService { + + constructor(private restService: DspaceRestService, protected halService: HALEndpointService) { + } + + /** + * Retrieve the list of signposting links related to the given resource's id + * + * @param uuid + */ + getLinks(uuid: string): Observable { + const baseUrl = this.halService.getRootHref().replace('/api', ''); + + return this.restService.get(`${baseUrl}/signposting/links/${uuid}`).pipe( + catchError((err) => { + return observableOf([]); + }), + map((res: RawRestResponse) => res.statusCode === 200 ? res.payload as SignpostingLink[] : []) + ); + } + +} diff --git a/src/app/core/data/signposting-links.model.ts b/src/app/core/data/signposting-links.model.ts new file mode 100644 index 0000000000..11d2cafe00 --- /dev/null +++ b/src/app/core/data/signposting-links.model.ts @@ -0,0 +1,8 @@ +/** + * Represents the link object received by the signposting endpoint + */ +export interface SignpostingLink { + href?: string, + rel?: string, + type?: string +} diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index 94b9ed6198..de8b45b0e5 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -46,6 +46,7 @@ export class ServerHardRedirectService extends HardRedirectService { } console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`); + this.res.redirect(status, url); this.res.end(); // I haven't found a way to correctly stop Angular rendering. diff --git a/src/app/core/services/server-response.service.ts b/src/app/core/services/server-response.service.ts index 02e00446bc..0b193d536c 100644 --- a/src/app/core/services/server-response.service.ts +++ b/src/app/core/services/server-response.service.ts @@ -1,7 +1,11 @@ import { RESPONSE } from '@nguniversal/express-engine/tokens'; import { Inject, Injectable, Optional } from '@angular/core'; + import { Response } from 'express'; +/** + * Service responsible to provide method to manage the response object + */ @Injectable() export class ServerResponseService { private response: Response; @@ -10,6 +14,12 @@ export class ServerResponseService { this.response = response; } + /** + * Set a status code to response + * + * @param code + * @param message + */ setStatus(code: number, message?: string): this { if (this.response) { this.response.statusCode = code; @@ -20,19 +30,51 @@ export class ServerResponseService { return this; } + /** + * Set Unauthorized status + * + * @param message + */ setUnauthorized(message = 'Unauthorized'): this { return this.setStatus(401, message); } + /** + * Set Forbidden status + * + * @param message + */ setForbidden(message = 'Forbidden'): this { return this.setStatus(403, message); } + /** + * Set Not found status + * + * @param message + */ setNotFound(message = 'Not found'): this { return this.setStatus(404, message); } + /** + * Set Internal Server Error status + * + * @param message + */ setInternalServerError(message = 'Internal Server Error'): this { return this.setStatus(500, message); } + + /** + * Set a response's header + * + * @param header + * @param content + */ + setHeader(header: string, content: string) { + if (this.response) { + this.response.setHeader(header, content); + } + } } diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index 53e36be1d1..9fc078c2cd 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/cor import { ItemDataService } from '../../core/data/item-data.service'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core'; import { TruncatePipe } from '../../shared/utils/truncate.pipe'; import { FullItemPageComponent } from './full-item-page.component'; import { MetadataService } from '../../core/metadata/metadata.service'; @@ -20,6 +20,9 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { createRelationshipsObservable } from '../simple/item-types/shared/item.component.spec'; import { RemoteData } from '../../core/data/remote-data'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -55,8 +58,21 @@ describe('FullItemPageComponent', () => { let routeStub: ActivatedRouteStub; let routeData; let authorizationDataService: AuthorizationDataService; + let serverResponseService: jasmine.SpyObj; + let signpostingDataService: jasmine.SpyObj; + let linkHeadService: jasmine.SpyObj; + const mocklink = { + href: 'http://test.org', + rel: 'test', + type: 'test' + }; + const mocklink2 = { + href: 'http://test2.org', + rel: 'test', + type: 'test' + }; beforeEach(waitForAsync(() => { authService = jasmine.createSpyObj('authService', { @@ -76,6 +92,19 @@ describe('FullItemPageComponent', () => { isAuthorized: observableOf(false), }); + serverResponseService = jasmine.createSpyObj('ServerResponseService', { + setHeader: jasmine.createSpy('setHeader'), + }); + + signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { + getLinks: observableOf([mocklink, mocklink2]), + }); + + linkHeadService = jasmine.createSpyObj('LinkHeadService', { + addTag: jasmine.createSpy('setHeader'), + removeTag: jasmine.createSpy('removeTag'), + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -90,8 +119,11 @@ describe('FullItemPageComponent', () => { { provide: MetadataService, useValue: metadataServiceStub }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: ServerResponseService, useValue: serverResponseService }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: PLATFORM_ID, useValue: 'server' } ], - schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(FullItemPageComponent, { set: { changeDetection: ChangeDetectionStrategy.Default } @@ -143,6 +175,11 @@ describe('FullItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); expect(objectLoader.nativeElement).not.toBeNull(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); describe('when the item is withdrawn and the user is not an admin', () => { beforeEach(() => { @@ -167,6 +204,11 @@ describe('FullItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); expect(objectLoader).not.toBeNull(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); describe('when the item is not withdrawn and the user is not an admin', () => { @@ -179,5 +221,10 @@ describe('FullItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); expect(objectLoader).not.toBeNull(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 118e436004..31dd2c5fc2 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -1,5 +1,5 @@ import { filter, map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { ActivatedRoute, Data, Router } from '@angular/router'; import { BehaviorSubject, Observable } from 'rxjs'; @@ -16,7 +16,9 @@ import { hasValue } from '../../shared/empty.util'; import { AuthService } from '../../core/auth/auth.service'; import { Location } from '@angular/common'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; - +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; /** * This component renders a full item page. @@ -43,13 +45,19 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, subs = []; - constructor(protected route: ActivatedRoute, - router: Router, - items: ItemDataService, - authService: AuthService, - authorizationService: AuthorizationDataService, - private _location: Location) { - super(route, router, items, authService, authorizationService); + constructor( + protected route: ActivatedRoute, + protected router: Router, + protected items: ItemDataService, + protected authService: AuthService, + protected authorizationService: AuthorizationDataService, + protected _location: Location, + protected responseService: ServerResponseService, + protected signpostingDataService: SignpostingDataService, + protected linkHeadService: LinkHeadService, + @Inject(PLATFORM_ID) protected platformId: string, + ) { + super(route, router, items, authService, authorizationService, responseService, signpostingDataService, linkHeadService, platformId); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/item-page/simple/item-page.component.spec.ts b/src/app/item-page/simple/item-page.component.spec.ts index 9b0e87939d..b3202108f4 100644 --- a/src/app/item-page/simple/item-page.component.spec.ts +++ b/src/app/item-page/simple/item-page.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { ItemDataService } from '../../core/data/item-data.service'; -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core'; import { ItemPageComponent } from './item-page.component'; import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; @@ -22,6 +22,10 @@ import { import { AuthService } from '../../core/auth/auth.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service'; +import { SignpostingLink } from '../../core/data/signposting-links.model'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -36,11 +40,28 @@ const mockWithdrawnItem: Item = Object.assign(new Item(), { isWithdrawn: true }); +const mocklink = { + href: 'http://test.org', + rel: 'rel1', + type: 'type1' +}; + +const mocklink2 = { + href: 'http://test2.org', + rel: 'rel2', + type: undefined +}; + +const mockSignpostingLinks: SignpostingLink[] = [mocklink, mocklink2]; + describe('ItemPageComponent', () => { let comp: ItemPageComponent; let fixture: ComponentFixture; let authService: AuthService; let authorizationDataService: AuthorizationDataService; + let serverResponseService: jasmine.SpyObj; + let signpostingDataService: jasmine.SpyObj; + let linkHeadService: jasmine.SpyObj; const mockMetadataService = { /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ @@ -60,6 +81,18 @@ describe('ItemPageComponent', () => { authorizationDataService = jasmine.createSpyObj('authorizationDataService', { isAuthorized: observableOf(false), }); + serverResponseService = jasmine.createSpyObj('ServerResponseService', { + setHeader: jasmine.createSpy('setHeader'), + }); + + signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { + getLinks: observableOf([mocklink, mocklink2]), + }); + + linkHeadService = jasmine.createSpyObj('LinkHeadService', { + addTag: jasmine.createSpy('setHeader'), + removeTag: jasmine.createSpy('removeTag'), + }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ @@ -76,6 +109,10 @@ describe('ItemPageComponent', () => { { provide: Router, useValue: {} }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: ServerResponseService, useValue: serverResponseService }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: PLATFORM_ID, useValue: 'server' }, ], schemas: [NO_ERRORS_SCHEMA] @@ -126,6 +163,33 @@ describe('ItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); expect(objectLoader.nativeElement).toBeDefined(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); + + + it('should add link tags correctly', () => { + + expect(comp.signpostingLinks).toEqual([mocklink, mocklink2]); + + // Check if linkHeadService.addTag() was called with the correct arguments + expect(linkHeadService.addTag).toHaveBeenCalledTimes(mockSignpostingLinks.length); + let expected: LinkDefinition = mockSignpostingLinks[0] as LinkDefinition; + expect(linkHeadService.addTag).toHaveBeenCalledWith(expected); + expected = { + href: 'http://test2.org', + rel: 'rel2' + }; + expect(linkHeadService.addTag).toHaveBeenCalledWith(expected); + }); + + it('should set Link header on the server', () => { + + expect(serverResponseService.setHeader).toHaveBeenCalledWith('Link', ' ; rel="rel1" ; type="type1" , ; rel="rel2" '); + }); + }); describe('when the item is withdrawn and the user is not an admin', () => { beforeEach(() => { @@ -150,6 +214,11 @@ describe('ItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); expect(objectLoader.nativeElement).toBeDefined(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); describe('when the item is not withdrawn and the user is not an admin', () => { @@ -162,6 +231,11 @@ describe('ItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); expect(objectLoader.nativeElement).toBeDefined(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index 6e0db386db..b9be6bebfb 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -1,8 +1,9 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { isPlatformServer } from '@angular/common'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; @@ -15,6 +16,11 @@ import { getItemPageRoute } from '../item-page-routing-paths'; import { redirectOn4xx } from '../../core/shared/authorized.operators'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { SignpostingLink } from '../../core/data/signposting-links.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service'; /** * This component renders a simple item page. @@ -28,7 +34,7 @@ import { FeatureID } from '../../core/data/feature-authorization/feature-id'; changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) -export class ItemPageComponent implements OnInit { +export class ItemPageComponent implements OnInit, OnDestroy { /** * The item's id @@ -57,13 +63,23 @@ export class ItemPageComponent implements OnInit { itemUrl: string; + /** + * Contains a list of SignpostingLink related to the item + */ + signpostingLinks: SignpostingLink[] = []; + constructor( protected route: ActivatedRoute, - private router: Router, - private items: ItemDataService, - private authService: AuthService, - private authorizationService: AuthorizationDataService + protected router: Router, + protected items: ItemDataService, + protected authService: AuthService, + protected authorizationService: AuthorizationDataService, + protected responseService: ServerResponseService, + protected signpostingDataService: SignpostingDataService, + protected linkHeadService: LinkHeadService, + @Inject(PLATFORM_ID) protected platformId: string ) { + this.initPageLinks(); } /** @@ -82,4 +98,42 @@ export class ItemPageComponent implements OnInit { this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); } + + /** + * Create page links if any are retrieved by signposting endpoint + * + * @private + */ + private initPageLinks(): void { + this.route.params.subscribe(params => { + this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => { + let links = ''; + this.signpostingLinks = signpostingLinks; + + signpostingLinks.forEach((link: SignpostingLink) => { + links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' '); + let tag: LinkDefinition = { + href: link.href, + rel: link.rel + }; + if (isNotEmpty(link.type)) { + tag = Object.assign(tag, { + type: link.type + }); + } + this.linkHeadService.addTag(tag); + }); + + if (isPlatformServer(this.platformId)) { + this.responseService.setHeader('Link', links); + } + }); + }); + } + + ngOnDestroy(): void { + this.signpostingLinks.forEach((link: SignpostingLink) => { + this.linkHeadService.removeTag(`href='${link.href}'`); + }); + } } diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts index d909bb0e8d..715f872cd9 100644 --- a/src/modules/app/server-init.service.ts +++ b/src/modules/app/server-init.service.ts @@ -38,7 +38,7 @@ export class ServerInitService extends InitService { protected metadata: MetadataService, protected breadcrumbsService: BreadcrumbsService, protected themeService: ThemeService, - protected menuService: MenuService, + protected menuService: MenuService ) { super( store,