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

@@ -258,12 +258,15 @@ export * from './pods/generate/variables/VariableSetter';
// Pods/Generate
export * from './pods/generate/BaseComponentsJsFactory';
export * from './pods/generate/BaseResourcesGenerator';
export * from './pods/generate/ComponentsJsFactory';
export * from './pods/generate/GenerateUtil';
export * from './pods/generate/IdentifierGenerator';
export * from './pods/generate/PodGenerator';
export * from './pods/generate/ResourcesGenerator';
export * from './pods/generate/StaticFolderGenerator';
export * from './pods/generate/SubdomainIdentifierGenerator';
export * from './pods/generate/SubfolderResourcesGenerator';
export * from './pods/generate/SuffixIdentifierGenerator';
export * from './pods/generate/TemplatedPodGenerator';
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 {
/**
* 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 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 {
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 type { Resource } from './ResourcesGenerator';
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.
*/
templateFolder: string;
/**
* 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;
}
/**
* 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,
* unless it's preceded by `@css:`, e.g. `@css:foo/bar`.
*/
export class TemplatedResourcesGenerator implements ResourcesGenerator {
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.
* 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.
* @param templateFolder - Folder where the templates are located.
* @param location - Base identifier.
* @param options - Options that can be used when generating resources.
*
* @param args - TemplatedResourcesGeneratorArgs
* @returns A map where the keys are the identifiers and the values the corresponding representations to store.
*/
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));
}
generate: (templateFolder: string, location: ResourceIdentifier, options: Dict<string>) => AsyncIterable<Resource>;
}

View File

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

View File

@@ -113,3 +113,93 @@ export function reduce<TIn, TOut>(iterable: Iterable<TIn>,
}
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;
}