feat(variform): implement variform engine

This commit is contained in:
zmstone 2024-03-28 18:03:03 +01:00
parent ad95473aae
commit 5f26e4ed5e
3 changed files with 158 additions and 11 deletions

View File

@ -14,17 +14,46 @@
%% limitations under the License. %% limitations under the License.
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
%% Predefined functions for templating %% @doc This module provides a single-line expression string rendering engine.
%% A predefined set of functions are allowed to be called in the expressions.
%% Only simple string expressions are supported, and no control flow is allowed.
%% However, with the help from the functions, some control flow can be achieved.
%% For example, the `coalesce` function can be used to provide a default value,
%% or used to choose the first non-empty value from a list of variables.
-module(emqx_variform). -module(emqx_variform).
-export([render/2]). -export([inject_allowed_modules/1]).
-export([render/2, render/3]).
render(Expression, Context) -> %% @doc Render a variform expression with bindings.
%% A variform expression is a template string which supports variable substitution
%% and function calls.
%%
%% The function calls are in the form of `module.function(arg1, arg2, ...)` where `module`
%% is optional, and if not provided, the function is assumed to be in the `emqx_variform_str` module.
%% Both module and function must be existing atoms, and only whitelisted functions are allowed.
%%
%% A function arg can be a constant string or a number.
%% Strings can be quoted with single quotes or double quotes, without support of escape characters.
%% If some special characters are needed, the function `unescape' can be used convert a escaped string
%% to raw bytes.
%% For example, to get the first line of a multi-line string, the expression can be
%% `coalesce(tokens(variable_name, unescape("\n")))'.
%%
%% The bindings is a map of variables to their values.
%%
%% For unresolved variables, empty string (but not "undefined") is used.
%% In case of runtime exeption, an error is returned.
-spec render(string(), map()) -> {ok, binary()} | {error, term()}.
render(Expression, Bindings) ->
render(Expression, Bindings, #{}).
render(Expression, Bindings, Opts) ->
case emqx_variform_scan:string(Expression) of case emqx_variform_scan:string(Expression) of
{ok, Tokens, _Line} -> {ok, Tokens, _Line} ->
case emqx_variform_parser:parse(Tokens) of case emqx_variform_parser:parse(Tokens) of
{ok, Expr} -> {ok, Expr} ->
eval(Expr, Context); eval_as_string(Expr, Bindings, Opts);
{error, {_, emqx_variform_parser, Msg}} -> {error, {_, emqx_variform_parser, Msg}} ->
%% syntax error %% syntax error
{error, lists:flatten(Msg)}; {error, lists:flatten(Msg)};
@ -35,5 +64,122 @@ render(Expression, Context) ->
{error, Reason} {error, Reason}
end. end.
eval(Expr, _Context) -> eval_as_string(Expr, Bindings, _Opts) ->
io:format(user, "~p~n", [Expr]). try
{ok, iolist_to_binary(eval(Expr, Bindings))}
catch
throw:Reason ->
{error, Reason};
C:E:S ->
{error, #{exception => C, reason => E, stack_trace => S}}
end.
eval({str, Str}, _Bindings) ->
str(Str);
eval({num, Num}, _Bindings) ->
str(Num);
eval({call, FuncNameStr, Args}, Bindings) ->
{Mod, Fun} = resolve_func_name(FuncNameStr),
ok = assert_func_exported(Mod, Fun, length(Args)),
call(Mod, Fun, eval(Args, Bindings));
eval({var, VarName}, Bindings) ->
resolve_var_value(VarName, Bindings);
eval([Arg | Args], Bindings) ->
[eval(Arg, Bindings) | eval(Args, Bindings)];
eval([], _Bindings) ->
[].
%% Some functions accept arbitrary number of arguments but implemented as /1.
call(emqx_variform_str, concat, Args) ->
str(emqx_variform_str:concat(Args));
call(emqx_variform_str, coalesce, Args) ->
str(emqx_variform_str:coalesce(Args));
call(Mod, Fun, Args) ->
str(erlang:apply(Mod, Fun, Args)).
resolve_func_name(FuncNameStr) ->
case string:tokens(FuncNameStr, ".") of
[Mod0, Fun0] ->
Mod =
try
list_to_existing_atom(Mod0)
catch
error:badarg ->
throw(#{unknown_module => Mod0})
end,
ok = assert_module_allowed(Mod),
Fun =
try
list_to_existing_atom(Fun0)
catch
error:badarg ->
throw(#{unknown_function => Fun0})
end,
{Mod, Fun};
[Fun] ->
FuncName =
try
list_to_existing_atom(Fun)
catch
error:badarg ->
throw(#{
reason => "unknown_variform_function",
function => Fun
})
end,
{emqx_variform_str, FuncName}
end.
resolve_var_value(VarName, Bindings) ->
case emqx_template:lookup_var(split(VarName), Bindings) of
{ok, Value} ->
str(Value);
{error, _Reason} ->
<<>>
end.
assert_func_exported(emqx_variform_str, concat, _Arity) ->
ok;
assert_func_exported(emqx_variform_str, coalesce, _Arity) ->
ok;
assert_func_exported(Mod, Fun, Arity) ->
_ = Mod:module_info(md5),
case erlang:function_exported(Mod, Fun, Arity) of
true ->
ok;
false ->
throw(#{
reason => "unknown_variform_function",
module => Mod,
function => Fun,
arity => Arity
})
end.
assert_module_allowed(emqx_variform_str) ->
ok;
assert_module_allowed(Mod) ->
Allowed = get_allowed_modules(),
case lists:member(Mod, Allowed) of
true ->
ok;
false ->
throw(#{
reason => "unallowed_veriform_module",
module => Mod
})
end.
inject_allowed_modules(Modules) ->
Allowed0 = get_allowed_modules(),
Allowed = lists:usort(Allowed0 ++ Modules),
persistent_term:put({emqx_variform, allowed_modules}, Allowed).
get_allowed_modules() ->
persistent_term:get({emqx_variform, allowed_modules}, []).
str(Value) ->
emqx_utils_conv:bin(Value).
split(VarName) ->
lists:map(fun erlang:iolist_to_binary/1, string:tokens(VarName, ".")).

View File

@ -64,10 +64,10 @@
%% @doc Return the first non-empty string %% @doc Return the first non-empty string
coalesce(A, B) when ?IS_EMPTY(A) andalso ?IS_EMPTY(B) -> coalesce(A, B) when ?IS_EMPTY(A) andalso ?IS_EMPTY(B) ->
<<>>; <<>>;
coalesce(A, _) when is_binary(A) -> coalesce(A, B) when ?IS_EMPTY(A) ->
A; B;
coalesce(_, B) -> coalesce(A, _B) ->
B. A.
%% @doc Return the first non-empty string %% @doc Return the first non-empty string
coalesce([]) -> coalesce([]) ->
@ -140,7 +140,7 @@ tokens(S, Separators, <<"nocrlf">>) ->
%% implicit convert args to strings, and then do concatenation %% implicit convert args to strings, and then do concatenation
concat(S1, S2) -> concat(S1, S2) ->
concat([S1, S2], unicode). concat([S1, S2]).
concat(List) -> concat(List) ->
unicode:characters_to_binary(lists:map(fun str/1, List), unicode). unicode:characters_to_binary(lists:map(fun str/1, List), unicode).

View File

@ -145,6 +145,7 @@
upper/1, upper/1,
split/2, split/2,
split/3, split/3,
concat/1,
concat/2, concat/2,
tokens/2, tokens/2,
tokens/3, tokens/3,