diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index 789a889bf..7c3972f1a 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -23,26 +23,7 @@ "idpPath": "/idp", "requestParser": { "@id": "urn:solid-server:default:RequestParser" }, "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, - "templateHandler": { - "@type": "TemplateHandler", - "responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, - "templateEngine": { - "comment": "Renders the specific page and embeds it into the main HTML body.", - "@type": "ChainedTemplateEngine", - "renderedName": "htmlBody", - "engines": [ - { - "comment": "Will be called with specific interaction templates to generate HTML snippets.", - "@type": "EjsTemplateEngine" - }, - { - "comment": "Will embed the result of the first engine into the main HTML template.", - "@type": "EjsTemplateEngine", - "template": "@css:templates/main.html.ejs" - } - ] - } - }, + "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "interactionCompleter": { "comment": "Responsible for finishing OIDC interactions.", "@type": "InteractionCompleter", diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json index fc0e48d38..3d72f53de 100644 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -6,8 +6,14 @@ "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", "@type": "InteractionRoute", "route": "^/forgotpassword/?$", - "viewTemplate": "@css:templates/identity/email-password/forgot-password.html.ejs", - "responseTemplate": "@css:templates/identity/email-password/forgot-password-response.html.ejs", + "viewTemplates": { + "InteractionRoute:_viewTemplates_key": "text/html", + "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs" + }, + "responseTemplates": { + "InteractionRoute:_responseTemplates_key": "text/html", + "InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs" + }, "handler": { "@type": "ForgotPasswordHandler", "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index 60b583f2a..d91f3adfc 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -7,7 +7,10 @@ "@type": "InteractionRoute", "route": "^/login/?$", "prompt": "default", - "viewTemplate": "@css:templates/identity/email-password/login.html.ejs", + "viewTemplates": { + "InteractionRoute:_viewTemplates_key": "text/html", + "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs" + }, "handler": { "@type": "LoginHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index b7af5b605..c047290ee 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -7,8 +7,14 @@ "@id": "urn:solid-server:auth:password:ResetPasswordRoute", "@type": "InteractionRoute", "route": "^/resetpassword(/[^/]*)?$", - "viewTemplate": "@css:templates/identity/email-password/reset-password.html.ejs", - "responseTemplate": "@css:templates/identity/email-password/reset-password-response.html.ejs", + "viewTemplates": { + "InteractionRoute:_viewTemplates_key": "text/html", + "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs" + }, + "responseTemplates": { + "InteractionRoute:_responseTemplates_key": "text/html", + "InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs" + }, "handler": { "@type": "ResetPasswordHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } diff --git a/config/identity/handler/interaction/routes/session.json b/config/identity/handler/interaction/routes/session.json index 8b2fe5f8a..d202d2ac9 100644 --- a/config/identity/handler/interaction/routes/session.json +++ b/config/identity/handler/interaction/routes/session.json @@ -7,7 +7,10 @@ "@type": "InteractionRoute", "route": "^/confirm/?$", "prompt": "consent", - "viewTemplate": "@css:templates/identity/email-password/confirm.html.ejs", + "viewTemplates": { + "InteractionRoute:_viewTemplates_key": "text/html", + "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs" + }, "handler": { "@type": "SessionHttpHandler" } } ] diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json index ce68c8516..0d38f8807 100644 --- a/config/identity/registration/route/registration.json +++ b/config/identity/registration/route/registration.json @@ -6,8 +6,14 @@ "@id": "urn:solid-server:auth:password:RegistrationRoute", "@type": "InteractionRoute", "route": "^/register/?$", - "viewTemplate": "@css:templates/identity/email-password/register.html.ejs", - "responseTemplate": "@css:templates/identity/email-password/register-response.html.ejs", + "viewTemplates": { + "InteractionRoute:_viewTemplates_key": "text/html", + "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs" + }, + "responseTemplates": { + "InteractionRoute:_responseTemplates_key": "text/html", + "InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs" + }, "handler": { "@type": "RegistrationHandler", "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, diff --git a/config/util/representation-conversion/converters/dynamic-json-template.json b/config/util/representation-conversion/converters/dynamic-json-template.json new file mode 100644 index 000000000..908775738 --- /dev/null +++ b/config/util/representation-conversion/converters/dynamic-json-template.json @@ -0,0 +1,26 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Uses the JSON data as parameters for a template.", + "@id": "urn:solid-server:default:DynamicJsonToTemplateConverter", + "@type": "DynamicJsonToTemplateConverter", + "templateEngine": { + "comment": "Renders the specific page and embeds it into the main HTML body.", + "@type": "ChainedTemplateEngine", + "renderedName": "htmlBody", + "engines": [ + { + "comment": "Will be called with specific templates to generate HTML snippets.", + "@type": "EjsTemplateEngine" + }, + { + "comment": "Will embed the result of the first engine into the main HTML template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/main.html.ejs" + } + ] + } + } + ] +} diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json index 4c48f8c13..a1038096c 100644 --- a/config/util/representation-conversion/default.json +++ b/config/util/representation-conversion/default.json @@ -2,6 +2,7 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "import": [ "files-scs:config/util/representation-conversion/converters/content-type-replacer.json", + "files-scs:config/util/representation-conversion/converters/dynamic-json-template.json", "files-scs:config/util/representation-conversion/converters/errors.json", "files-scs:config/util/representation-conversion/converters/markdown.json", "files-scs:config/util/representation-conversion/converters/quad-to-rdf.json", @@ -15,6 +16,7 @@ "handlers": [ { "@id": "urn:solid-server:default:DefaultUiConverter" }, { "@id": "urn:solid-server:default:MarkdownToHtmlConverter" }, + { "@id": "urn:solid-server:default:DynamicJsonToTemplateConverter" }, { "@type": "IfNeededConverter", "comment": "Only continue converting if the requester cannot accept the available content type" diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index 580ecce1d..2d9034134 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -1,55 +1,67 @@ import urljoin from 'url-join'; import type { ErrorHandler } from '../ldp/http/ErrorHandler'; import type { RequestParser } from '../ldp/http/RequestParser'; +import { OkResponseDescription } from '../ldp/http/response/OkResponseDescription'; import { RedirectResponseDescription } from '../ldp/http/response/RedirectResponseDescription'; +import type { ResponseDescription } from '../ldp/http/response/ResponseDescription'; import type { ResponseWriter } from '../ldp/http/ResponseWriter'; import type { Operation } from '../ldp/operations/Operation'; +import { BasicRepresentation } from '../ldp/representation/BasicRepresentation'; import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; import { getLoggerFor } from '../logging/LogUtil'; import type { HttpHandlerInput } from '../server/HttpHandler'; import { HttpHandler } from '../server/HttpHandler'; import type { HttpRequest } from '../server/HttpRequest'; import type { HttpResponse } from '../server/HttpResponse'; -import type { TemplateHandler } from '../server/util/TemplateHandler'; +import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; +import { APPLICATION_JSON } from '../util/ContentTypes'; import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; import { assertError, createErrorMessage } from '../util/errors/ErrorUtil'; import { InternalServerError } from '../util/errors/InternalServerError'; import { trimTrailingSlashes } from '../util/PathUtil'; +import { addTemplateMetadata } from '../util/ResourceUtil'; import type { ProviderFactory } from './configuration/ProviderFactory'; -import type { Interaction, +import type { + Interaction, InteractionHandler, - InteractionHandlerResult } from './interaction/email-password/handler/InteractionHandler'; + InteractionHandlerResult, + InteractionResponseResult, +} from './interaction/email-password/handler/InteractionHandler'; import { IdpInteractionError } from './interaction/util/IdpInteractionError'; import type { InteractionCompleter } from './interaction/util/InteractionCompleter'; +const API_VERSION = '0.1'; + /** * All the information that is required to handle a request to a custom IDP path. */ export class InteractionRoute { public readonly route: RegExp; public readonly handler: InteractionHandler; - public readonly viewTemplate: string; + public readonly viewTemplates: Record; public readonly prompt?: string; - public readonly responseTemplate?: string; + public readonly responseTemplates: Record; /** * @param route - Regex to match this route. - * @param viewTemplate - Template to render on GET requests. + * @param viewTemplates - Templates to render on GET requests. + * Keys are content-types, values paths to a template. * @param handler - Handler to call on POST requests. * @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this. * One entry should have a value of "default" here in case there are no prompt matches. - * @param responseTemplate - Template to render as a response to POST requests when required. + * @param responseTemplates - Templates to render as a response to POST requests when required. + * Keys are content-types, values paths to a template. */ public constructor(route: string, - viewTemplate: string, + viewTemplates: Record, handler: InteractionHandler, prompt?: string, - responseTemplate?: string) { + responseTemplates: Record = {}) { this.route = new RegExp(route, 'u'); - this.viewTemplate = viewTemplate; + this.viewTemplates = viewTemplates; this.handler = handler; this.prompt = prompt; - this.responseTemplate = responseTemplate; + this.responseTemplates = responseTemplates; } } @@ -72,7 +84,7 @@ export class IdentityProviderHttpHandler extends HttpHandler { private readonly requestParser: RequestParser; private readonly providerFactory: ProviderFactory; private readonly interactionRoutes: InteractionRoute[]; - private readonly templateHandler: TemplateHandler; + private readonly converter: RepresentationConverter; private readonly interactionCompleter: InteractionCompleter; private readonly errorHandler: ErrorHandler; private readonly responseWriter: ResponseWriter; @@ -83,7 +95,7 @@ export class IdentityProviderHttpHandler extends HttpHandler { * @param requestParser - Used for parsing requests. * @param providerFactory - Used to generate the OIDC provider. * @param interactionRoutes - All routes handling the custom IDP behaviour. - * @param templateHandler - Used for rendering responses. + * @param converter - Used for content negotiation.. * @param interactionCompleter - Used for POST requests that need to be handled by the OIDC library. * @param errorHandler - Converts errors to responses. * @param responseWriter - Renders error responses. @@ -94,7 +106,7 @@ export class IdentityProviderHttpHandler extends HttpHandler { requestParser: RequestParser, providerFactory: ProviderFactory, interactionRoutes: InteractionRoute[], - templateHandler: TemplateHandler, + converter: RepresentationConverter, interactionCompleter: InteractionCompleter, errorHandler: ErrorHandler, responseWriter: ResponseWriter, @@ -105,7 +117,7 @@ export class IdentityProviderHttpHandler extends HttpHandler { this.requestParser = requestParser; this.providerFactory = providerFactory; this.interactionRoutes = interactionRoutes; - this.templateHandler = templateHandler; + this.converter = converter; this.interactionCompleter = interactionCompleter; this.errorHandler = errorHandler; this.responseWriter = responseWriter; @@ -142,30 +154,15 @@ export class IdentityProviderHttpHandler extends HttpHandler { // If our own interaction handler does not support the input, it is either invalid or a request for the OIDC library const route = await this.findRoute(operation, oidcInteraction); if (!route) { - // Make sure the request stream still works in case the RequestParser read it const provider = await this.providerFactory.getProvider(); this.logger.debug(`Sending request to oidc-provider: ${request.url}`); return provider.callback(request, response); } - const { result, templateFile } = await this.resolveRoute(operation, route, oidcInteraction); - if (result.type === 'complete') { - if (!oidcInteraction) { - // Once https://github.com/solid/community-server/pull/898 is merged - // we want to assign an error code here to have a more thorough explanation - throw new BadRequestHttpError( - 'This action can only be executed as part of an authentication flow. It should not be used directly.', - ); - } - // We need the original request object for the OIDC library - const location = await this.interactionCompleter.handleSafe({ ...result.details, request }); - return await this.responseWriter.handleSafe({ response, result: new RedirectResponseDescription(location) }); - } - if (result.type === 'response' && templateFile) { - return await this.handleTemplateResponse(response, templateFile, result.details, oidcInteraction); - } - - throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`); + const { result, templateFiles } = await this.resolveRoute(operation, route, oidcInteraction); + const responseDescription = + await this.handleInteractionResult(operation, request, result, templateFiles, oidcInteraction); + await this.responseWriter.handleSafe({ response, result: responseDescription }); } /** @@ -190,29 +187,30 @@ export class IdentityProviderHttpHandler extends HttpHandler { * Handles the behaviour of an InteractionRoute. * Will error if the route does not support the given request. * - * GET requests go to the templateHandler, POST requests to the specific InteractionHandler of the route. + * GET requests return a default response result, + * POST requests to the specific InteractionHandler of the route. */ private async resolveRoute(operation: Operation, route: InteractionRoute, oidcInteraction?: Interaction): - Promise<{ result: InteractionHandlerResult; templateFile?: string }> { + Promise<{ result: InteractionHandlerResult; templateFiles: Record }> { if (operation.method === 'GET') { // .ejs templates errors on undefined variables return { result: { type: 'response', details: { errorMessage: '', prefilled: {}}}, - templateFile: route.viewTemplate, + templateFiles: route.viewTemplates, }; } if (operation.method === 'POST') { try { const result = await route.handler.handleSafe({ operation, oidcInteraction }); - return { result, templateFile: route.responseTemplate }; + return { result, templateFiles: route.responseTemplates }; } catch (error: unknown) { // Render error in the view const prefilled = IdpInteractionError.isInstance(error) ? error.prefilled : {}; const errorMessage = createErrorMessage(error); return { result: { type: 'response', details: { errorMessage, prefilled }}, - templateFile: route.viewTemplate, + templateFiles: route.viewTemplates, }; } } @@ -220,11 +218,53 @@ export class IdentityProviderHttpHandler extends HttpHandler { throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`); } - private async handleTemplateResponse(response: HttpResponse, templateFile: string, data?: NodeJS.Dict, - oidcInteraction?: Interaction): Promise { - const contents = data ?? {}; - contents.authenticating = Boolean(oidcInteraction); - await this.templateHandler.handleSafe({ response, templateFile, contents }); + /** + * Creates a ResponseDescription based on the InteractionHandlerResult. + * This will either be a redirect if type is "complete" or a data stream if the type is "response". + */ + private async handleInteractionResult(operation: Operation, request: HttpRequest, result: InteractionHandlerResult, + templateFiles: Record, oidcInteraction?: Interaction): Promise { + let responseDescription: ResponseDescription | undefined; + + if (result.type === 'complete') { + if (!oidcInteraction) { + // Once https://github.com/solid/community-server/pull/898 is merged + // we want to assign an error code here to have a more thorough explanation + throw new BadRequestHttpError( + 'This action can only be executed as part of an authentication flow. It should not be used directly.', + ); + } + // Create a redirect URL with the OIDC library + const location = await this.interactionCompleter.handleSafe({ ...result.details, request }); + responseDescription = new RedirectResponseDescription(location); + } else { + // Convert the response object to a data stream + responseDescription = await this.handleResponseResult(result, templateFiles, operation, oidcInteraction); + } + + return responseDescription; + } + + /** + * Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation + * and applying necessary conversions. + */ + private async handleResponseResult(result: InteractionResponseResult, templateFiles: Record, + operation: Operation, oidcInteraction?: Interaction): Promise { + // Convert the object to a valid JSON representation + const json = { ...result.details, authenticating: Boolean(oidcInteraction), apiVersion: API_VERSION }; + const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); + + // Template metadata is required for conversion + for (const [ type, templateFile ] of Object.entries(templateFiles)) { + addTemplateMetadata(representation.metadata, templateFile, type); + } + + // Potentially convert the Representation based on the preferences + const args = { representation, preferences: operation.preferences, identifier: operation.target }; + const converted = await this.converter.handleSafe(args); + + return new OkResponseDescription(converted.metadata, converted.data); } /** diff --git a/src/index.ts b/src/index.ts index c5dc0f233..1a7a3f144 100644 --- a/src/index.ts +++ b/src/index.ts @@ -208,7 +208,6 @@ export * from './server/middleware/WebSocketAdvertiser'; // Server/Util export * from './server/util/RouterHandler'; -export * from './server/util/TemplateHandler'; // Storage/Accessors export * from './storage/accessors/DataAccessor'; @@ -222,6 +221,7 @@ export * from './storage/conversion/ConstantConverter'; export * from './storage/conversion/ContainerToTemplateConverter'; export * from './storage/conversion/ContentTypeReplacer'; export * from './storage/conversion/ConversionUtil'; +export * from './storage/conversion/DynamicJsonToTemplateConverter'; export * from './storage/conversion/ErrorToQuadConverter'; export * from './storage/conversion/ErrorToTemplateConverter'; export * from './storage/conversion/IfNeededConverter'; diff --git a/src/ldp/representation/RepresentationMetadata.ts b/src/ldp/representation/RepresentationMetadata.ts index 7ba5181b9..a998c5950 100644 --- a/src/ldp/representation/RepresentationMetadata.ts +++ b/src/ldp/representation/RepresentationMetadata.ts @@ -112,10 +112,10 @@ export class RepresentationMetadata { * @returns All matching metadata quads. */ public quads( - subject: Term | null = null, - predicate: Term | null = null, - object: Term | null = null, - graph: Term | null = null, + subject: NamedNode | BlankNode | string | null = null, + predicate: NamedNode | string | null = null, + object: NamedNode | BlankNode | Literal | string | null = null, + graph: MetadataGraph | null = null, ): Quad[] { return this.store.getQuads(subject, predicate, object, graph); } diff --git a/src/server/util/TemplateHandler.ts b/src/server/util/TemplateHandler.ts deleted file mode 100644 index e579b91f5..000000000 --- a/src/server/util/TemplateHandler.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ResponseDescription } from '../../ldp/http/response/ResponseDescription'; -import type { ResponseWriter } from '../../ldp/http/ResponseWriter'; -import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; -import { AsyncHandler } from '../../util/handlers/AsyncHandler'; -import { guardedStreamFrom } from '../../util/StreamUtil'; -import type { TemplateEngine } from '../../util/templates/TemplateEngine'; -import type { HttpResponse } from '../HttpResponse'; -import Dict = NodeJS.Dict; - -/** - * A Render Handler that uses a template engine to render a response. - */ -export class TemplateHandler = Dict> - extends AsyncHandler<{ response: HttpResponse; templateFile: string; contents: T }> { - private readonly responseWriter: ResponseWriter; - private readonly templateEngine: TemplateEngine; - private readonly contentType: string; - - public constructor(responseWriter: ResponseWriter, templateEngine: TemplateEngine, contentType = 'text/html') { - super(); - this.responseWriter = responseWriter; - this.templateEngine = templateEngine; - this.contentType = contentType; - } - - public async handle({ response, templateFile, contents }: - { response: HttpResponse; templateFile: string; contents: T }): Promise { - const rendered = await this.templateEngine.render(contents, { templateFile }); - const result: ResponseDescription = { - statusCode: 200, - data: guardedStreamFrom(rendered), - metadata: new RepresentationMetadata(this.contentType), - }; - await this.responseWriter.handleSafe({ response, result }); - } -} diff --git a/src/storage/conversion/DynamicJsonToTemplateConverter.ts b/src/storage/conversion/DynamicJsonToTemplateConverter.ts new file mode 100644 index 000000000..ee231c50a --- /dev/null +++ b/src/storage/conversion/DynamicJsonToTemplateConverter.ts @@ -0,0 +1,96 @@ +import type { Term, NamedNode } from 'rdf-js'; +import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import type { Representation } from '../../ldp/representation/Representation'; +import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; +import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { readableToString } from '../../util/StreamUtil'; +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; +import { CONTENT_TYPE, CONTENT_TYPE_TERM, SOLID_META } from '../../util/Vocabularies'; +import { getConversionTarget } from './ConversionUtil'; +import { RepresentationConverter } from './RepresentationConverter'; +import type { RepresentationConverterArgs } from './RepresentationConverter'; + +/** + * Converts JSON data by using it as input parameters for rendering a template. + * The `extension` field can be used to only support a specific type of templates, + * such as ".ejs" for EJS templates. + * + * To find the templates it expects the Representation metadata to contain `SOLID_META.template` triples, + * with the objects being the template paths. + * For each of those templates there also needs to be a CONTENT_TYPE triple + * describing the content-type of that template. + * + * The output of the result depends on the content-type matched with the template. + */ +export class DynamicJsonToTemplateConverter extends RepresentationConverter { + private readonly templateEngine: TemplateEngine; + + public constructor(templateEngine: TemplateEngine) { + super(); + this.templateEngine = templateEngine; + } + + public async canHandle(input: RepresentationConverterArgs): Promise { + if (input.representation.metadata.contentType !== APPLICATION_JSON) { + throw new NotImplementedHttpError('Only JSON data is supported'); + } + + const { identifier, representation, preferences } = input; + + // Can only handle this input if we can find a type to convert to + const typeMap = this.constructTypeMap(identifier, representation); + this.findType(typeMap, preferences.type); + } + + public async handle(input: RepresentationConverterArgs): Promise { + const { identifier, representation, preferences } = input; + + const typeMap = this.constructTypeMap(identifier, representation); + const type = this.findType(typeMap, preferences.type); + + const json = JSON.parse(await readableToString(representation.data)); + + const rendered = await this.templateEngine.render(json, { templateFile: typeMap[type] }); + const metadata = new RepresentationMetadata(representation.metadata, { [CONTENT_TYPE]: type }); + + return new BasicRepresentation(rendered, metadata); + } + + /** + * Uses the metadata of the Representation to create a map where each key is a content-type + * and each value is the path of the corresponding template. + */ + private constructTypeMap(identifier: ResourceIdentifier, representation: Representation): Record { + // Finds the templates in the metadata + const templates: NamedNode[] = representation.metadata.quads(identifier.path, SOLID_META.terms.template) + .map((quad): Term => quad.object) + .filter((term: Term): boolean => term.termType === 'NamedNode') as NamedNode[]; + + // Maps all content-types to their template + const typeMap: Record = {}; + for (const template of templates) { + const types = representation.metadata.quads(template, CONTENT_TYPE_TERM).map((quad): string => quad.object.value); + for (const type of types) { + typeMap[type] = template.value; + } + } + return typeMap; + } + + /** + * Finds the best content-type to convert to based on the provided templates and preferences. + */ + private findType(typeMap: Record, typePreferences: ValuePreferences = {}): string { + const typeWeights = Object.fromEntries(Object.keys(typeMap).map((type: string): [ string, 1 ] => [ type, 1 ])); + const type = getConversionTarget(typeWeights, typePreferences); + if (!type) { + throw new NotImplementedHttpError( + `No templates found matching ${Object.keys(typePreferences)}, only ${Object.keys(typeMap)}`, + ); + } + return type; + } +} diff --git a/src/util/ResourceUtil.ts b/src/util/ResourceUtil.ts index d7507f933..735dc7fa8 100644 --- a/src/util/ResourceUtil.ts +++ b/src/util/ResourceUtil.ts @@ -1,11 +1,12 @@ import arrayifyStream from 'arrayify-stream'; +import { DataFactory } from 'n3'; import { BasicRepresentation } from '../ldp/representation/BasicRepresentation'; import type { Representation } from '../ldp/representation/Representation'; import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata'; import { guardedStreamFrom } from './StreamUtil'; import { toLiteral } from './TermUtil'; - -import { DC, LDP, RDF, XSD } from './Vocabularies'; +import { CONTENT_TYPE_TERM, DC, LDP, RDF, SOLID_META, XSD } from './Vocabularies'; +import namedNode = DataFactory.namedNode; /** * Helper function to generate type quads for a Container or Resource. @@ -34,6 +35,19 @@ export function updateModifiedDate(metadata: RepresentationMetadata, date = new metadata.set(DC.terms.modified, toLiteral(lastModified.toISOString(), XSD.terms.dateTime)); } +/** + * Links a template file with a given content-type to the metadata using the SOLID_META.template predicate. + * @param metadata - Metadata to update. + * @param templateFile - Path to the template. + * @param contentType - Content-type of the template after it is rendered. + */ +export function addTemplateMetadata(metadata: RepresentationMetadata, templateFile: string, contentType: string): +void { + const templateNode = namedNode(templateFile); + metadata.add(SOLID_META.terms.template, templateNode); + metadata.addQuad(templateNode, CONTENT_TYPE_TERM, contentType); +} + /** * Helper function to clone a representation, the original representation can still be used. * This function loads the entire stream in memory. diff --git a/src/util/ResourceUtil.ts.orig b/src/util/ResourceUtil.ts.orig new file mode 100644 index 000000000..63e06da1d --- /dev/null +++ b/src/util/ResourceUtil.ts.orig @@ -0,0 +1,72 @@ +import arrayifyStream from 'arrayify-stream'; +import { DataFactory } from 'n3'; +import { BasicRepresentation } from '../ldp/representation/BasicRepresentation'; +import type { Representation } from '../ldp/representation/Representation'; +import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata'; +import { guardedStreamFrom } from './StreamUtil'; +<<<<<<< Updated upstream +import { toLiteral } from './TermUtil'; + +import { DC, LDP, RDF, XSD } from './Vocabularies'; +======= +import { CONTENT_TYPE_TERM, LDP, RDF, SOLID_META } from './Vocabularies'; +import namedNode = DataFactory.namedNode; +>>>>>>> Stashed changes + +/** + * Helper function to generate type quads for a Container or Resource. + * @param metadata - Metadata to add to. + * @param isContainer - If the identifier corresponds to a container. + * + * @returns The generated quads. + */ +export function addResourceMetadata(metadata: RepresentationMetadata, isContainer: boolean): void { + if (isContainer) { + metadata.add(RDF.terms.type, LDP.terms.Container); + metadata.add(RDF.terms.type, LDP.terms.BasicContainer); + } + metadata.add(RDF.terms.type, LDP.terms.Resource); +} + +/** +<<<<<<< Updated upstream + * Updates the dc:modified time to the given time. + * @param metadata - Metadata to update. + * @param date - Last modified date. Defaults to current time. + */ +export function updateModifiedDate(metadata: RepresentationMetadata, date = new Date()): void { + // Milliseconds get lost in some serializations, potentially causing mismatches + const lastModified = new Date(date); + lastModified.setMilliseconds(0); + metadata.set(DC.terms.modified, toLiteral(lastModified.toISOString(), XSD.terms.dateTime)); +======= + * Links a template file with a given content-type to the metadata using the SOLID_META.template predicate. + * @param metadata - Metadata to update. + * @param templateFile - Path to the template. + * @param contentType - Content-type of the template after it is rendered. + */ +export function addTemplateMetadata(metadata: RepresentationMetadata, templateFile: string, contentType: string): +void { + const templateNode = namedNode(templateFile); + metadata.add(SOLID_META.terms.template, templateNode); + metadata.addQuad(templateNode, CONTENT_TYPE_TERM, contentType); +>>>>>>> Stashed changes +} + +/** + * Helper function to clone a representation, the original representation can still be used. + * This function loads the entire stream in memory. + * @param representation - The representation to clone. + * + * @returns The cloned representation. + */ +export async function cloneRepresentation(representation: Representation): Promise { + const data = await arrayifyStream(representation.data); + const result = new BasicRepresentation( + data, + new RepresentationMetadata(representation.metadata), + representation.binary, + ); + representation.data = guardedStreamFrom(data); + return result; +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 64480aa3e..740567b10 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -135,6 +135,8 @@ export const SOLID_HTTP = createUriAndTermNamespace('urn:npm:solid:community-ser export const SOLID_META = createUriAndTermNamespace('urn:npm:solid:community-server:meta:', // This identifier is used as graph for all metadata that is generated on the fly and should not be stored 'ResponseMetadata', + // This is used to identify templates that can be used for the representation of a resource + 'template', ); export const VANN = createUriAndTermNamespace('http://purl.org/vocab/vann/', diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index 58f0a806d..6be3b140a 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -10,14 +10,20 @@ import type { RequestParser } from '../../../src/ldp/http/RequestParser'; import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter'; import type { Operation } from '../../../src/ldp/operations/Operation'; import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation'; +import type { Representation } from '../../../src/ldp/representation/Representation'; import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; -import type { TemplateHandler } from '../../../src/server/util/TemplateHandler'; +import type { + RepresentationConverter, + RepresentationConverterArgs, +} from '../../../src/storage/conversion/RepresentationConverter'; import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError'; import { InternalServerError } from '../../../src/util/errors/InternalServerError'; -import { SOLID_HTTP } from '../../../src/util/Vocabularies'; +import { readableToString } from '../../../src/util/StreamUtil'; +import { SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies'; describe('An IdentityProviderHttpHandler', (): void => { + const apiVersion = '0.1'; const baseUrl = 'http://test.com/'; const idpPath = '/idp'; let request: HttpRequest; @@ -26,7 +32,7 @@ describe('An IdentityProviderHttpHandler', (): void => { let providerFactory: jest.Mocked; let routes: { response: InteractionRoute; complete: InteractionRoute }; let interactionCompleter: jest.Mocked; - let templateHandler: jest.Mocked; + let converter: jest.Mocked; let errorHandler: jest.Mocked; let responseWriter: jest.Mocked; let provider: jest.Mocked; @@ -59,11 +65,20 @@ describe('An IdentityProviderHttpHandler', (): void => { ]; routes = { - response: new InteractionRoute('/routeResponse', '/view1', handlers[0], 'default', '/response1'), - complete: new InteractionRoute('/routeComplete', '/view2', handlers[1], 'other', '/response2'), + response: new InteractionRoute('/routeResponse', + { 'text/html': '/view1' }, + handlers[0], + 'default', + { 'text/html': '/response1' }), + complete: new InteractionRoute('/routeComplete', + { 'text/html': '/view2' }, + handlers[1], + 'other'), }; - templateHandler = { handleSafe: jest.fn() } as any; + converter = { + handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => input.representation), + } as any; interactionCompleter = { handleSafe: jest.fn().mockResolvedValue('http://test.com/idp/auth') } as any; @@ -77,7 +92,7 @@ describe('An IdentityProviderHttpHandler', (): void => { requestParser, providerFactory, Object.values(routes), - templateHandler, + converter, interactionCompleter, errorHandler, responseWriter, @@ -91,26 +106,35 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(provider.callback).toHaveBeenLastCalledWith(request, response); }); - it('calls the templateHandler for matching GET requests.', async(): Promise => { + it('creates default Representations for GET requests.', async(): Promise => { request.url = '/idp/routeResponse'; await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(templateHandler.handleSafe).toHaveBeenLastCalledWith({ response, - templateFile: routes.response.viewTemplate, - contents: { errorMessage: '', prefilled: {}, authenticating: false }}); + + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; + expect(mockResponse).toBe(response); + expect(JSON.parse(await readableToString(result.data!))) + .toEqual({ apiVersion, errorMessage: '', prefilled: {}, authenticating: false }); + expect(result.statusCode).toBe(200); + expect(result.metadata?.contentType).toBe('application/json'); + expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']); }); - it('calls the templateHandler for InteractionResponseResults.', async(): Promise => { + it('creates Representations for InteractionResponseResults.', async(): Promise => { request.url = '/idp/routeResponse'; request.method = 'POST'; await expect(handler.handle({ request, response })).resolves.toBeUndefined(); const operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); - expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(templateHandler.handleSafe).toHaveBeenLastCalledWith( - { response, templateFile: routes.response.responseTemplate, contents: { key: 'val', authenticating: false }}, - ); + + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; + expect(mockResponse).toBe(response); + expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, key: 'val', authenticating: false }); + expect(result.statusCode).toBe(200); + expect(result.metadata?.contentType).toBe('application/json'); + expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.responseTemplates['text/html']); }); it('indicates to the templates if the request is part of an auth flow.', async(): Promise => { @@ -120,13 +144,10 @@ describe('An IdentityProviderHttpHandler', (): void => { provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); (routes.response.handler as jest.Mocked).handleSafe.mockResolvedValueOnce({ type: 'response' }); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - const operation = await requestParser.handleSafe.mock.results[0].value; - expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); - expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(templateHandler.handleSafe).toHaveBeenLastCalledWith( - { response, templateFile: routes.response.responseTemplate, contents: { authenticating: true }}, - ); + + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const { result } = responseWriter.handleSafe.mock.calls[0][0]; + expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, authenticating: true }); }); it('errors for InteractionCompleteResults if no oidcInteraction is defined.', async(): Promise => { @@ -191,19 +212,21 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(0); }); - it('displays the viewTemplate again in case of POST errors.', async(): Promise => { + it('displays a viewTemplate again in case of POST errors.', async(): Promise => { request.url = '/idp/routeResponse'; request.method = 'POST'; (routes.response.handler.handleSafe as any) .mockRejectedValueOnce(new IdpInteractionError(500, 'handle error', { name: 'name' })); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(templateHandler.handleSafe).toHaveBeenLastCalledWith({ - response, - templateFile: routes.response.viewTemplate, - contents: { errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false }, - }); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; + expect(mockResponse).toBe(response); + expect(JSON.parse(await readableToString(result.data!))) + .toEqual({ apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false }); + expect(result.statusCode).toBe(200); + expect(result.metadata?.contentType).toBe('application/json'); + expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']); }); it('defaults to an empty prefilled object in case of POST errors.', async(): Promise => { @@ -211,19 +234,19 @@ describe('An IdentityProviderHttpHandler', (): void => { request.method = 'POST'; (routes.response.handler.handleSafe as any).mockRejectedValueOnce(new Error('handle error')); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(templateHandler.handleSafe).toHaveBeenLastCalledWith({ - response, - templateFile: routes.response.viewTemplate, - contents: { errorMessage: 'handle error', prefilled: {}, authenticating: false }, - }); + + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; + expect(mockResponse).toBe(response); + expect(JSON.parse(await readableToString(result.data!))) + .toEqual({ apiVersion, errorMessage: 'handle error', prefilled: {}, authenticating: false }); }); it('calls the errorHandler if there is a problem resolving the request.', async(): Promise => { request.url = '/idp/routeResponse'; request.method = 'GET'; const error = new Error('bad template'); - templateHandler.handleSafe.mockRejectedValueOnce(error); + converter.handleSafe.mockRejectedValueOnce(error); errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 500 }); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); @@ -244,19 +267,6 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); }); - it('can only resolve InteractionResponseResult responses if a responseTemplate is set.', async(): Promise => { - request.url = '/idp/routeResponse'; - request.method = 'POST'; - (routes.response as any).responseTemplate = undefined; - const error = new BadRequestHttpError('Unsupported request: POST http://test.com/idp/routeResponse'); - errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 500 }); - await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/html': 1 }}}); - expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); - expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); - }); - it('errors if no route is configured for the default prompt.', async(): Promise => { handler = new IdentityProviderHttpHandler( baseUrl, @@ -264,7 +274,7 @@ describe('An IdentityProviderHttpHandler', (): void => { requestParser, providerFactory, [], - templateHandler, + converter, interactionCompleter, errorHandler, responseWriter, diff --git a/test/unit/server/util/TemplateHandler.test.ts b/test/unit/server/util/TemplateHandler.test.ts deleted file mode 100644 index 883caab31..000000000 --- a/test/unit/server/util/TemplateHandler.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { readableToString } from '../../../../src'; -import type { ResponseDescription, ResponseWriter, HttpResponse } from '../../../../src'; - -import { TemplateHandler } from '../../../../src/server/util/TemplateHandler'; -import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; - -describe('A TemplateHandler', (): void => { - const contents = { contents: 'contents' }; - const templateFile = '/templates/main.html.ejs'; - let responseWriter: jest.Mocked; - let templateEngine: jest.Mocked; - const response: HttpResponse = {} as any; - - beforeEach((): void => { - responseWriter = { - handleSafe: jest.fn(), - } as any; - - templateEngine = { - render: jest.fn().mockResolvedValue('rendered'), - }; - }); - - it('renders the template in the response.', async(): Promise => { - const handler = new TemplateHandler(responseWriter, templateEngine); - await handler.handle({ response, contents, templateFile }); - - expect(templateEngine.render).toHaveBeenCalledTimes(1); - expect(templateEngine.render).toHaveBeenCalledWith(contents, { templateFile }); - - expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); - const input: { response: HttpResponse; result: ResponseDescription } = responseWriter.handleSafe.mock.calls[0][0]; - - expect(input.response).toBe(response); - expect(input.result.statusCode).toBe(200); - expect(input.result.metadata?.contentType).toBe('text/html'); - await expect(readableToString(input.result.data!)).resolves.toBe('rendered'); - }); -}); diff --git a/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts b/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts new file mode 100644 index 000000000..ab8ec6cc3 --- /dev/null +++ b/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts @@ -0,0 +1,67 @@ +import { DataFactory } from 'n3'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; +import { DynamicJsonToTemplateConverter } from '../../../../src/storage/conversion/DynamicJsonToTemplateConverter'; +import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; +import { CONTENT_TYPE_TERM, SOLID_META } from '../../../../src/util/Vocabularies'; +import namedNode = DataFactory.namedNode; + +describe('A DynamicJsonToTemplateConverter', (): void => { + const templateFile = '/path/to/template.html.ejs'; + const identifier = { path: 'http://test.com/foo' }; + let representation: Representation; + let preferences: RepresentationPreferences; + let input: RepresentationConverterArgs; + let templateEngine: jest.Mocked; + let converter: DynamicJsonToTemplateConverter; + + beforeEach(async(): Promise => { + representation = new BasicRepresentation('{ "json": true }', identifier, 'application/json'); + + // Create dummy template metadata + const templateNode = namedNode(templateFile); + representation.metadata.add(SOLID_META.terms.template, templateNode); + representation.metadata.addQuad(templateNode, CONTENT_TYPE_TERM, 'text/html'); + + preferences = { type: { 'text/html': 1 }}; + + input = { identifier, representation, preferences }; + + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('')), + }; + converter = new DynamicJsonToTemplateConverter(templateEngine); + }); + + it('can only handle JSON data.', async(): Promise => { + representation.metadata.contentType = 'text/plain'; + await expect(converter.canHandle(input)).rejects.toThrow('Only JSON data is supported'); + }); + + it('can only handle preferences matching the templates found.', async(): Promise => { + input.preferences = { type: { 'text/plain': 1 }}; + await expect(converter.canHandle(input)).rejects.toThrow('No templates found matching text/plain, only text/html'); + }); + + it('can handle JSON input with templates matching the preferences.', async(): Promise => { + await expect(converter.canHandle(input)).resolves.toBeUndefined(); + }); + + it('uses the input JSON as parameters for the matching template.', async(): Promise => { + const result = await converter.handle(input); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(result.binary).toBe(true); + expect(result.metadata.contentType).toBe('text/html'); + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render).toHaveBeenLastCalledWith({ json: true }, { templateFile }); + }); + + it('supports missing type preferences.', async(): Promise => { + input.preferences = {}; + const result = await converter.handle(input); + await expect(readableToString(result.data)).resolves.toBe(''); + }); +}); diff --git a/test/unit/util/ResourceUtil.test.ts b/test/unit/util/ResourceUtil.test.ts index cc96e0397..16d889b0c 100644 --- a/test/unit/util/ResourceUtil.test.ts +++ b/test/unit/util/ResourceUtil.test.ts @@ -1,10 +1,10 @@ import 'jest-rdf'; -import type { Literal } from 'n3'; +import type { NamedNode, Literal } from 'n3'; import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation'; import type { Representation } from '../../../src/ldp/representation/Representation'; import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; -import { cloneRepresentation, updateModifiedDate } from '../../../src/util/ResourceUtil'; -import { DC, XSD } from '../../../src/util/Vocabularies'; +import { addTemplateMetadata, cloneRepresentation, updateModifiedDate } from '../../../src/util/ResourceUtil'; +import { CONTENT_TYPE_TERM, DC, SOLID_META, XSD } from '../../../src/util/Vocabularies'; describe('ResourceUtil', (): void => { let representation: Representation; @@ -30,6 +30,21 @@ describe('ResourceUtil', (): void => { }); }); + describe('#addTemplateMetadata', (): void => { + const filePath = '/templates/template.html.ejs'; + const contentType = 'text/html'; + + it('stores the template metadata.', (): void => { + const metadata = new RepresentationMetadata(); + addTemplateMetadata(metadata, filePath, contentType); + const templateNode = metadata.get(SOLID_META.terms.template); + expect(templateNode?.value).toBe(filePath); + const quads = metadata.quads(templateNode as NamedNode, CONTENT_TYPE_TERM); + expect(quads).toHaveLength(1); + expect(quads[0].object.value).toBe(contentType); + }); + }); + describe('#cloneRepresentation', (): void => { it('returns a clone of the passed representation.', async(): Promise => { const res = await cloneRepresentation(representation);