diff --git a/lib/AbstractReader.js b/lib/AbstractReader.js index c4bab6b3..032d519a 100644 --- a/lib/AbstractReader.js +++ b/lib/AbstractReader.js @@ -102,6 +102,20 @@ class AbstractReader { callback }); } + /** + * Create a [Link-Reader]{@link module:@ui5/fs.readers.Link} from the current reader + * + * @public + * @param {module:@ui5/fs.reader.Link.PathMapping} pathMapping Link configuration + * @returns {module:@ui5/fs.reader.Link} Link instance + */ + link(pathMapping) { + const Link = require("./readers/Link"); + return new Link({ + reader: this, + pathMapping + }); + } /** * Locates resources by one or more glob patterns. diff --git a/lib/WriterCollection.js b/lib/WriterCollection.js new file mode 100644 index 00000000..94467b83 --- /dev/null +++ b/lib/WriterCollection.js @@ -0,0 +1,121 @@ +const AbstractReaderWriter = require("./AbstractReaderWriter"); +const ReaderCollection = require("./ReaderCollection"); + +/** + * Resource Locator WriterCollection + * + * @public + * @memberof module:@ui5/fs + * @augments module:@ui5/fs.AbstractReaderWriter + */ +class WriterCollection extends AbstractReaderWriter { + /** + * The constructor. + * + * @param {object} parameters Parameters + * @param {object[]} parameters.writerMapping + * Mapping of virtual base paths to writers. Path are matched greedy + * @param {string} parameters.name The collection name + */ + constructor({writerMapping, name}) { + super(); + this._name = name; + + if (!writerMapping) { + throw new Error("Missing parameter 'writerMapping'"); + } + const basePaths = Object.keys(writerMapping); + if (!basePaths.length) { + throw new Error("Empty parameter 'writerMapping'"); + } + + // Create a regular expression (which is greedy by nature) from all paths to easily + // find the correct writer for any given resource path + this._basePathRegex = basePaths.sort().reduce((regex, basePath, idx) => { + // Validate base path + if (!basePath) { + throw new Error(`Empty path in path mapping of WriterCollection ${this._name}`); + } + if (!basePath.startsWith("/")) { + throw new Error( + `Missing leading slash in path mapping '${basePath}' of WriterCollection ${this._name}`); + } + if (!basePath.endsWith("/")) { + throw new Error( + `Missing trailing slash in path mapping '${basePath}' of WriterCollection ${this._name}`); + } + + return `${regex}(?:${basePath.replace(/\//g, "\\/")})??`; + }, "^(") + ")+.*?$"; + + this._writerMapping = writerMapping; + this._readerCollection = new ReaderCollection({ + name: `Reader collection of writer collection '${this._name}'`, + readers: Object.values(writerMapping) + }); + } + + /** + * Locates resources by glob. + * + * @private + * @param {string|string[]} pattern glob pattern as string or an array of + * glob patterns for virtual directory structure + * @param {object} options glob options + * @param {module:@ui5/fs.tracing.Trace} trace Trace instance + * @returns {Promise} Promise resolving to list of resources + */ + _byGlob(pattern, options, trace) { + return this._readerCollection._byGlob(pattern, options, trace); + } + + /** + * Locates resources by path. + * + * @private + * @param {string} virPath Virtual path + * @param {object} options Options + * @param {module:@ui5/fs.tracing.Trace} trace Trace instance + * @returns {Promise} Promise resolving to a single resource + */ + _byPath(virPath, options, trace) { + return this._readerCollection._byPath(virPath, options, trace); + } + + /** + * Writes the content of a resource to a path. + * + * @private + * @param {module:@ui5/fs.Resource} resource The Resource to write + * @param {object} [options] Write options, see above + * @returns {Promise} Promise resolving once data has been written + */ + _write(resource, options) { + const resourcePath = resource.getPath(); + + const basePathMatch = resourcePath.match(this._basePathRegex); + if (!basePathMatch || basePathMatch.length < 2) { + throw new Error( + `Failed to find a writer for resource with path ${resourcePath} in WriterCollection ${this._name}. ` + + `Base paths handled by this collection are: ${Object.keys(this._writerMapping).join(", ")}`); + } + const writer = this._writerMapping[basePathMatch[1]]; + return writer._write(resource, options); + } + + _validateBasePath(writerMapping) { + Object.keys(writerMapping).forEach((path) => { + if (!path) { + throw new Error(`Empty path in path mapping of WriterCollection ${this._name}`); + } + if (!path.startsWith("/")) { + throw new Error(`Missing leading slash in path mapping '${path}' of WriterCollection ${this._name}`); + } + if (!path.endsWith("/")) { + throw new Error(`Missing trailing slash in path mapping '${path}' of WriterCollection ${this._name}`); + } + }); + } +} + +module.exports = WriterCollection; diff --git a/lib/readers/Link.js b/lib/readers/Link.js new file mode 100644 index 00000000..20986d2a --- /dev/null +++ b/lib/readers/Link.js @@ -0,0 +1,122 @@ +const AbstractReader = require("../AbstractReader"); +const ResourceFacade = require("../ResourceFacade"); +const resourceFactory = require("../resourceFactory"); +const log = require("@ui5/logger").getLogger("resources:readers:Link"); + +/** + * A reader that allows modification of all resources passed through it. + * + * @public + * @memberof module:@ui5/fs.readers + * @augments module:@ui5/fs.AbstractReader + */ +class Link extends AbstractReader { + /** + * Path mapping for a [Link]{@link module:@ui5/fs.readers.Link} + * + * @public + * @typedef {object} PathMapping + * @property {string} pathMapping.linkPath Input path to rewrite + * @property {string} pathMapping.targetPath Path to rewrite to + */ + /** + * Constructor + * + * @public + * @param {object} parameters Parameters + * @param {module:@ui5/fs.AbstractReader} parameters.reader The resource reader to wrap + * @param {PathMapping} parameters.pathMapping + */ + constructor({reader, pathMapping}) { + super(); + if (!reader) { + throw new Error(`Missing parameter "reader"`); + } + if (!pathMapping) { + throw new Error(`Missing parameter "pathMapping"`); + } + this._reader = reader; + this._pathMapping = pathMapping; + this._validatePathMapping(pathMapping); + } + + /** + * Locates resources by glob. + * + * @private + * @param {string|string[]} patterns glob pattern as string or an array of + * glob patterns for virtual directory structure + * @param {object} options glob options + * @param {module:@ui5/fs.tracing.Trace} trace Trace instance + * @returns {Promise} Promise resolving to list of resources + */ + async _byGlob(patterns, options, trace) { + if (!(patterns instanceof Array)) { + patterns = [patterns]; + } + patterns = patterns.map((pattern) => { + if (pattern.startsWith(this._pathMapping.linkPath)) { + pattern = pattern.substr(this._pathMapping.linkPath.length); + } + return resourceFactory.prefixGlobPattern(pattern, this._pathMapping.targetPath); + }); + + // Flatten prefixed patterns + patterns = Array.prototype.concat.apply([], patterns); + + // Keep resource's internal path unchanged for now + const resources = await this._reader._byGlob(patterns, options, trace); + return resources.map((resource) => { + const resourcePath = resource.getPath(); + if (resourcePath.startsWith(this._pathMapping.targetPath)) { + return new ResourceFacade({ + resource, + path: this._pathMapping.linkPath + resourcePath.substr(this._pathMapping.targetPath.length) + }); + } + }); + } + + /** + * Locates resources by path. + * + * @private + * @param {string} virPath Virtual path + * @param {object} options Options + * @param {module:@ui5/fs.tracing.Trace} trace Trace instance + * @returns {Promise} Promise resolving to a single resource + */ + async _byPath(virPath, options, trace) { + if (!virPath.startsWith(this._pathMapping.linkPath)) { + return null; + } + const targetPath = this._pathMapping.targetPath + virPath.substr(this._pathMapping.linkPath.length); + log.verbose(`byPath: Rewriting virtual path ${virPath} to ${targetPath}`); + + const resource = await this._reader._byPath(targetPath, options, trace); + if (resource) { + return new ResourceFacade({ + resource, + path: this._pathMapping.linkPath + resource.getPath().substr(this._pathMapping.targetPath.length) + }); + } + return null; + } + + _validatePathMapping({linkPath, targetPath}) { + if (!linkPath) { + throw new Error(`Path mapping is missing attribute "linkPath"`); + } + if (!targetPath) { + throw new Error(`Path mapping is missing attribute "targetPath"`); + } + if (!linkPath.endsWith("/")) { + throw new Error(`Link path must end with a slash: ${linkPath}`); + } + if (!targetPath.endsWith("/")) { + throw new Error(`Target path must end with a slash: ${targetPath}`); + } + } +} + +module.exports = Link; diff --git a/lib/resourceFactory.js b/lib/resourceFactory.js index d69fc1ac..dde39407 100644 --- a/lib/resourceFactory.js +++ b/lib/resourceFactory.js @@ -268,12 +268,29 @@ const resourceFactory = { }, createReaderCollection({name, readers}) { + const ReaderCollection = require("./ReaderCollection"); return new ReaderCollection({ name, readers }); }, + createReaderCollectionPrioritized({name, readers}) { + const ReaderCollectionPrioritized = require("./ReaderCollectionPrioritized"); + return new ReaderCollectionPrioritized({ + name, + readers + }); + }, + + createWriterCollection({name, writerMapping}) { + const WriterCollection = require("./WriterCollection"); + return new WriterCollection({ + name, + writerMapping + }); + }, + /** * Creates a Resource. Accepts the same parameters as the Resource constructor. * @@ -313,6 +330,31 @@ const resourceFactory = { writer, name }); + }, + + /** + * Normalizes virtual glob patterns by prefixing them with + * a given virtual base directory path + * + * @param {string} virPattern glob pattern for virtual directory structure + * @param {string} virBaseDir virtual base directory path to prefix the given patterns with + * @returns {string[]} A list of normalized glob patterns + */ + prefixGlobPattern(virPattern, virBaseDir) { + const path = require("path"); + const minimatch = require("minimatch"); + const mm = new minimatch.Minimatch(virPattern); + + const resultGlobs = []; + for (let i = 0; i < mm.globSet.length; i++) { + let resultPattern = path.posix.join(virBaseDir, mm.globSet[i]); + + if (mm.negate) { + resultPattern = "!" + resultPattern; + } + resultGlobs.push(resultPattern); + } + return Array.prototype.concat.apply([], resultGlobs); } }; diff --git a/test/lib/WriterCollection.js b/test/lib/WriterCollection.js new file mode 100644 index 00000000..1ec51bc3 --- /dev/null +++ b/test/lib/WriterCollection.js @@ -0,0 +1,196 @@ +const test = require("ava"); +const sinon = require("sinon"); +const WriterCollection = require("../../lib/WriterCollection"); +const Resource = require("../../lib/Resource"); + +test("Constructor: Path mapping regex", async (t) => { + const myWriter = {}; + const writer = new WriterCollection({ + name: "myCollection", + writerMapping: { + "/": myWriter, + "/my/path/": myWriter, + "/my/": myWriter, + } + }); + t.is(writer._basePathRegex.toString(), "^((?:\\/)??(?:\\/my\\/)??(?:\\/my\\/path\\/)??)+.*?$", + "Created correct path mapping regular expression"); +}); + +test("Constructor: Throws for missing path mapping", async (t) => { + const err = t.throws(() => { + new WriterCollection({ + name: "myCollection" + }); + }); + t.is(err.message, "Missing parameter 'writerMapping'", "Threw with expected error message"); +}); + +test("Constructor: Throws for empty path mapping", async (t) => { + const err = t.throws(() => { + new WriterCollection({ + name: "myCollection", + writerMapping: {} + }); + }); + t.is(err.message, "Empty parameter 'writerMapping'", "Threw with expected error message"); +}); + +test("Constructor: Throws for empty path", async (t) => { + const myWriter = { + _write: sinon.stub() + }; + const err = t.throws(() => { + new WriterCollection({ + name: "myCollection", + writerMapping: { + "": myWriter + } + }); + }); + t.is(err.message, "Empty path in path mapping of WriterCollection myCollection", + "Threw with expected error message"); +}); + +test("Constructor: Throws for missing leading slash", async (t) => { + const myWriter = { + _write: sinon.stub() + }; + const err = t.throws(() => { + new WriterCollection({ + name: "myCollection", + writerMapping: { + "my/path/": myWriter + } + }); + }); + t.is(err.message, "Missing leading slash in path mapping 'my/path/' of WriterCollection myCollection", + "Threw with expected error message"); +}); + +test("Constructor: Throws for missing trailing slash", async (t) => { + const myWriter = { + _write: sinon.stub() + }; + const err = t.throws(() => { + new WriterCollection({ + name: "myCollection", + writerMapping: { + "/my/path": myWriter + } + }); + }); + t.is(err.message, "Missing trailing slash in path mapping '/my/path' of WriterCollection myCollection", + "Threw with expected error message"); +}); + +test("Write", async (t) => { + const myPathWriter = { + _write: sinon.stub() + }; + const myWriter = { + _write: sinon.stub() + }; + const generalWriter = { + _write: sinon.stub() + }; + const writerCollection = new WriterCollection({ + name: "myCollection", + writerMapping: { + "/my/path/": myPathWriter, + "/my/": myWriter, + "/": generalWriter + } + }); + + const myPathResource = new Resource({ + path: "/my/path/resource.res", + string: "content" + }); + const myResource = new Resource({ + path: "/my/resource.res", + string: "content" + }); + const resource = new Resource({ + path: "/resource.res", + string: "content" + }); + + await writerCollection.write(myPathResource, "options 1"); + await writerCollection.write(myResource, "options 2"); + await writerCollection.write(resource, "options 3"); + + t.is(myPathWriter._write.callCount, 1, "One write to /my/path/ writer"); + t.is(myWriter._write.callCount, 1, "One write to /my/ writer"); + t.is(generalWriter._write.callCount, 1, "One write to / writer"); + + t.is(myPathWriter._write.getCall(0).args[0], myPathResource, "Correct resource for /my/path/ writer"); + t.is(myPathWriter._write.getCall(0).args[1], "options 1", "Correct write options for /my/path/ writer"); + t.is(myWriter._write.getCall(0).args[0], myResource, "Correct resource for /my/ writer"); + t.is(myWriter._write.getCall(0).args[1], "options 2", "Correct write options for /my/ writer"); + t.is(generalWriter._write.getCall(0).args[0], resource, "Correct resource for / writer"); + t.is(generalWriter._write.getCall(0).args[1], "options 3", "Correct write options for / writer"); +}); + +test("byGlob", async (t) => { + const myPathWriter = { + _byGlob: sinon.stub().resolves([]) + }; + const myWriter = { + _byGlob: sinon.stub().resolves([]) + }; + const generalWriter = { + _byGlob: sinon.stub().resolves([]) + }; + const writerCollection = new WriterCollection({ + name: "myCollection", + writerMapping: { + "/my/path/": myPathWriter, + "/my/": myWriter, + "/": generalWriter + } + }); + + await writerCollection.byGlob("/**"); + + t.is(myPathWriter._byGlob.callCount, 1, "One _byGlob call to /my/path/ writer"); + t.is(myWriter._byGlob.callCount, 1, "One _byGlob call to /my/ writer"); + t.is(generalWriter._byGlob.callCount, 1, "One _byGlob call to / writer"); + + t.is(myPathWriter._byGlob.getCall(0).args[0], "/**", "Correct glob pattern passed to /my/path/ writer"); + t.is(myWriter._byGlob.getCall(0).args[0], "/**", "Correct glob pattern passed to /my/ writer"); + t.is(generalWriter._byGlob.getCall(0).args[0], "/**", "Correct glob pattern passed to / writer"); +}); + +test("byPath", async (t) => { + const myPathWriter = { + _byPath: sinon.stub().resolves(null) + }; + const myWriter = { + _byPath: sinon.stub().resolves(null) + }; + const generalWriter = { + _byPath: sinon.stub().resolves(null) + }; + const writerCollection = new WriterCollection({ + name: "myCollection", + writerMapping: { + "/my/path/": myPathWriter, + "/my/": myWriter, + "/": generalWriter + } + }); + + await writerCollection.byPath("/my/resource.res"); + + t.is(myPathWriter._byPath.callCount, 1, "One _byPath to /my/path/ writer"); + t.is(myWriter._byPath.callCount, 1, "One _byPath to /my/ writer"); + t.is(generalWriter._byPath.callCount, 1, "One _byPath to / writer"); + + t.is(myPathWriter._byPath.getCall(0).args[0], "/my/resource.res", + "Correct _byPath argument passed to /my/path/ writer"); + t.is(myWriter._byPath.getCall(0).args[0], "/my/resource.res", + "Correct _byPath argument passed to /my/ writer"); + t.is(generalWriter._byPath.getCall(0).args[0], "/my/resource.res", + "Correct _byPath argument passed to / writer"); +}); diff --git a/test/lib/resources.js b/test/lib/resources.js index 3e21f653..8ccb50e6 100644 --- a/test/lib/resources.js +++ b/test/lib/resources.js @@ -309,7 +309,7 @@ test.serial("_createFsAdapterForVirtualBasePath: library", (t) => { test("_prefixGlobPattern", (t) => { t.deepEqual( - ui5Fs.resourceFactory._prefixGlobPattern("{/sub-directory-1/,/sub-directory-2/}**", "/pony/path/a"), + ui5Fs.resourceFactory.prefixGlobPattern("{/sub-directory-1/,/sub-directory-2/}**", "/pony/path/a"), [ "/pony/path/a/sub-directory-1/**", "/pony/path/a/sub-directory-2/**" @@ -317,27 +317,27 @@ test("_prefixGlobPattern", (t) => { "GLOBs correctly prefixed"); t.deepEqual( - ui5Fs.resourceFactory._prefixGlobPattern("/pony-path/**", "/pony/path/a"), + ui5Fs.resourceFactory.prefixGlobPattern("/pony-path/**", "/pony/path/a"), ["/pony/path/a/pony-path/**"], "GLOBs correctly prefixed"); t.deepEqual( - ui5Fs.resourceFactory._prefixGlobPattern("!/duck*path/**", "/pony/path/a"), + ui5Fs.resourceFactory.prefixGlobPattern("!/duck*path/**", "/pony/path/a"), ["!/pony/path/a/duck*path/**"], "GLOBs correctly prefixed"); t.deepEqual( - ui5Fs.resourceFactory._prefixGlobPattern("!**.json", "/pony/path/a"), + ui5Fs.resourceFactory.prefixGlobPattern("!**.json", "/pony/path/a"), ["!/pony/path/a/**.json"], "GLOBs correctly prefixed"); t.deepEqual( - ui5Fs.resourceFactory._prefixGlobPattern("!**.json", "/pony/path/a/"), // trailing slash + ui5Fs.resourceFactory.prefixGlobPattern("!**.json", "/pony/path/a/"), // trailing slash ["!/pony/path/a/**.json"], "GLOBs correctly prefixed"); t.deepEqual( - ui5Fs.resourceFactory._prefixGlobPattern("pony-path/**", "/pony/path/a/"), // trailing slash + ui5Fs.resourceFactory.prefixGlobPattern("pony-path/**", "/pony/path/a/"), // trailing slash ["/pony/path/a/pony-path/**"], "GLOBs correctly prefixed"); });