Просмотр исходного кода

feat(tpl): factor out loose json concept into a separate module

Which is called `emqx_jsonish`. Also introduce an _access module_
abstraction to extract information from such data during rendering.
Andrew Mayorov 2 лет назад
Родитель
Сommit
02c1bd70b6

+ 2 - 3
apps/emqx_rule_engine/src/emqx_rule_actions.erl

@@ -232,11 +232,10 @@ parse_user_properties(_) ->
     undefined.
 
 render_template(Template, Bindings) ->
-    Opts = #{var_lookup => fun emqx_template:lookup_loose_json/2},
-    emqx_template:render(Template, Bindings, Opts).
+    emqx_template:render(Template, {emqx_jsonish, Bindings}).
 
 render_simple_var([{var, _Name, Accessor}], Data, Default) ->
-    case emqx_template:lookup_loose_json(Accessor, Data) of
+    case emqx_jsonish:lookup(Accessor, Data) of
         {ok, Var} -> Var;
         %% cannot find the variable from Data
         {error, _} -> Default

+ 71 - 0
apps/emqx_utils/src/emqx_jsonish.erl

@@ -0,0 +1,71 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2020-2022 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_jsonish).
+
+-export([lookup/2]).
+
+-export_type([t/0]).
+
+%% @doc Either a map or a JSON serial.
+%% Think of it as a kind of lazily parsed and/or constructed JSON.
+-type t() :: propmap() | serial().
+
+%% @doc JSON in serialized form.
+-type serial() :: binary().
+
+-type propmap() :: #{prop() => value()}.
+-type prop() :: atom() | binary().
+-type value() :: scalar() | [scalar() | propmap()] | t().
+-type scalar() :: atom() | unicode:chardata() | number().
+
+%%
+
+%% @doc Lookup a value in the JSON-ish map accessible through the given accessor.
+%% If accessor implies drilling down into a binary, it will be treated as JSON serial.
+%% Failure to parse the binary as JSON will result in an _invalid type_ error.
+%% Nested JSON is NOT parsed recursively.
+-spec lookup(emqx_template:accessor(), t()) ->
+    {ok, value()}
+    | {error, undefined | {_Location :: non_neg_integer(), _InvalidType :: atom()}}.
+lookup(Var, Jsonish) ->
+    lookup(0, _Decoded = false, Var, Jsonish).
+
+lookup(_, _, [], Value) ->
+    {ok, Value};
+lookup(Loc, Decoded, [Prop | Rest], Jsonish) when is_map(Jsonish) ->
+    case emqx_template:lookup(Prop, Jsonish) of
+        {ok, Value} ->
+            lookup(Loc + 1, Decoded, Rest, Value);
+        {error, Reason} ->
+            {error, Reason}
+    end;
+lookup(Loc, _Decoded = false, Rest, Json) when is_binary(Json) ->
+    try emqx_utils_json:decode(Json) of
+        Value ->
+            % NOTE: This is intentional, we don't want to parse nested JSON.
+            lookup(Loc, true, Rest, Value)
+    catch
+        error:_ ->
+            {error, {Loc, binary}}
+    end;
+lookup(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.

+ 46 - 67
apps/emqx_utils/src/emqx_template.erl

@@ -29,7 +29,8 @@
 -export([render_strict/3]).
 
 -export([lookup_var/2]).
--export([lookup_loose_json/2]).
+-export([lookup/2]).
+
 -export([to_string/1]).
 
 -export_type([t/0]).
@@ -38,6 +39,10 @@
 -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()}.
 
@@ -70,19 +75,22 @@
     fun((Value :: term()) -> unicode:chardata())
     | fun((varname(), Value :: term()) -> unicode:chardata()).
 
--type var_lookup() ::
-    fun((accessor(), bindings()) -> {ok, binding()} | {error, reason()}).
-
 -type parse_opts() :: #{
     strip_double_quote => boolean()
 }.
 
 -type render_opts() :: #{
