在类json对象中为函数输出替换函数名和参数



有时将函数名和参数存储在类似json的对象结构中是很有用的,以便允许包含变化的值,或者直到运行时才知道的值。例如,AWS Cloudformation允许包含以Fn::为前缀的函数,例如Fn::Select,其中一个示例可能是:

const sourceObject = {
"fruit": { "Fn::Select" : [ "1", [ "apples", "grapes", "oranges", "mangoes" ] ] },
"somethingElse": [
{"Fn::Join" : [ " ", [ "a", "b", "c" ] ]},
"some value"
]
}
const substituted = substituteFunctionsInObject(sourceObject) 
// {"fruit": "grapes", "somethingElse":["a b c", "some value"]}

这种形式的替代在表达自由(允许数据以纯JSON形式存储和传递)和防止任意代码注入之间取得了某种平衡。(参见EVO对如何在JSON中存储javascript函数的另一个示例的回答,其中使用了"$"作为函数标签,而不是"Fn::")

谁能建议一个好的版本的函数做这样的替换(substituteFunctionsInObject在上面的例子)?也许是在现有的库中,或者是一个比我可能想到的更优雅或更少问题的。

这样的函数应该允许指定任意函数来替代,并且应该递归地(深度地)工作,也许允许通过额外的函数调用生成函数参数。

我已经基于lodash的变换函数编写了自己的实现,并允许通过curry参数提供额外的data。这是有效的,但我不禁想到这个问题一定已经以更严格的方式解决了(因为AWS,例如,使用它),也许使用更优雅的函数式编程模式。

const {transform} = require('lodash')   // fp.transform is broken
const fp = require('lodash/fp')
const substituteFunctionsInObject = ({
validFunctions = {
"date": x=>new Date(x),
"get": fp.get
},
getFunctionTag = fnName => "$" + fnName
}={}) => function applyFunctionsInObjectInner (obj){
return (...data) => transform(    
obj,
(acc, item, key) => {
const firstKey = Object.keys(item)?.[0];
const functionIndex = Object.keys(validFunctions).map(getFunctionTag).indexOf(firstKey);
const newItem = (fp.isPlainObject(item) || fp.isArray(item) ) 
// only allow where one key, which is valid function name
? (firstKey && Object.keys(item).length===1 && functionIndex >-1 )  
// call the function 
? Object.values(validFunctions)[functionIndex](     
// allow substitution on arguments
...applyFunctionsInObjectInner(fp.castArray(item[firstKey]))(...data),
// optional data 
...fp.castArray(data)       
)  
// recurse objects (deepness)
: applyFunctionsInObjectInner(item)(...data) 
: item ;        // pass non-object/array values directly
acc[key]=newItem;
}
) 
}
const source = {
dateExample: {"$date":["01-01-2020"]},
getExample: {"$get":"test"},
getWithRecursedArg: {"$get":{"$get":"test2"}},
};
const data = {"test":99,"test2":"test"};
const result = substituteFunctionsInObject({
get: fp.get,
date: x=>new Date(x)
})(source)(data);
console.log(JSON.stringify(result))  
// {"dateExample":"2020-01-01T00:00:00.000Z","getExample":99,"getWithRecursedArg":99}

JSON.parse(jsonString[, reviver])接受第二个可选参数reviver。恶意JSON字符串不可能注入有害代码,因为您不运行任意函数。相反,您需要明确定义哪些键是callable,并定义每个键的行为。注意fn::前缀是完全可选的-

const callable = {
"fn::console.log": console.log,
"fn::times10": x => x * 10
}
function reviver(key, value) {
if (callable[key] == null)
return value
else
return callable[key](value)
}

const j = `{"a": 1, "b": [{ "fn::times10": 5 }], "c": { "fn::console.log": "hi" }}`
const o = JSON.parse(j, reviver)
console.log(o)

hi
{
"a": 1,
"b": [
{
"fn::times10": 5
}
],
"c": {}  // empty object because `console.log` returns undefined!
}

注意,这里不需要拉入lodash。这可能不是你想要的形式,但它应该足以让你开始。

基于Mulan的非常有用的答案,建议使用JSON.parse与一个reviver函数作为第二个参数(docs),我正在寻找的函数的纯Javascript(没有lodash)版本可能看起来像这样(为数据对象提供一个科里化的参数,在我的用例中需要):

const substituteFunctionsInObject = ({
functions = {}, /* { add: (x,y)=>x+y, get: prop=>data=>data[prop] } */ 
addFunctionTag = f => "$"+f,
} = {}) => jsonLikeObj => data => {
function reviveFunctions(key, item) {
if (typeof item === "object" && item != null && !Array.isArray(item)) {
const [firstKey, args] = Object.entries(item)?.[0] ?? [];
const func = Object.entries(functions)
.find(([key, f]) => addFunctionTag(key) === firstKey)
?.[1];
if (firstKey && func && Object.keys(item).length === 1) {
const funcRes = func(  // call the function
...(
[].concat(args)  // allow single arg without array
.map(arg => reviveFunctions(null, arg))  // argument recursion 
),
//...[data]    // could also apply data here?
);
// optional data as curried last argument:
return typeof (funcRes) === "function" ? funcRes(data) : funcRes;
}
};
return item;  // default to unmodified
}
return JSON.parse(JSON.stringify(jsonLikeObj), reviveFunctions);
}

我还没有对此进行广泛的测试,所以可能会有错误,特别是在类型检查方面。一个基本的测试用例和输出如下:

// BASIC TEST CASE
const obj = {
someGet: { $get: ["test", { test: 1 }] },
getCurriedFromData: [
{ $getCurried: "test" },
{ $getCurried: ["test"] },
],
someDates: { fixed: { $date: ["01-01-2020"] }, now: { "$date": [] } },
recursiveArgs: {
$get: [
{ "$always": "test" }, { "$always": [{ "test": { "$always": 3 } }] }
]
},
always: { "$always": "unchanged" },
added: { $add: [1, { $add: [2, 3.1] }] },
invalidFuncName: { $invalid: "fakearg" },
literals: [
1, -1.23, "string", "$string", 
null, undefined, false, true, 
[], , [[]], {}, { p: "val" }
]
};
const data = { "test": 2 };
console.log(
substituteFunctionsInObject({
functions: {
date: x => new Date(x === undefined ? Date.now() : x),
get: (label, x) => x[label],
getCurried: label => x => x[label],
always: val => x => val,
add: (x, y) => x + y,
}
})(obj)(data)
);
/*
{
someGet: 1,
getCurriedFromData: [ 2, 2 ],
someDates: { 
fixed: 2020-01-01T00:00:00.000Z,
now: 2022-04-20T16:05:27.536Z
},
recursiveArgs: 3,
always: 'unchanged',
added: 6.1,
invalidFuncName: { '$invalid': 'fakearg' },
literalsDontChange: [
1,            -1.23,
'string',     '$string',
null,         null,
false,        true,
[],           null,
[ [] ],       {},
{ p: 'val' }
]
}
*/

最新更新