feat: Update templates and generators to support ACP

This commit is contained in:
Joachim Van Herwegen 2022-08-19 11:12:02 +02:00
parent 728617ac77
commit 40f2c8ea42
41 changed files with 800 additions and 271 deletions

View File

@ -14,12 +14,9 @@
"args_path": "/", "args_path": "/",
"args_store": { "@id": "urn:solid-server:default:ResourceStore" }, "args_store": { "@id": "urn:solid-server:default:ResourceStore" },
"args_generator": { "args_generator": {
"@type": "TemplatedResourcesGenerator", "@type": "StaticFolderGenerator",
"templateFolder": "@css:templates/root/prefilled", "templateFolder": "@css:templates/root/prefilled",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },
"args_storageKey": "rootInitialized", "args_storageKey": "rootInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" } "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -14,12 +14,9 @@
"args_path": "/", "args_path": "/",
"args_store": { "@id": "urn:solid-server:default:ResourceStore" }, "args_store": { "@id": "urn:solid-server:default:ResourceStore" },
"args_generator": { "args_generator": {
"@type": "TemplatedResourcesGenerator", "@type": "StaticFolderGenerator",
"templateFolder": "@css:templates/root/empty", "templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },
"args_storageKey": "rootInitialized", "args_storageKey": "rootInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" } "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -60,12 +60,9 @@
"args_path": "/", "args_path": "/",
"args_store": { "@id": "urn:solid-server:default:ResourceStore" }, "args_store": { "@id": "urn:solid-server:default:ResourceStore" },
"args_generator": { "args_generator": {
"@type": "TemplatedResourcesGenerator", "@type": "StaticFolderGenerator",
"templateFolder": "@css:templates/root/empty", "templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },
"args_storageKey": "rootInitialized", "args_storageKey": "rootInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" } "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -14,12 +14,9 @@
"args_path": "/idp/", "args_path": "/idp/",
"args_store": { "@id": "urn:solid-server:default:ResourceStore" }, "args_store": { "@id": "urn:solid-server:default:ResourceStore" },
"args_generator": { "args_generator": {
"@type": "TemplatedResourcesGenerator", "@type": "StaticFolderGenerator",
"templateFolder": "@css:templates/root/empty", "templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },
"args_storageKey": "idpContainerInitialized", "args_storageKey": "idpContainerInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" } "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -14,12 +14,9 @@
"args_path": "/.well-known/", "args_path": "/.well-known/",
"args_store": { "@id": "urn:solid-server:default:ResourceStore" }, "args_store": { "@id": "urn:solid-server:default:ResourceStore" },
"args_generator": { "args_generator": {
"@type": "TemplatedResourcesGenerator", "@type": "StaticFolderGenerator",
"templateFolder": "@css:templates/root/empty", "templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },
"args_storageKey": "wellKnownContainerInitialized", "args_storageKey": "wellKnownContainerInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" } "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -12,7 +12,7 @@
"@type": "ConfigPodManager", "@type": "ConfigPodManager",
"podGenerator": { "@id": "urn:solid-server:default:PodGenerator" }, "podGenerator": { "@id": "urn:solid-server:default:PodGenerator" },
"routingStorage": { "@id": "urn:solid-server:default:PodRoutingStorage" }, "routingStorage": { "@id": "urn:solid-server:default:PodRoutingStorage" },
"resourcesGenerator": { "@id": "urn:solid-server:default:ResourcesGenerator" }, "resourcesGenerator": { "@id": "urn:solid-server:default:PodResourcesGenerator" },
"store": { "@id": "urn:solid-server:default:ResourceStore" } "store": { "@id": "urn:solid-server:default:ResourceStore" }
}, },

View File

@ -2,16 +2,31 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [ "@graph": [
{ {
"comment": "Generates resources based on the templates stored in the template folder.", "comment": "Generates pods based on the templates in the corresponding folder.",
"@id": "urn:solid-server:default:ResourcesGenerator", "@id": "urn:solid-server:default:PodResourcesGenerator",
"@type": "TemplatedResourcesGenerator", "@type": "StaticFolderGenerator",
"templateFolder": "@css:templates/pod", "templateFolder": "@css:templates/pod",
"resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
},
{
"comment": [
"Generates resources in the base subfolder of the provided folder.",
"Authorization configurations should add their corresponding subfolder containing the relevant authorization files."
],
"@id": "urn:solid-server:default:TemplatedResourcesGenerator",
"@type": "SubfolderResourcesGenerator",
"subfolders": [ "base" ],
"resourcesGenerator": {
"@type": "BaseResourcesGenerator",
"factory": { "factory": {
"@type": "ExtensionBasedMapperFactory" "@type": "ExtensionBasedMapperFactory"
}, },
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, "templateEngine": {
"@id": "urn:solid-server:default:TemplateEngine"
},
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"} "store": { "@id": "urn:solid-server:default:ResourceStore"}
} }
}
] ]
} }

View File

@ -9,7 +9,7 @@
"@id": "urn:solid-server:default:PodManager", "@id": "urn:solid-server:default:PodManager",
"@type": "GeneratedPodManager", "@type": "GeneratedPodManager",
"store": { "@id": "urn:solid-server:default:ResourceStore" }, "store": { "@id": "urn:solid-server:default:ResourceStore" },
"resourcesGenerator": { "@id": "urn:solid-server:default:ResourcesGenerator" } "resourcesGenerator": { "@id": "urn:solid-server:default:PodResourcesGenerator" }
} }
] ]
} }

View File

