Skip to content
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Encoding a struct type (a map type with a `__struct__` field, e.g. from Elixir) with non-map data (a string, integer, list, or atom) crashed with a raw `badmap` error instead of returning `{error, [spectra:error()]}`. Plain map types, list types, record types, and decoding were unaffected — only the struct branch of `to_json/4` was missing its `is_map/1` guard.

## [0.14.0] - 2026-09-11

### Fixed
Expand Down
10 changes: 3 additions & 7 deletions src/spectra_json.erl
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ to_json(TypeInfo, #sp_nonempty_list{} = Type, Data, Config) ->
nonempty_list_to_json(TypeInfo, Type, Data, Config);
to_json(TypeInfo, #sp_list{} = ListType, Data, Config) when is_list(Data) ->
list_to_json(TypeInfo, ListType, Data, Config);
to_json(TypeInfo, #sp_map{struct_name = StructName} = Map, Data, Config) ->
to_json(TypeInfo, #sp_map{struct_name = StructName} = Map, Data, Config) when is_map(Data) ->
case StructName of
undefined ->
map_to_json(TypeInfo, Map, Data, Config);
Expand Down Expand Up @@ -243,9 +243,7 @@ list_to_json(TypeInfo, #sp_list{type = Type} = ListType, Data, Config) when is_l
Config :: spectra:sp_config()
) ->
{ok, json:encode_value()} | {error, [spectra:error()]}.
map_to_json(TypeInfo, #sp_map{fields = Fields}, Data, Config) when
is_map(Data)
->
map_to_json(TypeInfo, #sp_map{fields = Fields}, Data, Config) ->
%% Check if this is an Elixir struct and remove __struct__ field for JSON serialization
DataWithoutStruct =
case maps:take('__struct__', Data) of
Expand All @@ -259,9 +257,7 @@ map_to_json(TypeInfo, #sp_map{fields = Fields}, Data, Config) when
{ok, maps:from_list(MapFields)};
{error, Errors} ->
{error, Errors}
end;
map_to_json(_TypeInfo, MapType, Data, _Config) ->
{error, [sp_error:type_mismatch(MapType, Data)]}.
end.

-spec map_fields_to_json(
TypeInfo :: spectra:type_info(),
Expand Down
17 changes: 17 additions & 0 deletions test/elixir_struct_test.erl
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

-include_lib("eunit/include/eunit.hrl").

-include("../include/spectra.hrl").

-define(SKIP_IF_NO_ELIXIR(Body),
case code:is_loaded('Elixir.TestUserStruct') of
false ->
Expand Down Expand Up @@ -209,6 +211,21 @@ run_from_json_field_absent_from_struct_errors_gracefully() ->
Result = spectra:decode(json, TypeInfo, extra_field_struct_type(), JsonBinary),
?assertMatch({error, _}, Result).

%% --- Fix #3: to_json/4 crashed with badmap for non-map data on struct types ---

to_json_non_map_data_returns_error_test() ->
TypeInfo = spectra_type_info:new(?MODULE, false),
Type = struct_type(),
lists:foreach(
fun(BadData) ->
?assertMatch(
{error, [#sp_error{type = type_mismatch}]},
spectra:encode(json, TypeInfo, Type, BadData)
)
end,
[<<"a string">>, 42, [1, 2, 3], an_atom]
).

%% --- Fix #2: apply_only/2 must recurse into #sp_type_with_variables{} ---

schema_only_parameterized_type_test() ->
Expand Down
Loading