diff --git a/src/ldp/http/metadata/LinkRelMetadataWriter.ts b/src/ldp/http/metadata/LinkRelMetadataWriter.ts new file mode 100644 index 000000000..be5d19a69 --- /dev/null +++ b/src/ldp/http/metadata/LinkRelMetadataWriter.ts @@ -0,0 +1,28 @@ +import type { HttpResponse } from '../../../server/HttpResponse'; +import { addHeader } from '../../../util/Util'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataWriter } from './MetadataWriter'; + +/** + * A {@link MetadataWriter} that takes a linking metadata predicates to Link header "rel" values. + * The values of the objects will be put in a Link header with the corresponding "rel" value. + */ +export class LinkRelMetadataWriter extends MetadataWriter { + private readonly linkRelMap: Record; + + // Not supported by Components.js yet + // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style + public constructor(linkRelMap: { [predicate: string]: string }) { + super(); + this.linkRelMap = linkRelMap; + } + + public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise { + for (const key of Object.keys(this.linkRelMap)) { + const values = input.metadata.getAll(key).map((term): string => `<${term.value}>; rel="${this.linkRelMap[key]}"`); + if (values.length > 0) { + addHeader(input.response, 'link', values); + } + } + } +} diff --git a/src/ldp/http/metadata/MappedMetadataWriter.ts b/src/ldp/http/metadata/MappedMetadataWriter.ts new file mode 100644 index 000000000..c425bde73 --- /dev/null +++ b/src/ldp/http/metadata/MappedMetadataWriter.ts @@ -0,0 +1,28 @@ +import type { HttpResponse } from '../../../server/HttpResponse'; +import { addHeader } from '../../../util/Util'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataWriter } from './MetadataWriter'; + +/** + * A {@link MetadataWriter} that takes a map directly converting metadata predicates to headers. + * The header value(s) will be the same as the corresponding object value(s). + */ +export class MappedMetadataWriter extends MetadataWriter { + private readonly headerMap: Record; + + // Not supported by Components.js yet + // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style + public constructor(headerMap: { [predicate: string]: string }) { + super(); + this.headerMap = headerMap; + } + + public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise { + for (const key of Object.keys(this.headerMap)) { + const values = input.metadata.getAll(key).map((term): string => term.value); + if (values.length > 0) { + addHeader(input.response, this.headerMap[key], values); + } + } + } +} diff --git a/src/ldp/http/metadata/MetadataWriter.ts b/src/ldp/http/metadata/MetadataWriter.ts new file mode 100644 index 000000000..46dd8aa3f --- /dev/null +++ b/src/ldp/http/metadata/MetadataWriter.ts @@ -0,0 +1,9 @@ +import type { HttpResponse } from '../../../server/HttpResponse'; +import { AsyncHandler } from '../../../util/AsyncHandler'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; + +/** + * A serializer that converts metadata to headers for an HttpResponse. + */ +export abstract class MetadataWriter + extends AsyncHandler<{ response: HttpResponse; metadata: RepresentationMetadata }> { } diff --git a/src/util/Util.ts b/src/util/Util.ts index 3b5b43290..679cc0983 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -3,6 +3,7 @@ import arrayifyStream from 'arrayify-stream'; import { DataFactory } from 'n3'; import type { Literal, NamedNode, Quad } from 'rdf-js'; import { getLoggerFor } from '../logging/LogUtil'; +import type { HttpResponse } from '../server/HttpResponse'; const logger = getLoggerFor('Util'); @@ -100,3 +101,25 @@ export const encodeUriPathComponents = (path: string): string => path.split('/') export const pushQuad = (quads: Quad[], subject: NamedNode, predicate: NamedNode, object: NamedNode | Literal): number => quads.push(DataFactory.quad(subject, predicate, object)); + +/** + * Adds a header value without overriding previous values. + */ +export const addHeader = (response: HttpResponse, name: string, value: string | string[]): void => { + let allValues: string[] = []; + if (response.hasHeader(name)) { + let oldValues = response.getHeader(name)!; + if (typeof oldValues === 'string') { + oldValues = [ oldValues ]; + } else if (typeof oldValues === 'number') { + oldValues = [ `${oldValues}` ]; + } + allValues = oldValues; + } + if (Array.isArray(value)) { + allValues.push(...value); + } else { + allValues.push(value); + } + response.setHeader(name, allValues.length === 1 ? allValues[0] : allValues); +}; diff --git a/test/unit/ldp/http/metadata/LinkRelMetadataWriter.test.ts b/test/unit/ldp/http/metadata/LinkRelMetadataWriter.test.ts new file mode 100644 index 000000000..c1e59abfd --- /dev/null +++ b/test/unit/ldp/http/metadata/LinkRelMetadataWriter.test.ts @@ -0,0 +1,27 @@ +import { LinkRelMetadataWriter } from '../../../../../src/ldp/http/metadata/LinkRelMetadataWriter'; +import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata'; +import { LDP, RDF } from '../../../../../src/util/UriConstants'; +import { toNamedNode } from '../../../../../src/util/UriUtil'; +import * as util from '../../../../../src/util/Util'; + +describe('A LinkRelMetadataWriter', (): void => { + const writer = new LinkRelMetadataWriter({ [RDF.type]: 'type', dummy: 'dummy' }); + let mock: jest.SpyInstance; + let addHeaderMock: jest.Mock; + + beforeEach(async(): Promise => { + addHeaderMock = jest.fn(); + mock = jest.spyOn(util, 'addHeader').mockImplementation(addHeaderMock); + }); + + afterEach(async(): Promise => { + mock.mockRestore(); + }); + + it('adds the correct link headers.', async(): Promise => { + const metadata = new RepresentationMetadata({ [RDF.type]: toNamedNode(LDP.Resource), unused: 'text' }); + await expect(writer.handle({ response: 'response' as any, metadata })).resolves.toBeUndefined(); + expect(addHeaderMock).toHaveBeenCalledTimes(1); + expect(addHeaderMock).toHaveBeenLastCalledWith('response', 'link', [ `<${LDP.Resource}>; rel="type"` ]); + }); +}); diff --git a/test/unit/ldp/http/metadata/MappedMetadataWriter.test.ts b/test/unit/ldp/http/metadata/MappedMetadataWriter.test.ts new file mode 100644 index 000000000..b093f069b --- /dev/null +++ b/test/unit/ldp/http/metadata/MappedMetadataWriter.test.ts @@ -0,0 +1,26 @@ +import { MappedMetadataWriter } from '../../../../../src/ldp/http/metadata/MappedMetadataWriter'; +import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata'; +import { CONTENT_TYPE } from '../../../../../src/util/UriConstants'; +import * as util from '../../../../../src/util/Util'; + +describe('A MappedMetadataWriter', (): void => { + const writer = new MappedMetadataWriter({ [CONTENT_TYPE]: 'content-type', dummy: 'dummy' }); + let mock: jest.SpyInstance; + let addHeaderMock: jest.Mock; + + beforeEach(async(): Promise => { + addHeaderMock = jest.fn(); + mock = jest.spyOn(util, 'addHeader').mockImplementation(addHeaderMock); + }); + + afterEach(async(): Promise => { + mock.mockRestore(); + }); + + it('adds metadata to the corresponding header.', async(): Promise => { + const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle', unused: 'text' }); + await expect(writer.handle({ response: 'response' as any, metadata })).resolves.toBeUndefined(); + expect(addHeaderMock).toHaveBeenCalledTimes(1); + expect(addHeaderMock).toHaveBeenLastCalledWith('response', 'content-type', [ 'text/turtle' ]); + }); +}); diff --git a/test/unit/util/Util.test.ts b/test/unit/util/Util.test.ts index b0344f8b2..74ec279af 100644 --- a/test/unit/util/Util.test.ts +++ b/test/unit/util/Util.test.ts @@ -2,7 +2,9 @@ import { PassThrough } from 'stream'; import { DataFactory } from 'n3'; import type { Quad } from 'rdf-js'; import streamifyArray from 'streamify-array'; +import type { HttpResponse } from '../../../src/server/HttpResponse'; import { + addHeader, decodeUriPathComponents, encodeUriPathComponents, ensureTrailingSlash, @@ -96,4 +98,41 @@ describe('Util function', (): void => { ]); }); }); + + describe('addHeader', (): void => { + let response: HttpResponse; + + beforeEach(async(): Promise => { + const headers: Record = {}; + response = { + hasHeader: (name: string): boolean => Boolean(headers[name]), + getHeader: (name: string): number | string | string[] | undefined => headers[name], + setHeader(name: string, value: number | string | string[]): void { + headers[name] = value; + }, + } as any; + }); + + it('adds values if there are none already.', async(): Promise => { + expect(addHeader(response, 'name', 'value')).toBeUndefined(); + expect(response.getHeader('name')).toBe('value'); + + expect(addHeader(response, 'names', [ 'value1', 'values2' ])).toBeUndefined(); + expect(response.getHeader('names')).toEqual([ 'value1', 'values2' ]); + }); + + it('appends values to already existing values.', async(): Promise => { + response.setHeader('name', 'oldValue'); + expect(addHeader(response, 'name', 'value')).toBeUndefined(); + expect(response.getHeader('name')).toEqual([ 'oldValue', 'value' ]); + + response.setHeader('number', 5); + expect(addHeader(response, 'number', 'value')).toBeUndefined(); + expect(response.getHeader('number')).toEqual([ '5', 'value' ]); + + response.setHeader('names', [ 'oldValue1', 'oldValue2' ]); + expect(addHeader(response, 'names', [ 'value1', 'values2' ])).toBeUndefined(); + expect(response.getHeader('names')).toEqual([ 'oldValue1', 'oldValue2', 'value1', 'values2' ]); + }); + }); });