%%-------------------------------------------------------------------- %% Copyright (c) 2020-2024 EMQ Technologies Co., Ltd. All Rights Reserved. %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. %% You may obtain a copy of the License at %% %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, software %% distributed under the License is distributed on an "AS IS" BASIS, %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. %%-------------------------------------------------------------------- -module(emqx_template). -export([parse/1]). -export([parse/2]). -export([parse_deep/1]). -export([parse_deep/2]). -export([placeholders/1]). -export([validate/2]). -export([is_const/1]). -export([unparse/1]). -export([render/2]). -export([render/3]). -export([render_strict/2]). -export([render_strict/3]). -export([lookup_var/2]). -export([lookup/2]). -export([to_string/1]). -export([escape_disallowed/2]). -export_type([t/0]). -export_type([str/0]). -export_type([deep/0]). -export_type([placeholder/0]). -export_type([varname/0]). -export_type([bindings/0]). -export_type([accessor/0]). -export_type([context/0]). -export_type([render_opts/0]). -type t() :: str() | {'$tpl', deeptpl()}. -type str() :: [iodata() | byte() | placeholder()]. -type deep() :: {'$tpl', deeptpl()}. -type deeptpl() :: t() | #{deeptpl() => deeptpl()} | {list, [deeptpl()]} | {tuple, [deeptpl()]} | scalar() | function() | pid() | port() | reference(). -type placeholder() :: {var, varname(), accessor()}. -type accessor() :: [binary()]. -type varname() :: string(). -type scalar() :: atom() | unicode:chardata() | binary() | number(). -type binding() :: scalar() | list(scalar()) | bindings(). -type bindings() :: #{atom() | binary() => binding()}. -type reason() :: undefined | {location(), _InvalidType :: atom()}. -type location() :: non_neg_integer(). -type var_trans() :: fun((Value :: term()) -> unicode:chardata()) | fun((varname(), Value :: term()) -> unicode:chardata()). -type parse_opts() :: #{ strip_double_quote => boolean() }. -type render_opts() :: #{ var_trans => var_trans() }. -type context() :: %% Map with (potentially nested) bindings. bindings() %% Arbitrary term accessible via an access module with `lookup/2` function. | {_AccessModule :: module(), _Bindings}. %% Access module API -callback lookup(accessor(), _Bindings) -> {ok, _Value} | {error, reason()}. -define(RE_PLACEHOLDER, "\\$\\{[.]?([a-zA-Z0-9._]*)\\}"). -define(RE_ESCAPE, "\\$\\{(\\$)\\}"). %% @doc Parse a unicode string into a template. %% String might contain zero or more of placeholders in the form of `${var}`, %% where `var` is a _location_ (possibly deeply nested) of some value in the %% bindings map. %% String might contain special escaped form `$${...}` which interpreted as a %% literal `${...}`. -spec parse(String :: unicode:chardata()) -> t(). parse(String) -> parse(String, #{}). -spec parse(String :: unicode:chardata(), parse_opts()) -> t(). parse(String, Opts) -> RE = case Opts of #{strip_double_quote := true} -> <<"((?|" ?RE_PLACEHOLDER "|\"" ?RE_PLACEHOLDER "\")|" ?RE_ESCAPE ")">>; #{} -> <<"(" ?RE_PLACEHOLDER "|" ?RE_ESCAPE ")">> end, Splits = re:split(String, RE, [{return, binary}, group, trim, unicode]), lists:flatmap(fun parse_split/1, Splits). parse_split([Part, _PH, Var, <<>>]) -> % Regular placeholder prepend(Part, [{var, unicode:characters_to_list(Var), parse_accessor(Var)}]); parse_split([Part, _Escape, <<>>, <<"$">>]) -> % Escaped literal `$`. % Use single char as token so the `unparse/1` function can distinguish escaped `$`. prepend(Part, [$$]); parse_split([Tail]) -> [Tail]. prepend(<<>>, To) -> To; prepend(Head, To) -> [Head | To]. parse_accessor(Var) -> case string:split(Var, <<".">>, all) of [<<>>] -> []; Name -> Name end. -spec placeholders(t()) -> [varname()]. placeholders(Template) when is_list(Template) -> [Name || {var, Name, _} <- Template]; placeholders({'$tpl', Template}) -> placeholders_deep(Template). %% @doc Validate a template against a set of allowed variables. %% If the given template contains any variable not in the allowed set, an error %% is returned. -spec validate([varname() | {var_namespace, varname()}], t()) -> ok | {error, [_Error :: {varname(), disallowed}]}. validate(Allowed, Template) -> Used = placeholders(Template), case find_disallowed(lists:usort(Used), Allowed) of [] -> ok; Disallowed -> {error, [{Var, disallowed} || Var <- Disallowed]} end. %% @doc Escape `$' with `${$}' for the variable references %% which are not allowed, so the original variable name %% can be preserved instead of rendered as `undefined'. %% E.g. to render `${var1}/${clientid}', if only `clientid' %% is allowed, the rendering result should be `${var1}/client1' %% but not `undefined/client1'. escape_disallowed(Template, Allowed) -> {Result, _} = render(Template, #{}, #{ var_trans => fun(Name, _) -> case is_allowed(Name, Allowed) of true -> "${" ++ Name ++ "}"; false -> "${$}{" ++ Name ++ "}" end end }), Result. find_disallowed(Vars, Allowed) -> lists:filter(fun(Var) -> not is_allowed(Var, Allowed) end, Vars). %% @private Return 'true' if a variable reference matches %% at least one allowed variables. %% For `"${var_name}"' kind of reference, its a `=:=' compare %% for `{var_namespace, "namespace"}' kind of reference %% it matches the `"namespace."' prefix. is_allowed(_Var, []) -> false; is_allowed(Var, [{var_namespace, VarPrefix} | Allowed]) -> case lists:prefix(VarPrefix ++ ".", Var) of true -> true; false -> is_allowed(Var, Allowed) end; is_allowed(Var, [VarAllowed | Rest]) -> is_same_varname(Var, VarAllowed) orelse is_allowed(Var, Rest). is_same_varname("", ".") -> true; is_same_varname(V1, V2) -> V1 =:= V2. %% @doc Check if a template is constant with respect to rendering, i.e. does not %% contain any placeholders. -spec is_const(t()) -> boolean(). is_const(Template) -> validate([], Template) == ok. %% @doc Restore original term from a parsed template. -spec unparse(t()) -> term(). unparse({'$tpl', Template}) -> unparse_deep(Template); unparse(Template) -> unicode:characters_to_list(lists:map(fun unparse_part/1, Template)). unparse_part({var, Name, _Accessor}) -> render_placeholder(Name); unparse_part($$) -> <<"${$}">>; unparse_part(Part) -> Part. render_placeholder(Name) -> "${" ++ Name ++ "}". %% @doc Render a template with given bindings. %% Returns a term with all placeholders replaced with values from bindings. %% If one or more placeholders are not found in bindings, an error is returned. %% By default, all binding values are converted to strings using `to_string/1` %% function. Option `var_trans` can be used to override this behaviour. -spec render(t(), context()) -> {term(), [_Error :: {varname(), reason()}]}. render(Template, Context) -> render(Template, Context, #{}). -spec render(t(), context(), render_opts()) -> {term(), [_Error :: {varname(), undefined}]}. render(Template, Context, Opts) when is_list(Template) -> lists:mapfoldl( fun ({var, Name, Accessor}, EAcc) -> {String, Errors} = render_binding(Name, Accessor, Context, Opts), {String, Errors ++ EAcc}; (String, EAcc) -> {String, EAcc} end, [], Template ); render({'$tpl', Template}, Context, Opts) -> render_deep(Template, Context, Opts). render_binding(Name, Accessor, Context, Opts) -> case lookup_value(Accessor, Context) of {ok, Value} -> {render_value(Name, Value, Opts), []}; {error, Reason} -> % TODO % Currently, it's not possible to distinguish between a missing value % and an atom `undefined` in `TransFun`. {render_value(Name, undefined, Opts), [{Name, Reason}]} end. lookup_value(Accessor, {AccessMod, Bindings}) -> AccessMod:lookup(Accessor, Bindings); lookup_value(Accessor, Bindings) -> lookup_var(Accessor, Bindings). render_value(_Name, Value, #{var_trans := TransFun}) when is_function(TransFun, 1) -> TransFun(Value); render_value(Name, Value, #{var_trans := TransFun}) when is_function(TransFun, 2) -> TransFun(Name, Value); render_value(_Name, Value, #{}) -> to_string(Value). %% @doc Render a template with given bindings. %% Behaves like `render/2`, but raises an error exception if one or more placeholders %% are not found in the bindings. -spec render_strict(t(), context()) -> term(). render_strict(Template, Context) -> render_strict(Template, Context, #{}). -spec render_strict(t(), context(), render_opts()) -> term(). render_strict(Template, Context, Opts) -> case render(Template, Context, Opts) of {Render, []} -> Render; {_, Errors = [_ | _]} -> error(Errors, [unparse(Template), Context]) end. %% @doc Parse an arbitrary Erlang term into a "deep" template. %% Any binaries nested in the term are treated as string templates, while %% lists are not analyzed for "printability" and are treated as nested terms. %% The result is a usual template, and can be fed to other functions in this %% module. -spec parse_deep(term()) -> t(). parse_deep(Term) -> parse_deep(Term, #{}). -spec parse_deep(term(), parse_opts()) -> t(). parse_deep(Term, Opts) -> {'$tpl', parse_deep_term(Term, Opts)}. parse_deep_term(Term, Opts) when is_map(Term) -> maps:fold( fun(K, V, Acc) -> Acc#{parse_deep_term(K, Opts) => parse_deep_term(V, Opts)} end, #{}, Term ); parse_deep_term(Term, Opts) when is_list(Term) -> {list, [parse_deep_term(E, Opts) || E <- Term]}; parse_deep_term(Term, Opts) when is_tuple(Term) -> {tuple, [parse_deep_term(E, Opts) || E <- tuple_to_list(Term)]}; parse_deep_term(Term, Opts) when is_binary(Term) -> parse(Term, Opts); parse_deep_term(Term, _Opts) -> Term. -spec placeholders_deep(deeptpl()) -> [varname()]. placeholders_deep(Template) when is_map(Template) -> maps:fold( fun(KT, VT, Acc) -> placeholders_deep(KT) ++ placeholders_deep(VT) ++ Acc end, [], Template ); placeholders_deep({list, Template}) when is_list(Template) -> lists:flatmap(fun placeholders_deep/1, Template); placeholders_deep({tuple, Template}) when is_list(Template) -> lists:flatmap(fun placeholders_deep/1, Template); placeholders_deep(Template) when is_list(Template) -> placeholders(Template); placeholders_deep(_Term) -> []. render_deep(Template, Context, Opts) when is_map(Template) -> maps:fold( fun(KT, VT, {Acc, Errors}) -> {K, KErrors} = render_deep(KT, Context, Opts), {V, VErrors} = render_deep(VT, Context, Opts), {Acc#{K => V}, KErrors ++ VErrors ++ Errors} end, {#{}, []}, Template ); render_deep({list, Template}, Context, Opts) when is_list(Template) -> lists:mapfoldr( fun(T, Errors) -> {E, VErrors} = render_deep(T, Context, Opts), {E, VErrors ++ Errors} end, [], Template ); render_deep({tuple, Template}, Context, Opts) when is_list(Template) -> {Term, Errors} = render_deep({list, Template}, Context, Opts), {list_to_tuple(Term), Errors}; render_deep(Template, Context, Opts) when is_list(Template) -> {String, Errors} = render(Template, Context, Opts), {character_segments_to_binary(String), Errors}; render_deep(Term, _Bindings, _Opts) -> {Term, []}. unparse_deep(Template) when is_map(Template) -> maps:fold( fun(K, V, Acc) -> Acc#{unparse_deep(K) => unparse_deep(V)} end, #{}, Template ); unparse_deep({list, Template}) when is_list(Template) -> [unparse_deep(E) || E <- Template]; unparse_deep({tuple, Template}) when is_list(Template) -> list_to_tuple(unparse_deep({list, Template})); unparse_deep(Template) when is_list(Template) -> unicode:characters_to_binary(unparse(Template)); unparse_deep(Term) -> Term. %% %% @doc Lookup a variable in the bindings accessible through the accessor. %% Lookup is "loose" in the sense that atom and binary keys in the bindings are %% treated equally. This is useful for both hand-crafted and JSON-like bindings. %% This is the default lookup function used by rendering functions. -spec lookup_var(accessor(), bindings()) -> {ok, binding()} | {error, reason()}. lookup_var(Var, Bindings) -> lookup_var(0, Var, Bindings). lookup_var(_, [], Value) -> {ok, Value}; lookup_var(Loc, [Prop | Rest], Bindings) when is_map(Bindings) -> case lookup(Prop, Bindings) of {ok, Value} -> lookup_var(Loc + 1, Rest, Value); {error, Reason} -> {error, Reason} end; lookup_var(Loc, _, Invalid) -> {error, {Loc, type_name(Invalid)}}. type_name(Term) when is_atom(Term) -> atom; type_name(Term) when is_number(Term) -> number; type_name(Term) when is_binary(Term) -> binary; type_name(Term) when is_list(Term) -> list. -spec lookup(Prop :: binary(), bindings()) -> {ok, binding()} | {error, undefined}. lookup(Prop, Bindings) when is_binary(Prop) -> case maps:get(Prop, Bindings, undefined) of undefined -> try {ok, maps:get(binary_to_existing_atom(Prop, utf8), Bindings)} catch error:{badkey, _} -> {error, undefined}; error:badarg -> {error, undefined} end; Value -> {ok, Value} end. -spec to_string(binding()) -> unicode:chardata(). to_string(Bin) when is_binary(Bin) -> Bin; to_string(Num) when is_integer(Num) -> integer_to_binary(Num); to_string(Num) when is_float(Num) -> float_to_binary(Num, [{decimals, 10}, compact]); to_string(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8); to_string(Map) when is_map(Map) -> emqx_utils_json:encode(Map); to_string(List) when is_list(List) -> case io_lib:printable_unicode_list(List) of true -> List; false -> emqx_utils_json:encode(List) end. character_segments_to_binary(StringSegments) -> iolist_to_binary( lists:map( fun ($$) -> $$; (Bin) when is_binary(Bin) -> Bin; (Chars) when is_list(Chars) -> case unicode:characters_to_binary(Chars) of Bin when is_binary(Bin) -> Bin; _ -> emqx_utils_json:encode(Chars) end end, StringSegments ) ).