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