Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Swap JSX for Euneus #11

Merged
merged 6 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

# JSON encoding with records and 'null'/'undefined' mapping

This is a wrapper around `jsx` to handle encoding and decoding of Erlang records.
Originally, this was a wrapper around `jsx` to handle encoding and decoding of Erlang records, but [euneus](https://github.com/williamthome/euneus) gives to
jsxrecord a better performance.

## JSON null handling

Expand Down Expand Up @@ -56,25 +57,25 @@ Decoding returns the `#test{}`:
Defaults are automatically added for fields missing in the JSON:

#test{ a = 1, b = 2, c = undefined } = jsxrecord:decode(<<"{\"_record\":\"test\"}">>).

### Encoding and decoding datetime and timestamp tuples

Datetime tuples are assumed to be in UTC, and are converted into an ISO8601 string:

<<"\"2008-12-10T13:30:00Z\"">> = jsxrecord:encode({{2008, 12, 10}, {13, 30, 0}})

They are converted back into a datetime tuple:

{{2008, 12, 10}, {13, 30, 0}} = jsxrecord:decode(<<"\"2008-12-10T13:30:00Z\"">>)

Erlang timestamp tuples are also converted into an ISO8601 string, but with added precision:

<<"\"2020-06-12T14:00:11.571Z\"">> = jsxrecord:encode({1591,970411,571321})

A little bit of precision is lost when converting it back to a timestamp tuple:

{1591,970411,571000} = jsxrecord:decode(<<"\"2020-06-12T14:00:11.571Z\"">>)


## Configuration

Expand Down
6 changes: 5 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{require_min_otp_vsn, "21"}.

{deps, [
{jsx, "3.1.0"}
{euneus, "0.6.0"}
]}.

{dialyzer, [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah.. @mworrell here you add the dependency for dialyzer. If it is listed in applications in the app.src file it is automatically picked up.

{plt_extra_apps, [euneus]}
]}.

{erl_opts, [
Expand Down
6 changes: 3 additions & 3 deletions rebar.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{"1.2.0",
[{<<"jsx">>,{pkg,<<"jsx">>,<<"3.1.0">>},0}]}.
[{<<"euneus">>,{pkg,<<"euneus">>,<<"0.6.0">>},0}]}.
[
{pkg_hash,[
{<<"jsx">>, <<"D12516BAA0BB23A59BB35DCCAF02A1BD08243FCBB9EFE24F2D9D056CCFF71268">>}]},
{<<"euneus">>, <<"92F0B3DD9440EE59DBE809E8A02C518CC15FA23DA456E351AE9720F06DEB6007">>}]},
{pkg_hash_ext,[
{<<"jsx">>, <<"0C5CC8FDC11B53CC25CF65AC6705AD39E54ECC56D1C22E4ADB8F5A53FB9427F3">>}]}
{<<"euneus">>, <<"1F9ECA0AC888C5F564A40C3662AC5CA92C474C0D225F5C6B665B2ABBF38C932D">>}]}
].
2 changes: 1 addition & 1 deletion rebar.test.config
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
{deps, [
{lager, "3.8.0"},
{proper, "1.3.0"},
{jsx, "3.1.0"}
{euneus, "0.6.0"}
]}.
2 changes: 1 addition & 1 deletion src/jsxrecord.app.src
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{description, "JSX wrapper to handle records and 'undefined'"},
{vsn, "git"},
{registered, []},
{applications, [ kernel, stdlib, syntax_tools, compiler, jsx ]},
{applications, [ kernel, stdlib, syntax_tools, compiler ]},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add euneus here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Euneus does not need to be started as an application. I think it's not necessary to add it there.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of months ago I had to add dependencies there because dialyzer couldn't find types if I didn't. But reading from erlang documentation, the applications entry is indeed intended for applications which must be started before this application.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mmzeeman I think I had the same issue with Dialyzer by using cowboy, but adding the plt_extra_apps option as below solved it:

