mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Support content negotiation for IDP requests
This commit is contained in:
parent
7b42c72142
commit
80ebd02cc4
@ -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",
|
||||
|
@ -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" },
|
||||
|
@ -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" }
|
||||
|
@ -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" }
|
||||
|
@ -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" }
|
||||
}
|
||||
]
|
||||
|
@ -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" },
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -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"
|
||||
|
@ -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<string, string>;
|
||||
public readonly prompt?: string;
|
||||
public readonly responseTemplate?: string;
|
||||
public readonly responseTemplates: Record<string, string>;
|
||||
|
||||
/**
|
||||
* @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<string, string>,
|
||||
handler: InteractionHandler,
|
||||
prompt?: string,
|
||||
responseTemplate?: string) {
|
||||
responseTemplates: Record<string, string> = {}) {
|
||||
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<string, string> }> {
|
||||
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<any>,
|
||||
oidcInteraction?: Interaction): Promise<void> {
|
||||
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<string, string>, oidcInteraction?: Interaction): Promise<ResponseDescription> {
|
||||
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<string, string>,
|
||||
operation: Operation, oidcInteraction?: Interaction): Promise<ResponseDescription> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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<T extends Dict<any> = Dict<any>>
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
96
src/storage/conversion/DynamicJsonToTemplateConverter.ts
Normal file
96
src/storage/conversion/DynamicJsonToTemplateConverter.ts
Normal file
@ -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<void> {
|
||||
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<Representation> {
|
||||
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<string, string> {
|
||||
// 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<string, string> = {};
|
||||
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<string, string>, 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;
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
72
src/util/ResourceUtil.ts.orig
Normal file
72
src/util/ResourceUtil.ts.orig
Normal file
@ -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<BasicRepresentation> {
|
||||
const data = await arrayifyStream(representation.data);
|
||||
const result = new BasicRepresentation(
|
||||
data,
|
||||
new RepresentationMetadata(representation.metadata),
|
||||
representation.binary,
|
||||
);
|
||||
representation.data = guardedStreamFrom(data);
|
||||
return result;
|
||||
}
|
@ -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/',
|
||||
|
@ -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<ProviderFactory>;
|
||||
let routes: { response: InteractionRoute; complete: InteractionRoute };
|
||||
let interactionCompleter: jest.Mocked<InteractionCompleter>;
|
||||
let templateHandler: jest.Mocked<TemplateHandler>;
|
||||
let converter: jest.Mocked<RepresentationConverter>;
|
||||
let errorHandler: jest.Mocked<ErrorHandler>;
|
||||
let responseWriter: jest.Mocked<ResponseWriter>;
|
||||
let provider: jest.Mocked<Provider>;
|
||||
@ -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<void> => {
|
||||
it('creates default Representations for GET requests.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
it('creates Representations for InteractionResponseResults.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
@ -120,13 +144,10 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction);
|
||||
(routes.response.handler as jest.Mocked<InteractionHandler>).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<void> => {
|
||||
@ -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<void> => {
|
||||
it('displays a viewTemplate again in case of POST errors.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
handler = new IdentityProviderHttpHandler(
|
||||
baseUrl,
|
||||
@ -264,7 +274,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
requestParser,
|
||||
providerFactory,
|
||||
[],
|
||||
templateHandler,
|
||||
converter,
|
||||
interactionCompleter,
|
||||
errorHandler,
|
||||
responseWriter,
|
||||
|
@ -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<ResponseWriter>;
|
||||
let templateEngine: jest.Mocked<TemplateEngine>;
|
||||
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<void> => {
|
||||
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');
|
||||
});
|
||||
});
|
@ -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<TemplateEngine>;
|
||||
let converter: DynamicJsonToTemplateConverter;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
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('<html>')),
|
||||
};
|
||||
converter = new DynamicJsonToTemplateConverter(templateEngine);
|
||||
});
|
||||
|
||||
it('can only handle JSON data.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
await expect(converter.canHandle(input)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the input JSON as parameters for the matching template.', async(): Promise<void> => {
|
||||
const result = await converter.handle(input);
|
||||
await expect(readableToString(result.data)).resolves.toBe('<html>');
|
||||
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<void> => {
|
||||
input.preferences = {};
|
||||
const result = await converter.handle(input);
|
||||
await expect(readableToString(result.data)).resolves.toBe('<html>');
|
||||
});
|
||||
});
|
@ -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<void> => {
|
||||
const res = await cloneRepresentation(representation);
|
||||
|
Loading…
x
Reference in New Issue
Block a user