Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/app/shared/services/signposting.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { DOCUMENT } from '@angular/common';
import { RESPONSE_INIT } from '@angular/core';
import { TestBed } from '@angular/core/testing';

import { SignpostingService } from './signposting.service';

describe('Service: Signposting', () => {
let service: SignpostingService;
let mockDocument: Document;
let mockResponseInit: ResponseInit;

beforeEach(() => {
mockResponseInit = {
headers: new Headers(),
};
mockDocument = {
createElement: jest.fn((tagName: string) => {
const element: any = {
tagName,
attributes: {} as Record<string, string>,
setAttribute: function (attrName: string, attrValue: string) {
this.attributes[attrName] = attrValue;
},
getAttribute: function (attrName: string) {
return this.attributes[attrName];
},
};
return element;
}),
head: {
appendChild: jest.fn(),
},
} as any;
TestBed.configureTestingModule({
providers: [
SignpostingService,
{ provide: DOCUMENT, useValue: mockDocument },
{ provide: RESPONSE_INIT, useValue: mockResponseInit },
],
});

service = TestBed.inject(SignpostingService);
mockDocument = TestBed.inject(DOCUMENT);
mockResponseInit = TestBed.inject(RESPONSE_INIT) as ResponseInit;
});

it('should set headers using addSignposting', () => {
service.addSignposting('abcde');
const linkHeader = (mockResponseInit.headers as Headers).get('Link');
expect(linkHeader).toBe(
'<https://staging3.osf.io/metadata/abcde/?format=linkset>; rel="linkset"; type="application/linkset", <https://staging3.osf.io/metadata/abcde/?format=linkset%2Bjson>; rel="linkset"; type="application/linkset+json"'
);
});

it('should add link tags using addSignposting', () => {
service.addSignposting('abcde');
expect(mockDocument.createElement).toHaveBeenNthCalledWith(1, 'link');
expect(mockDocument.createElement).toHaveBeenNthCalledWith(2, 'link');

expect(mockDocument.head.appendChild).toHaveBeenCalledTimes(2);

const firstLinkElement = (mockDocument.head.appendChild as jest.Mock).mock.calls[0][0];
expect(firstLinkElement.getAttribute('rel')).toBe('linkset');
expect(firstLinkElement.getAttribute('href')).toBe('https://staging3.osf.io/metadata/abcde/?format=linkset');
expect(firstLinkElement.getAttribute('type')).toBe('application/linkset');
const secondLinkElement = (mockDocument.head.appendChild as jest.Mock).mock.calls[1][0];
expect(secondLinkElement.getAttribute('rel')).toBe('linkset');
expect(secondLinkElement.getAttribute('href')).toBe(
'https://staging3.osf.io/metadata/abcde/?format=linkset%2Bjson'
);
expect(secondLinkElement.getAttribute('type')).toBe('application/linkset+json');
});
});
98 changes: 98 additions & 0 deletions src/app/shared/services/signposting.service.ts
Copy link
Collaborator

@nsemets nsemets Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is simplified code:

import { inject, Injectable, RendererFactory2, RESPONSE_INIT } from '@angular/core';

import { ENVIRONMENT } from '@core/provider/environment.provider';

export interface SignpostingLink {
  rel: string;
  href: string;
  type: string;
}

const LINKSET_TYPE = 'application/linkset';
const LINKSET_JSON_TYPE = 'application/linkset+json';

@Injectable({
  providedIn: 'root',
})
export class SignpostingService {
  private readonly document = inject(DOCUMENT);
  private readonly environment = inject(ENVIRONMENT);
  private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
  private readonly renderer = inject(RendererFactory2).createRenderer(null, null);

  addSignposting(guid: string): void {
    const links = this.generateSignpostingLinks(guid);

    this.addSignpostingLinkHeaders(links);
    this.addSignpostingLinkTags(links);
  }