% rebar.config
{dialyzer, [
    {plt_extra_apps, [cowboy]}
]}.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah... but cowboy is an application which must be started. It's a bit strange that as a user of an application you have to know how it works internally.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember correctly, maybe I have made a mistake, but I was using ErlangLS extension for VSCode and cowboy behaviors were not being recognized.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had a similar problem. The CI scripts suddenly all failed. I didn't know about the dialyzer config at the time.

Question @mworrell. What about other deps we use which uses jsx. For core zotonic that is probably mqtt_sessions. Should we change the default json encoder there too? There are also a couple of other places where it is used directly.

{env, [
{record_modules, [ ]}
]},
Expand Down
174 changes: 55 additions & 119 deletions src/jsxrecord.erl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
%% @author Marc Worrell <[email protected]>
%% @copyright 2018-2023 Marc Worrell
%% @doc JSON with records and 'undefined'/'null' mapping. Wrapper around jsx.
%% @doc JSON with records and 'undefined'/'null' mapping.
%% @end

%% Copyright 2018-2023 Marc Worrell
Expand Down Expand Up @@ -30,9 +30,7 @@
]).

-define(RECORD_TYPE, <<"_type">>).

-define(IS_NUMBER(C), C >= $0, C =< $9).

-define(IS_PROPLIST_KEY(X), is_binary(X) orelse is_atom(X) orelse is_integer(X)).

-include_lib("kernel/include/logger.hrl").

Expand Down Expand Up @@ -91,41 +89,68 @@ do_load_records(Modules, CurrRecordDefs) ->
Records),
compile_module(New).


encode_json(undefined) -> <<"null">>;
encode_json(null) -> <<"null">>;
encode_json(true) -> <<"true">>;
encode_json(false) -> <<"false">>;
encode_json({struct, _} = MochiJSON) ->
encode_json( mochijson_to_map(MochiJSON) );
encode_json(Term) ->
Options = [
{error_handler, fun jsx_error/3}
],
jsx:encode(expand_records(Term), Options).
Options = #{
nulls => [undefined, null],
list_encoder => fun encode_list/2,
unhandled_encoder => fun encode_tuple/2,
error_handler => fun jsx_error/3
},
case euneus:encode_to_binary(Term, Options) of
{ok, JSON} ->
JSON;
{error, Reason} ->
error(Reason)
end.

decode_json(<<>>) -> undefined;
decode_json(<<"null">>) -> undefined;
decode_json(<<"true">>) -> true;
decode_json(<<"false">>) -> false;
decode_json(B) -> reconstitute_records( jsx:decode(B, [return_maps]) ).
decode_json(B) ->
Options = #{
objects => fun reconstitute_records/2
},
case euneus:decode(B, Options) of
{ok, Term} ->
Term;
{error, Reason} ->
error(Reason)
end.

jsx_error([T|Terms], {parser, State, Handler, Stack}, Config) ->
encode_list([{K, _} | _] = Proplist, Opts) when ?IS_PROPLIST_KEY(K) ->
Map = proplists:to_map(Proplist),
euneus_encoder:encode_map(Map, Opts);
encode_list(List, Opts) ->
euneus_encoder:encode_list(List, Opts).

encode_tuple({struct, MochiJSON}, Opts) ->
Map = mochijson_to_map(MochiJSON),
euneus_encoder:encode_map(Map, Opts);
encode_tuple(R, _Opts) when is_tuple(R), is_atom(element(1, R)) ->
T = atom_to_binary(element(1, R), utf8),
case maps:find(T, record_defs()) of
{ok, Def} ->
encode_json(expand_record_1(
Def, 2, R, #{ ?RECORD_TYPE => T }
));
error ->
euneus_encoder:throw_unsupported_type_error(R)
end;
encode_tuple(T, _Opts) ->
euneus_encoder:throw_unsupported_type_error(T).

