Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
10 changes: 8 additions & 2 deletions .github/workflows/erlang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ jobs:

runs-on: ubuntu-latest

strategy:
matrix:
otp-version: [26, 27, 28]

container:
image: erlang:26
image: erlang:${{ matrix.otp-version }}

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Run tests
run: make eunit && make cover
- name: Run Dialyzer
run: make dialyzer
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
* 2.11.0
- Deleted jsone as dependency.
When OTP release is 27 or later, the default JSON provider module is `json`, otherwise `jsone`.
For OTP release 26 or earlier version, you must add `jsone-1.8.1` or newer in your project dependency.
For OTP release 27 or later, you can choose to continue using jsone by calling `avro:set_json_provider(jsone)`.

* 2.10.3
- Allow union type to have zero member types.
* 2.10.2
Expand Down
17 changes: 6 additions & 11 deletions elvis.config
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@
[
#{dirs => ["src"],
filter => "*.erl",
rules => [ {elvis_style, line_length,
#{ limit => 80,
skip_comments => false
}}
, {elvis_style, no_tabs}
, {elvis_style, no_trailing_whitespace}
rules => [ {elvis_text_style, line_length, #{ limit => 80 }}
, {elvis_text_style, no_trailing_whitespace}
, {elvis_style, operator_spaces,
#{ rules => [ {right,","}
, {right,"+"}
Expand Down Expand Up @@ -54,16 +50,15 @@
, {elvis_style, dont_repeat_yourself,
#{ min_complexity => 15
}}
, {elvis_style, no_debug_call}
, {elvis_style, no_debug_call,
#{ignore => [avro_decoder_hooks]}}

]
},
#{dirs => ["test"],
filter => "*.erl",
rules => [ {elvis_style, line_length,
#{ limit => 80,
skip_comments => false
}}
rules => [ {elvis_text_style, line_length, #{ limit => 80 }}
, {elvis_text_style, no_trailing_whitespace}
]
}
]
Expand Down
4 changes: 2 additions & 2 deletions include/avro_internal.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
-type type_or_name() :: avro_type() | name_raw().

-type custom_prop_name() :: binary().
-type custom_prop_value() :: jsone:json_value().
-type custom_prop_value() :: avro_json_compat:json_value().
-type custom_prop() :: {custom_prop_name(), custom_prop_value()}.

-define(ASSIGNED_NAME, <<"_erlavro_assigned">>).
Expand Down Expand Up @@ -96,7 +96,7 @@
-type type_prop_value() :: namespace() | typedoc() | [name()] | custom_prop_value().
-type type_props() :: [{type_prop_name(), type_prop_value()}].

-type avro_json() :: jsone:json_object().
-type avro_json() :: avro_json_compat:json_value().
-type avro_binary() :: iolist().

-type avro_codec() :: null | deflate | snappy.
Expand Down
7 changes: 6 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@
]}.
{edoc_opts, [{preprocess, true}]}.
{deps,
[ {jsone, "1.8.1"},
[
{snappyer, "1.2.9"}
]}.

{profiles,
[{test,
[{deps, [{jsone, "1.8.1"}]}]
}]}.

{cover_opts, [verbose]}.
{cover_enabled, true}.
{cover_export_enabled, true}.
Expand Down
10 changes: 0 additions & 10 deletions rebar.config.script

This file was deleted.

13 changes: 12 additions & 1 deletion src/avro.erl
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@
, canonical_form_fingerprint/1
]).

-export([set_json_provider/1, get_json_provider/0]).

