diff --git a/config/ldp/metadata-writer/default.json b/config/ldp/metadata-writer/default.json index 62dd7ade6..dad682d35 100644 --- a/config/ldp/metadata-writer/default.json +++ b/config/ldp/metadata-writer/default.json @@ -3,6 +3,7 @@ "import": [ "css:config/ldp/metadata-writer/writers/allow-accept.json", "css:config/ldp/metadata-writer/writers/constant.json", + "css:config/ldp/metadata-writer/writers/content-type.json", "css:config/ldp/metadata-writer/writers/link-rel.json", "css:config/ldp/metadata-writer/writers/mapped.json", "css:config/ldp/metadata-writer/writers/modified.json", @@ -17,6 +18,7 @@ "handlers": [ { "@id": "urn:solid-server:default:MetadataWriter_AllowAccept" }, { "@id": "urn:solid-server:default:MetadataWriter_Constant" }, + { "@id": "urn:solid-server:default:MetadataWriter_ContentType" }, { "@id": "urn:solid-server:default:MetadataWriter_Mapped" }, { "@id": "urn:solid-server:default:MetadataWriter_Modified" }, { "@id": "urn:solid-server:default:MetadataWriter_LinkRel" }, diff --git a/config/ldp/metadata-writer/writers/content-type.json b/config/ldp/metadata-writer/writers/content-type.json new file mode 100644 index 000000000..747a0a3ed --- /dev/null +++ b/config/ldp/metadata-writer/writers/content-type.json @@ -0,0 +1,10 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Adds the Content-Type header with value and parameters (if available).", + "@id": "urn:solid-server:default:MetadataWriter_ContentType", + "@type": "ContentTypeMetadataWriter" + } + ] +} diff --git a/config/ldp/metadata-writer/writers/mapped.json b/config/ldp/metadata-writer/writers/mapped.json index c0cd6cbe6..45f4296a2 100644 --- a/config/ldp/metadata-writer/writers/mapped.json +++ b/config/ldp/metadata-writer/writers/mapped.json @@ -6,10 +6,6 @@ "@id": "urn:solid-server:default:MetadataWriter_Mapped", "@type": "MappedMetadataWriter", "headerMap": [ - { - "MappedMetadataWriter:_headerMap_key": "http://www.w3.org/ns/ma-ont#format", - "MappedMetadataWriter:_headerMap_value": "Content-Type" - }, { "MappedMetadataWriter:_headerMap_key": "urn:npm:solid:community-server:http:location", "MappedMetadataWriter:_headerMap_value": "Location" diff --git a/src/http/input/metadata/ContentTypeParser.ts b/src/http/input/metadata/ContentTypeParser.ts index eb95ddb73..fddc713ed 100644 --- a/src/http/input/metadata/ContentTypeParser.ts +++ b/src/http/input/metadata/ContentTypeParser.ts @@ -4,7 +4,6 @@ import { MetadataParser } from './MetadataParser'; /** * Parser for the `content-type` header. - * Currently only stores the media type and ignores other parameters such as charset. */ export class ContentTypeParser extends MetadataParser { public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise { diff --git a/src/http/output/metadata/ContentTypeMetadataWriter.ts b/src/http/output/metadata/ContentTypeMetadataWriter.ts new file mode 100644 index 000000000..d57809317 --- /dev/null +++ b/src/http/output/metadata/ContentTypeMetadataWriter.ts @@ -0,0 +1,15 @@ +import type { HttpResponse } from '../../../server/HttpResponse'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataWriter } from './MetadataWriter'; + +/** + * Adds the `Content-Type` header containing value and parameters (if available). + */ +export class ContentTypeMetadataWriter extends MetadataWriter { + public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise { + const { contentTypeObject } = input.metadata; + if (contentTypeObject) { + input.response.setHeader('Content-Type', contentTypeObject.toHeaderValueString()); + } + } +} diff --git a/src/http/representation/RepresentationMetadata.ts b/src/http/representation/RepresentationMetadata.ts index 2366b3ea2..22c8da74c 100644 --- a/src/http/representation/RepresentationMetadata.ts +++ b/src/http/representation/RepresentationMetadata.ts @@ -2,8 +2,7 @@ import { DataFactory, Store } from 'n3'; import type { BlankNode, DefaultGraph, Literal, NamedNode, Quad, Term } from 'rdf-js'; import { getLoggerFor } from '../../logging/LogUtil'; import { InternalServerError } from '../../util/errors/InternalServerError'; -import type { ContentType } from '../../util/HeaderUtil'; -import { parseContentType } from '../../util/HeaderUtil'; +import { ContentType, parseContentType } from '../../util/HeaderUtil'; import { toNamedTerm, toObjectTerm, isTerm, toLiteral } from '../../util/TermUtil'; import { CONTENT_TYPE_TERM, CONTENT_LENGTH_TERM, XSD, SOLID_META, RDFS } from '../../util/Vocabularies'; import type { ResourceIdentifier } from './ResourceIdentifier'; @@ -369,18 +368,16 @@ export class RepresentationMetadata { return; } const params = this.getAll(SOLID_META.terms.contentTypeParameter); - return { - value, - parameters: Object.fromEntries(params.map((param): [string, string] => { - const labels = this.store.getObjects(param, RDFS.terms.label, null); - const values = this.store.getObjects(param, SOLID_META.terms.value, null); - if (labels.length !== 1 || values.length !== 1) { - this.logger.error(`Detected invalid content-type metadata for ${this.id.value}`); - return [ 'invalid', '' ]; - } - return [ labels[0].value, values[0].value ]; - })), - }; + const parameters = Object.fromEntries(params.map((param): [string, string] => { + const labels = this.store.getObjects(param, RDFS.terms.label, null); + const values = this.store.getObjects(param, SOLID_META.terms.value, null); + if (labels.length !== 1 || values.length !== 1) { + this.logger.error(`Detected invalid content-type metadata for ${this.id.value}`); + return [ 'invalid', '' ]; + } + return [ labels[0].value, values[0].value ]; + })); + return new ContentType(value, parameters); } private removeContentType(): void { diff --git a/src/index.ts b/src/index.ts index aec9ff246..449728fcd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,6 +96,7 @@ export * from './http/output/error/SafeErrorHandler'; // HTTP/Output/Metadata export * from './http/output/metadata/AllowAcceptHeaderWriter'; export * from './http/output/metadata/ConstantMetadataWriter'; +export * from './http/output/metadata/ContentTypeMetadataWriter'; export * from './http/output/metadata/LinkRelMetadataWriter'; export * from './http/output/metadata/MappedMetadataWriter'; export * from './http/output/metadata/MetadataWriter'; diff --git a/src/util/HeaderUtil.ts b/src/util/HeaderUtil.ts index bc85e407c..915ed6879 100644 --- a/src/util/HeaderUtil.ts +++ b/src/util/HeaderUtil.ts @@ -106,9 +106,18 @@ export interface AcceptDatetime extends AcceptHeader { } * Contents of a HTTP Content-Type Header. * Optional parameters Record is included. */ -export interface ContentType { - value: string; - parameters: Record; +export class ContentType { + public constructor(public value: string, public parameters: Record = {}) {} + + /** + * Serialize this ContentType object to a ContentType header appropriate value string. + * @returns The value string, including parameters, if present. + */ + public toHeaderValueString(): string { + return Object.entries(this.parameters) + .sort((entry1, entry2): number => entry1[0].localeCompare(entry2[0])) + .reduce((acc, entry): string => `${acc}; ${entry[0]}=${entry[1]}`, this.value); + } } export interface LinkEntryParameters extends Record { @@ -463,7 +472,7 @@ export function parseContentType(input: string): ContentType { prev.parameters[cur.name] = cur.value; return prev; }, - { value, parameters: {}}, + new ContentType(value), ); } diff --git a/test/integration/ContentNegotiation.test.ts b/test/integration/ContentNegotiation.test.ts index 8573df15b..7ade49995 100644 --- a/test/integration/ContentNegotiation.test.ts +++ b/test/integration/ContentNegotiation.test.ts @@ -10,6 +10,7 @@ const baseUrl = `http://localhost:${port}`; const documents = [ [ '/turtle', 'text/turtle', '# Test' ], [ '/markdown', 'text/markdown', '# Test' ], + [ '/plain', 'text/plain; charset=utf-8', '# Test' ], ]; const cases: [string, string, string][] = [ @@ -27,6 +28,8 @@ const cases: [string, string, string][] = [ [ '/markdown', 'text/html', 'text/html,*/*;q=0.8' ], [ '/markdown', 'text/html', 'text/markdown;q=0.1, text/html;q=0.9' ], [ '/markdown', 'application/octet-stream', 'application/octet-stream' ], + [ '/plain', 'text/plain; charset=utf-8', 'text/plain' ], + [ '/plain', 'text/plain; charset=utf-8', 'text/plain;q=0.1, text/markdown;q=0.9' ], ]; describe('Content negotiation', (): void => { diff --git a/test/unit/http/output/metadata/ContentTypeMetadataWriter.test.ts b/test/unit/http/output/metadata/ContentTypeMetadataWriter.test.ts new file mode 100644 index 000000000..0ab61c7a5 --- /dev/null +++ b/test/unit/http/output/metadata/ContentTypeMetadataWriter.test.ts @@ -0,0 +1,43 @@ +import { createResponse } from 'node-mocks-http'; +import { ContentTypeMetadataWriter } from '../../../../../src/http/output/metadata/ContentTypeMetadataWriter'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { HttpResponse } from '../../../../../src/server/HttpResponse'; + +describe('A ContentTypeMetadataWriter', (): void => { + const writer = new ContentTypeMetadataWriter(); + let response: HttpResponse; + + beforeEach(async(): Promise => { + response = createResponse() as HttpResponse; + }); + + it('adds no header if there is no relevant metadata.', async(): Promise => { + const metadata = new RepresentationMetadata(); + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + expect(response.getHeaders()).toEqual({ }); + }); + + it('adds a Content-Type header with parameters if present.', async(): Promise => { + const metadata = new RepresentationMetadata('text/plain; charset=utf-8'); + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + + expect(response.getHeaders()).toEqual({ + 'content-type': 'text/plain; charset=utf-8', + }); + + const metadata2 = new RepresentationMetadata('text/plain; charset="utf-8"'); + await expect(writer.handle({ response, metadata: metadata2 })).resolves.toBeUndefined(); + expect(response.getHeaders()).toEqual({ + 'content-type': 'text/plain; charset=utf-8', + }); + }); + + it('adds a Content-Type header without parameters.', async(): Promise => { + const metadata = new RepresentationMetadata('text/plain'); + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + + expect(response.getHeaders()).toEqual({ + 'content-type': 'text/plain', + }); + }); +}); diff --git a/test/unit/http/representation/RepresentationMetadata.test.ts b/test/unit/http/representation/RepresentationMetadata.test.ts index 7d82e78c1..d64e8f639 100644 --- a/test/unit/http/representation/RepresentationMetadata.test.ts +++ b/test/unit/http/representation/RepresentationMetadata.test.ts @@ -2,6 +2,7 @@ import 'jest-rdf'; import type { BlankNode } from 'n3'; import { DataFactory } from 'n3'; import type { NamedNode, Quad } from 'rdf-js'; +import { ContentType } from '../../../../src'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import { CONTENT_TYPE_TERM, SOLID_META, RDFS } from '../../../../src/util/Vocabularies'; const { defaultGraph, literal, namedNode, quad } = DataFactory; @@ -320,13 +321,13 @@ describe('A RepresentationMetadata', (): void => { it('has a shorthand for Content-Type as object.', async(): Promise => { expect(metadata.contentType).toBeUndefined(); expect(metadata.contentTypeObject).toBeUndefined(); - metadata.contentTypeObject = { - value: 'text/plain', - parameters: { + metadata.contentTypeObject = new ContentType( + 'text/plain', + { charset: 'utf-8', test: 'value1', }, - }; + ); expect(metadata.contentTypeObject).toEqual({ value: 'text/plain', parameters: { diff --git a/test/unit/util/HeaderUtil.test.ts b/test/unit/util/HeaderUtil.test.ts index bb305585e..e8eb77f74 100644 --- a/test/unit/util/HeaderUtil.test.ts +++ b/test/unit/util/HeaderUtil.test.ts @@ -1,6 +1,6 @@ import type { HttpResponse } from '../../../src/server/HttpResponse'; import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError'; -import { +import { ContentType, addHeader, hasScheme, matchesAuthorizationScheme, @@ -11,8 +11,7 @@ import { parseAcceptLanguage, parseContentType, parseForwarded, - parseLinkHeader, -} from '../../../src/util/HeaderUtil'; + parseLinkHeader } from '../../../src/util/HeaderUtil'; describe('HeaderUtil', (): void => { describe('#parseAccept', (): void => { @@ -469,4 +468,21 @@ describe('HeaderUtil', (): void => { expect(hasScheme('wss://example.com', 'http', 'WSS')).toBeTruthy(); }); }); + describe('A ContentType instance', (): void => { + it('can serialize to a correct header value string with parameters.', (): void => { + const contentType: ContentType = new ContentType( + 'text/plain', + { + charset: 'utf-8', + extra: 'test', + }, + ); + expect(contentType.toHeaderValueString()).toBe('text/plain; charset=utf-8; extra=test'); + }); + + it('can serialize to a correct header value string without parameters.', (): void => { + const contentType: ContentType = new ContentType('text/plain'); + expect(contentType.toHeaderValueString()).toBe('text/plain'); + }); + }); });