jsx_error(throw, {{token, Token}, Rest, Opts, Input, Pos, Buffer}, _Stacktrace) ->
?LOG_ERROR(#{
in => jsxrecord,
text => <<"Error mapping value to JSON">>,
result => error,
reason => json_token,
token => T
token => Token
}),
Config1 = jsx_config:parse_config(Config),
jsx_parser:resume([null|Terms], State, Handler, Stack, Config1);
jsx_error(_Terms, _Error, _Config) ->
erlang:error(badarg).

Replacement = null,
euneus_decoder:resume(Token, Replacement, Rest, Opts, Input, Pos, Buffer);
jsx_error(Class, Reason, Stacktrace) ->
euneus_decoder:handle_error(Class, Reason, Stacktrace).

reconstitute_records( M ) when is_map(M) ->
M1 = maps:map( fun(_K, V) -> reconstitute_records(V) end, M ),
reconstitute_records(M1, _Opts) ->
case maps:find(?RECORD_TYPE, M1) of
{ok, Type} ->
case maps:find(Type, record_defs_int()) of
Expand All @@ -148,36 +173,7 @@ reconstitute_records( M ) when is_map(M) ->
end;
error ->
M1
end;
reconstitute_records( L ) when is_list(L) ->
[ reconstitute_records(X) || X <- L ];
reconstitute_records( null ) ->
undefined;
reconstitute_records( <<Y4, Y3, Y2, Y1, $-, M2, M1, $-, D2, D1, $T, H2, H1, $:, Min2, Min1, $:, S2, S1, $., Mil3, Mil2, Mil1, $Z>> )
when ?IS_NUMBER(Y4), ?IS_NUMBER(Y3), ?IS_NUMBER(Y2), ?IS_NUMBER(Y1),
?IS_NUMBER(M2), ?IS_NUMBER(M1),
?IS_NUMBER(D2), ?IS_NUMBER(D1),
?IS_NUMBER(H2), ?IS_NUMBER(H1),
?IS_NUMBER(Min2), ?IS_NUMBER(Min1),
?IS_NUMBER(S2), ?IS_NUMBER(S1),
?IS_NUMBER(Mil3), ?IS_NUMBER(Mil2), ?IS_NUMBER(Mil1) ->
DateTime = {{chars_to_integer(Y4, Y3, Y2, Y1), chars_to_integer(M2, M1), chars_to_integer(D2, D1)},
{chars_to_integer(H2, H1), chars_to_integer(Min2, Min1), chars_to_integer(S2, S1)}},
MilliSeconds = chars_to_integer(Mil3, Mil2, Mil1),
Seconds = calendar:datetime_to_gregorian_seconds(DateTime) - 62167219200,
%% 62167219200 == calendar:datetime_to_gregorian_seconds({{1970, 1, 1}, {0, 0, 0}})
{Seconds div 1000000, Seconds rem 1000000, MilliSeconds * 1000};
reconstitute_records( <<Y4, Y3, Y2, Y1, $-, M2, M1, $-, D2, D1, $T, H2, H1, $:, Min2, Min1, $:, S2, S1, $Z>> )
when ?IS_NUMBER(Y4), ?IS_NUMBER(Y3), ?IS_NUMBER(Y2), ?IS_NUMBER(Y1),
?IS_NUMBER(M2), ?IS_NUMBER(M1),
?IS_NUMBER(D2), ?IS_NUMBER(D1),
?IS_NUMBER(H2), ?IS_NUMBER(H1),
?IS_NUMBER(Min2), ?IS_NUMBER(Min1),
?IS_NUMBER(S2), ?IS_NUMBER(S1) ->
{{chars_to_integer(Y4, Y3, Y2, Y1), chars_to_integer(M2, M1), chars_to_integer(D2, D1)},
{chars_to_integer(H2, H1), chars_to_integer(Min2, Min1), chars_to_integer(S2, S1)}};
reconstitute_records( T ) ->
T.
end.

make_proplist(Map) ->
L = maps:to_list(Map),
Expand All @@ -194,69 +190,19 @@ make_proplist(Map) ->
end,
L).

