dlayer: Add module system
parent
b33cc34550
commit
87c95dc60a
@ -0,0 +1,38 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// A strict deep-merging implementation that *only* merges regular objects, and prevents prototype pollution
|
||||||
|
|
||||||
|
function isObject(value) {
|
||||||
|
// TODO: Disallow special object types, for statically defined values (or just disallow specifying static values in the root schema in dlayer?)
|
||||||
|
return (value != null && typeof value === "object" && !Array.isArray(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function deepMerge(a, b) {
|
||||||
|
let merged = Object.create(null);
|
||||||
|
let keys = new Set([ ... Object.keys(a), ... Object.keys(b) ]);
|
||||||
|
|
||||||
|
for (let key of keys) {
|
||||||
|
// Technically over-blocks *any* 'constructor' key
|
||||||
|
if (key === "__proto__" || key === "constructor") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let valueA = a[key];
|
||||||
|
let valueB = b[key];
|
||||||
|
|
||||||
|
if (isObject(valueA) && valueB === undefined) {
|
||||||
|
merged[key] = valueA;
|
||||||
|
} else if (isObject(valueB) && valueA === undefined) {
|
||||||
|
merged[key] = valueB;
|
||||||
|
} else if (isObject(valueA) && isObject(valueB)) {
|
||||||
|
merged[key] = deepMerge(valueA, valueB);
|
||||||
|
} else if (!isObject(valueA) && !isObject(valueB)) {
|
||||||
|
merged[key] = valueB ?? valueA;
|
||||||
|
} else {
|
||||||
|
// FIXME: Identifiable error type, and include the error path as well
|
||||||
|
throw new Error("Cannot merge non-object into object");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
};
|
@ -0,0 +1,128 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// const mergeByTemplate = require("merge-by-template");
|
||||||
|
const deepMerge = require("./deep-merge");
|
||||||
|
const syncpipe = require("syncpipe");
|
||||||
|
|
||||||
|
/*
|
||||||
|
Take a list of modules; each module specifies a name, schema root, types, type extensions, and a context factory function. Each module is internally assigned a unique ID. This unique ID is associated with each type factory and type extension method, and used as the key for a map of context factories; that way, upon invoking those methods, the module's own corresponding context can be injected. Only a single context should be created per module per request, so there should be a cache layer for the contexts (keyed by module ID), with each request creating a new cache.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// NOTE: This can be global because we identify existing assignments by object identity, and that will never conflict
|
||||||
|
let numberedModules = new WeakMap();
|
||||||
|
let currentModuleNumber = 0;
|
||||||
|
|
||||||
|
function getModuleID(module) {
|
||||||
|
if (!numberedModules.has(module)) {
|
||||||
|
numberedModules.set(module, currentModuleNumber++);
|
||||||
|
}
|
||||||
|
|
||||||
|
return numberedModules.get(module);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTypeTracker() {
|
||||||
|
let typeFactories = {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: function (module, name, factory) {
|
||||||
|
if (typeFactories[name] != null) {
|
||||||
|
let existingEntry = typeFactories[name];
|
||||||
|
throw new Error(`Type '${name}' already exists (from module '${module.name}', already defined by module '${existingEntry.source.name}')`);
|
||||||
|
} else {
|
||||||
|
typeFactories[name] = {
|
||||||
|
source: module,
|
||||||
|
// No context provided to type factory functions for now, since they are not allowed to be async for now anyway
|
||||||
|
// FIXME: Maybe add a warning if the user returns a Promise from a factory, asking them to file a bug if they really need it?
|
||||||
|
// func: wrapModuleFunction(module, factory)
|
||||||
|
func: factory
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get: function () {
|
||||||
|
return typeFactories;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExtensionTracker() {
|
||||||
|
let extendedTypes = {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: function (module, type, name, method) {
|
||||||
|
if (extendedTypes[type] == null) {
|
||||||
|
extendedTypes[type] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let extensions = extendedTypes[type];
|
||||||
|
|
||||||
|
if (extensions[name] != null) {
|
||||||
|
let existingEntry = extensions[name];
|
||||||
|
throw new Error(`Type '${type}' already has a method extension named '${name}' (from module '${module.name}', already defined by module '${existingEntry.source.name}')`);
|
||||||
|
} else {
|
||||||
|
extensions[name] = {
|
||||||
|
source: module,
|
||||||
|
func: wrapModuleFunction(module, method)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get: function () {
|
||||||
|
return extendedTypes;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapModuleFunction(module, func) {
|
||||||
|
return { __moduleID: getModuleID(module), func: func };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultContext() {
|
||||||
|
// Fallback function that generates an empty context, for when a module doesn't specify a makeContext handler
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function (modules) {
|
||||||
|
// TODO: Eventually replace hand-crafted merging logic with merge-by-template, once it can support this usecase properly(tm)
|
||||||
|
// TODO: Fix merge-by-template so that reasonable error messages can be generated here, that are actually aware of eg. the conflicting key
|
||||||
|
|
||||||
|
let types = createTypeTracker();
|
||||||
|
let typeExtensions = createExtensionTracker();
|
||||||
|
|
||||||
|
let contextFactories = syncpipe(modules, [
|
||||||
|
_ => _.map((module) => [ getModuleID(module), module.makeContext ?? defaultContext ]),
|
||||||
|
_ => new Map(_)
|
||||||
|
]);
|
||||||
|
|
||||||
|
let schemaRoots = modules.map((module) => module.root ?? {});
|
||||||
|
|
||||||
|
for (let module of modules) {
|
||||||
|
for (let [ type, factory ] of Object.entries(module.types)) {
|
||||||
|
types.add(module, type, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let [ type, extensions ] of Object.entries(module.extensions)) {
|
||||||
|
for (let [ name, method ] of Object.entries(extensions)) {
|
||||||
|
typeExtensions.add(module, type, name, method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: schemaRoots.reduce(deepMerge, {}),
|
||||||
|
types: types.get(),
|
||||||
|
extensions: typeExtensions.get(),
|
||||||
|
makeContextFactory: function (baseContext) {
|
||||||
|
let cache = new Map();
|
||||||
|
|
||||||
|
return function makeContextForModule(moduleID) {
|
||||||
|
if (!cache.has(moduleID)) {
|
||||||
|
cache.set(moduleID, {
|
||||||
|
... baseContext,
|
||||||
|
... contextFactories.get(moduleID)()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache.get(moduleID);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,137 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const Promise = require("bluebird");
|
||||||
|
const dlayer = require(".");
|
||||||
|
const syncpipe = require("syncpipe");
|
||||||
|
|
||||||
|
let fakeDriveTree = {
|
||||||
|
one: [ "/dev/1a", "/dev/1b" ],
|
||||||
|
two: [ "/dev/2a" ]
|
||||||
|
};
|
||||||
|
|
||||||
|
let invertedTree = {
|
||||||
|
"/dev/1a": "one",
|
||||||
|
"/dev/1b": "one",
|
||||||
|
"/dev/2a": "two"
|
||||||
|
};
|
||||||
|
|
||||||
|
let contextCounter = 1;
|
||||||
|
|
||||||
|
// FIXME: Disallow type name conflicts!
|
||||||
|
|
||||||
|
let moduleDrives = {
|
||||||
|
name: "Drives",
|
||||||
|
types: {
|
||||||
|
"sysquery.core.Drive": function ({ name }) {
|
||||||
|
return {
|
||||||
|
name: name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extensions: {
|
||||||
|
"sysquery.core.BlockDevice": {
|
||||||
|
drive: async function (_, { counter, $getProperty, $make }) {
|
||||||
|
console.log(`[context ${counter}] BlockDevice::drive`);
|
||||||
|
|
||||||
|
return $make("sysquery.core.Drive", {
|
||||||
|
name: invertedTree[await $getProperty(this, "path")]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
hardware: {
|
||||||
|
drives: function ({ names }, { counter, $make }) {
|
||||||
|
console.log(`[context ${counter}] root::drives`);
|
||||||
|
|
||||||
|
return syncpipe(fakeDriveTree, [
|
||||||
|
_ => Object.entries(_),
|
||||||
|
_ => (names != null)
|
||||||
|
? _.filter(([ name, _devices ]) => names.includes(name))
|
||||||
|
: _,
|
||||||
|
_ => _.map(([ name, _devices ]) => $make("sysquery.core.Drive", { name }))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeContext: () => {
|
||||||
|
return {
|
||||||
|
counter: contextCounter++
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let moduleBlockDevices = {
|
||||||
|
name: "Block Devices",
|
||||||
|
types: {
|
||||||
|
"sysquery.core.BlockDevice": function ({ path }) {
|
||||||
|
return {
|
||||||
|
path: path
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extensions: {
|
||||||
|
"sysquery.core.Drive": {
|
||||||
|
blockDevices: async function (_, { counter, $getProperty, $make }) {
|
||||||
|
console.log(`[context ${counter}] Drive::blockDevices`);
|
||||||
|
|
||||||
|
return fakeDriveTree[await $getProperty(this, "name")].map((path) => {
|
||||||
|
return $make("sysquery.core.BlockDevice", { path });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
hardware: {
|
||||||
|
blockDevices: function ({ paths }, { counter, $make }) {
|
||||||
|
console.log(`[context ${counter}] root::blockDevices`);
|
||||||
|
|
||||||
|
return syncpipe(fakeDriveTree, [
|
||||||
|
_ => Object.values(_),
|
||||||
|
_ => _.flat(),
|
||||||
|
_ => (paths != null)
|
||||||
|
? _.filter((path) => paths.includes(path))
|
||||||
|
: _,
|
||||||
|
_ => _.map((path) => $make("sysquery.core.BlockDevice", { path }))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeContext: () => {
|
||||||
|
return {
|
||||||
|
counter: contextCounter++
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let api = dlayer({
|
||||||
|
schema: {
|
||||||
|
foo: {
|
||||||
|
bar: "baz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modules: [ moduleBlockDevices, moduleDrives ]
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.try(() => {
|
||||||
|
return api.query({
|
||||||
|
hardware: {
|
||||||
|
blockDevices: {
|
||||||
|
$arguments: { paths: [ "/dev/1b" ] },
|
||||||
|
path: true,
|
||||||
|
drive: {
|
||||||
|
name: true,
|
||||||
|
blockDevices: {
|
||||||
|
path: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).then((result) => {
|
||||||
|
console.log("-- result:");
|
||||||
|
console.dir(result, {depth: null});
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log("Unhandled error:");
|
||||||
|
console.dir(error);
|
||||||
|
});
|
Loading…
Reference in New Issue