  private generateSignpostingLinks(guid: string): SignpostingLink[] {
    const baseUrl = `${this.environment.webUrl}/metadata/${guid}/`;

    return [
      {
        rel: 'linkset',
        href: this.buildUrl(baseUrl, 'linkset'),
        type: LINKSET_TYPE,
      },
      {
        rel: 'linkset',
        href: this.buildUrl(baseUrl, 'linkset+json'),
        type: LINKSET_JSON_TYPE,
      },
    ];
  }

  private buildUrl(base: string, format: string): string {
    const url = new URL(base);
    url.searchParams.set('format', format);
    return url.toString();
  }

  private addSignpostingLinkHeaders(links: SignpostingLink[]): void {
    if (!this.responseInit) return;

    const headers =
      this.responseInit.headers instanceof Headers ? this.responseInit.headers : new Headers(this.responseInit.headers);

    const linkHeaderValue = links.map((link) => `<${link.href}>; rel="${link.rel}"; type="${link.type}"`).join(', ');

    headers.set('Link', linkHeaderValue);
    this.responseInit.headers = headers;
  }

  private addSignpostingLinkTags(links: SignpostingLink[]): void {
    links.forEach((link) => {
      const linkElement = this.renderer.createElement('link');
      this.renderer.setAttribute(linkElement, 'rel', link.rel);
      this.renderer.setAttribute(linkElement, 'href', link.href);
      this.renderer.setAttribute(linkElement, 'type', link.type);
      this.renderer.appendChild(this.document.head, linkElement);
    });
  }
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { of } from 'rxjs';

import { DOCUMENT } from '@angular/common';
import { inject, Injectable, RESPONSE_INIT } from '@angular/core';

import { ENVIRONMENT } from '@core/provider/environment.provider';

export interface SignpostingLink {
rel: string;
href: string;
type: string;
}

export const linksetType = 'application/linkset';
export const linksetJsonType = 'application/linkset+json';
Comment on lines +8 to +15
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move it to separate file into shared/models.


@Injectable({
providedIn: 'root',
})
export class SignpostingService {
private readonly document = inject(DOCUMENT);
private readonly environment = inject(ENVIRONMENT);
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });

addSignposting(guid: string): void {
const baseUrl = `${this.environment.webUrl}/metadata/${guid}/`;
const linksetUrl = new URL(baseUrl);
linksetUrl.searchParams.set('format', 'linkset');
const linksetJsonUrl = new URL(baseUrl);
linksetJsonUrl.searchParams.set('format', 'linkset+json');
const signpostingLinks: SignpostingLink[] = [
{
rel: 'linkset',
href: linksetUrl.toString(),
type: linksetType,
},
{
rel: 'linkset',
href: linksetJsonUrl.toString(),
type: linksetJsonType,
},
];

this.addSignpostingLinkHeaders(signpostingLinks);
this.addSignpostingLinkTags(signpostingLinks);
}

private addSignpostingLinkHeaders(links: SignpostingLink[]): void {
of(links).subscribe({
next: (links) => {
if (!this.responseInit || !this.responseInit.headers) {
return;
}

const headers =
this.responseInit?.headers instanceof Headers
? this.responseInit.headers
: new Headers(this.responseInit?.headers);

const linkHeader = this.formatLinkHeaders(links);
headers.set('Link', linkHeader);

this.responseInit.headers = headers;
},
});
}

private addSignpostingLinkTags(links: SignpostingLink[]): void {
of(links).subscribe({
next: (links) => {
const linkElements = this.formatLinkElements(links);
linkElements.forEach((linkElement) => {
this.document.head.appendChild(linkElement);
});
},
});
}

private formatLinkHeaders(links: SignpostingLink[]): string {
return links
.map((link) => {
const parts = [`<${link.href}>`, `rel="${link.rel}"`];
if (link.type) parts.push(`type="${link.type}"`);
return parts.join('; ');
})
.join(', ');
}

private formatLinkElements(links: SignpostingLink[]): HTMLLinkElement[] {
return links.map((link) => {
const linkElement = this.document.createElement('link');
linkElement.setAttribute('rel', link.rel);
linkElement.setAttribute('href', link.href);
linkElement.setAttribute('type', link.type);
return linkElement;
});
}
}