expand_records(R) when is_tuple(R), is_atom(element(1, R)) ->
T = atom_to_binary(element(1, R), utf8),
case maps:find(T, record_defs()) of
{ok, Def} ->
expand_record_1(Def, 2, R, #{ ?RECORD_TYPE => T });
error ->
R
end;
expand_records({MegaSecs, Secs, MicroSecs}=Timestamp) when is_integer(MegaSecs) andalso is_integer(Secs) andalso is_integer(MicroSecs) ->
% Timestamp, map to date in UTC
MilliSecs = MicroSecs div 1000,
{{Year, Month, Day}, {Hour, Min, Sec}} = calendar:now_to_datetime(Timestamp),
unicode:characters_to_binary(io_lib:format("~4.10.0B-~2.10.0B-~2.10.0BT~2.10.0B:~2.10.0B:~2.10.0B.~3.10.0BZ",
[Year, Month, Day, Hour, Min, Sec, MilliSecs]));

expand_records({{Year,Month,Day},{Hour,Minute,Second}}) when is_integer(Year) andalso is_integer(Month) andalso is_integer(Second) andalso
is_integer(Hour) andalso is_integer(Minute) andalso is_integer(Second) ->
% Date tuple, assume it to be in UTC
unicode:characters_to_binary(io_lib:format(
"~4.10.0B-~2.10.0B-~2.10.0BT~2.10.0B:~2.10.0B:~2.10.0BZ",
[Year, Month, Day, Hour, Minute, Second]));

expand_records({A, B, Params} = Mime) when is_binary(A), is_binary(B), is_list(Params) ->
% Assume to be a MIME content type
format_content_type(Mime);
expand_records({K, V}) when is_number(K) ->
[ K, V ];
expand_records({K, V}) ->
{expand_records(K), expand_records(V)};
expand_records(L) when is_list(L) ->
lists:map(
fun
({K, V}) when is_binary(K); is_atom(K); is_number(K) -> {K, expand_records(V)};
(V) -> expand_records(V)
end,
L);
expand_records(M) when is_map(M) ->
maps:map( fun(_K, V) -> expand_records(V) end, M );
expand_records(undefined) ->
null;
expand_records(X) ->
X.

expand_record_1([ {F, _} | Fs ], N, R, Acc) ->
Acc1 = Acc#{ F => expand_records( element(N, R) ) },
Acc1 = Acc#{ F => element(N, R) },
expand_record_1(Fs, N+1, R, Acc1);
expand_record_1([], _N, _R, Acc) ->
Acc.


mochijson_to_map({struct, L}) ->
maps:from_list([ mochijson_to_map(V) || V <- L ]);
mochijson_to_map({K, V}) ->
{K, mochijson_to_map(V)};
mochijson_to_map(V) ->
V.

format_content_type({T1, T2, []}) ->
<<T1/binary, $/, T2/binary>>;
format_content_type({T1, T2, Params}) ->
ParamsBin = [ [$;, Param, $=, Value] || {Param,Value} <- Params ],
iolist_to_binary([T1, $/, T2, ParamsBin]).

%% @doc Compile the record defs to a module, for effictient caching of all definitions
-spec compile_module( map() ) -> ok.
compile_module( Defs ) ->
Expand Down Expand Up @@ -316,13 +262,3 @@ to_field_name({record_field, _Line, {atom, _, FieldName}}) ->
{FieldName, undefined};
to_field_name({record_field, _Line, {atom, _, FieldName}, InitExpr}) ->
{FieldName, erl_syntax:concrete(InitExpr)}.

chars_to_integer(N2, N1) ->
((N2 - $0) * 10) + (N1 - $0).

chars_to_integer(N3, N2, N1) ->
((N3 - $0) * 100) + ((N2 - $0) * 10) + (N1 - $0).

chars_to_integer(N4, N3, N2, N1) ->
((N4 - $0) * 1000) + ((N3 - $0) * 100) + ((N2 - $0) * 10) + (N1 - $0).

Loading