-export_type([ array_type/0
, avro_type/0
, avro_value/0
Expand Down Expand Up @@ -143,6 +145,14 @@
-type schema_all() :: avro_type() | binary() | lkup_fun() | schema_store().
-type crc64_fingerprint() :: avro_fingerprint:crc64().

%% @doc Set JSON library module.
-spec set_json_provider(json | jsone) -> ok.
set_json_provider(Module) -> avro_json_compat:set_provider(Module).

%% @doc Set JSON library module.
-spec get_json_provider() -> json | jsone.
get_json_provider() -> avro_json_compat:get_provider().

%% @doc Decode JSON format avro schema into `erlavro' internals.
-spec decode_schema(binary()) -> avro_type().
decode_schema(JSON) -> avro_json_decoder:decode_schema(JSON).
Expand Down Expand Up @@ -541,7 +551,8 @@ is_same_type(T1, T2) ->
{Aliases1, Aliases2} ->
%% Check if [Fullname1 | Aliases1] and [Fullname2 | Aliases2] have a
%% non-empty intersection. These lists are always very short.
is_nonempty_intersection([Fullname1 | Aliases1], [Fullname2 | Aliases2])
is_nonempty_intersection([Fullname1 | Aliases1],
[Fullname2 | Aliases2])
end
end.

Expand Down
219 changes: 219 additions & 0 deletions src/avro_json_compat.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
%%%-----------------------------------------------------------------------------
%%% Copyright (c) 2025 Klarna AB
%%%
%%% This file is provided to you 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.
%%%
%%% @doc Compatibility layer for JSON encoding/decoding.
%%% Uses Erlang's native json module on OTP 27+
%%% falls back to jsone on older versions.
%%% @end
%%%-----------------------------------------------------------------------------

-module(avro_json_compat).

%% Suppress Dialyzer warnings about json module functions on OTP < 27
-if(?OTP_RELEASE < 27).
-dialyzer({nowarn_function, [encode/2, decode/2, encoder/2]}).
-endif.

-export([set_provider/1, get_provider/0]).
-export([encode/1, encode/2, decode/1, decode/2]).
-export([inline/1]).
-export_type([json_value/0]).

%% Type definitions compatible with both jsone and native json
-type json_obj() :: #{binary() => json_value()} |
[{binary(), json_value()}] |
{[{binary(), json_value()}]}.

-type json_value() :: null
| boolean()
| integer()
| float()
| binary()
| [json_value()]
| json_obj().

-type encode_option() :: native_utf8.
-type decode_option() :: {object_format, tuple | map | proplist}.

%% Suppress warnings about json module functions on OTP < 27
-if(?OTP_RELEASE < 27).
-compile(nowarn_undefined_function).
-endif.

-define(PROVIDER_PTP_KEY, erlavro_json_provider).

%%%_* APIs =====================================================================

%% @doc Set `json' or `jsone' as the JSON encode/decode provider.
-spec set_provider(json | jsone) -> ok.
set_provider(Module) ->
persistent_term:put(?PROVIDER_PTP_KEY, Module).

%% @doc Get current JSON encode/decode provider.
-spec get_provider() -> json | jsone.
get_provider() ->
case persistent_term:get(?PROVIDER_PTP_KEY, undefined) of
undefined ->
Module = get_available_provider([json, jsone]),
persistent_term:put(?PROVIDER_PTP_KEY, Module),
Module;
Module ->
Module
end.

%% @doc Make a inline (already encoded JSON) JSON value.
%% This is a compatible format for jsone.
inline(JSON) -> {{json, JSON}}.

%% @doc Encode Erlang term to JSON.
%% Equivalent to jsone:encode/1
%% Returns iodata() (binary() or iolist())
-spec encode(json_value()) -> iodata().
encode(Value) -> encode(Value, []).

%% @doc Encode Erlang term to JSON with options.
-spec encode(json_value(), [encode_option()]) -> iodata().
encode(Value, Options) ->
case get_provider() of
json ->
iolist_to_binary(json:encode(Value, fun encoder/2));
Module ->
apply(Module, encode, [Value, Options])
Copy link
Member

Choose a reason for hiding this comment

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

nit: why the apply instead of simply Module:encode(Value, Options)? (likewise below for decode)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it's due to elvis style check.

# src/avro_json_compat.erl [FAIL]
  - invalid_dynamic_call (https://github.com/inaka/elvis_core/tree/main/doc_rules/elvis_style/invalid_dynamic_call.md)
    - Remove the dynamic function call on line 95. Only modules that define callbacks should make dynamic calls.

Copy link
Member

Choose a reason for hiding this comment

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

That is one stupid rule.

end.
%% Custom encoder callback for json:encode/2
%% Handles conversion from jsone format (lists of tuples) to
%% native json format inline
encoder(Value, Encode) when is_list(Value) ->
%% Check if it's a list of key-value pairs (object) or a plain list (array)
case is_key_value_list(Value) of
true ->
%% It's an object: [{Key, Value}] -> encode as key-value list
%% Convert atom keys to binary keys for native json
ConvertedList = [{convert_key(K), V} || {K, V} <- Value],
json:encode_key_value_list(ConvertedList, Encode);
false ->
%% It's an array: [Value] -> encode as array
json:encode_value(Value, Encode)
end;
encoder({{json, JSON}}, _Encode) ->
iolist_to_binary(JSON);
encoder({[]}, _Encode) ->
<<"{}">>;
encoder({[{_, _} | _] = KvList}, Encode) ->
encoder(KvList, Encode);
encoder(Value, Encode) ->
%% Primitive value or map - use default encoding
json:encode_value(Value, Encode).

%% @doc Decode JSON binary to Erlang term.
%% Default object format is tuple (compatible with jsone default).
-spec decode(binary()) -> json_value().
decode(JSON) ->
decode(JSON, [{object_format, tuple}]).

%% @doc Decode JSON binary to Erlang term with options.
%% Options:
%% {object_format, tuple} - Return objects as {[{Key, Value}]} tuples
%% (default, jsone format)
%% {object_format, map} - Return objects as #{Key => Value} maps
%% {object_format, proplist} - Return objects as [{Key, Value}] proplist
-spec decode(binary(), [decode_option()]) -> json_value().
decode(JSON, Options) ->
case get_provider() of
json ->
JsonBinary = iolist_to_binary(JSON),
ObjectFormat = proplists:get_value(object_format, Options, tuple),
Decoded = json:decode(JsonBinary),
convert_object_format(Decoded, ObjectFormat);
Module ->
apply(Module, decode, [JSON, Options])
end.

%%%_* Internal functions =======================================================

%% @private Check if a list represents a key-value object (proplist)
%% Accepts both binary keys and atom keys
-spec is_key_value_list([term()]) -> boolean().
is_key_value_list([{K, _V} | _]) when is_binary(K) orelse is_atom(K) ->
true;
is_key_value_list(_) ->
false.

%% @private Convert key to binary format (native json requires binary keys)
-spec convert_key(atom() | binary()) -> binary().
convert_key(Key) when is_atom(Key) ->
atom_to_binary(Key, utf8);
convert_key(Key) when is_binary(Key) ->
Key.

%% @private Convert decoded JSON value's object format
%% jsone with {object_format, tuple} returns objects as {Fields}
%% where Fields is [{Key, Value}]
%% native json returns objects as #{Key => Value} maps
-spec convert_object_format(json_value(), tuple | map | proplist) ->
json_value().
convert_object_format(Value, Format) when is_map(Value) ->
case Format of
tuple ->
%% Convert map to jsone format: {[{Key, Value}]}
%% Recursively convert nested maps in values
%% Note: maps don't preserve JSON key order, so the resulting tuple order
%% may differ from the original JSON order
Fields = [{Key, convert_object_format(V, Format)}
|| {Key, V} <- maps:to_list(Value)],
{Fields};
map ->
Value;
proplist ->
%% Convert to proplist, recursively convert nested maps
[{Key, convert_object_format(V, Format)}
|| {Key, V} <- maps:to_list(Value)]
end;
convert_object_format({Fields}, Format) when is_list(Fields) ->
%% Already in jsone tuple format, but need to recurse into
%% nested objects/arrays
{[{Key, convert_object_format(Value, Format)}
|| {Key, Value} <- Fields]};
convert_object_format(List, Format) when is_list(List) ->
%% Check if it's a list of key-value pairs (proplist) or a JSON array
case is_key_value_list(List) of
true ->
%% It's a proplist, recurse into values
[{Key, convert_object_format(Value, Format)} || {Key, Value} <- List];
false ->
%% It's a JSON array, recurse into elements
[convert_object_format(Item, Format) || Item <- List]
end;
convert_object_format(Value, _Format) ->
%% Primitive value (null, boolean, number, binary)
Value.

get_available_provider([]) ->
error(erlavro_no_json_provider);
get_available_provider([Module | Rest]) ->
try
apply(Module, module_info, [module])
catch
_:_ ->
get_available_provider(Rest)
end.

%%%_* Emacs ====================================================================
%%% Local Variables:
%%% allout-layout: t
%%% erlang-indent-level: 2
%%% End:
4 changes: 2 additions & 2 deletions src/avro_json_decoder.erl
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
%% Exported for test
-export([ parse_schema/1 ]).

-type json_value() :: jsone:json_value().
-type json_value() :: avro_json_compat:json_value().
-type sc_opts() :: avro:schema_opts().
-type default_parse_fun() :: fun((type_or_name(), json_value()) -> avro:out()).

Expand Down Expand Up @@ -514,7 +514,7 @@ do_parse_union_ex(ValueTypeName, Value, UnionType,
%% 'proplist' is not an option because otherwise there is no way to tell
%% apart 'object' and 'array'.
-spec decode_json(binary()) -> json_value().
decode_json(JSON) -> jsone:decode(JSON, [{object_format, tuple}]).
decode_json(JSON) -> avro_json_compat:decode(JSON, [{object_format, tuple}]).

%% Filter out non-custom properties.
-spec filter_custom_props([{binary(), json_value()}], [name()]) ->
Expand Down
Loading