-    var_trans => var_trans(),
-    var_lookup => var_lookup()
+    var_trans => var_trans()
 }.
 
--define(PH_VAR_THIS, '$this').
+-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, "\\$\\{(\\$)\\}").
@@ -130,7 +138,7 @@ prepend(Head, To) ->
 parse_accessor(Var) ->
     case string:split(Var, <<".">>, all) of
         [<<>>] ->
-            ?PH_VAR_THIS;
+            [];
         Name ->
             Name
     end.
@@ -180,18 +188,18 @@ render_placeholder(Name) ->
 %% 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(), bindings()) ->
+-spec render(t(), context()) ->
     {term(), [_Error :: {varname(), reason()}]}.
-render(Template, Bindings) ->
-    render(Template, Bindings, #{}).
+render(Template, Context) ->
+    render(Template, Context, #{}).
 
--spec render(t(), bindings(), render_opts()) ->
+-spec render(t(), context(), render_opts()) ->
     {term(), [_Error :: {varname(), undefined}]}.
-render(Template, Bindings, Opts) when is_list(Template) ->
+render(Template, Context, Opts) when is_list(Template) ->
     lists:mapfoldl(
         fun
             ({var, Name, Accessor}, EAcc) ->
-                {String, Errors} = render_binding(Name, Accessor, Bindings, Opts),
+                {String, Errors} = render_binding(Name, Accessor, Context, Opts),
                 {String, Errors ++ EAcc};
             (String, EAcc) ->
                 {String, EAcc}
@@ -199,11 +207,11 @@ render(Template, Bindings, Opts) when is_list(Template) ->
         [],
         Template
     );
-render({'$tpl', Template}, Bindings, Opts) ->
-    render_deep(Template, Bindings, Opts).
+render({'$tpl', Template}, Context, Opts) ->
+    render_deep(Template, Context, Opts).
 
-render_binding(Name, Accessor, Bindings, Opts) ->
-    case lookup_value(Accessor, Bindings, Opts) of
+render_binding(Name, Accessor, Context, Opts) ->
+    case lookup_value(Accessor, Context) of
         {ok, Value} ->
             {render_value(Name, Value, Opts), []};
         {error, Reason} ->
@@ -213,9 +221,9 @@ render_binding(Name, Accessor, Bindings, Opts) ->
             {render_value(Name, undefined, Opts), [{Name, Reason}]}
     end.
 
-lookup_value(Accessor, Bindings, #{var_lookup := LookupFun}) ->
-    LookupFun(Accessor, Bindings);
-lookup_value(Accessor, Bindings, #{}) ->
+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) ->
@@ -228,19 +236,19 @@ render_value(_Name, 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(), bindings()) ->
+-spec render_strict(t(), context()) ->
     term().
-render_strict(Template, Bindings) ->
-    render_strict(Template, Bindings, #{}).
+render_strict(Template, Context) ->
+    render_strict(Template, Context, #{}).
 
--spec render_strict(t(), bindings(), render_opts()) ->
+-spec render_strict(t(), context(), render_opts()) ->
     term().
-render_strict(Template, Bindings, Opts) ->
-    case render(Template, Bindings, Opts) of
+render_strict(Template, Context, Opts) ->
+    case render(Template, Context, Opts) of
         {Render, []} ->
             Render;
         {_, Errors = [_ | _]} ->
-            error(Errors, [unparse(Template), Bindings])
+            error(Errors, [unparse(Template), Context])
     end.
 
 %% @doc Parse an arbitrary Erlang term into a "deep" template.
@@ -275,30 +283,30 @@ parse_deep_term(Term, Opts) when is_binary(Term) ->
 parse_deep_term(Term, _Opts) ->
     Term.
 
-render_deep(Template, Bindings, Opts) when is_map(Template) ->
+render_deep(Template, Context, Opts) when is_map(Template) ->
     maps:fold(
         fun(KT, VT, {Acc, Errors}) ->
-            {K, KErrors} = render_deep(KT, Bindings, Opts),
-            {V, VErrors} = render_deep(VT, Bindings, Opts),
+            {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}, Bindings, Opts) when is_list(Template) ->
+render_deep({list, Template}, Context, Opts) when is_list(Template) ->
     lists:mapfoldr(
         fun(T, Errors) ->
-            {E, VErrors} = render_deep(T, Bindings, Opts),
+            {E, VErrors} = render_deep(T, Context, Opts),
             {E, VErrors ++ Errors}
         end,
         [],
         Template
     );
-render_deep({tuple, Template}, Bindings, Opts) when is_list(Template) ->
-    {Term, Errors} = render_deep({list, Template}, Bindings, Opts),
+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, Bindings, Opts) when is_list(Template) ->
-    {String, Errors} = render(Template, Bindings, Opts),
+render_deep(Template, Context, Opts) when is_list(Template) ->
+    {String, Errors} = render(Template, Context, Opts),
     {unicode:characters_to_binary(String), Errors};
 render_deep(Term, _Bindings, _Opts) ->
     {Term, []}.
@@ -331,7 +339,7 @@ unparse_deep(Term) ->
 lookup_var(Var, Bindings) ->
     lookup_var(0, Var, Bindings).
 
-lookup_var(_, Var, Value) when Var == ?PH_VAR_THIS orelse Var == [] ->
+lookup_var(_, [], Value) ->
     {ok, Value};
 lookup_var(Loc, [Prop | Rest], Bindings) when is_map(Bindings) ->
     case lookup(Prop, Bindings) of
@@ -343,35 +351,6 @@ lookup_var(Loc, [Prop | Rest], Bindings) when is_map(Bindings) ->
 lookup_var(Loc, _, Invalid) ->
     {error, {Loc, type_name(Invalid)}}.
 
-%% @doc Lookup a variable in the bindings accessible through the accessor.
-%% Additionally to `lookup_var/2` behavior, this function also tries to parse any
-%% binary as JSON to a map if accessor needs to go deeper into it.
--spec lookup_loose_json(accessor(), bindings() | binary()) ->
-    {ok, binding()} | {error, reason()}.
-lookup_loose_json(Var, Bindings) ->
-    lookup_loose_json(0, Var, Bindings).
-
-lookup_loose_json(_, Var, Value) when Var == ?PH_VAR_THIS orelse Var == [] ->
-    {ok, Value};
-lookup_loose_json(Loc, [Prop | Rest], Bindings) when is_map(Bindings) ->
-    case lookup(Prop, Bindings) of
-        {ok, Value} ->
-            lookup_loose_json(Loc + 1, Rest, Value);
-        {error, Reason} ->
-            {error, Reason}
-    end;
-lookup_loose_json(Loc, Rest, Json) when is_binary(Json) ->
-    try emqx_utils_json:decode(Json) of
-        Bindings ->
-            % NOTE: This is intentional, we don't want to parse nested JSON.
-            lookup_var(Loc, Rest, Bindings)
-    catch
-        error:_ ->
-            {error, {Loc, binary}}
-    end;
-lookup_loose_json(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;

+ 14 - 14
apps/emqx_utils/src/emqx_template_sql.erl

@@ -27,9 +27,9 @@
 
 -export_type([row_template/0]).
 
--type template() :: emqx_template:t().
+-type template() :: emqx_template:str().
 -type row_template() :: [emqx_template:placeholder()].
--type bindings() :: emqx_template:bindings().
+-type context() :: emqx_template:context().
 
 -type values() :: [emqx_utils_sql:value()].
 
@@ -62,19 +62,19 @@ parse(String, Opts) ->
 %% @doc Render an SQL statement template given a set of bindings.
 %% Interpolation generally follows the SQL syntax, strings are escaped according to the
 %% `escaping` option.
--spec render(template(), bindings(), render_opts()) ->
+-spec render(template(), context(), render_opts()) ->
     {unicode:chardata(), [_Error]}.
-render(Template, Bindings, Opts) ->
-    emqx_template:render(Template, Bindings, #{
+render(Template, Context, Opts) ->
+    emqx_template:render(Template, Context, #{
         var_trans => fun(Value) -> emqx_utils_sql:to_sql_string(Value, Opts) end
     }).
 
 %% @doc Render an SQL statement template given a set of bindings.
 %% Errors are raised if any placeholders are not bound.
--spec render_strict(template(), bindings(), render_opts()) ->
+-spec render_strict(template(), context(), render_opts()) ->
     unicode:chardata().
-render_strict(Template, Bindings, Opts) ->
-    emqx_template:render_strict(Template, Bindings, #{
+render_strict(Template, Context, Opts) ->
+    emqx_template:render_strict(Template, Context, #{
         var_trans => fun(Value) -> emqx_utils_sql:to_sql_string(Value, Opts) end
     }).
 
@@ -124,14 +124,14 @@ mk_replace(':n', N) ->
 %% An _SQL value_ is a vaguely defined concept here, it is something that's considered
 %% compatible with the protocol of the database being used. See the definition of
 %% `emqx_utils_sql:value()` for more details.
--spec render_prepstmt(template(), bindings()) ->
+-spec render_prepstmt(template(), context()) ->
     {values(), [_Error]}.
-render_prepstmt(Template, Bindings) ->
+render_prepstmt(Template, Context) ->
     Opts = #{var_trans => fun emqx_utils_sql:to_sql_value/1},
-    emqx_template:render(Template, Bindings, Opts).
+    emqx_template:render(Template, Context, Opts).
 
--spec render_prepstmt_strict(template(), bindings()) ->
+-spec render_prepstmt_strict(template(), context()) ->
     values().
-render_prepstmt_strict(Template, Bindings) ->
+render_prepstmt_strict(Template, Context) ->
     Opts = #{var_trans => fun emqx_utils_sql:to_sql_value/1},
-    emqx_template:render_strict(Template, Bindings, Opts).
+    emqx_template:render_strict(Template, Context, Opts).

+ 97 - 0
apps/emqx_utils/test/emqx_jsonish_tests.erl

@@ -0,0 +1,97 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2020-2022 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_jsonish_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+
+prop_prio_test_() ->
+    [
+        ?_assertEqual(
+            {ok, 42},
+            emqx_jsonish:lookup([<<"foo">>], #{<<"foo">> => 42, foo => 1337})
+        ),
+        ?_assertEqual(
+            {ok, 1337},
+            emqx_jsonish:lookup([<<"foo">>], #{foo => 1337})
+        )
+    ].
+
+undefined_test() ->
+    ?assertEqual(
+        {error, undefined},
+        emqx_jsonish:lookup([<<"foo">>], #{})
+    ).
+
+undefined_deep_test() ->
+    ?assertEqual(
+        {error, undefined},
+        emqx_jsonish:lookup([<<"foo">>, <<"bar">>], #{})
+    ).
+
+undefined_deep_json_test() ->
+    ?assertEqual(
+        {error, undefined},
+        emqx_jsonish:lookup(
+            [<<"foo">>, <<"bar">>, <<"baz">>],
+            <<"{\"foo\":{\"bar\":{\"no\":{}}}}">>
+        )
+    ).
+
+invalid_type_test() ->
+    ?assertEqual(
+        {error, {0, number}},
+        emqx_jsonish:lookup([<<"foo">>], <<"42">>)
+    ).
+
+invalid_type_deep_test() ->
+    ?assertEqual(
+        {error, {2, atom}},
+        emqx_jsonish:lookup([<<"foo">>, <<"bar">>, <<"tuple">>], #{foo => #{bar => baz}})
+    ).
+
+decode_json_test() ->
+    ?assertEqual(
+        {ok, 42},
+        emqx_jsonish:lookup([<<"foo">>, <<"bar">>], <<"{\"foo\":{\"bar\":42}}">>)
+    ).
+
+decode_json_deep_test() ->
+    ?assertEqual(
+        {ok, 42},
+        emqx_jsonish:lookup([<<"foo">>, <<"bar">>], #{<<"foo">> => <<"{\"bar\": 42}">>})
+    ).
+
+decode_json_invalid_type_test() ->
+    ?assertEqual(
+        {error, {1, list}},
+        emqx_jsonish:lookup([<<"foo">>, <<"bar">>], #{<<"foo">> => <<"[1,2,3]">>})
+    ).
+
+decode_no_json_test() ->
+    ?assertEqual(
+        {error, {1, binary}},
+        emqx_jsonish:lookup([<<"foo">>, <<"bar">>], #{<<"foo">> => <<0, 1, 2, 3>>})
+    ).
+
+decode_json_no_nested_test() ->
+    ?assertEqual(
+        {error, {2, binary}},
+        emqx_jsonish:lookup(
+            [<<"foo">>, <<"bar">>, <<"baz">>],
+            #{<<"foo">> => <<"{\"bar\":\"{\\\"baz\\\":42}\"}">>}
+        )
+    ).

+ 65 - 41
apps/emqx_utils/test/emqx_template_SUITE.erl

@@ -25,7 +25,7 @@
 all() -> emqx_common_test_helpers:all(?MODULE).
 
 t_render(_) ->
-    Bindings = #{
+    Context = #{
         a => <<"1">>,
         b => 1,
         c => 1.0,
@@ -38,15 +38,15 @@ t_render(_) ->
     ),
     ?assertEqual(
         {<<"a:1,b:1,c:1.0,d:{\"d1\":\"hi\"},d1:hi,l:[0,1,1000],u:utf-8 is ǝɹǝɥ"/utf8>>, []},
-        render_string(Template, Bindings)
+        render_string(Template, Context)
     ).
 
 t_render_var_trans(_) ->
-    Bindings = #{a => <<"1">>, b => 1, c => #{prop => 1.0}},
+    Context = #{a => <<"1">>, b => 1, c => #{prop => 1.0}},
     Template = emqx_template:parse(<<"a:${a},b:${b},c:${c.prop}">>),
     {String, Errors} = emqx_template:render(
         Template,
-        Bindings,
+        Context,
         #{var_trans => fun(Name, _) -> "<" ++ Name ++ ">" end}
     ),
     ?assertEqual(
@@ -55,7 +55,7 @@ t_render_var_trans(_) ->
     ).
 
 t_render_path(_) ->
-    Bindings = #{d => #{d1 => <<"hi">>}},
+    Context = #{d => #{d1 => <<"hi">>}},
     Template = emqx_template:parse(<<"d.d1:${d.d1}">>),
     ?assertEqual(
         ok,
@@ -63,11 +63,11 @@ t_render_path(_) ->
     ),
     ?assertEqual(
         {<<"d.d1:hi">>, []},
-        render_string(Template, Bindings)
+        render_string(Template, Context)
     ).
 
 t_render_custom_ph(_) ->
-    Bindings = #{a => <<"a">>, b => <<"b">>},
+    Context = #{a => <<"a">>, b => <<"b">>},
     Template = emqx_template:parse(<<"a:${a},b:${b}">>),
     ?assertEqual(
         {error, [{"b", disallowed}]},
@@ -75,21 +75,21 @@ t_render_custom_ph(_) ->
     ),
     ?assertEqual(
         <<"a:a,b:b">>,
-        render_strict_string(Template, Bindings)
+        render_strict_string(Template, Context)
     ).
 
 t_render_this(_) ->
-    Bindings = #{a => <<"a">>, b => [1, 2, 3]},
+    Context = #{a => <<"a">>, b => [1, 2, 3]},
     Template = emqx_template:parse(<<"this:${} / also:${.}">>),
     ?assertEqual(ok, emqx_template:validate(["."], Template)),
     ?assertEqual(
         % NOTE: order of the keys in the JSON object depends on the JSON encoder
         <<"this:{\"b\":[1,2,3],\"a\":\"a\"} / also:{\"b\":[1,2,3],\"a\":\"a\"}">>,
-        render_strict_string(Template, Bindings)
+        render_strict_string(Template, Context)
     ).
 
 t_render_missing_bindings(_) ->
-    Bindings = #{no => #{}, c => #{<<"c1">> => 42}},
+    Context = #{no => #{}, c => #{<<"c1">> => 42}},
     Template = emqx_template:parse(
         <<"a:${a},b:${b},c:${c.c1.c2},d:${d.d1},e:${no.such_atom_i_swear}">>
     ),
@@ -101,7 +101,7 @@ t_render_missing_bindings(_) ->
             {"b", undefined},
             {"a", undefined}
         ]},
-        render_string(Template, Bindings)
+        render_string(Template, Context)
     ),
     ?assertError(
         [
@@ -111,7 +111,21 @@ t_render_missing_bindings(_) ->
             {"b", undefined},
             {"a", undefined}
         ],
-        render_strict_string(Template, Bindings)
+        render_strict_string(Template, Context)
+    ).
+
+t_render_custom_bindings(_) ->
+    _ = erlang:put(a, <<"foo">>),
+    _ = erlang:put(b, #{<<"bar">> => #{atom => 42}}),
+    Template = emqx_template:parse(
+        <<"a:${a},b:${b.bar.atom},c:${c},oops:${b.bar.atom.oops}">>
+    ),
+    ?assertEqual(
+        {<<"a:foo,b:42,c:undefined,oops:undefined">>, [
+            {"b.bar.atom.oops", {2, number}},
+            {"c", undefined}
+        ]},
+        render_string(Template, {?MODULE, []})
     ).
 
 t_unparse(_) ->
@@ -141,33 +155,33 @@ t_const(_) ->
     ).
 
 t_render_partial_ph(_) ->
-    Bindings = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
+    Context = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
     Template = emqx_template:parse(<<"a:$a,b:b},c:{c},d:${d">>),
     ?assertEqual(
         <<"a:$a,b:b},c:{c},d:${d">>,
-        render_strict_string(Template, Bindings)
+        render_strict_string(Template, Context)
     ).
 
 t_parse_escaped(_) ->
-    Bindings = #{a => <<"1">>, b => 1, c => "VAR"},
+    Context = #{a => <<"1">>, b => 1, c => "VAR"},
     Template = emqx_template:parse(<<"a:${a},b:${$}{b},c:${$}{${c}},lit:${$}{$}">>),
     ?assertEqual(
         <<"a:1,b:${b},c:${VAR},lit:${$}">>,
-        render_strict_string(Template, Bindings)
+        render_strict_string(Template, Context)
     ).
 
 t_parse_escaped_dquote(_) ->
-    Bindings = #{a => <<"1">>, b => 1},
+    Context = #{a => <<"1">>, b => 1},
     Template = emqx_template:parse(<<"a:\"${a}\",b:\"${$}{b}\"">>, #{
         strip_double_quote => true
     }),
     ?assertEqual(
         <<"a:1,b:\"${b}\"">>,
-        render_strict_string(Template, Bindings)
+        render_strict_string(Template, Context)
     ).
 
 t_parse_sql_prepstmt(_) ->
-    Bindings = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
+    Context = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
     {PrepareStatement, RowTemplate} =
         emqx_template_sql:parse_prepstmt(<<"a:${a},b:${b},c:${c},d:${d}">>, #{
             parameters => '?'
@@ -175,11 +189,11 @@ t_parse_sql_prepstmt(_) ->
     ?assertEqual(<<"a:?,b:?,c:?,d:?">>, bin(PrepareStatement)),
     ?assertEqual(
         {[<<"1">>, 1, 1.0, <<"{\"d1\":\"hi\"}">>], _Errors = []},
-        emqx_template_sql:render_prepstmt(RowTemplate, Bindings)
+        emqx_template_sql:render_prepstmt(RowTemplate, Context)
     ).
 
 t_parse_sql_prepstmt_n(_) ->
-    Bindings = #{a => undefined, b => true, c => atom, d => #{d1 => 42.1337}},
+    Context = #{a => undefined, b => true, c => atom, d => #{d1 => 42.1337}},
     {PrepareStatement, RowTemplate} =
         emqx_template_sql:parse_prepstmt(<<"a:${a},b:${b},c:${c},d:${d}">>, #{
             parameters => '$n'
@@ -187,7 +201,7 @@ t_parse_sql_prepstmt_n(_) ->
     ?assertEqual(<<"a:$1,b:$2,c:$3,d:$4">>, bin(PrepareStatement)),
     ?assertEqual(
         [null, true, <<"atom">>, <<"{\"d1\":42.1337}">>],
-        emqx_template_sql:render_prepstmt_strict(RowTemplate, Bindings)
+        emqx_template_sql:render_prepstmt_strict(RowTemplate, Context)
     ).
 
 t_parse_sql_prepstmt_colon(_) ->
@@ -198,14 +212,14 @@ t_parse_sql_prepstmt_colon(_) ->
     ?assertEqual(<<"a=:1,b=:2,c=:3,d=:4">>, bin(PrepareStatement)).
 
 t_parse_sql_prepstmt_partial_ph(_) ->
-    Bindings = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
+    Context = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
     {PrepareStatement, RowTemplate} =
         emqx_template_sql:parse_prepstmt(<<"a:$a,b:b},c:{c},d:${d">>, #{parameters => '?'}),
     ?assertEqual(<<"a:$a,b:b},c:{c},d:${d">>, bin(PrepareStatement)),
-    ?assertEqual([], emqx_template_sql:render_prepstmt_strict(RowTemplate, Bindings)).
+    ?assertEqual([], emqx_template_sql:render_prepstmt_strict(RowTemplate, Context)).
 
 t_render_sql(_) ->
-    Bindings = #{
+    Context = #{
         a => <<"1">>,
         b => 1,
         c => 1.0,
@@ -216,17 +230,17 @@ t_render_sql(_) ->
     Template = emqx_template:parse(<<"a:${a},b:${b},c:${c},d:${d},n:${n},u:${u}">>),
     ?assertMatch(
         {_String, _Errors = []},
-        emqx_template_sql:render(Template, Bindings, #{})
+        emqx_template_sql:render(Template, Context, #{})
     ),
     ?assertEqual(
         <<"a:'1',b:1,c:1.0,d:'{\"d1\":\"hi\"}',n:NULL,u:'utf8\\'s cool 🐸'"/utf8>>,
-        bin(emqx_template_sql:render_strict(Template, Bindings, #{}))
+        bin(emqx_template_sql:render_strict(Template, Context, #{}))
     ).
 
 t_render_mysql(_) ->
     %% with apostrophes
     %% https://github.com/emqx/emqx/issues/4135
-    Bindings = #{
+    Context = #{
         a => <<"1''2">>,
         b => 1,
         c => 1.0,
@@ -245,13 +259,13 @@ t_render_mysql(_) ->
             "e:'\\\\\\0💩',f:0x6E6F6E2D75746638DCC900,g:'utf8\\'s cool 🐸',"/utf8,
             "h:'imgood'"
         >>,
-        bin(emqx_template_sql:render_strict(Template, Bindings, #{escaping => mysql}))
+        bin(emqx_template_sql:render_strict(Template, Context, #{escaping => mysql}))
     ).
 
 t_render_cql(_) ->
     %% with apostrophes for cassandra
     %% https://github.com/emqx/emqx/issues/4148
-    Bindings = #{
+    Context = #{
         a => <<"1''2">>,
         b => 1,
         c => 1.0,
@@ -260,7 +274,7 @@ t_render_cql(_) ->
     Template = emqx_template:parse(<<"a:${a},b:${b},c:${c},d:${d}">>),
     ?assertEqual(
         <<"a:'1''''2',b:1,c:1.0,d:'{\"d1\":\"someone''s phone\"}'">>,
-        bin(emqx_template_sql:render_strict(Template, Bindings, #{escaping => cql}))
+        bin(emqx_template_sql:render_strict(Template, Context, #{escaping => cql}))
     ).
 
 t_render_sql_custom_ph(_) ->
@@ -273,7 +287,7 @@ t_render_sql_custom_ph(_) ->
     ?assertEqual(<<"a:$1,b:$2">>, bin(PrepareStatement)).
 
 t_render_sql_strip_double_quote(_) ->
-    Bindings = #{a => <<"a">>, b => <<"b">>},
+    Context = #{a => <<"a">>, b => <<"b">>},
 
     %% no strip_double_quote option: "${key}" -> "value"
     {PrepareStatement1, RowTemplate1} = emqx_template_sql:parse_prepstmt(
@@ -283,7 +297,7 @@ t_render_sql_strip_double_quote(_) ->
     ?assertEqual(<<"a:\"$1\",b:\"$2\"">>, bin(PrepareStatement1)),
     ?assertEqual(
         [<<"a">>, <<"b">>],
-        emqx_template_sql:render_prepstmt_strict(RowTemplate1, Bindings)
+        emqx_template_sql:render_prepstmt_strict(RowTemplate1, Context)
     ),
 
     %% strip_double_quote = true:  "${key}" -> value
@@ -294,11 +308,11 @@ t_render_sql_strip_double_quote(_) ->
     ?assertEqual(<<"a:$1,b:$2">>, bin(PrepareStatement2)),
     ?assertEqual(
         [<<"a">>, <<"b">>],
-        emqx_template_sql:render_prepstmt_strict(RowTemplate2, Bindings)
+        emqx_template_sql:render_prepstmt_strict(RowTemplate2, Context)
     ).
 
 t_render_tmpl_deep(_) ->
-    Bindings = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
+    Context = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
 
     Template = emqx_template:parse_deep(
         #{<<"${a}">> => [<<"$${b}">>, "c", 2, 3.0, '${d}', {[<<"${c}">>, <<"${$}{d}">>], 0}]}
@@ -311,7 +325,7 @@ t_render_tmpl_deep(_) ->
 
     ?assertEqual(
         #{<<"1">> => [<<"$1">>, "c", 2, 3.0, '${d}', {[<<"1.0">>, <<"${d}">>], 0}]},
-        emqx_template:render_strict(Template, Bindings)
+        emqx_template:render_strict(Template, Context)
     ).
 
 t_unparse_tmpl_deep(_) ->
@@ -321,12 +335,22 @@ t_unparse_tmpl_deep(_) ->
 
 %%
 
-render_string(Template, Bindings) ->
-    {String, Errors} = emqx_template:render(Template, Bindings),
+render_string(Template, Context) ->
+    {String, Errors} = emqx_template:render(Template, Context),
     {bin(String), Errors}.
 
-render_strict_string(Template, Bindings) ->
-    bin(emqx_template:render_strict(Template, Bindings)).
+render_strict_string(Template, Context) ->
+    bin(emqx_template:render_strict(Template, Context)).
 
 bin(String) ->
     unicode:characters_to_binary(String).
+
+%% Access module API
+
+lookup([], _) ->
+    {error, undefined};
+lookup([Prop | Rest], _) ->
+    case erlang:get(binary_to_atom(Prop)) of
+        undefined -> {error, undefined};
+        Value -> emqx_template:lookup_var(Rest, Value)
+    end.