-module(spectra_json). -moduledoc """ JSON encode and decode traversal for `sp_type()` values. `to_json/4` walks an Erlang term against an `sp_type()` and produces a `json:encode_value()` (a map/list/scalar tree accepted by `json:encode/1`). `from_json/4` does the inverse: it walks a decoded JSON value and produces the corresponding Erlang term. Neither function calls `json:encode/1` or `json:decode/1` — that is the responsibility of the caller (typically `spectra.erl`). Codec dispatch is handled mid-traversal at `#sp_user_type_ref{}`, `#sp_remote_type{}`, and `#sp_rec_ref{}` nodes via `spectra_codec:try_codec_encode/7` and `try_codec_decode/7`. """. -export([to_json/4, from_json/4]). -ignore_xref([to_json/4, from_json/4]). -include("../include/spectra_internal.hrl"). -doc """ Encodes `Data` to a JSON-compatible value according to `Type`. Walks the type tree recursively, converting each node. Records become maps with binary keys, lists stay lists, atoms in unions become their binary representations. Optional fields whose value equals the missing sentinel (`nil` / `undefined`) are omitted from the output. Returns `{ok, Value}` on success or `{error, Errors}` with a list of structured `#sp_error{}` values describing every mismatch found. """. -spec to_json( TypeInfo :: spectra:type_info(), Type :: spectra:sp_type(), Data :: dynamic(), Config :: spectra:sp_config() ) -> {ok, json:encode_value()} | {error, [spectra:error()]}. to_json( TypeInfo, #sp_user_type_ref{type_name = TypeName, variables = Args, arity = Arity} = UserTypeRef, Data, Config ) when is_atom(TypeName) -> TypeRef = {type, TypeName, Arity}, Module = spectra_type_info:get_module(TypeInfo), case spectra_codec:try_codec_encode(json, TypeInfo, Module, TypeRef, UserTypeRef, Data, Config) of continue -> Type = spectra_type_info:get_type(TypeInfo, TypeName, Arity), TypeWithoutVars0 = spectra_util:apply_args(TypeInfo, Type, Args), TypeWithoutVars = spectra_abstract_code:apply_ref_meta( TypeWithoutVars0, UserTypeRef#sp_user_type_ref.meta ), to_json(TypeInfo, TypeWithoutVars, Data, Config); Result -> Result end; to_json( TypeInfo, #sp_remote_type{mfargs = {Module, TypeName, Args}, arity = TypeArity} = RemoteRef, Data, Config ) -> TypeRef = {type, TypeName, TypeArity}, case spectra_codec:try_codec_encode(json, TypeInfo, Module, TypeRef, RemoteRef, Data, Config) of continue -> RemoteTypeInfo = spectra_module_types:get(Module, Config), RemoteType = spectra_type_info:get_type(RemoteTypeInfo, TypeName, TypeArity), TypeResolved0 = spectra_type:propagate_params( RemoteRef, spectra_util:apply_args(RemoteTypeInfo, RemoteType, Args) ), TypeResolved = spectra_abstract_code:apply_ref_meta( TypeResolved0, RemoteRef#sp_remote_type.meta ), to_json(RemoteTypeInfo, TypeResolved, Data, Config); Result -> Result end; to_json(TypeInfo, #sp_rec{} = RecordInfo, Record, Config) when is_tuple(Record) -> record_to_json(TypeInfo, RecordInfo, Record, [], Config); to_json( TypeInfo, #sp_rec_ref{record_name = RecordName, field_types = TypeArgs} = RecordRef, Record, Config ) when is_atom(RecordName) -> TypeRef = {record, RecordName}, Module = spectra_type_info:get_module(TypeInfo), case spectra_codec:try_codec_encode(json, TypeInfo, Module, TypeRef, RecordRef, Record, Config) of continue -> RecordType0 = spectra_type_info:get_record(TypeInfo, RecordName), #sp_rec{} = RecordType = spectra_abstract_code:apply_ref_meta( RecordType0, RecordRef#sp_rec_ref.meta ), record_to_json(TypeInfo, RecordType, Record, TypeArgs, Config); Result -> Result end; to_json(_TypeInfo, #sp_simple_type{type = NotSupported} = Type, _Data, _Config) when NotSupported =:= pid orelse NotSupported =:= port orelse NotSupported =:= reference orelse NotSupported =:= bitstring orelse NotSupported =:= nonempty_bitstring orelse NotSupported =:= none -> erlang:error({type_not_supported, Type}); to_json(_TypeInfo, #sp_simple_type{} = Type, Value, _Config) -> prim_type_to_json(Type, Value); to_json( _TypeInfo, #sp_range{ type = integer, lower_bound = Min, upper_bound = Max }, Value, _Config ) when is_integer(Value) andalso Min =< Value, Value =< Max -> {ok, Value}; to_json(_TypeInfo, #sp_literal{value = Value, binary_value = BinaryValue}, Value, _Config) when is_atom(Value) -> {ok, BinaryValue}; to_json(_TypeInfo, #sp_literal{value = Value}, Value, _Config) when is_integer(Value) orelse Value =:= [] -> {ok, Value}; to_json(TypeInfo, #sp_union{} = Type, Data, Config) -> union(fun to_json/4, TypeInfo, Type, Data, Config); 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) -> case StructName of undefined -> map_to_json(TypeInfo, Map, Data, Config); _ -> %% For Elixir structs, we expect the data to have the __struct__ field case maps:get('__struct__', Data, undefined) of StructName -> map_to_json(TypeInfo, Map, Data, Config); _ -> {error, [sp_error:type_mismatch(Map, Data, #{message => "Struct mismatch"})]} end end; to_json(_TypeInfo, #sp_maybe_improper_list{} = Type, _Data, _Config) -> erlang:error({type_not_supported, Type}); to_json(_TypeInfo, #sp_nonempty_improper_list{} = Type, _Data, _Config) -> erlang:error({type_not_supported, Type}); to_json(_TypeInfo, #sp_tuple{} = Type, _Data, _Config) -> erlang:error({type_not_supported, Type}); to_json(_TypeInfo, #sp_function{} = Type, _Data, _Config) -> erlang:error({type_not_supported, Type}); to_json(_TypeInfo, Type, OtherValue, _Config) -> {error, [sp_error:type_mismatch(Type, OtherValue)]}. -spec prim_type_to_json(Type :: spectra:sp_type(), Value :: dynamic()) -> {ok, json:encode_value()} | {error, [spectra:error()]}. prim_type_to_json(#sp_simple_type{type = Type} = T, Value) -> case check_type_to_json(Type, Value) of {true, NewValue} when Type =:= binary orelse Type =:= nonempty_binary orelse Type =:= string orelse Type =:= nonempty_string -> Params = spectra_type:parameters(T), check_string_params(T, Params, NewValue); {true, NewValue} -> {ok, NewValue}; {error, Reason} -> {error, Reason}; false -> {error, [sp_error:type_mismatch(T, Value)]} end. nonempty_list_to_json(TypeInfo, #sp_nonempty_list{type = Type}, Data, Config) when is_list(Data) andalso Data =/= [] -> list_to_json(TypeInfo, #sp_list{type = Type}, Data, Config); nonempty_list_to_json(_TypeInfo, Type, Data, _Config) -> {error, [sp_error:type_mismatch(Type, Data)]}. -spec list_to_json( TypeInfo :: spectra:type_info(), ListType :: #sp_list{}, Data :: [dynamic()], Config :: spectra:sp_config() ) -> {ok, [json:encode_value()]} | {error, [spectra:error()]}. list_to_json(TypeInfo, #sp_list{type = Type} = ListType, Data, Config) when is_list(Data) -> try case spectra_util:fold_until_error( fun(Item, {Nr, Acc}) -> case to_json(TypeInfo, Type, Item, Config) of {ok, Json} -> {ok, {Nr + 1, [Json | Acc]}}; {error, Errs} -> Errs2 = lists:map( fun(Err) -> sp_error:append_location(Err, Nr) end, Errs ), {error, Errs2} end end, {1, []}, Data ) of {ok, {_Nr, Json}} -> {ok, lists:reverse(Json)}; {error, _} = Err -> Err end catch error:{improper_list, _ImproperTail} -> {error, [sp_error:type_mismatch(ListType, Data)]} end. -spec map_to_json( TypeInfo :: spectra:type_info(), MapFieldTypes :: #sp_map{}, Data :: map(), 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) -> %% Check if this is an Elixir struct and remove __struct__ field for JSON serialization DataWithoutStruct = case maps:take('__struct__', Data) of {_StructName, CleanData} -> CleanData; error -> Data end, case map_fields_to_json(TypeInfo, Fields, DataWithoutStruct, Config) of {ok, MapFields} -> {ok, maps:from_list(MapFields)}; {error, Errors} -> {error, Errors} end; map_to_json(_TypeInfo, MapType, Data, _Config) -> {error, [sp_error:type_mismatch(MapType, Data)]}. -spec map_fields_to_json( TypeInfo :: spectra:type_info(), MapFieldTypes :: [spectra:map_field()], Data :: map(), Config :: spectra:sp_config() ) -> {ok, [{binary(), json:encode_value()}]} | {error, [spectra:error()]}. map_fields_to_json(TypeInfo, MapFieldTypes, Data, Config) -> %% Two-phase processing: all literal fields (assoc and exact) first, then typed fields {LiteralFields, TypedFields} = lists:partition( fun (#literal_map_field{}) -> true; (_) -> false end, MapFieldTypes ), case spectra_util:fold_until_error( fun map_literal_field_to_json/3, {TypeInfo, Data, Config}, {[], []}, LiteralFields ) of {ok, {LiteralMapFields, ConsumedKeys}} -> case TypedFields of [] -> {ok, LiteralMapFields}; _ -> %% Phase 2: Process typed fields with remaining data entries RemainingDataList = maps:to_list(maps:without(ConsumedKeys, Data)), case spectra_util:fold_until_error( fun map_typed_field_to_json/3, {TypeInfo, Config}, {[], RemainingDataList}, TypedFields ) of {ok, {TypedMapFields, _}} -> {ok, TypedMapFields ++ LiteralMapFields}; {error, _} = Err -> Err end end; {error, _} = Err -> Err end. -spec map_literal_field_to_json( {spectra:type_info(), map(), spectra:sp_config()}, #literal_map_field{}, {[{binary(), json:encode_value()}], [atom() | integer()]} ) -> {ok, {[{binary(), json:encode_value()}], [atom() | integer()]}} | {error, [spectra:error()]}. map_literal_field_to_json({TypeInfo, Data, Config}, Type, {FieldsAcc, ConsumedKeys}) -> #literal_map_field{ kind = Kind, name = FieldName, binary_name = BinaryFieldName, val_type = FieldType } = Type, Missing = spectra_type:can_be_missing(TypeInfo, FieldType), case Data of #{FieldName := FieldData} -> case {FieldData, Missing} of {MissingValue, {true, MissingValue}} -> {ok, {FieldsAcc, [FieldName | ConsumedKeys]}}; _ -> case to_json(TypeInfo, FieldType, FieldData, Config) of {ok, FieldJson} -> {ok, { [{BinaryFieldName, FieldJson} | FieldsAcc], [FieldName | ConsumedKeys] }}; {error, Errs} -> {error, append_error_location(Errs, FieldName)} end end; #{} -> case {Kind, Missing} of {assoc, _} -> {ok, {FieldsAcc, ConsumedKeys}}; {exact, {true, _}} -> {ok, {FieldsAcc, ConsumedKeys}}; {exact, false} -> Remainder = maps:without(ConsumedKeys, Data), {error, [sp_error:missing_data(Type, Remainder, [FieldName])]} end end. -spec map_typed_field_to_json( {spectra:type_info(), spectra:sp_config()}, #typed_map_field{}, {[{json:encode_value(), json:encode_value()}], [{dynamic(), dynamic()}]} ) -> {ok, {[{json:encode_value(), json:encode_value()}], [{dynamic(), dynamic()}]}} | {error, [spectra:error()]}. map_typed_field_to_json( {TypeInfo, Config}, #typed_map_field{kind = assoc, key_type = KeyType, val_type = ValueType}, {FieldsAcc, CurrentRemainingDataList} ) -> case map_typed_field_to_json_list(TypeInfo, KeyType, ValueType, CurrentRemainingDataList, Config) of {ok, {NewFields, NextRemainingDataList}} -> {ok, {lists:reverse(NewFields, FieldsAcc), NextRemainingDataList}}; {error, _} = Err -> Err end; map_typed_field_to_json( {TypeInfo, Config}, #typed_map_field{kind = exact, key_type = KeyType, val_type = ValueType} = Type, {FieldsAcc, CurrentRemainingDataList} ) -> case map_typed_field_to_json_list(TypeInfo, KeyType, ValueType, CurrentRemainingDataList, Config) of {ok, {NewFields, NextRemainingDataList}} -> case NewFields of [] -> Remainder = maps:from_list(CurrentRemainingDataList), {error, [sp_error:not_matched_fields(Type, Remainder)]}; _ -> {ok, {lists:reverse(NewFields, FieldsAcc), NextRemainingDataList}} end; {error, _} = Err -> Err end. -spec map_typed_field_to_json_list( TypeInfo :: spectra:type_info(), KeyType :: spectra:sp_type(), ValueType :: spectra:sp_type(), DataList :: [{dynamic(), dynamic()}], Config :: spectra:sp_config() ) -> {ok, {[{json:encode_value(), json:encode_value()}], [{dynamic(), dynamic()}]}} | {error, [spectra:error()]}. map_typed_field_to_json_list(TypeInfo, KeyType, ValueType, DataList, Config) -> Fun = fun({Key, Value}, {FieldsAcc, RemainingAcc}) -> case to_json(TypeInfo, KeyType, Key, Config) of {ok, KeyJson} -> case {Value, spectra_type:can_be_missing(TypeInfo, ValueType)} of {MissingValue, {true, MissingValue}} -> {ok, {FieldsAcc, RemainingAcc}}; _ -> case to_json(TypeInfo, ValueType, Value, Config) of {ok, ValueJson} -> {ok, { [{KeyJson, ValueJson} | FieldsAcc], RemainingAcc }}; {error, Errs} -> Errs2 = lists:map( fun(Err) -> sp_error:append_location(Err, Key) end, Errs ), {error, Errs2} end end; {error, _Errs} -> {ok, {FieldsAcc, [{Key, Value} | RemainingAcc]}} end end, spectra_util:fold_until_error(Fun, {[], []}, DataList). -spec record_to_json( TypeInfo :: spectra:type_info(), RecordType :: #sp_rec{}, Record :: dynamic(), TypeArgs :: [{atom(), spectra:sp_type()}], Config :: spectra:sp_config() ) -> {ok, #{atom() => json:encode_value()}} | {error, [spectra:error()]}. record_to_json( TypeInfo, #sp_rec{ name = RecordName, fields = Fields, arity = Arity, meta = Meta }, Record, TypeArgs, Config ) when is_tuple(Record) andalso element(1, Record) =:= RecordName andalso tuple_size(Record) =:= Arity -> [RecordName | FieldsData] = tuple_to_list(Record), RecFieldTypes = spectra_util:record_replace_vars(Fields, TypeArgs), RecFieldTypesWithData0 = lists:zip(RecFieldTypes, FieldsData), RecFieldTypesWithData = case Meta of #{only := Only} -> [ {F, D} || {#sp_rec_field{name = N} = F, D} <- RecFieldTypesWithData0, lists:member(N, Only) ]; _ -> RecFieldTypesWithData0 end, do_record_to_json(TypeInfo, RecFieldTypesWithData, Config); record_to_json(_TypeInfo, RecordType, Record, TypeArgs, _Config) -> {error, [sp_error:type_mismatch(RecordType, Record, #{type_args => TypeArgs})]}. -spec do_record_to_json( spectra:type_info(), [{#sp_rec_field{}, Value :: dynamic()}], Config :: spectra:sp_config() ) -> {ok, #{atom() => json}} | {error, [spectra:error()]}. do_record_to_json(TypeInfo, RecFieldTypesWithData, Config) -> Fun = fun( { #sp_rec_field{name = FieldName, binary_name = BinaryFieldName, type = FieldType}, RecordFieldData }, FieldsAcc ) -> case {RecordFieldData, spectra_type:can_be_missing(TypeInfo, FieldType)} of {MissingValue, {true, MissingValue}} -> {ok, FieldsAcc}; _ -> case to_json(TypeInfo, FieldType, RecordFieldData, Config) of {ok, FieldJson} -> {ok, [{BinaryFieldName, FieldJson} | FieldsAcc]}; {error, Errors} -> {error, lists:map( fun(Error) -> sp_error:append_location(Error, FieldName) end, Errors )} end end end, case spectra_util:fold_until_error(Fun, [], RecFieldTypesWithData) of {ok, Fields} -> {ok, maps:from_list(Fields)}; {error, _} = Err -> Err end. -doc """ Decodes `Json` into an Erlang term according to `Type`. The inverse of `to_json/4`. Binary map keys become atom keys, binary atom values are converted via `binary_to_existing_atom/2`, JSON `null` maps to `nil` or `undefined` where the type allows it, and JSON arrays become lists. Returns `{ok, Term}` on success or `{error, Errors}` with structured errors. """. -spec from_json( TypeInfo :: spectra:type_info(), Type :: spectra:sp_type(), Json :: json:decode_value(), Config :: spectra:sp_config() ) -> {ok, dynamic()} | {error, [spectra:error()]}. from_json(TypeInfo, Type, Json, Config) -> do_from_json(TypeInfo, Type, Json, Config). -spec do_from_json( TypeInfo :: spectra:type_info(), Type :: spectra:sp_type(), Json :: json:decode_value(), Config :: spectra:sp_config() ) -> {ok, dynamic()} | {error, [spectra:error()]}. do_from_json( TypeInfo, #sp_user_type_ref{type_name = TypeName, variables = Args, arity = Arity} = UserTypeRef, Json, Config ) when is_atom(TypeName) -> TypeRef = {type, TypeName, Arity}, Module = spectra_type_info:get_module(TypeInfo), case spectra_codec:try_codec_decode(json, TypeInfo, Module, TypeRef, UserTypeRef, Json, Config) of continue -> Type = spectra_type_info:get_type(TypeInfo, TypeName, Arity), TypeWithoutVars0 = spectra_util:apply_args(TypeInfo, Type, Args), TypeWithoutVars = spectra_abstract_code:apply_ref_meta( TypeWithoutVars0, UserTypeRef#sp_user_type_ref.meta ), do_from_json(TypeInfo, TypeWithoutVars, Json, Config); Result -> Result end; do_from_json( TypeInfo, #sp_remote_type{mfargs = {Module, TypeName, Args}, arity = TypeArity} = RemoteRef, Json, Config ) -> TypeRef = {type, TypeName, TypeArity}, case spectra_codec:try_codec_decode(json, TypeInfo, Module, TypeRef, RemoteRef, Json, Config) of continue -> RemoteTypeInfo = spectra_module_types:get(Module, Config), RemoteType = spectra_type_info:get_type(RemoteTypeInfo, TypeName, TypeArity), TypeResolved0 = spectra_type:propagate_params( RemoteRef, spectra_util:apply_args(RemoteTypeInfo, RemoteType, Args) ), TypeResolved = spectra_abstract_code:apply_ref_meta( TypeResolved0, RemoteRef#sp_remote_type.meta ), do_from_json(RemoteTypeInfo, TypeResolved, Json, Config); Result -> Result end; do_from_json(TypeInfo, #sp_rec{} = Rec, Json, Config) -> record_from_json(TypeInfo, Rec, Json, [], Config); do_from_json( TypeInfo, #sp_rec_ref{record_name = RecordName, field_types = TypeArgs} = RecordRef, Json, Config ) when is_atom(RecordName) -> TypeRef = {record, RecordName}, Module = spectra_type_info:get_module(TypeInfo), case spectra_codec:try_codec_decode(json, TypeInfo, Module, TypeRef, RecordRef, Json, Config) of continue -> RecordType0 = spectra_type_info:get_record(TypeInfo, RecordName), #sp_rec{} = RecordType = spectra_abstract_code:apply_ref_meta( RecordType0, RecordRef#sp_rec_ref.meta ), record_from_json(TypeInfo, RecordType, Json, TypeArgs, Config); Result -> Result end; do_from_json(TypeInfo, #sp_map{} = Type, Json, Config) -> map_from_json(TypeInfo, Type, Json, Config); do_from_json(TypeInfo, #sp_nonempty_list{} = Type, Data, Config) -> nonempty_list_from_json(TypeInfo, Type, Data, Config); do_from_json(TypeInfo, #sp_list{type = ListType} = Type, Data, Config) -> list_from_json(TypeInfo, ListType, Data, Type, Config); do_from_json(_TypeInfo, #sp_simple_type{type = NotSupported} = T, _Value, _Config) when NotSupported =:= pid orelse NotSupported =:= port orelse NotSupported =:= reference orelse NotSupported =:= bitstring orelse NotSupported =:= nonempty_bitstring orelse NotSupported =:= none -> erlang:error({type_not_supported, T}); do_from_json(_TypeInfo, #sp_simple_type{type = PrimaryType} = Type, Json, _Config) when PrimaryType =:= binary orelse PrimaryType =:= nonempty_binary orelse PrimaryType =:= string orelse PrimaryType =:= nonempty_string -> case check_type_from_json(PrimaryType, Json) of {true, NewValue} -> Params = spectra_type:parameters(Type), check_string_params(Type, Params, NewValue); {error, Reason} -> {error, Reason}; false -> {error, [sp_error:type_mismatch(Type, Json)]} end; do_from_json(_TypeInfo, #sp_simple_type{type = PrimaryType} = Type, Json, _Config) -> case check_type_from_json(PrimaryType, Json) of {true, NewValue} -> {ok, NewValue}; {error, Reason} -> {error, Reason}; false -> {error, [sp_error:type_mismatch(Type, Json)]} end; do_from_json(_TypeInfo, #sp_literal{value = Literal}, Literal, _Config) -> {ok, Literal}; do_from_json(_TypeInfo, #sp_literal{} = Type, Value, _Config) -> case try_convert_to_literal(Type, Value) of {ok, Literal} -> {ok, Literal}; false -> {error, [sp_error:type_mismatch(Type, Value)]} end; do_from_json(TypeInfo, #sp_union{} = Type, Json, Config) -> union(fun do_from_json/4, TypeInfo, Type, Json, Config); do_from_json( _TypeInfo, #sp_range{ type = integer, lower_bound = Min, upper_bound = Max }, Value, _Config ) when Min =< Value, Value =< Max, is_integer(Value) -> {ok, Value}; do_from_json( _TypeInfo, #sp_range{ type = integer, lower_bound = _Min, upper_bound = _Max } = Range, Value, _Config ) when is_integer(Value) -> {error, [sp_error:type_mismatch(Range, Value)]}; do_from_json(_TypeInfo, #sp_maybe_improper_list{} = Type, _Value, _Config) -> erlang:error({type_not_supported, Type}); do_from_json(_TypeInfo, #sp_nonempty_improper_list{} = Type, _Value, _Config) -> erlang:error({type_not_supported, Type}); do_from_json(_TypeInfo, #sp_function{} = Type, _Value, _Config) -> erlang:error({type_not_supported, Type}); do_from_json(_TypeInfo, #sp_tuple{} = Type, _Value, _Config) -> erlang:error({type_not_supported, Type}); do_from_json(_TypeInfo, Type, Value, _Config) -> {error, [sp_error:type_mismatch(Type, Value)]}. -spec try_convert_to_literal( Type :: #sp_literal{}, Value :: dynamic() ) -> {ok, integer() | atom() | []} | false. try_convert_to_literal( #sp_literal{value = []}, _Value ) -> false; try_convert_to_literal( #sp_literal{value = LiteralValue}, null ) when LiteralValue =:= nil orelse LiteralValue =:= undefined -> {ok, LiteralValue}; try_convert_to_literal( #sp_literal{value = LiteralValue, binary_value = BinaryLiteralValue}, Value ) when Value =:= BinaryLiteralValue -> {ok, LiteralValue}; try_convert_to_literal(#sp_literal{}, _Value) -> false. nonempty_list_from_json(TypeInfo, #sp_nonempty_list{type = ListType} = Type, Data, Config) when is_list(Data) andalso Data =/= [] -> list_from_json(TypeInfo, ListType, Data, Type, Config); nonempty_list_from_json(_TypeInfo, Type, Data, _Config) -> {error, [sp_error:type_mismatch(Type, Data)]}. list_from_json(TypeInfo, Type, Data, ListType, Config) when is_list(Data) -> try case spectra_util:fold_until_error( fun(Item, {Nr, Acc}) -> case do_from_json(TypeInfo, Type, Item, Config) of {ok, Json} -> {ok, {Nr + 1, [Json | Acc]}}; {error, Errs} -> Errs2 = lists:map( fun(Err) -> sp_error:append_location(Err, Nr) end, Errs ), {error, Errs2} end end, {1, []}, Data ) of {ok, {_Nr, Json}} -> {ok, lists:reverse(Json)}; {error, _} = Err -> Err end catch error:{improper_list, _ImproperTail} -> %% Improper lists cannot be decoded from JSON {error, [sp_error:type_mismatch(ListType, Data)]} end; list_from_json(_TypeInfo, _ListType, Data, Type, _Config) -> {error, [sp_error:type_mismatch(Type, Data)]}. string_from_json(Type, Json) -> case unicode:characters_to_list(Json) of StringValue when is_list(StringValue) -> {true, StringValue}; _Other -> {error, [ sp_error:type_mismatch(#sp_simple_type{type = Type}, Json, #{ message => "unicode conversion failed" }) ]} end. check_type_from_json(string, Json) when is_binary(Json) -> string_from_json(string, Json); check_type_from_json(nonempty_string, Json) when is_binary(Json), byte_size(Json) > 0 -> string_from_json(nonempty_string, Json); check_type_from_json(iodata, Json) when is_binary(Json) -> {true, Json}; check_type_from_json(iolist, Json) when is_binary(Json) -> {true, [Json]}; check_type_from_json(atom, Json) when is_binary(Json) -> try {true, binary_to_existing_atom(Json, utf8)} catch error:badarg -> false end; check_type_from_json(map, Json) when is_map(Json) -> {true, Json}; check_type_from_json(Type, Json) -> check_type(Type, Json). check_type_to_json(iodata, Json) when is_binary(Json) -> {true, Json}; check_type_to_json(iodata, Json) when is_list(Json) -> iolist_to_json(Json); check_type_to_json(iolist, Json) when is_list(Json) -> iolist_to_json(Json); check_type_to_json(nonempty_string, Json) when is_list(Json), Json =/= [] -> do_string_to_json(nonempty_string, Json); check_type_to_json(string, Json) when is_list(Json) -> do_string_to_json(string, Json); check_type_to_json(Type, Json) -> check_type(Type, Json). %% A list typed as iodata/iolist is only valid if it is a proper iolist. %% `iolist_to_binary/1` raises badarg on malformed input (e.g. `[-1]`, `[256]`), %% so catch it and report `false`, which the caller turns into a type_mismatch. iolist_to_json(Json) -> try {true, iolist_to_binary(Json)} catch error:badarg -> false end. check_type(integer, Json) when is_integer(Json) -> {true, Json}; check_type(boolean, Json) when is_boolean(Json) -> {true, Json}; check_type(float, Json) when is_float(Json) -> {true, Json}; check_type(number, Json) when is_integer(Json) orelse is_float(Json) -> {true, Json}; check_type(non_neg_integer, Json) when is_integer(Json) andalso Json >= 0 -> {true, Json}; check_type(pos_integer, Json) when is_integer(Json) andalso Json > 0 -> {true, Json}; check_type(neg_integer, Json) when is_integer(Json) andalso Json < 0 -> {true, Json}; check_type(binary, Json) when is_binary(Json) -> {true, Json}; check_type(nonempty_binary, Json) when is_binary(Json), byte_size(Json) > 0 -> {true, Json}; check_type(atom, Json) when is_atom(Json) -> {true, Json}; check_type(term, Json) -> {true, Json}; check_type(map, Json) when is_map(Json) -> {true, Json}; check_type(_Type, _Json) -> false. do_string_to_json(Type, Json) -> try unicode:characters_to_binary(Json) of {Err, _, _} when Err =:= error orelse Err =:= incomplete -> {error, [ sp_error:type_mismatch(#sp_simple_type{type = Type}, Json, #{ message => "non printable" }) ]}; Bin when is_binary(Bin) -> {true, Bin} catch error:badarg -> {error, [sp_error:type_mismatch(#sp_simple_type{type = Type}, Json)]} end. -spec check_string_params( Type :: #sp_simple_type{}, Params :: term(), Value :: dynamic() ) -> {ok, dynamic()} | {error, [spectra:error()]}. check_string_params(_Type, undefined, Value) -> {ok, Value}; check_string_params(Type, Params, Value) when is_map(Params) -> spectra_util:fold_until_error( fun ({min_length, MinLen}, V) when is_integer(MinLen), MinLen >= 0 -> case string:length(V) >= MinLen of true -> {ok, V}; false -> {error, [sp_error:type_mismatch(Type, V, #{min_length => MinLen})]} end; ({max_length, MaxLen}, V) when is_integer(MaxLen), MaxLen >= 0 -> case string:length(V) =< MaxLen of true -> {ok, V}; false -> {error, [sp_error:type_mismatch(Type, V, #{max_length => MaxLen})]} end; ({pattern, Pat}, V) when is_binary(Pat) -> case to_binary_for_pattern(Type, Pat, V) of {ok, Bin} -> try case re:run(Bin, Pat, [{capture, none}, unicode, ucp]) of match -> {ok, V}; nomatch -> {error, [sp_error:type_mismatch(Type, V, #{pattern => Pat})]}; {error, Reason} -> erlang:error({invalid_string_pattern, Pat, Reason}) end catch error:badarg -> erlang:error({invalid_string_pattern, Pat, badarg}) end; {error, _} = Err -> Err end; ({format, _Format}, V) -> %% format is schema-only metadata; no runtime validation {ok, V}; ({Key, Val}, _V) -> erlang:error({invalid_string_constraint, Key, Val}) end, Value, maps:to_list(Params) ); check_string_params(_Type, Params, _Value) when not is_map(Params) -> erlang:error({invalid_string_constraints, Params}). -spec to_binary_for_pattern( Type :: #sp_simple_type{}, Pat :: binary(), Value :: dynamic() ) -> {ok, binary()} | {error, [spectra:error()]}. to_binary_for_pattern(Type, Pat, V) when is_binary(V) -> case unicode:characters_to_binary(V) of Bin when is_binary(Bin) -> {ok, Bin}; _ -> {error, [sp_error:type_mismatch(Type, V, #{pattern => Pat})]} end; to_binary_for_pattern(Type, Pat, V) when is_list(V) -> case unicode:characters_to_binary(V) of Bin when is_binary(Bin) -> {ok, Bin}; _ -> {error, [sp_error:type_mismatch(Type, V, #{pattern => Pat})]} end. union(Fun, TypeInfo, #sp_union{types = Types} = T, Json, Config) -> case do_first(Fun, TypeInfo, Types, Json, Config, []) of {error, UnionErrors} -> {error, [sp_error:no_match(T, Json, UnionErrors)]}; Result -> Result end. do_first(_Fun, _TypeInfo, [], _Json, _Config, Errors) -> {error, Errors}; do_first(Fun, TypeInfo, [Type | Rest], Json, Config, ErrorsAcc) -> case Fun(TypeInfo, Type, Json, Config) of {ok, Result} -> {ok, Result}; {error, Errors} -> do_first(Fun, TypeInfo, Rest, Json, Config, [{Type, Errors} | ErrorsAcc]) end. -spec map_from_json( spectra:type_info(), #sp_map{}, json:decode_value(), spectra:sp_config() ) -> {ok, #{json:encode_value() => json:encode_value()}} | {error, [spectra:error()]}. map_from_json(TypeInfo, #sp_map{fields = MapFieldType, struct_name = StructName}, Json, Config) when is_map(Json) -> Defaults = case StructName of undefined -> undefined; _ -> StructName:'__struct__'() end, %% Phase 1: Process all literal fields, tracking consumed keys {LiteralFields, TypedFields} = lists:partition( fun (#literal_map_field{}) -> true; (_) -> false end, MapFieldType ), BuildResult = fun(AllFields) -> Decoded = maps:from_list(AllFields), case Defaults of undefined -> {ok, Decoded}; _ -> {ok, maps:merge(Defaults, Decoded)} end end, case spectra_util:fold_until_error( fun map_literal_field_from_json/3, {TypeInfo, Json, Defaults, Config}, {[], []}, LiteralFields ) of {ok, {LiteralMapFields, ConsumedKeys}} -> case TypedFields of [] -> BuildResult(LiteralMapFields); _ -> %% Phase 2: Process typed fields with remaining JSON entries RemainingJsonList = maps:to_list(maps:without(ConsumedKeys, Json)), case spectra_util:fold_until_error( fun map_typed_field_from_json/3, {TypeInfo, Config}, {[], RemainingJsonList}, TypedFields ) of {ok, {TypedMapFields, _FinalRemainingJsonList}} -> BuildResult(TypedMapFields ++ LiteralMapFields); {error, _} = Err -> Err end end; {error, _} = Err -> Err end; map_from_json(_TypeInfo, MapType, Json, _Config) -> %% Return error when Json is not a map {error, [sp_error:type_mismatch(MapType, Json)]}. -spec map_literal_field_from_json( {spectra:type_info(), map(), undefined | map(), spectra:sp_config()}, #literal_map_field{}, {[{atom() | integer(), dynamic()}], [binary()]} ) -> {ok, {[{atom() | integer(), dynamic()}], [binary()]}} | {error, [spectra:error()]}. map_literal_field_from_json( {TypeInfo, Json, Defaults, Config}, #literal_map_field{ kind = Kind, name = FieldName, binary_name = BinaryName, val_type = FieldType } = Type, {FieldsAcc, ConsumedKeys} ) -> case Json of #{BinaryName := FieldData} -> case do_from_json(TypeInfo, FieldType, FieldData, Config) of {ok, FieldJson} -> {ok, {[{FieldName, FieldJson} | FieldsAcc], [BinaryName | ConsumedKeys]}}; {error, Errs} -> {error, append_error_location(Errs, FieldName)} end; #{} -> case {Kind, spectra_type:can_be_missing(TypeInfo, FieldType)} of {assoc, _} -> {ok, {FieldsAcc, ConsumedKeys}}; {exact, {true, MissingValue}} when Defaults =:= undefined -> {ok, {[{FieldName, MissingValue} | FieldsAcc], ConsumedKeys}}; {exact, {true, _}} -> {ok, {FieldsAcc, ConsumedKeys}}; {exact, false} -> case struct_default_value(Defaults, FieldName) of {ok, _} -> {ok, {FieldsAcc, ConsumedKeys}}; error -> Remainder = maps:without(ConsumedKeys, Json), {error, [sp_error:missing_data(Type, Remainder, [FieldName])]} end end end. -spec map_typed_field_from_json( {spectra:type_info(), spectra:sp_config()}, #typed_map_field{}, {[{dynamic(), dynamic()}], [{json:encode_value(), json:decode_value()}]} ) -> {ok, {[{dynamic(), dynamic()}], [{json:encode_value(), json:decode_value()}]}} | {error, [spectra:error()]}. map_typed_field_from_json( {TypeInfo, Config}, #typed_map_field{kind = assoc, key_type = KeyType, val_type = ValueType}, {FieldsAcc, CurrentRemainingJsonList} ) -> case map_field_type_from_json_list( TypeInfo, KeyType, ValueType, CurrentRemainingJsonList, Config ) of {ok, {NewFields, NextRemainingJsonList}} -> {ok, {NewFields ++ FieldsAcc, NextRemainingJsonList}}; {error, Reason} -> {error, Reason} end; map_typed_field_from_json( {TypeInfo, Config}, #typed_map_field{kind = exact, key_type = KeyType, val_type = ValueType} = Type, {FieldsAcc, CurrentRemainingJsonList} ) -> case map_field_type_from_json_list( TypeInfo, KeyType, ValueType, CurrentRemainingJsonList, Config ) of {ok, {NewFields, NextRemainingJsonList}} -> case NewFields of [] -> Remainder = maps:from_list(CurrentRemainingJsonList), {error, [sp_error:not_matched_fields(Type, Remainder)]}; _ -> {ok, {NewFields ++ FieldsAcc, NextRemainingJsonList}} end; {error, _} = Err -> Err end. -spec field_default_value(#sp_rec_field{}) -> term(). field_default_value(#sp_rec_field{default = {value, V}}) -> V; field_default_value(#sp_rec_field{default = undefined}) -> undefined. -spec struct_default_value(undefined | map(), atom() | integer()) -> {ok, term()} | error. struct_default_value(undefined, _FieldName) -> error; struct_default_value(Defaults, FieldName) -> case maps:find(FieldName, Defaults) of {ok, V} when V =/= nil, V =/= undefined -> {ok, V}; _ -> error end. -spec append_error_location([spectra:error()], atom() | integer()) -> [spectra:error()]. append_error_location(Errors, FieldName) -> lists:map(fun(Err) -> sp_error:append_location(Err, FieldName) end, Errors). map_field_type_from_json_list(TypeInfo, KeyType, ValueType, JsonList, Config) -> spectra_util:fold_until_error( fun({Key, Value}, {FieldsAcc, RemainingAcc}) -> case do_from_json(TypeInfo, KeyType, Key, Config) of {ok, KeyJson} -> case do_from_json(TypeInfo, ValueType, Value, Config) of {ok, ValueJson} -> {ok, { [{KeyJson, ValueJson} | FieldsAcc], RemainingAcc }}; {error, Errs} -> Errs2 = lists:map( fun(Err) -> sp_error:append_location(Err, Key) end, Errs ), {error, Errs2} end; {error, _Errs} -> {ok, {FieldsAcc, [{Key, Value} | RemainingAcc]}} end end, {[], []}, JsonList ). -spec record_from_json( TypeInfo :: spectra:type_info(), RecordType :: #sp_rec{}, Json :: json:decode_value(), TypeArgs :: [spectra:record_field_arg()], Config :: spectra:sp_config() ) -> {ok, dynamic()} | {error, [spectra:error()]}. record_from_json( TypeInfo, #sp_rec{} = ARec, Json, TypeArgs, Config ) -> RecordInfo = spectra_util:record_replace_vars(ARec#sp_rec.fields, TypeArgs), NewRec = ARec#sp_rec{fields = RecordInfo}, do_record_from_json(TypeInfo, NewRec, Json, Config). -spec do_record_from_json( TypeInfo :: spectra:type_info(), #sp_rec{}, Json :: json:decode_value(), Config :: spectra:sp_config() ) -> {ok, dynamic()} | {error, [spectra:error()]}. do_record_from_json( TypeInfo, #sp_rec{name = RecordName, fields = RecordInfo, meta = Meta}, Json, Config ) when is_map(Json) -> Only = maps:get(only, Meta, all), Fun = fun( #sp_rec_field{name = FieldName, binary_name = BinaryName, type = FieldType} = Type, {FieldsAcc, ConsumedFields} ) -> IsIncluded = Only =:= all orelse lists:member(FieldName, Only), case IsIncluded of false -> {ok, {[field_default_value(Type) | FieldsAcc], ConsumedFields}}; true -> case Json of #{BinaryName := RecordFieldData} -> case do_from_json(TypeInfo, FieldType, RecordFieldData, Config) of {ok, FieldJson} -> {ok, {[FieldJson | FieldsAcc], [BinaryName | ConsumedFields]}}; {error, Errs} -> Errs2 = lists:map( fun(Err) -> sp_error:append_location(Err, FieldName) end, Errs ), {error, Errs2} end; #{} -> case spectra_type:can_be_missing(TypeInfo, FieldType) of {true, MissingValue} -> {ok, {[MissingValue | FieldsAcc], ConsumedFields}}; false -> RemainingJson = maps:without(ConsumedFields, Json), {error, [sp_error:missing_data(Type, RemainingJson, [FieldName])]} end end end end, case spectra_util:fold_until_error(Fun, {[], []}, RecordInfo) of {ok, {Fields, _}} -> {ok, list_to_tuple([RecordName | lists:reverse(Fields)])}; % TODO: Add config option to optionally error on extra fields {error, Errs} -> {error, Errs} end; do_record_from_json(_TypeInfo, Type, Json, _Config) -> {error, [sp_error:type_mismatch(Type, Json)]}.