-module(spectra_json_schema). -export([to_schema/2]). -ignore_xref([to_schema/2]). -include("../include/spectra.hrl"). -include("../include/spectra_internal.hrl"). %% API -spec to_schema(module() | spectra:type_info(), spectra:sp_type_or_ref()) -> {ok, Schema :: map()} | {error, [spectra:error()]}. to_schema(Module, Type) when is_atom(Module) -> TypeInfo = spectra_module_types:get(Module), to_schema(TypeInfo, Type); %% Type references to_schema(TypeInfo, {type, TypeName, TypeArity}) when is_atom(TypeName) -> Type = spectra_type_info:get_type(TypeInfo, TypeName, TypeArity), TypeWithoutVars = apply_args(TypeInfo, Type, []), add_schema_version(do_to_schema(TypeInfo, TypeWithoutVars)); to_schema(TypeInfo, Type) -> add_schema_version(do_to_schema(TypeInfo, Type)). -spec do_to_schema( TypeInfo :: spectra:type_info(), Type :: spectra:sp_type_or_ref() ) -> {ok, Schema :: map()} | {error, [spectra:error()]}. %% Simple types do_to_schema(_TypeInfo, #sp_simple_type{type = integer}) -> {ok, #{type => <<"integer">>}}; do_to_schema(_TypeInfo, #sp_simple_type{type = string}) -> {ok, #{type => <<"string">>}}; do_to_schema(_TypeInfo, #sp_simple_type{type = iodata}) -> {ok, #{type => <<"string">>}}; do_to_schema(_TypeInfo, #sp_simple_type{type = iolist}) -> {ok, #{type => <<"string">>}}; do_to_schema(_TypeInfo, #sp_simple_type{type = boolean}) -> {ok, #{type => <<"boolean">>}}; do_to_schema(_TypeInfo, #sp_simple_type{type = number}) -> {ok, #{type => <<"number">>}}; do_to_schema(_TypeInfo, #sp_simple_type{type = float}) -> {ok, #{type => <<"number">>, format => <<"float">>}}; do_to_schema(_TypeInfo, #sp_simple_type{type = atom}) -> {ok, #{type => <<"string">>}}; do_to_schema(_TypeInfo, #sp_simple_type{type = binary}) -> {ok, #{type => <<"string">>}}; do_to_schema(_TypeInfo, #sp_simple_type{type = nonempty_binary}) -> {ok, #{type => <<"string">>, minLength => 1}}; do_to_schema(_TypeInfo, #sp_simple_type{type = nonempty_string}) -> {ok, #{type => <<"string">>, minLength => 1}}; do_to_schema(_TypeInfo, #sp_simple_type{type = pos_integer}) -> {ok, #{type => <<"integer">>, minimum => 1}}; do_to_schema(_TypeInfo, #sp_simple_type{type = non_neg_integer}) -> {ok, #{type => <<"integer">>, minimum => 0}}; do_to_schema(_TypeInfo, #sp_simple_type{type = neg_integer}) -> {ok, #{type => <<"integer">>, maximum => -1}}; do_to_schema(_TypeInfo, #sp_simple_type{type = term}) -> % any type {ok, #{}}; do_to_schema(_TypeInfo, #sp_simple_type{type = map}) -> % generic map type - allows any keys and values {ok, #{type => <<"object">>}}; %% Range types do_to_schema( _TypeInfo, #sp_range{ type = integer, lower_bound = Min, upper_bound = Max } ) -> {ok, #{ type => <<"integer">>, minimum => Min, maximum => Max }}; %% Literal types do_to_schema(_TypeInfo, #sp_literal{value = Value}) when Value =:= undefined orelse Value =:= nil -> {ok, #{enum => [null]}}; do_to_schema(_TypeInfo, #sp_literal{value = Value, binary_value = BinaryValue}) when is_atom(Value) -> {ok, #{enum => [BinaryValue]}}; do_to_schema(_TypeInfo, #sp_literal{value = Value}) -> {ok, #{enum => [Value]}}; %% List types do_to_schema(TypeInfo, #sp_list{type = ItemType}) -> case do_to_schema(TypeInfo, ItemType) of {ok, ItemSchema} -> {ok, #{type => <<"array">>, items => ItemSchema}}; {error, _} = Err -> Err end; do_to_schema(TypeInfo, #sp_nonempty_list{type = ItemType}) -> case do_to_schema(TypeInfo, ItemType) of {ok, ItemSchema} -> {ok, #{ type => <<"array">>, items => ItemSchema, minItems => 1 }}; {error, _} = Err -> Err end; %% Union types do_to_schema(TypeInfo, #sp_union{types = Types}) -> case lists:partition( fun (#sp_literal{value = Value}) when Value =:= undefined orelse Value =:= nil -> true; (_) -> false end, Types ) of {[_MissingLiteral], [SingleType]} -> do_to_schema(TypeInfo, SingleType); {[], NonMissingTypes} -> case try_generate_enum_schema(NonMissingTypes, TypeInfo) of {ok, _} = EnumSchema -> EnumSchema; not_all_literals -> generate_oneof_schema(TypeInfo, NonMissingTypes) end; {[_MissingLiteral], OtherTypes} when length(OtherTypes) > 1 -> case try_generate_enum_schema(OtherTypes, TypeInfo) of {ok, _} = EnumSchema -> EnumSchema; not_all_literals -> generate_oneof_schema(TypeInfo, Types) end end; %% Map types do_to_schema(TypeInfo, #sp_map{fields = Fields}) -> map_fields_to_schema(TypeInfo, Fields); %% Record types do_to_schema(TypeInfo, {record, RecordName}) when is_atom(RecordName) -> record_to_schema_internal(TypeInfo, RecordName); do_to_schema(TypeInfo, #sp_rec{} = RecordInfo) -> record_to_schema_internal(TypeInfo, RecordInfo); %% Record references do_to_schema(TypeInfo, #sp_rec_ref{record_name = RecordName}) -> record_to_schema_internal(TypeInfo, RecordName); %% User type references do_to_schema(TypeInfo, #sp_user_type_ref{type_name = TypeName, variables = TypeArgs}) -> TypeArity = length(TypeArgs), Type = spectra_type_info:get_type(TypeInfo, TypeName, TypeArity), TypeWithoutVars = apply_args(TypeInfo, Type, TypeArgs), do_to_schema(TypeInfo, TypeWithoutVars); %% Remote types do_to_schema(_TypeInfo, #sp_remote_type{mfargs = {Module, TypeName, Args}}) -> TypeInfo = spectra_module_types:get(Module), TypeArity = length(Args), Type = spectra_type_info:get_type(TypeInfo, TypeName, TypeArity), TypeWithoutVars = apply_args(TypeInfo, Type, Args), do_to_schema(TypeInfo, TypeWithoutVars); %% Unsupported types do_to_schema(_TypeInfo, #sp_simple_type{type = NotSupported} = Type) 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}); do_to_schema(_TypeInfo, #sp_tuple{} = Type) -> erlang:error({type_not_supported, Type}); do_to_schema(_TypeInfo, #sp_function{} = Type) -> erlang:error({type_not_supported, Type}); do_to_schema(_TypeInfo, #sp_maybe_improper_list{} = Type) -> erlang:error({type_not_supported, Type}); do_to_schema(_TypeInfo, #sp_nonempty_improper_list{} = Type) -> erlang:error({type_not_supported, Type}); %% Fallback do_to_schema(_TypeInfo, Type) -> {error, [ #sp_error{ type = no_match, location = [], ctx = #{type => Type} } ]}. %% Helper functions %% Check if a type can be used as a JSON object key (must be string-like) -spec can_be_json_key(spectra:type_info(), spectra:sp_type()) -> boolean(). can_be_json_key(_TypeInfo, #sp_simple_type{type = Type}) when Type =:= string orelse Type =:= binary orelse Type =:= nonempty_string orelse Type =:= nonempty_binary orelse Type =:= atom -> true; can_be_json_key(_TypeInfo, #sp_literal{value = Value}) when is_atom(Value) -> true; can_be_json_key(TypeInfo, #sp_union{types = Types}) -> lists:all(fun(T) -> can_be_json_key(TypeInfo, T) end, Types); can_be_json_key(TypeInfo, #sp_user_type_ref{type_name = TypeName, variables = TypeArgs}) -> TypeArity = length(TypeArgs), Type = spectra_type_info:get_type(TypeInfo, TypeName, TypeArity), TypeWithoutVars = apply_args(TypeInfo, Type, TypeArgs), can_be_json_key(TypeInfo, TypeWithoutVars); can_be_json_key(_TypeInfo, _Type) -> false. %% Add JSON Schema version to the schema -spec add_schema_version({ok, map()} | {error, [spectra:error()]}) -> {ok, map()} | {error, [spectra:error()]}. add_schema_version({ok, Schema}) -> {ok, Schema#{<<"$schema">> => <<"https://json-schema.org/draft/2020-12/schema">>}}; add_schema_version({error, _} = Error) -> Error. arg_names(#sp_type_with_variables{vars = Args}) -> Args; arg_names(_) -> []. apply_args(TypeInfo, Type, TypeArgs) when is_list(TypeArgs) -> ArgNames = arg_names(Type), NamedTypes = maps:from_list( lists:zip(ArgNames, TypeArgs) ), spectra_util:type_replace_vars(TypeInfo, Type, NamedTypes). -spec map_fields_to_schema(spectra:type_info(), [spectra:map_field()]) -> {ok, map()} | {error, [spectra:error()]}. map_fields_to_schema(TypeInfo, Fields) -> case process_map_fields(TypeInfo, Fields, #{}, [], false) of {ok, Properties, Required, HasAdditional} -> Schema = lists:foldl( fun({Key, Value, SkipValue}, Acc) -> map_add_if_not_value(Acc, Key, Value, SkipValue) end, #{type => <<"object">>, additionalProperties => HasAdditional}, [{properties, Properties, #{}}, {required, Required, []}] ), {ok, Schema}; {error, _} = Err -> Err end. -spec process_map_fields( spectra:type_info(), [spectra:map_field()], map(), [binary()], boolean() ) -> {ok, map(), [binary()], boolean()} | {error, [spectra:error()]}. process_map_fields(_TypeInfo, [], Properties, Required, HasAdditional) -> {ok, Properties, Required, HasAdditional}; process_map_fields( TypeInfo, [#literal_map_field{kind = Kind, binary_name = BinaryName, val_type = FieldType} | Rest], Properties, Required, HasAdditional ) -> case do_to_schema(TypeInfo, FieldType) of {ok, FieldSchema} -> NewProperties = maps:put(BinaryName, FieldSchema, Properties), NewRequired = case Kind of exact -> [BinaryName | Required]; assoc -> Required end, process_map_fields(TypeInfo, Rest, NewProperties, NewRequired, HasAdditional); {error, _} = Err -> Err end; process_map_fields( TypeInfo, [#typed_map_field{key_type = KeyType, val_type = ValType} = Field | Rest], Properties, Required, _HasAdditional ) -> case can_be_json_key(TypeInfo, KeyType) of false -> erlang:error({type_not_supported, Field}); true -> validate_typed_map_field_schema(TypeInfo, KeyType, ValType, Rest, Properties, Required) end. validate_typed_map_field_schema(TypeInfo, KeyType, ValType, Rest, Properties, Required) -> case do_to_schema(TypeInfo, KeyType) of {error, _} = Err -> Err; {ok, _} -> case do_to_schema(TypeInfo, ValType) of {error, _} = Err -> Err; {ok, _} -> process_map_fields(TypeInfo, Rest, Properties, Required, true) end end. -spec record_to_schema_internal(spectra:type_info(), atom() | #sp_rec{}) -> {ok, map()} | {error, [spectra:error()]}. record_to_schema_internal(TypeInfo, RecordName) when is_atom(RecordName) -> case spectra_type_info:find_record(TypeInfo, RecordName) of {ok, RecordInfo} -> record_to_schema_internal(TypeInfo, RecordInfo); error -> erlang:error({record_not_found, RecordName}) end; record_to_schema_internal(TypeInfo, #sp_rec{fields = Fields}) -> case process_record_fields(TypeInfo, Fields, #{}, []) of {ok, Properties, Required} -> Schema = #{ type => <<"object">>, properties => Properties, required => Required }, {ok, Schema}; {error, _} = Err -> Err end. -spec process_record_fields( spectra:type_info(), [#sp_rec_field{}], map(), [binary()] ) -> {ok, map(), [binary()]} | {error, [spectra:error()]}. process_record_fields(_TypeInfo, [], Properties, Required) -> {ok, Properties, lists:reverse(Required)}; process_record_fields( TypeInfo, [#sp_rec_field{binary_name = BinaryName, type = FieldType} | Rest], Properties, Required ) -> case do_to_schema(TypeInfo, FieldType) of {ok, FieldSchema} -> NewProperties = Properties#{BinaryName => FieldSchema}, NewRequired = case spectra_type:can_be_missing(TypeInfo, FieldType) of {true, _} -> Required; false -> [BinaryName | Required] end, process_record_fields(TypeInfo, Rest, NewProperties, NewRequired); {error, _} = Err -> Err end. %% Helper function to generate oneOf schemas generate_oneof_schema(TypeInfo, Types) -> case spectra_util:fold_until_error( fun(T, Acc) -> case do_to_schema(TypeInfo, T) of {ok, Schema} -> {ok, [Schema | Acc]}; {error, _} = Err -> Err end end, [], Types ) of {ok, Schemas} -> {ok, #{oneOf => lists:reverse(Schemas)}}; {error, _} = Err -> Err end. try_generate_enum_schema(Types, TypeInfo) -> %% First, expand all types to their base forms (resolving references) ExpandResults = lists:map(fun(T) -> expand_to_literals(T, TypeInfo) end, Types), %% Check if all types could be expanded to literals case lists:all( fun ({ok, _}) -> true; (_) -> false end, ExpandResults ) of false -> not_all_literals; true -> %% Flatten all the literal lists ExpandedTypes = lists:flatmap( fun ({ok, Lits}) -> Lits; (_) -> [] end, ExpandResults ), %% Now try to extract literal values from all expanded types Enums = spectra_util:map_until_error( fun(Type) -> extract_literal_value(Type) end, ExpandedTypes ), case Enums of {error, not_all_literals} -> not_all_literals; {ok, EnumValues} -> JsonType = infer_json_type(ExpandedTypes), case JsonType of undefined -> {ok, #{enum => EnumValues}}; Type -> {ok, #{type => Type, enum => EnumValues}} end end end. %% Expand a type to a list of literal types, resolving references and unions -spec expand_to_literals(spectra:sp_type(), spectra:type_info() | undefined) -> {ok, [spectra:sp_type()]} | {error, not_all_literals}. expand_to_literals(#sp_literal{} = Literal, _TypeInfo) -> {ok, [Literal]}; %% Resolve remote types expand_to_literals(#sp_remote_type{mfargs = {Module, TypeName, Args}}, _TypeInfo) -> RemoteTypeInfo = spectra_module_types:get(Module), TypeArity = length(Args), Type = spectra_type_info:get_type(RemoteTypeInfo, TypeName, TypeArity), TypeWithoutVars = apply_args(RemoteTypeInfo, Type, Args), expand_to_literals(TypeWithoutVars, RemoteTypeInfo); %% Resolve user type references expand_to_literals(#sp_user_type_ref{type_name = TypeName, variables = TypeArgs}, TypeInfo) when TypeInfo =/= undefined -> TypeArity = length(TypeArgs), Type = spectra_type_info:get_type(TypeInfo, TypeName, TypeArity), TypeWithoutVars = apply_args(TypeInfo, Type, TypeArgs), expand_to_literals(TypeWithoutVars, TypeInfo); %% Flatten unions - all members must expand to literals expand_to_literals(#sp_union{types = UnionTypes}, TypeInfo) -> Results = lists:map(fun(T) -> expand_to_literals(T, TypeInfo) end, UnionTypes), case lists:all( fun ({ok, _}) -> true; (_) -> false end, Results ) of true -> AllLiterals = lists:flatmap( fun ({ok, Lits}) -> Lits; (_) -> [] end, Results ), {ok, AllLiterals}; false -> {error, not_all_literals} end; %% Anything else cannot be expanded to literals expand_to_literals(_Type, _TypeInfo) -> {error, not_all_literals}. %% Helper to extract literal value from a type (non-recursive, only handles direct literals) -spec extract_literal_value(spectra:sp_type()) -> {ok, term()} | {error, not_all_literals}. extract_literal_value(#sp_literal{value = Value}) when Value =:= undefined orelse Value =:= nil -> {ok, null}; extract_literal_value(#sp_literal{value = Value}) when Value =:= true orelse Value =:= false -> {ok, Value}; extract_literal_value(#sp_literal{value = Value, binary_value = BinaryValue}) when is_atom(Value) -> {ok, BinaryValue}; extract_literal_value(#sp_literal{value = Value}) when is_integer(Value) -> {ok, Value}; extract_literal_value(_Type) -> {error, not_all_literals}. infer_json_type(Types) -> JsonTypes = lists:map(fun literal_to_json_type/1, Types), case lists:usort(JsonTypes) of [SingleType] -> SingleType; _ -> undefined end. literal_to_json_type(#sp_literal{value = Value}) when Value =:= undefined orelse Value =:= nil -> null; literal_to_json_type(#sp_literal{value = Value}) when Value =:= true orelse Value =:= false -> <<"boolean">>; literal_to_json_type(#sp_literal{value = Value}) when is_integer(Value) -> <<"integer">>; literal_to_json_type(#sp_literal{value = Value}) when is_atom(Value) -> <<"string">>. %% Helper function to conditionally add key-value pairs to a map -spec map_add_if_not_value(Map, Key, Value, SkipValue) -> Map when Map :: map(), Key :: term(), Value :: term(), SkipValue :: term(). map_add_if_not_value(Map, _Key, Value, SkipValue) when Value =:= SkipValue -> Map; map_add_if_not_value(Map, Key, Value, _SkipValue) -> Map#{Key => Value}.