From b0228666c0802f02bf1aa7ebe8f425c26bdf78e7 Mon Sep 17 00:00:00 2001 From: Thales Macedo Garitezi <16166434+thalesmg@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:00:44 -0300 Subject: [PATCH 1/3] feat: add `root_converter` callback --- sample-schemas/demo_schema7.erl | 37 +++++++++++++ src/hocon_schema.erl | 36 +++++++++++- src/hocon_tconf.erl | 97 +++++++++++++++++++++++++++------ test/hocon_tconf_tests.erl | 48 ++++++++++++++++ 4 files changed, 201 insertions(+), 17 deletions(-) create mode 100644 sample-schemas/demo_schema7.erl diff --git a/sample-schemas/demo_schema7.erl b/sample-schemas/demo_schema7.erl new file mode 100644 index 0000000..23e0f30 --- /dev/null +++ b/sample-schemas/demo_schema7.erl @@ -0,0 +1,37 @@ +-module(demo_schema7). + +-include_lib("typerefl/include/types.hrl"). + +-behaviour(hocon_schema). + +-export([namespace/0, roots/0, fields/1, desc/1, root_converter/1]). + +namespace() -> undefined. + +roots() -> + [ {foo, hoconsc:map(name, hoconsc:ref(bar))} + ]. + +fields(bar) -> + [ {int, hoconsc:mk(integer(), #{})} + , {baz, hoconsc:mk(binary(), #{})} + , {quux, hoconsc:mk(hoconsc:ref(quux), #{})} + ]; +fields(quux) -> + [ {int, hoconsc:mk(integer(), #{})} + ]. + +desc(bar) -> + {bar, invalid}; +desc(_) -> + undefined. + +root_converter(bar) -> + fun bar_root_converter/2; +root_converter(_) -> + undefined. + +bar_root_converter(#{<<"int">> := N} = Conf0, _HoconOpts) -> + Conf0#{<<"int">> := N + 10}; +bar_root_converter(Conf0, _HoconOpts) -> + Conf0. diff --git a/src/hocon_schema.erl b/src/hocon_schema.erl index cacf1f6..61f8092 100644 --- a/src/hocon_schema.erl +++ b/src/hocon_schema.erl @@ -30,6 +30,7 @@ find_structs/2, override/2, namespace/1, + root_converter/2, resolve_struct_name/2, root_names/1, field_schema/2, @@ -72,13 +73,15 @@ -callback validations() -> [validation()]. -callback desc(name()) -> desc() | undefined. -callback tags() -> [tag()]. +-callback root_converter(term()) -> term(). -optional_callbacks([ translations/0, translation/1, validations/0, desc/1, - tags/0 + tags/0, + root_converter/1 ]). -include("hoconsc.hrl"). @@ -240,6 +243,37 @@ namespace(Schema) -> maps:get(namespace, Schema, undefined) end. +root_converter(Schema, Ref) -> + case is_atom(Schema) of + true -> + _ = code:ensure_loaded(Schema), + case erlang:function_exported(Schema, root_converter, 1) of + true -> + try Schema:root_converter(Ref) of + undefined -> + undefined; + Converter when is_function(Converter, 2) -> + Converter; + BadConverter -> + throw({bad_root_converter, BadConverter}) + catch + _:_ -> + undefined + end; + false -> + undefined + end; + false -> + case Schema of + #{root_converter := #{Ref := Converter}} when is_function(Converter, 2) -> + Converter; + #{root_converter := #{Ref := BadConverter}} -> + throw({bad_root_converter, BadConverter}); + _ -> + undefined + end + end. + %% @doc Resolve struct name from a guess. resolve_struct_name(Schema, StructName) -> case lists:keyfind(bin(StructName), 1, roots(Schema)) of diff --git a/src/hocon_tconf.erl b/src/hocon_tconf.erl index bb2dc63..db620df 100644 --- a/src/hocon_tconf.erl +++ b/src/hocon_tconf.erl @@ -285,15 +285,16 @@ map(Schema, Conf, RootNames) -> map(Schema, Conf, all, Opts) -> map(Schema, Conf, hocon_schema:root_names(Schema), Opts); map(Schema, Conf0, Roots0, Opts0) -> - Opts = merge_opts( + Opts1 = merge_opts( #{ schema => Schema, format => richmap }, Opts0 ), + Opts = ensure_stack(Opts1), Conf1 = ensure_format(Conf0, Opts), - Roots = resolve_root_types(hocon_schema:roots(Schema), Roots0), + Roots = resolve_root_types(hocon_schema:roots(Schema), Roots0, Schema), Conf2 = filter_by_roots(Opts, Conf1, Roots), Conf3 = apply_envs(Schema, Conf2, Opts, Roots), {Mapped0, Conf4} = do_map(Roots, Conf3, Opts, ?MAGIC_SCHEMA), @@ -320,7 +321,7 @@ merge_env_overrides(Schema, Conf0, all, Opts) -> merge_env_overrides(Schema, Conf0, Roots0, Opts0) -> %% force Opts = Opts0#{apply_override_envs => true}, - Roots = resolve_root_types(hocon_schema:roots(Schema), Roots0), + Roots = resolve_root_types(hocon_schema:roots(Schema), Roots0, Schema), Conf1 = filter_by_roots(Opts, Conf0, Roots), apply_envs(Schema, Conf1, Opts, Roots). @@ -363,15 +364,25 @@ filter_by_roots(Opts, Conf, Roots) -> Names = names_and_aliases(Roots), boxit(Opts, maps:with(Names, unbox(Opts, Conf)), Conf). -resolve_root_types(_Roots, []) -> +resolve_root_types(_Roots, [], _Schema) -> []; -resolve_root_types(Roots, [Name | Rest]) -> +resolve_root_types(Roots, [Name | Rest], Schema) -> case lists:keyfind(bin(Name), 1, Roots) of - {_, {OrigName, Sc}} -> - [{OrigName, Sc} | resolve_root_types(Roots, Rest)]; + {_, {OrigName, Sc0}} -> + Sc = maybe_override_root_converter(Schema, OrigName, Sc0), + [{OrigName, Sc} | resolve_root_types(Roots, Rest, Schema)]; false -> %% maybe a private struct which is not exposed in roots/0 - [{Name, hoconsc:ref(Name)} | resolve_root_types(Roots, Rest)] + Sc = maybe_override_root_converter(Schema, Name, hoconsc:ref(Name)), + [{Name, Sc} | resolve_root_types(Roots, Rest, Schema)] + end. + +maybe_override_root_converter(Schema, Name, FieldSc) -> + case hocon_schema:root_converter(Schema, Name) of + undefined -> + FieldSc; + Converter -> + hocon_schema:override(FieldSc, #{converter => Converter}) end. str(A) when is_atom(A) -> str(atom_to_binary(A, utf8)); @@ -599,15 +610,30 @@ map_field(?MAP(NameType, Type), FieldSchema, Value, Opts) -> {validation_errs(Opts, Context), Value} end end; -map_field(?R_REF(Module, Ref), FieldSchema, Value, Opts) -> +map_field(?R_REF(Module, Ref), FieldSchema, Value0, Opts) -> %% Switching to another module, good luck. - do_map(hocon_schema:fields(Module, Ref), Value, Opts#{schema := Module}, FieldSchema); -map_field(?REF(Ref), FieldSchema, Value, #{schema := Schema} = Opts) -> - Fields = hocon_schema:fields(Schema, Ref), - do_map(Fields, Value, Opts, FieldSchema); -map_field(Ref, FieldSchema, Value, #{schema := Schema} = Opts) when is_list(Ref) -> - Fields = hocon_schema:fields(Schema, Ref), - do_map(Fields, Value, Opts, FieldSchema); + case eval_root_converter(Module, Ref, Value0, Opts) of + {ok, Value} -> + do_map(hocon_schema:fields(Module, Ref), Value, Opts#{schema := Module}, FieldSchema); + Errors -> + Errors + end; +map_field(?REF(Ref), FieldSchema, Value0, #{schema := Schema} = Opts) -> + case eval_root_converter(Schema, Ref, Value0, Opts) of + {ok, Value} -> + Fields = hocon_schema:fields(Schema, Ref), + do_map(Fields, Value, Opts, FieldSchema); + Errors -> + Errors + end; +map_field(Ref, FieldSchema, Value0, #{schema := Schema} = Opts) when is_list(Ref) -> + case eval_root_converter(Schema, Ref, Value0, Opts) of + {ok, Value} -> + Fields = hocon_schema:fields(Schema, Ref), + do_map(Fields, Value, Opts, FieldSchema); + Errors -> + Errors + end; map_field(?UNION(Types0, _), Schema0, Value, Opts) -> try select_union_members(Types0, Value, Opts) of Types -> @@ -689,6 +715,45 @@ eval_builtin_converter(PlainValue, Type, Opts) -> hocon_schema_builtin:convert(PlainValue, Type) end. +eval_root_converter(Schema, Ref, Value0, Opts) -> + case hocon_schema:root_converter(Schema, Ref) of + undefined -> + {ok, Value0}; + Converter when is_function(Converter, 2) -> + Value1 = ensure_plain(Value0), + try Converter(Value1, Opts) of + Value2 -> + Box = + case Value0 of + undefined -> + ?META_BOX(from_converter, Converter); + _ -> + Value0 + end, + {ok, maybe_mkrich(Opts, Value2, Box)} + catch + throw:Reason -> + {validation_errs(ensure_stack(Opts), #{reason => Reason}), Value0}; + C:E:St -> + { + validation_errs(ensure_stack(Opts), #{ + reason => root_converter_crashed, + exception => {C, E}, + stacktrace => St + }), + Value0 + } + end + end. + +ensure_stack(#{stack := _} = Opts) -> + Opts; +ensure_stack(#{} = Opts) -> + Opts#{stack => []}; +ensure_stack(Opts) -> + %% Impossible? + Opts. + get_validators(Schema, Type, Opts) -> case is_make_serializable(Opts) of true -> diff --git a/test/hocon_tconf_tests.erl b/test/hocon_tconf_tests.erl index 39310d6..facd4a1 100644 --- a/test/hocon_tconf_tests.erl +++ b/test/hocon_tconf_tests.erl @@ -2804,3 +2804,51 @@ computed_fields_test() -> ), ok. + +%% Smoke tests for using the `root_converter/1` callback (map schema). +root_converter_test() -> + RootConverter1 = fun(X, _) -> X#{<<"new_key">> => true} end, + RootConverter2 = fun(N, _) -> N + 10 end, + Sc = #{ + roots => [ + {"root1", hoconsc:mk(map(), #{})}, + {root2, hoconsc:mk(binary(), #{})}, + {root3, hoconsc:mk(integer(), #{})} + ], + root_converter => #{"root1" => RootConverter1, root3 => RootConverter2} + }, + Conf = #{<<"root1">> => #{}, <<"root2">> => <<"unaltered">>, <<"root3">> => 1}, + ?assertEqual( + #{ + <<"root1">> => #{<<"new_key">> => true}, + <<"root2">> => <<"unaltered">>, + <<"root3">> => 11 + }, + hocon_tconf:check_plain(Sc, Conf) + ), + ok. + +%% Smoke tests for using the `root_converter/1` callback (module schema). +root_converter_module_test() -> + Conf = #{ + <<"foo">> => #{ + <<"myfoo">> => #{ + <<"int">> => 1, + <<"baz">> => <<"hey">>, + <<"quux">> => #{<<"int">> => 2} + } + } + }, + ?assertEqual( + #{ + <<"foo">> => #{ + <<"myfoo">> => #{ + <<"int">> => 11, + <<"baz">> => <<"hey">>, + <<"quux">> => #{<<"int">> => 2} + } + } + }, + hocon_tconf:check_plain(demo_schema7, Conf) + ), + ok. From 6ef2708d8d6921464cf1886f1fc9736159e9cd5d Mon Sep 17 00:00:00 2001 From: Thales Macedo Garitezi <16166434+thalesmg@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:55:15 -0300 Subject: [PATCH 2/3] chore: fix dialyzer complaints --- src/hocon_tconf.erl | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/hocon_tconf.erl b/src/hocon_tconf.erl index db620df..50bd533 100644 --- a/src/hocon_tconf.erl +++ b/src/hocon_tconf.erl @@ -749,10 +749,7 @@ eval_root_converter(Schema, Ref, Value0, Opts) -> ensure_stack(#{stack := _} = Opts) -> Opts; ensure_stack(#{} = Opts) -> - Opts#{stack => []}; -ensure_stack(Opts) -> - %% Impossible? - Opts. + Opts#{stack => []}. get_validators(Schema, Type, Opts) -> case is_make_serializable(Opts) of @@ -831,9 +828,7 @@ maybe_mapping(_, undefined) -> []; maybe_mapping(MappedPath, PlainValue) -> [{string:tokens(MappedPath, "."), PlainValue}]. push_stack(#{stack := Stack} = X, New) -> - X#{stack := [New | Stack]}; -push_stack(X, New) -> - X#{stack => [New]}. + X#{stack := [New | Stack]}. %% get type validation stack. path(#{stack := Stack}) -> From f4983abaf274c5e99d5b586bc576d63bc4ce811b Mon Sep 17 00:00:00 2001 From: Thales Macedo Garitezi <16166434+thalesmg@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:01:00 -0300 Subject: [PATCH 3/3] chore: fix elvis' useless nits --- src/hocon_tconf.erl | 42 +++++++++++++----------------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/src/hocon_tconf.erl b/src/hocon_tconf.erl index 50bd533..6764485 100644 --- a/src/hocon_tconf.erl +++ b/src/hocon_tconf.erl @@ -551,6 +551,15 @@ maybe_computed(_FieldSchema, CheckedValue, _Opts) -> map_field_maybe_convert(Type, Schema, Value0, Opts, undefined) -> map_field(Type, Schema, Value0, Opts); map_field_maybe_convert(Type, Schema, Value0, Opts, Converter) -> + case do_apply_converter(Converter, Value0, Opts) of + {ok, Value1} -> + {Mapped, Value2} = map_field(Type, Schema, Value1, Opts), + {Mapped, ensure_obfuscate_sensitive(Opts, Schema, Value2)}; + Errors -> + Errors + end. + +do_apply_converter(Converter, Value0, Opts) -> Value1 = ensure_plain(Value0), try Converter(Value1, Opts) of Value2 -> @@ -561,15 +570,13 @@ map_field_maybe_convert(Type, Schema, Value0, Opts, Converter) -> _ -> Value0 end, - Value3 = maybe_mkrich(Opts, Value2, Box), - {Mapped, Value4} = map_field(Type, Schema, Value3, Opts), - {Mapped, ensure_obfuscate_sensitive(Opts, Schema, Value4)} + {ok, maybe_mkrich(Opts, Value2, Box)} catch throw:Reason -> - {validation_errs(Opts, #{reason => Reason}), Value0}; + {validation_errs(ensure_stack(Opts), #{reason => Reason}), Value0}; C:E:St -> { - validation_errs(Opts, #{ + validation_errs(ensure_stack(Opts), #{ reason => converter_crashed, exception => {C, E}, stacktrace => St @@ -720,30 +727,7 @@ eval_root_converter(Schema, Ref, Value0, Opts) -> undefined -> {ok, Value0}; Converter when is_function(Converter, 2) -> - Value1 = ensure_plain(Value0), - try Converter(Value1, Opts) of - Value2 -> - Box = - case Value0 of - undefined -> - ?META_BOX(from_converter, Converter); - _ -> - Value0 - end, - {ok, maybe_mkrich(Opts, Value2, Box)} - catch - throw:Reason -> - {validation_errs(ensure_stack(Opts), #{reason => Reason}), Value0}; - C:E:St -> - { - validation_errs(ensure_stack(Opts), #{ - reason => root_converter_crashed, - exception => {C, E}, - stacktrace => St - }), - Value0 - } - end + do_apply_converter(Converter, Value0, Opts) end. ensure_stack(#{stack := _} = Opts) ->