@ -31,6 +31,12 @@
] ]
} }
}, },
{
"comment": "The templates for ACP authorization documents are in the acp subfolder.",
"@id": "urn:solid-server:default:TemplatedResourcesGenerator",
"@type": "SubfolderResourcesGenerator",
"subfolders": [ "acp" ]
},
{ {
"comment": "In case of ACP authorization the ACR resources determine authorization.", "comment": "In case of ACP authorization the ACR resources determine authorization.",
"@id": "urn:solid-server:default:AuthResourceHttpHandler", "@id": "urn:solid-server:default:AuthResourceHttpHandler",

View File

@ -31,6 +31,12 @@
] ]
} }
}, },
{
"comment": "The templates for WAC authorization documents are in the wac subfolder.",
"@id": "urn:solid-server:default:TemplatedResourcesGenerator",
"@type": "SubfolderResourcesGenerator",
"subfolders": [ "wac" ]
},
{ {
"comment": "In case of WebACL authorization the ACL resources determine authorization.", "comment": "In case of WebACL authorization the ACL resources determine authorization.",
"@id": "urn:solid-server:default:AuthResourceHttpHandler", "@id": "urn:solid-server:default:AuthResourceHttpHandler",

View File

@ -258,12 +258,15 @@ export * from './pods/generate/variables/VariableSetter';
// Pods/Generate // Pods/Generate
export * from './pods/generate/BaseComponentsJsFactory'; export * from './pods/generate/BaseComponentsJsFactory';
export * from './pods/generate/BaseResourcesGenerator';
export * from './pods/generate/ComponentsJsFactory'; export * from './pods/generate/ComponentsJsFactory';
export * from './pods/generate/GenerateUtil'; export * from './pods/generate/GenerateUtil';
export * from './pods/generate/IdentifierGenerator'; export * from './pods/generate/IdentifierGenerator';
export * from './pods/generate/PodGenerator'; export * from './pods/generate/PodGenerator';
export * from './pods/generate/ResourcesGenerator'; export * from './pods/generate/ResourcesGenerator';
export * from './pods/generate/StaticFolderGenerator';
export * from './pods/generate/SubdomainIdentifierGenerator'; export * from './pods/generate/SubdomainIdentifierGenerator';
export * from './pods/generate/SubfolderResourcesGenerator';
export * from './pods/generate/SuffixIdentifierGenerator'; export * from './pods/generate/SuffixIdentifierGenerator';
export * from './pods/generate/TemplatedPodGenerator'; export * from './pods/generate/TemplatedPodGenerator';
export * from './pods/generate/TemplatedResourcesGenerator'; export * from './pods/generate/TemplatedResourcesGenerator';

View File

@ -0,0 +1,237 @@
import { createReadStream, promises as fsPromises } from 'fs';
import type { Readable } from 'stream';
import { pathExists } from 'fs-extra';
import { Parser } from 'n3';
import type { AuxiliaryStrategy } from '../../http/auxiliary/AuxiliaryStrategy';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../../logging/LogUtil';
import type {
FileIdentifierMapper,
FileIdentifierMapperFactory,
ResourceLink,
} from '../../storage/mapping/FileIdentifierMapper';
import type { ResourceSet } from '../../storage/ResourceSet';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { guardStream } from '../../util/GuardedStream';
import type { Guarded } from '../../util/GuardedStream';
import { joinFilePath, isContainerIdentifier, resolveAssetPath } from '../../util/PathUtil';
import { addResourceMetadata } from '../../util/ResourceUtil';
import { guardedStreamFrom, readableToString } from '../../util/StreamUtil';
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
import type { Resource } from './ResourcesGenerator';
import type { TemplatedResourcesGenerator } from './TemplatedResourcesGenerator';
import Dict = NodeJS.Dict;
interface TemplateResourceLink extends ResourceLink {
isTemplate: boolean;
}
/**
* Input arguments required for {@link BaseResourcesGenerator}
*/
export interface SubfolderResourcesGeneratorArgs {
/**
* Factory used to generate mapper relative to the base identifier.
*/
factory: FileIdentifierMapperFactory;
/**
* Template engine for generating the resources.
*/
templateEngine: TemplateEngine;
/**
* The extension of files that need to be interpreted as templates.
* Will be removed to generate the identifier.
*/
templateExtension?: string;
/**
* The metadataStrategy
*/
metadataStrategy: AuxiliaryStrategy;
/**
* The default ResourceStore
*/
store: ResourceSet;
}
// Comparator for the results of the `groupLinks` call
function comparator(left: { link: TemplateResourceLink }, right: { link: TemplateResourceLink }): number {
return left.link.identifier.path.localeCompare(right.link.identifier.path);
}
/**
* Generates resources by making use of a template engine.
* The template folder structure will be kept.
* Folders will be interpreted as containers and files as documents.
* A FileIdentifierMapper will be used to generate identifiers that correspond to the relative structure.
*
* Metadata resources will be yielded separately from their subject resource.
*
* A relative `templateFolder` is resolved relative to cwd,
* unless it's preceded by `@css:`, e.g. `@css:foo/bar`.
*/
export class BaseResourcesGenerator implements TemplatedResourcesGenerator {
protected readonly logger = getLoggerFor(this);
private readonly factory: FileIdentifierMapperFactory;
private readonly templateEngine: TemplateEngine;
private readonly templateExtension: string;
private readonly metadataStrategy: AuxiliaryStrategy;
private readonly store: ResourceSet;
/**
* A mapper is needed to convert the template file paths to identifiers relative to the given base identifier.
*
* @param args - TemplatedResourcesGeneratorArgs
*/
public constructor(args: SubfolderResourcesGeneratorArgs) {
this.factory = args.factory;
this.templateEngine = args.templateEngine;
this.templateExtension = args.templateExtension ?? '.hbs';
this.metadataStrategy = args.metadataStrategy;
this.store = args.store;
}
public async* generate(templateFolder: string, location: ResourceIdentifier, options: Dict<string>):
AsyncIterable<Resource> {
templateFolder = resolveAssetPath(templateFolder);
// Ignore folders that don't exist
if (!await pathExists(templateFolder)) {
this.logger.warn(`Ignoring non-existing template folder ${templateFolder}`);
return;
}
const mapper = await this.factory.create(location.path, templateFolder);
const folderLink = await this.toTemplateLink(templateFolder, mapper);
yield* this.processFolder(folderLink, mapper, options);
}
/**
* Generates results for all entries in the given folder, including the folder itself.
*/
private async* processFolder(folderLink: TemplateResourceLink, mapper: FileIdentifierMapper, options: Dict<string>):
AsyncIterable<Resource> {
// Group resource links with their corresponding metadata links
const links = await this.groupLinks(folderLink.filePath, mapper);
// Remove root metadata if it exists
const metaLink = links[folderLink.identifier.path]?.meta;
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete links[folderLink.identifier.path];
yield* this.generateResource(folderLink, options, metaLink);
// Make sure the results are sorted
for (const { link, meta } of Object.values(links).sort(comparator)) {
if (isContainerIdentifier(link.identifier)) {
yield* this.processFolder(link, mapper, options);
} else {
yield* this.generateResource(link, options, meta);
}
}
}
/**
* Creates a TemplateResourceLink for the given filePath,
* which connects a resource URL to its template file.
* The identifier will be based on the file path stripped from the template extension,
* but the filePath parameter will still point to the original file.
*/
private async toTemplateLink(filePath: string, mapper: FileIdentifierMapper): Promise<TemplateResourceLink> {
const stats = await fsPromises.lstat(filePath);
// Slice the template extension from the filepath for correct identifier generation
const isTemplate = filePath.endsWith(this.templateExtension);
const slicedPath = isTemplate ? filePath.slice(0, -this.templateExtension.length) : filePath;
const link = await mapper.mapFilePathToUrl(slicedPath, stats.isDirectory());
// We still need the original file path for disk reading though
return {
...link,
filePath,
isTemplate,
};
}
/**
* Generates TemplateResourceLinks for each entry in the given folder
* and combines the results so resources and their metadata are grouped together.
*/
private async groupLinks(folderPath: string, mapper: FileIdentifierMapper):
Promise<Record<string, { link: TemplateResourceLink; meta?: TemplateResourceLink }>> {
const files = await fsPromises.readdir(folderPath);
const links: Record<string, { link: TemplateResourceLink; meta?: TemplateResourceLink }> = { };
for (const name of files) {
const link = await this.toTemplateLink(joinFilePath(folderPath, name), mapper);
const { path } = link.identifier;
links[path] = Object.assign(links[path] || {}, link.isMetadata ? { meta: link } : { link });
}
return links;
}
/**
* Generates a Resource object for the given ResourceLink.
* In the case of documents the corresponding template will be used.
* If a ResourceLink of metadata is provided the corresponding metadata resource
* will be yielded as a separate resource.
*/
private async* generateResource(link: TemplateResourceLink, options: Dict<string>, metaLink?: TemplateResourceLink):
AsyncIterable<Resource> {
let data: Guarded<Readable> | undefined;
const metadata = new RepresentationMetadata(link.identifier);
// Read file if it is not a container
if (!isContainerIdentifier(link.identifier)) {
data = await this.processFile(link, options);
metadata.contentType = link.contentType;
}
// Do not yield a container resource if it already exists
if (!isContainerIdentifier(link.identifier) || !await this.store.hasResource(link.identifier)) {
this.logger.debug(`Generating resource ${link.identifier.path}`);
yield {
identifier: link.identifier,
representation: new BasicRepresentation(data ?? [], metadata),
};
}
// Add metadata from .meta file if there is one
if (metaLink) {
const rawMetadata = await this.generateMetadata(metaLink, options);
const metaIdentifier = this.metadataStrategy.getAuxiliaryIdentifier(link.identifier);
const descriptionMeta = new RepresentationMetadata(metaIdentifier);
addResourceMetadata(rawMetadata, isContainerIdentifier(link.identifier));
this.logger.debug(`Generating resource ${metaIdentifier.path}`);
yield {
identifier: metaIdentifier,
representation: new BasicRepresentation(rawMetadata.quads(), descriptionMeta, INTERNAL_QUADS),
};
}
}
/**
* Generates a RepresentationMetadata using the given template.
*/
private async generateMetadata(metaLink: TemplateResourceLink, options: Dict<string>):
Promise<RepresentationMetadata> {
const metadata = new RepresentationMetadata(metaLink.identifier);
const data = await this.processFile(metaLink, options);
const parser = new Parser({ format: metaLink.contentType, baseIRI: metaLink.identifier.path });
const quads = parser.parse(await readableToString(data));
metadata.addQuads(quads);
return metadata;
}
/**
* Creates a read stream from the file and applies the template if necessary.
*/
private async processFile(link: TemplateResourceLink, contents: Dict<string>): Promise<Guarded<Readable>> {
if (link.isTemplate) {
const rendered = await this.templateEngine.handleSafe({ contents, template: { templateFile: link.filePath }});
return guardedStreamFrom(rendered);
}
return guardStream(createReadStream(link.filePath));
}
}

View File

@ -14,7 +14,7 @@ export interface Resource {
export interface ResourcesGenerator { export interface ResourcesGenerator {
/** /**
* Generates resources with the given options. * Generates resources with the given options.
* The output Map should be sorted so that containers always appear before their contents. * The output Iterable should be sorted so that containers always appear before their contents.
* @param location - Base identifier. * @param location - Base identifier.
* @param options - Options that can be used when generating resources. * @param options - Options that can be used when generating resources.
* *

View File

@ -0,0 +1,21 @@
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import type { Resource, ResourcesGenerator } from './ResourcesGenerator';
import type { TemplatedResourcesGenerator } from './TemplatedResourcesGenerator';
import Dict = NodeJS.Dict;
/**
* Stores a static template folder that will be used to call the wrapped {@link TemplatedResourcesGenerator}.
*/
export class StaticFolderGenerator implements ResourcesGenerator {
private readonly resourcesGenerator: TemplatedResourcesGenerator;
private readonly templateFolder: string;
public constructor(resourcesGenerator: TemplatedResourcesGenerator, templateFolder: string) {
this.resourcesGenerator = resourcesGenerator;
this.templateFolder = templateFolder;
}
public generate(location: ResourceIdentifier, options: Dict<string>): AsyncIterable<Resource> {
return this.resourcesGenerator.generate(this.templateFolder, location, options);
}
}

View File

@ -0,0 +1,56 @@
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { sortedAsyncMerge } from '../../util/IterableUtil';
import { joinFilePath, resolveAssetPath } from '../../util/PathUtil';
import type { Resource } from './ResourcesGenerator';
import type { TemplatedResourcesGenerator } from './TemplatedResourcesGenerator';
import Dict = NodeJS.Dict;
// Sorts Resources based on their identifiers
function comparator(left: Resource, right: Resource): number {
return left.identifier.path.localeCompare(right.identifier.path);
}
/**
* Generates all resources found in specific subfolders of the given template folder.
* In case the same resource is defined in several subfolders,
* the data of the last subfolder in the list will be used.
*
* The results of all the subfolders will be merged so the end result is still a sorted stream of identifiers.
*
* One of the main use cases for this class is so template resources can be in a separate folder
* than their corresponding authorization resources,
* allowing for authorization-independent templates.
*/
export class SubfolderResourcesGenerator implements TemplatedResourcesGenerator {
private readonly resourcesGenerator: TemplatedResourcesGenerator;
private readonly subfolders: string[];
public constructor(resourcesGenerator: TemplatedResourcesGenerator, subfolders: string[]) {
this.resourcesGenerator = resourcesGenerator;
this.subfolders = subfolders;
}
public async* generate(templateFolder: string, location: ResourceIdentifier, options: Dict<string>):
AsyncIterable<Resource> {
const root = resolveAssetPath(templateFolder);
const templateSubfolders = this.subfolders.map((subfolder): string => joinFilePath(root, subfolder));
// Build all generators
const generators: AsyncIterator<Resource>[] = [];
for (const templateSubfolder of templateSubfolders) {
generators.push(this.resourcesGenerator.generate(templateSubfolder, location, options)[Symbol.asyncIterator]());
}
let previous: ResourceIdentifier = { path: '' };
for await (const result of sortedAsyncMerge(generators, comparator)) {
// Skip duplicate results.
// In practice these are just going to be the same empty containers.
if (result.identifier.path === previous.path) {
result.representation.data.destroy();
} else {
previous = result.identifier;
yield result;
}
}
}
}

View File

@ -1,216 +1,21 @@
import { createReadStream, promises as fsPromises } from 'fs';
import type { Readable } from 'stream';
import { Parser } from 'n3';
import type { AuxiliaryStrategy } from '../../http/auxiliary/AuxiliaryStrategy';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import type { import type { Resource } from './ResourcesGenerator';
FileIdentifierMapper,
FileIdentifierMapperFactory,
ResourceLink,
} from '../../storage/mapping/FileIdentifierMapper';
import type { ResourceSet } from '../../storage/ResourceSet';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { guardStream } from '../../util/GuardedStream';
import type { Guarded } from '../../util/GuardedStream';
import { joinFilePath, isContainerIdentifier, resolveAssetPath } from '../../util/PathUtil';
import { addResourceMetadata } from '../../util/ResourceUtil';
import { guardedStreamFrom, readableToString } from '../../util/StreamUtil';
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
import type { Resource, ResourcesGenerator } from './ResourcesGenerator';
import Dict = NodeJS.Dict; import Dict = NodeJS.Dict;
interface TemplateResourceLink extends ResourceLink {
isTemplate: boolean;
}
/** /**
* Input arguments required for {@link TemplatedResourcesGenerator} * Generator used to create resources relative to a given base identifier.
* Similar to {@link ResourcesGenerator}, but takes as input a string
* indicating where the templates are stored that need to be used for resource generation.
*/ */
export interface TemplatedResourcesGeneratorArgs { export interface TemplatedResourcesGenerator {
/** /**
* Folder where the templates are located. * Generates resources with the given options, based on the given template folder.
*/ * The output Iterable should be sorted so that containers always appear before their contents.
templateFolder: string; * @param templateFolder - Folder where the templates are located.
/** * @param location - Base identifier.
* Factory used to generate mapper relative to the base identifier. * @param options - Options that can be used when generating resources.
*/
factory: FileIdentifierMapperFactory;
/**
* Template engine for generating the resources.
*/
templateEngine: TemplateEngine;
/**
* The extension of files that need to be interpreted as templates.
* Will be removed to generate the identifier.
*/
templateExtension?: string;
/**
* The metadataStrategy
*/
metadataStrategy: AuxiliaryStrategy;
/**
* The default ResourceStore
*/
store: ResourceSet;
}
/**
* Generates resources by making use of a template engine.
* The template folder structure will be kept.
* Folders will be interpreted as containers and files as documents.
* A FileIdentifierMapper will be used to generate identifiers that correspond to the relative structure.
* *
* A relative `templateFolder` is resolved relative to cwd, * @returns A map where the keys are the identifiers and the values the corresponding representations to store.
* unless it's preceded by `@css:`, e.g. `@css:foo/bar`.
*/ */
export class TemplatedResourcesGenerator implements ResourcesGenerator { generate: (templateFolder: string, location: ResourceIdentifier, options: Dict<string>) => AsyncIterable<Resource>;
private readonly templateFolder: string;
private readonly factory: FileIdentifierMapperFactory;
private readonly templateEngine: TemplateEngine;
private readonly templateExtension: string;
private readonly metadataStrategy: AuxiliaryStrategy;
private readonly store: ResourceSet;
/**
* A mapper is needed to convert the template file paths to identifiers relative to the given base identifier.
*
* @param args - TemplatedResourcesGeneratorArgs
*/
public constructor(args: TemplatedResourcesGeneratorArgs) {
this.templateFolder = resolveAssetPath(args.templateFolder);
this.factory = args.factory;
this.templateEngine = args.templateEngine;
this.templateExtension = args.templateExtension ?? '.hbs';
this.metadataStrategy = args.metadataStrategy;
this.store = args.store;
}
public async* generate(location: ResourceIdentifier, options: Dict<string>): AsyncIterable<Resource> {
const mapper = await this.factory.create(location.path, this.templateFolder);
const folderLink = await this.toTemplateLink(this.templateFolder, mapper);
yield* this.processFolder(folderLink, mapper, options);
}
/**
* Generates results for all entries in the given folder, including the folder itself.
*/
private async* processFolder(folderLink: TemplateResourceLink, mapper: FileIdentifierMapper, options: Dict<string>):
AsyncIterable<Resource> {
// Group resource links with their corresponding metadata links
const links = await this.groupLinks(folderLink.filePath, mapper);
// Remove root metadata if it exists
const metaLink = links[folderLink.identifier.path]?.meta;
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete links[folderLink.identifier.path];
yield* this.generateResource(folderLink, options, metaLink);
for (const { link, meta } of Object.values(links)) {
if (isContainerIdentifier(link.identifier)) {
yield* this.processFolder(link, mapper, options);
} else {
yield* this.generateResource(link, options, meta);
}
}
}
/**
* Creates a TemplateResourceLink for the given filePath.
* The identifier will be based on the file path stripped from the template extension,
* but the filePath parameter will still point to the original file.
*/
private async toTemplateLink(filePath: string, mapper: FileIdentifierMapper): Promise<TemplateResourceLink> {
const stats = await fsPromises.lstat(filePath);
// Slice the template extension from the filepath for correct identifier generation
const isTemplate = filePath.endsWith(this.templateExtension);
const slicedPath = isTemplate ? filePath.slice(0, -this.templateExtension.length) : filePath;
const link = await mapper.mapFilePathToUrl(slicedPath, stats.isDirectory());
// We still need the original file path for disk reading though
return {
...link,
filePath,
isTemplate,
};
}
/**
* Generates TemplateResourceLinks for each entry in the given folder
* and combines the results so resources and their metadata are grouped together.
*/
private async groupLinks(folderPath: string, mapper: FileIdentifierMapper):
Promise<Record<string, { link: TemplateResourceLink; meta?: TemplateResourceLink }>> {
const files = await fsPromises.readdir(folderPath);
const links: Record<string, { link: TemplateResourceLink; meta?: TemplateResourceLink }> = { };
for (const name of files) {
const link = await this.toTemplateLink(joinFilePath(folderPath, name), mapper);
const { path } = link.identifier;
links[path] = Object.assign(links[path] || {}, link.isMetadata ? { meta: link } : { link });
}
return links;
}
/**
* Generates a Resource object for the given ResourceLink.
* In the case of documents the corresponding template will be used.
* If a ResourceLink of metadata is provided the corresponding data will be added as metadata.
*/
private async* generateResource(link: TemplateResourceLink, options: Dict<string>, metaLink?: TemplateResourceLink):
AsyncIterable<Resource> {
let data: Guarded<Readable> | undefined;
const metadata = new RepresentationMetadata(link.identifier);
// Read file if it is not a container
if (!isContainerIdentifier(link.identifier)) {
data = await this.processFile(link, options);
metadata.contentType = link.contentType;
}
// Do not yield a container resource if it already exists
if (!isContainerIdentifier(link.identifier) || !await this.store.hasResource(link.identifier)) {
yield {
identifier: link.identifier,
representation: new BasicRepresentation(data ?? [], metadata),
};
}
// Add metadata from .meta file if there is one
if (metaLink) {
const rawMetadata = await this.generateMetadata(metaLink, options);
const metaIdentifier = this.metadataStrategy.getAuxiliaryIdentifier(link.identifier);
const descriptionMeta = new RepresentationMetadata(metaIdentifier);
addResourceMetadata(rawMetadata, isContainerIdentifier(link.identifier));
yield {
identifier: metaIdentifier,
representation: new BasicRepresentation(rawMetadata.quads(), descriptionMeta, INTERNAL_QUADS),
};
}
}
/**
* Generates a RepresentationMetadata using the given template.
*/
private async generateMetadata(metaLink: TemplateResourceLink, options: Dict<string>):
Promise<RepresentationMetadata> {
const metadata = new RepresentationMetadata(metaLink.identifier);
const data = await this.processFile(metaLink, options);
const parser = new Parser({ format: metaLink.contentType, baseIRI: metaLink.identifier.path });
const quads = parser.parse(await readableToString(data));
metadata.addQuads(quads);
return metadata;
}
/**
* Creates a read stream from the file and applies the template if necessary.
*/
private async processFile(link: TemplateResourceLink, contents: Dict<string>): Promise<Guarded<Readable>> {
if (link.isTemplate) {
const rendered = await this.templateEngine.handleSafe({ contents, template: { templateFile: link.filePath }});
return guardedStreamFrom(rendered);
}
return guardStream(createReadStream(link.filePath));
}
} }

View File

@ -15,5 +15,6 @@ export const INTERNAL_ERROR = 'internal/error';
export const DEFAULT_CUSTOM_TYPES = { export const DEFAULT_CUSTOM_TYPES = {
acl: TEXT_TURTLE, acl: TEXT_TURTLE,
acr: TEXT_TURTLE,
meta: TEXT_TURTLE, meta: TEXT_TURTLE,
}; };

View File

@ -113,3 +113,93 @@ export function reduce<TIn, TOut>(iterable: Iterable<TIn>,
} }
return previousValue; return previousValue;
} }
/**
* Helper function for {@link sortedAsyncMerge}.
*
* Returns the next result of an AsyncIterator, or undefined if the iterator is finished.
*/
async function nextAsyncEntry<T>(iterator: AsyncIterator<T>): Promise<T | undefined> {
const result = await iterator.next();
if (result.done) {
return;
}
return result.value;
}
/**
* Helper function for {@link sortedAsyncMerge}.
*
* Compares the next results of all `iterators` and returns the first one,
* determined by the provided `comparator`.
*
* `results` should contain the first result of all these iterators.
* This array will also be updated, replacing the result of the iterator whose result was chosen by the next one.
*/
async function findNextSorted<T>(iterators: AsyncIterator<T>[], results: (T | undefined)[],
comparator: (left: T, right: T) => number): Promise<T | undefined> {
let best: { idx: number; value: T } | undefined;
// For every iterator: see if their next result is the best one so far
for (let i = 0; i < iterators.length; ++i) {
const value = results[i];
if (typeof value !== 'undefined') {
let compare = 1;
if (best) {
compare = comparator(best.value, value);
}
if (compare > 0) {
best = { idx: i, value };
}
}
}
if (best) {
// Advance the iterator that returned the new result
results[best.idx] = await nextAsyncEntry(iterators[best.idx]);
}
// Will return undefined if `best` was never initialized above
return best?.value;
}
/**
* Merges the results of several sorted iterators.
* In case the results of the individual iterators are not sorted the outcome results will also not be sorted.
*
* @param iterators - The iterators whose results need to be merged.
* @param comparator - The comparator to use to compare the results.
*/
export async function* sortedAsyncMerge<T>(iterators: AsyncIterator<T>[], comparator?: (left: T, right: T) => number):
AsyncIterable<T> {
if (!comparator) {
// eslint-disable-next-line @typescript-eslint/no-extra-parens
comparator = (left, right): number => left < right ? -1 : (left > right ? 1 : 0);
}
// Initialize the array to the first result of every iterator
const results: (T | undefined)[] = [];
for (const iterator of iterators) {
results.push(await nextAsyncEntry(iterator));
}
// Keep returning results as long as we find them
while (true) {
const next = await findNextSorted(iterators, results, comparator);
if (typeof next === 'undefined') {
return;
}
yield next;
}
}
/**
* Converts an `AsyncIterator` to an array.
*/
export async function asyncToArray<T>(iterable: AsyncIterable<T>): Promise<T[]> {
const arr: T[] = [];
for await (const result of iterable) {
arr.push(result);
}
return arr;
}

View File

@ -0,0 +1,39 @@
# Root ACR for the agent account
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
# The owner has full access to every resource in their pod.
# Other agents have no access rights,
# unless specifically authorized in other ACRs.
<#root>
a acp:AccessControlResource;
# Set the access to the root storage folder itself
acp:resource <./>;
# The homepage is readable by the public
acp:accessControl <#fullOwnerAccess>, <#publicReadAccess>;
# All resources will inherit this authorization
acp:memberAccessControl <#fullOwnerAccess>.
# The public only has read access
<#publicReadAccess>
a acp:AccessControl;
acp:apply [
a acp:Policy;
acp:allow acl:Read;
acp:anyOf [
a acp:Matcher;
acp:agent acp:PublicAgent
]
].
# The owner has all of the access modes allowed
<#fullOwnerAccess>
a acp:AccessControl;
acp:apply [
a acp:Policy;
acp:allow acl:Read, acl:Write, acl:Control;
acp:anyOf [
a acp:Matcher;
acp:agent <{{webId}}>
]
].

View File

@ -0,0 +1,18 @@
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
<#card>
a acp:AccessControlResource;
acp:resource <./README>;
acp:accessControl <#publicReadAccess>.
<#publicReadAccess>
a acp:AccessControl;
acp:apply [
a acp:Policy;
acp:allow acl:Read;
acp:anyOf [
a acp:Matcher;
acp:agent acp:PublicAgent
]
].

View File

@ -0,0 +1,22 @@
# ACR for the WebID profile document
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
# The WebID profile is readable by the public.
# This is required for discovery and verification,
# e.g. when checking identity providers.
<#card>
a acp:AccessControlResource;
acp:resource <./card>;
acp:accessControl <#publicReadAccess>.
<#publicReadAccess>
a acp:AccessControl;
acp:apply [
a acp:Policy;
acp:allow acl:Read;
acp:anyOf [
a acp:Matcher;
acp:agent acp:PublicAgent
]
].

View File

@ -0,0 +1,32 @@
# WARNING: DO NOT USE UNMODIFIED UNLESS FOR TESTING PURPOSES.
# WHEN IN DOUBT, DELETE THIS DOCUMENT.
#
# This root ACR allows unrestricted public access to all documents and subcontainers.
#
# This document was automatically generated by the Community Solid Server
# because the "Expose a public root Pod" option was selected during setup,
# or because setup has been bypassed.
#
# We strongly suggest to edit this document such that it restricts permissions.
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
# Give all agents Read, Write, and Control permissions on everything
<#card>
a acp:AccessControlResource;
acp:resource <./>;
acp:accessControl <#publicReadAccess>;
acp:memberAccessControl <#publicReadAccess> .
<#publicReadAccess>
a acp:AccessControl;
acp:apply [
a acp:Policy;
acp:allow acl:Read, acl:Write, acl:Control;
acp:anyOf [
a acp:Matcher;
acp:agent acp:PublicAgent
]
].

View File

@ -0,0 +1,32 @@
# WARNING: DO NOT USE UNMODIFIED UNLESS FOR TESTING PURPOSES.
# WHEN IN DOUBT, DELETE THIS DOCUMENT.
#
# This root ACR allows unrestricted public access to all documents and subcontainers.
#
# This document was automatically generated by the Community Solid Server
# because the "Expose a public root Pod" option was selected during setup,
# or because setup has been bypassed.
#
# We strongly suggest to edit this document such that it restricts permissions.
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
# Give all agents Read, Write, and Control permissions on everything
<#card>
a acp:AccessControlResource;
acp:resource <./>;
acp:accessControl <#publicReadAccess>;
acp:memberAccessControl <#publicReadAccess> .
<#publicReadAccess>
a acp:AccessControl;
acp:apply [
a acp:Policy;
acp:allow acl:Read, acl:Write, acl:Control;
acp:anyOf [
a acp:Matcher;
acp:agent acp:PublicAgent
]
].

View File

@ -8,7 +8,7 @@
</head> </head>
<body> <body>
<header> <header>
<a href="./"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a> <a href=".."><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1> <h1>Community Solid Server</h1>
</header> </header>
<main> <main>

View File

@ -1,18 +1,20 @@
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import { TemplatedResourcesGenerator } from '../../../../src/pods/generate/TemplatedResourcesGenerator'; import { BaseResourcesGenerator } from '../../../../src/pods/generate/BaseResourcesGenerator';
import type { import type {
FileIdentifierMapper, FileIdentifierMapper,
FileIdentifierMapperFactory, FileIdentifierMapperFactory,
ResourceLink, ResourceLink,
} from '../../../../src/storage/mapping/FileIdentifierMapper'; } from '../../../../src/storage/mapping/FileIdentifierMapper';
import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { ensureTrailingSlash, trimTrailingSlashes } from '../../../../src/util/PathUtil'; import { asyncToArray } from '../../../../src/util/IterableUtil';
import { ensureTrailingSlash, joinFilePath, trimTrailingSlashes } from '../../../../src/util/PathUtil';
import { readableToQuads, readableToString } from '../../../../src/util/StreamUtil'; import { readableToQuads, readableToString } from '../../../../src/util/StreamUtil';
import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine'; import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine';
import { SimpleSuffixStrategy } from '../../../util/SimpleSuffixStrategy'; import { SimpleSuffixStrategy } from '../../../util/SimpleSuffixStrategy';
import { mockFileSystem } from '../../../util/Util'; import { mockFileSystem } from '../../../util/Util';
jest.mock('fs'); jest.mock('fs');
jest.mock('fs-extra');
class DummyFactory implements FileIdentifierMapperFactory { class DummyFactory implements FileIdentifierMapperFactory {
public async create(base: string, rootFilePath: string): Promise<FileIdentifierMapper> { public async create(base: string, rootFilePath: string): Promise<FileIdentifierMapper> {
@ -36,20 +38,12 @@ class DummyFactory implements FileIdentifierMapperFactory {
} }
} }
async function genToArray<T>(iterable: AsyncIterable<T>): Promise<T[]> { describe('A BaseResourcesGenerator', (): void => {
const arr: T[] = [];
for await (const result of iterable) {
arr.push(result);
}
return arr;
}
describe('A TemplatedResourcesGenerator', (): void => {
const rootFilePath = '/templates/pod'; const rootFilePath = '/templates/pod';
// Using handlebars engine since it's smaller than any possible dummy // Using handlebars engine since it's smaller than any possible dummy
const metadataStrategy = new SimpleSuffixStrategy('.meta'); const metadataStrategy = new SimpleSuffixStrategy('.meta');
let store: jest.Mocked<ResourceStore>; let store: jest.Mocked<ResourceStore>;
let generator: TemplatedResourcesGenerator; let generator: BaseResourcesGenerator;
let cache: { data: any }; let cache: { data: any };
const template = '<{{webId}}> a <http://xmlns.com/foaf/0.1/Person>.'; const template = '<{{webId}}> a <http://xmlns.com/foaf/0.1/Person>.';
const location = { path: 'http://test.com/alice/' }; const location = { path: 'http://test.com/alice/' };
@ -61,8 +55,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
hasResource: jest.fn(), hasResource: jest.fn(),
} as any; } as any;
generator = new TemplatedResourcesGenerator({ generator = new BaseResourcesGenerator({
templateFolder: rootFilePath,
factory: new DummyFactory(), factory: new DummyFactory(),
templateEngine: new HandlebarsTemplateEngine('http://test.com/'), templateEngine: new HandlebarsTemplateEngine('http://test.com/'),
metadataStrategy, metadataStrategy,
@ -72,7 +65,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
it('fills in a template with the given options.', async(): Promise<void> => { it('fills in a template with the given options.', async(): Promise<void> => {
cache.data = { 'template.hbs': template }; cache.data = { 'template.hbs': template };
const result = await genToArray(generator.generate(location, { webId })); const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
const identifiers = result.map((res): ResourceIdentifier => res.identifier); const identifiers = result.map((res): ResourceIdentifier => res.identifier);
const id = { path: `${location.path}template` }; const id = { path: `${location.path}template` };
expect(identifiers).toEqual([ location, id ]); expect(identifiers).toEqual([ location, id ]);
@ -86,7 +79,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
it('creates the necessary containers.', async(): Promise<void> => { it('creates the necessary containers.', async(): Promise<void> => {
cache.data = { container: { container: { 'template.hbs': template }}}; cache.data = { container: { container: { 'template.hbs': template }}};
const result = await genToArray(generator.generate(location, { webId })); const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
const identifiers = result.map((res): ResourceIdentifier => res.identifier); const identifiers = result.map((res): ResourceIdentifier => res.identifier);
const id = { path: `${location.path}container/container/template` }; const id = { path: `${location.path}container/container/template` };
expect(identifiers).toEqual([ expect(identifiers).toEqual([
@ -103,7 +96,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
it('copies the file stream directly if no template extension is found.', async(): Promise<void> => { it('copies the file stream directly if no template extension is found.', async(): Promise<void> => {
cache.data = { noTemplate: template }; cache.data = { noTemplate: template };
const result = await genToArray(generator.generate(location, { webId })); const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
const identifiers = result.map((res): ResourceIdentifier => res.identifier); const identifiers = result.map((res): ResourceIdentifier => res.identifier);
const id = { path: `${location.path}noTemplate` }; const id = { path: `${location.path}noTemplate` };
expect(identifiers).toEqual([ location, id ]); expect(identifiers).toEqual([ location, id ]);
@ -119,7 +112,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
cache.data = { '.meta': meta, container: { 'template.meta': meta, template }}; cache.data = { '.meta': meta, container: { 'template.meta': meta, template }};
// Not using options since our dummy template generator generates invalid turtle // Not using options since our dummy template generator generates invalid turtle
const result = await genToArray(generator.generate(location, { webId })); const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
const identifiers = result.map((res): ResourceIdentifier => res.identifier); const identifiers = result.map((res): ResourceIdentifier => res.identifier);
expect(identifiers).toEqual([ expect(identifiers).toEqual([
location, location,
@ -158,7 +151,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
cache.data = { '.meta': meta }; cache.data = { '.meta': meta };
store.hasResource = jest.fn().mockResolvedValue(true); store.hasResource = jest.fn().mockResolvedValue(true);
const result = await genToArray(generator.generate(location, { webId })); const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
const identifiers = result.map((res): ResourceIdentifier => res.identifier); const identifiers = result.map((res): ResourceIdentifier => res.identifier);
expect(identifiers).toEqual([ expect(identifiers).toEqual([
{ path: `${location.path}.meta` }, { path: `${location.path}.meta` },
@ -169,4 +162,20 @@ describe('A TemplatedResourcesGenerator', (): void => {
expect(expQuads).toHaveLength(1); expect(expQuads).toHaveLength(1);
expect(expQuads[0].object.value).toBe('metadata'); expect(expQuads[0].object.value).toBe('metadata');
}); });
it('returns no results if the target folder does not exist.', async(): Promise<void> => {
const result = await asyncToArray(generator.generate(joinFilePath(rootFilePath, 'nope'), location, { webId }));
expect(result).toHaveLength(0);
});
it('makes sure the results are sorted.', async(): Promise<void> => {
cache.data = { 'template2.hbs': template, 'template1.hbs': template };
const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
const identifiers = result.map((res): ResourceIdentifier => res.identifier);
expect(identifiers).toEqual([
location,
{ path: `${location.path}template1` },
{ path: `${location.path}template2` },
]);
});
}); });

View File

@ -0,0 +1,26 @@
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { Resource } from '../../../../src/pods/generate/ResourcesGenerator';
import { StaticFolderGenerator } from '../../../../src/pods/generate/StaticFolderGenerator';
import type { TemplatedResourcesGenerator } from '../../../../src/pods/generate/TemplatedResourcesGenerator';
describe('A StaticFolderGenerator', (): void => {
const location: ResourceIdentifier = { path: 'http://example.com/foo' };
const options = { foo: 'bar' };
const folder = '/data/templates/';
let source: jest.Mocked<TemplatedResourcesGenerator>;
const response: AsyncIterable<Resource> = {} as any;
let generator: StaticFolderGenerator;
beforeEach(async(): Promise<void> => {
source = {
generate: jest.fn().mockReturnValue(response),
};
generator = new StaticFolderGenerator(source, folder);
});
it('calls the source generator with the stored template folder.', async(): Promise<void> => {
expect(generator.generate(location, options)).toBe(response);
expect(source.generate).toHaveBeenCalledTimes(1);
expect(source.generate).toHaveBeenLastCalledWith(folder, location, options);
});
});

View File

@ -0,0 +1,90 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Resource } from '../../../../src/pods/generate/ResourcesGenerator';
import { SubfolderResourcesGenerator } from '../../../../src/pods/generate/SubfolderResourcesGenerator';
import type { TemplatedResourcesGenerator } from '../../../../src/pods/generate/TemplatedResourcesGenerator';
import { asyncToArray } from '../../../../src/util/IterableUtil';
async function* yieldResources(resources: Resource[]): AsyncIterable<Resource> {
yield* resources;
}
function createResource(path: string): Resource {
const identifier = { path };
const representation = new BasicRepresentation('data', 'text/plain');
return { identifier, representation };
}
describe('A SubfolderResourcesGenerator', (): void => {
const templateFolder = '/data/templates/';
const identifier = { path: 'http://example.com/foo' };
const options = { foo: 'bar' };
const subfolders = [ 'base', 'empty', 'extra' ];
let baseResources: Resource[];
let extraResources: Resource[];
let source: jest.Mocked<TemplatedResourcesGenerator>;
let generator: SubfolderResourcesGenerator;
beforeEach(async(): Promise<void> => {
baseResources = [];
extraResources = [];
source = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
generate: jest.fn((folder, loc, opt): AsyncIterable<Resource> => {
if (folder.endsWith('base')) {
return yieldResources(baseResources);
}
if (folder.endsWith('extra')) {
return yieldResources(extraResources);
}
return yieldResources([]);
}),
};
generator = new SubfolderResourcesGenerator(source, subfolders);
});
it('merges the results of the subfolders into one sorted result.', async(): Promise<void> => {
baseResources = [ createResource('a'), createResource('c'), createResource('d'), createResource('f') ];
extraResources = [ createResource('b'), createResource('e'), createResource('g') ];
const resources = await asyncToArray(generator.generate(templateFolder, identifier, options));
expect(resources.map((resource): string => resource.identifier.path)).toEqual([
'a', 'b', 'c', 'd', 'e', 'f', 'g',
]);
expect(source.generate).toHaveBeenCalledTimes(3);
expect(source.generate).toHaveBeenNthCalledWith(1, '/data/templates/base', identifier, options);
expect(source.generate).toHaveBeenNthCalledWith(2, '/data/templates/empty', identifier, options);
expect(source.generate).toHaveBeenNthCalledWith(3, '/data/templates/extra', identifier, options);
});
it('keeps the first result in case of duplicate identifiers.', async(): Promise<void> => {
const resource1 = createResource('foo');
const resource2 = createResource('foo');
baseResources = [ createResource('b'), resource1, createResource('g') ];
extraResources = [ createResource('a'), resource2, createResource('h') ];
const resources = await asyncToArray(generator.generate(templateFolder, identifier, options));
expect(resources.map((resource): string => resource.identifier.path)).toEqual([
'a', 'b', 'foo', 'g', 'h',
]);
expect(resources[2]).toBe(resource1);
expect(resource2.representation.data.destroyed).toBe(true);
});
it('correctly sorts containers.', async(): Promise<void> => {
baseResources = [ createResource('/'), createResource('/container/'),
createResource('/container/foo.acl'), createResource('README.acl') ];
extraResources = [ createResource('/'), createResource('/container/'),
createResource('/container/foo'), createResource('README') ];
const resources = await asyncToArray(generator.generate(templateFolder, identifier, options));
expect(resources.map((resource): string => resource.identifier.path)).toEqual([
'/',
'/container/',
'/container/foo',
'/container/foo.acl',
'README',
'README.acl',
]);
});
});

View File

@ -1,4 +1,4 @@
import { concat, filter, find, map, reduce } from '../../../src/util/IterableUtil'; import { asyncToArray, concat, filter, find, map, reduce, sortedAsyncMerge } from '../../../src/util/IterableUtil';
describe('IterableUtil', (): void => { describe('IterableUtil', (): void => {
describe('#map', (): void => { describe('#map', (): void => {
@ -50,4 +50,32 @@ describe('IterableUtil', (): void => {
expect((): number => reduce(input, (acc, cur): number => acc + cur)).toThrow(TypeError); expect((): number => reduce(input, (acc, cur): number => acc + cur)).toThrow(TypeError);
}); });
}); });
describe('#sortedAsyncMerge', (): void => {
it('sorts the iterables.', async(): Promise<void> => {
// eslint-disable-next-line unicorn/consistent-function-scoping
async function* left(): AsyncIterator<number> {
yield* [ 1, 3, 5, 7, 9 ];
}
// eslint-disable-next-line unicorn/consistent-function-scoping
async function* right(): AsyncIterator<number> {
yield* [ 0, 2, 3, 4 ];
}
await expect(asyncToArray(sortedAsyncMerge([ left(), right() ]))).resolves
.toEqual([ 0, 1, 2, 3, 3, 4, 5, 7, 9 ]);
});
it('accepts a custom comparator.', async(): Promise<void> => {
// eslint-disable-next-line unicorn/consistent-function-scoping
async function* left(): AsyncIterator<string> {
yield* [ 'apple', 'citrus', 'date' ];
}
// eslint-disable-next-line unicorn/consistent-function-scoping
async function* right(): AsyncIterator<string> {
yield* [ 'banana', 'donut' ];
}
await expect(asyncToArray(sortedAsyncMerge([ left(), right() ]))).resolves
.toEqual([ 'apple', 'banana', 'citrus', 'date', 'donut' ]);
});
});
}); });

View File

@ -272,6 +272,14 @@ export function mockFileSystem(rootFilepath?: string, time?: Date): { data: any
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete folder[name]; delete folder[name];
}, },
async pathExists(path: string): Promise<boolean> {
try {
const { folder, name } = getFolder(path);
return Boolean(folder[name]);
} catch {
return false;
}
},
createReadStream(path: string): any { createReadStream(path: string): any {
return mockFs.createReadStream(path); return mockFs.createReadStream(path);
}, },