Skip to content

Instantly share code, notes, and snippets.

@maxlapshin
Created April 23, 2025 08:09
Show Gist options
  • Save maxlapshin/35589b30cc9b13a08cbef84d188b2311 to your computer and use it in GitHub Desktop.
Save maxlapshin/35589b30cc9b13a08cbef84d188b2311 to your computer and use it in GitHub Desktop.
openapi
-module(c2_schema).
-include_lib("kernel/include/logger.hrl").
-export([json2config/1, json2config/2]).
-record(ctx, {
drop_unknown,
fill_defaults,
allow_miss_mandatory,
allow_forced_undefined,
validate,
add_compat,
records = #{},
validators = #{}
}).
-type options() :: #{
drop_unknown => boolean(),
add_position => boolean(),
validate => boolean(),
type_name => atom(),
add_compat => boolean(),
allow_miss_mandatory => boolean(),
allow_forced_undefined => boolean(),
fill_defaults => boolean(),
_ => _
}.
-spec json2config(config2:json()|tuple()) -> config2:config() | {error, config2:error()}.
json2config(Object) ->
json2config(Object, #{}).
-spec json2config(config2:json()|tuple(), options()) -> config2:config() | {error, config2:error()}.
json2config(Object, #{} = Options) ->
RootType = maps:get(type_name, Options, server_config),
Ctx = #ctx{
drop_unknown = maps:get(drop_unknown, Options, false),
fill_defaults = maps:get(fill_defaults, Options, false),
allow_miss_mandatory = maps:get(allow_miss_mandatory, Options, false),
allow_forced_undefined = maps:get(allow_forced_undefined, Options, false),
validate = maps:get(validate, Options, true),
add_compat = maps:get(add_compat, Options, false),
records = maps:get(records, Options, #{}),
validators = (server_config:validators())#{
hexbinary => fun server_config:hexbinary/1,
query_session_key => fun server_config:session_key_query/1,
cidr => fun server_config:cidr/1,
url => fun server_config:url/1,
auth_url => fun server_config:auth_url/1,
agent_url => fun server_config:agent_url/1,
input_url => fun server_config:input_url/1,
push_url => fun server_config:push_url/1,
dvr_url => fun server_config:disk_path/1
}
},
Result1 = visit(Ctx, Object, config2_schema:schema(RootType), [RootType]),
AddPosition = maps:get(add_position, Options, false),
Result2 = case AddPosition of
true ->
NewResult1 = enumerate(vods, Result1),
enumerate(streams, NewResult1);
false ->
Result1
end,
Result2.
visit(#ctx{records = Records} = Ctx, Object0, #{type := object} = Spec, Path) ->
Object = case Object0 of
#{} -> Object0;
_ when is_tuple(Object0) andalso is_map_key(element(1,Object0),Records) -> record2json(Ctx,Object0);
_ -> Object0
end,
Nullable = maps:get(nullable, Spec, false),
case Object of
#{} ->
add_compat(Ctx, visit_object(Ctx, Object, Spec, Path), Spec);
_ when (Object == undefined orelse Object == null) andalso
(Ctx#ctx.allow_forced_undefined orelse Nullable) -> undefined;
_ when is_tuple(Object) ->
{error, #{error => non_converted_record, path => Path, what => element(1,Object)}};
_ ->
{error, #{error => non_map_input, path => Path, what => Object}}
end;
visit(Ctx, Value, #{anyOf := Types}, Path) ->
visit_anyOf(Ctx, Value, Types, Path);
visit(#ctx{allow_miss_mandatory = true}, undefined, #{oneOf := _}, _Path) ->
undefined;
visit(Ctx, Value, #{oneOf := Types, discriminator := #{propertyName := DKey, mapping := DMap}}, Path) when is_map(Value) orelse is_tuple(Value) ->
%% Загадочно. Это делалось для того, что в зависимости от контента в track_info оставлялись разные поля.
%% В api_v3_handler:postprocess приходит уже провалидированная и отфильтрованная структура media_info в виде мапа (видно, что там уже нет pix_fmt)
%% Но до этой функции (visit) доходит опять рекорд #track_info{}, и вся фильтрация-валидация делается ещё раз, но другим кодом.
%% FIXME: разобраться в том, когда какой код вызывается на самом деле, и как можно убрать дублирующуюся логику.
% Приведём дискриминатор в окончательный вид, для этого найдем значение
% дискриминатора по ключу (или по ключу в бинарном виде) в Value и
% прогоним это значение через один из типов. На выходе получим дискриминатор
% в обработанном виде и по нему найдём верный тип в mapping
BinDKey = is_atom(DKey) andalso atom_to_binary(DKey),
Try0 = case Value of
#{DKey := Undef} when Undef == undefined orelse Undef == null ->
visit(Ctx#ctx{fill_defaults = true, validate = false}, #{}, hd(Types), Path);
#{<<BinDKey/binary>> := Undef} when Undef == undefined orelse Undef == null ->
visit(Ctx#ctx{fill_defaults = true, validate = false}, #{}, hd(Types), Path);
#{DKey := InDValue} ->
visit(Ctx#ctx{fill_defaults = true, validate = false}, #{DKey => InDValue}, hd(Types), Path);
#{<<BinDKey/binary>> := InDValue} ->
visit(Ctx#ctx{fill_defaults = true, validate = false}, #{DKey => InDValue}, hd(Types), Path);
#{} -> % в этом месте, попробуем подобрать дефолт для discriminator, когда в описании нет propertyName
visit(Ctx#ctx{fill_defaults = true, validate = false}, Value, hd(Types), Path);
_ ->
% Старое поведение. Сюда попадают Value = {track_info, ...}
% Это только ради приведения к мапу, в котором можно искать ключ-дискриминатор
visit_anyOf(Ctx, Value, Types, Path)
end,
case Try0 of
#{DKey := DValue} ->
case DMap of
#{DValue := DType} ->
visit(Ctx, Value, #{'$ref' => DType}, Path);
_ ->
{error, #{error => discriminator_unmapped, path => Path, what => DValue}}
end;
{error, _} = Error ->
Error;
_ ->
{error, #{error => discriminator_missing, path => Path, what => DKey}}
end;
visit(Ctx, Value, #{oneOf := Types, 'x-pattern-discriminator' := BinDKey} = Spec, Path) ->
DKey = binary_to_atom(BinDKey, latin1),
case Value of
#{DKey := InDValue} ->
visit_anyOf_with_pattern_discriminator(Ctx, Value, Types, Path, DKey, BinDKey, InDValue);
#{<<BinDKey/binary>> := InDValue} ->
visit_anyOf_with_pattern_discriminator(Ctx, Value, Types, Path, DKey, BinDKey, InDValue);
% Если нет данных по 'x-pattern-discriminator', то тут 2 варианта, либо забыли указать, либо это возможны специальные сервисные данные(delta)
% oneOf без `x-pattern-discriminator` либо вернет, что discriminator required(так как в схеме он должен быть required), либо пройдет без ошибок
_ ->
visit(Ctx, Value, maps:remove('x-pattern-discriminator', Spec), Path)
end;
visit(Ctx, Value, #{oneOf := Types}, Path) ->
visit_anyOf(Ctx, Value, Types, Path);
visit(Ctx, Object, #{'$ref' := <<"#/components/schemas/",RefType/binary>>} = Spec, Path) ->
Type = binary_to_atom(RefType,latin1),
Spec1 = maps:merge(maps:with([nullable],Spec),config2_schema:schema(Type)),
% если на ref_type есть валидатор - запустим его
case Ctx#ctx.validators of
_ when (Object == undefined orelse Object == null) andalso Ctx#ctx.allow_forced_undefined ->
visit(Ctx, Object, Spec1, Path);
#{Type := Validator} ->
case Validator(Object) of
{ok, Object1} -> visit(Ctx, Object1, Spec1, Path);
{error, E} -> {error, E#{path => Path}}
end;
_ ->
visit(Ctx, Object, Spec1, Path)
end;
visit(Ctx, Value, #{enum := Values, type := string}, Path) ->
case [V || V <- Values, V == Value orelse atom_to_binary(V,latin1) == Value] of
[V1] -> V1;
[] when (Value == undefined orelse Value == null) andalso Ctx#ctx.allow_forced_undefined -> undefined;
[] when is_binary(Value); is_atom(Value) -> {error, #{path => Path, error => unenumerated_value, what => Value}};
[] -> {error, #{path => Path, error => unenumerated_value}}
end;
visit(Ctx, Value, #{enum := Values, type := integer} = Spec, Path) ->
Nullable = maps:get(nullable, Spec, false),
case lists:member(Value, Values) of
true -> Value;
false when (Value == undefined orelse Value == null) andalso
(Ctx#ctx.allow_forced_undefined orelse Nullable) -> undefined;
false -> {error, #{path => Path, error => unenumerated_value}}
end;
visit(Ctx, List, #{type := array, items := Spec} = ArraySpec, Path) ->
Nullable = maps:get(nullable, Spec, false),
MaxItems = maps:get(maxItems, ArraySpec, undefined),
case List of
_ when is_list(List) andalso is_integer(MaxItems) andalso length(List) > MaxItems ->
{error, #{path => Path, error => too_many_items}};
_ when is_list(List) ->
visit_array(Ctx, List, Spec, Path, []);
_ when (List == undefined orelse List == null) andalso
(Ctx#ctx.allow_forced_undefined orelse Nullable) ->
undefined;
_ ->
{error, #{path => Path, error => non_list}}
end;
visit(Ctx, Int, #{type := integer} = Spec, Path) ->
Min = maps:get(minimum,Spec,undefined),
Max = maps:get(maximum,Spec,undefined),
Nullable = maps:get(nullable, Spec, false),
case Int of
_ when Int - Min < 0 -> {error, #{path => Path, error => too_small, what => Int}};
_ when Int - Max > 0 -> {error, #{path => Path, error => too_big, what => Int}};
_ when is_integer(Int) -> Int;
_ when (Int == undefined orelse Int == null) andalso
(Ctx#ctx.allow_forced_undefined orelse Nullable) -> undefined;
_ -> {error, #{path => Path, error => non_integer, what => Int}}
end;
visit(Ctx, Num, #{type := number} = Spec, Path) ->
Min = maps:get(minimum,Spec,undefined),
Max = maps:get(maximum,Spec,undefined),
Nullable = maps:get(nullable, Spec, false),
case Num of
_ when Num - Min < 0 -> {error, #{path => Path, error => too_small, what => Num}};
_ when Num - Max > 0 -> {error, #{path => Path, error => too_big, what => Num}};
_ when is_integer(Num) -> Num;
_ when is_number(Num) -> Num;
_ when (Num == undefined orelse Num == null) andalso
(Ctx#ctx.allow_forced_undefined orelse Nullable) -> undefined;
_ -> {error, #{path => Path, error => non_number}}
end;
visit(Ctx, Bool, #{type := boolean} = Spec, Path) ->
Nullable = maps:get(nullable, Spec, false),
case Bool of
true -> true;
false -> false;
_ when (Bool == undefined orelse Bool == null) andalso
(Ctx#ctx.allow_forced_undefined orelse Nullable) -> undefined;
_ -> {error, #{path => Path, error => non_boolean_value, what => Bool}}
end;
visit(_Ctx, Value, #{const := Value}, _Path) ->
Value;
visit(Ctx, Str, #{const := Value}, Path) when is_atom(Value) ->
case atom_to_binary(Value,latin1) of
Str -> Value;
_ when (Str == undefined orelse Str == null) andalso Ctx#ctx.allow_forced_undefined -> undefined;
_ when is_binary(Str); is_atom(Str) -> {error, #{path => Path, error => unenumerated_value, what => Str}};
_ -> {error, #{path => Path, error => unenumerated_value}}
end;
visit(Ctx, Str, #{const := Value}, Path) when is_integer(Value) ->
case integer_to_binary(Value) of
Str -> Str;
_ when (Str == undefined orelse Str == null) andalso Ctx#ctx.allow_forced_undefined -> undefined;
_ when is_binary(Str); is_atom(Str) -> {error, #{path => Path, error => unenumerated_value, what => Str}};
_ -> {error, #{path => Path, error => unenumerated_value}}
end;
visit(_Ctx, Str, #{type := atom} = Spec, _Path) ->
Nullable = maps:get(nullable, Spec, false),
case Str of
_ when is_binary(Str) -> binary_to_atom(Str,latin1);
undefined when Nullable -> undefined;
undefined when not Nullable -> {error, #{error => non_atom_value, what => Str}};
_ when is_atom(Str) -> Str;
_ -> {error, #{error => non_atom_value, what => Str}}
end;
visit(Ctx = #ctx{validators = Validators}, Str, #{type := string} = Spec, Path) ->
Format = maps:get(format, Spec, undefined),
Pattern = maps:get(pattern, Spec, undefined),
Validator = maps:get(Format, Validators, undefined),
Nullable = maps:get(nullable, Spec, false),
MinLength = maps:get(minLength, Spec, undefined),
MaxLength = maps:get(maxLength, Spec, undefined),
Type = case Spec of
#{'x-atom' := true} -> atom;
_ -> binary
end,
Str1 = case Type of
atom when is_binary(Str) -> binary_to_atom(Str,latin1);
binary when is_atom(Str) -> atom_to_binary(Str,latin1);
_ -> Str
end,
try
case Str of
_ when (Str == undefined orelse Str == null) andalso
(Ctx#ctx.allow_forced_undefined orelse Nullable) -> throw({ok, undefined});
_ ->
ok
end,
Str2 = case Str1 of
_ when is_binary(Str1) andalso Validator =/= undefined ->
case Validator(Str1) of
{ok, Str1_} -> Str1_;
{error, E} -> throw({error, E#{path => Path}})
end;
_ ->
Str1
end,
case Str2 of
_ when is_binary(Str2) andalso Pattern =/= undefined ->
case re:run(Str2, Pattern) of
{match, _} -> Str2;
nomatch -> throw({error, #{error => nomatch_pattern, path => Path}})
end;
_ ->
ok
end,
case Str2 of
_ when is_binary(Str2) andalso is_integer(MinLength) andalso size(Str2) < MinLength ->
throw({error, #{error => too_short, path => Path}});
_ ->
ok
end,
case Str2 of
_ when is_binary(Str2) andalso is_integer(MaxLength) andalso size(Str2) > MaxLength ->
throw({error, #{error => too_long, path => Path}});
_ ->
ok
end,
case Str2 of
_ when is_binary(Str2) andalso Type == binary ->
Str2;
_ when is_atom(Str2) andalso Type == atom ->
Str2;
_ ->
{error, #{path => Path, error => non_binary, what => Str, spec => Spec}}
end
catch
throw:{ok, Result} ->
Result;
throw:{error, E0} ->
{error, E0}
end;
visit(_Ctx, Any, #{} = Spec, _Path) when not is_map_key(type,Spec) ->
Any.
visit_object(Ctx, Object, #{properties := Props} = ObjectSpec, Path) ->
KeysProps = [begin
KeyA = case Key of
_ when is_binary(Key) -> binary_to_atom(Key,latin1);
_ -> Key
end,
case Props of
#{Key := S} -> {Key,Value,S};
#{KeyA := S} -> {KeyA,Value,S};
_ -> {Key,Value,undefined}
end
end || {Key,Value} <- maps:to_list(Object)],
Default = case Ctx#ctx.fill_defaults of
true ->
Prefilled = lists:flatmap(fun({K,Spec}) ->
XDefault = case maps:get('x-default',Spec,undefined) of
undefined -> undefined;
#{'$ref' := _} -> #{}
end,
V = maps:get(default,Spec,XDefault),
K_bin = atom_to_binary(K,latin1),
case Object of
#{K := _} -> [];
#{K_bin := _} -> [];
#{} when V =/= undefined -> [{K,V}];
_ -> []
end
end, maps:to_list(Props)),
maps:from_list(Prefilled);
false ->
#{}
end,
case visit_object_fields(Ctx, KeysProps, Default, Path) of
{error, E} ->
{error, E};
#{} = NewObject when Ctx#ctx.validate == false ->
NewObject;
#{} = NewObject when Ctx#ctx.allow_miss_mandatory == true ->
NewObject;
#{} = NewObject ->
RequiredProps = maps:get(required, ObjectSpec, []),
Missing = [K || K <- RequiredProps, not maps:is_key(K,NewObject)],
case Missing of
[] ->
NewObject;
[_|_] ->
{error, #{error => lacks_mandatory, path => Path, what => Missing}}
end
end;
visit_object(_Ctx, Object, #{additionalProperties := #{}, maxItems := MaxItems}, Path) when map_size(Object) > MaxItems ->
{error, #{error => too_many_items, path => Path}};
visit_object(Ctx, Object, #{additionalProperties := #{} = Spec} = FullSpec, Path) ->
KeyTypeSpec = case FullSpec of
#{'x-key-type' := <<"string">>} ->
#{type => string};
#{'x-key-type' := Other} ->
case config2_schema:schema(binary_to_atom(Other,latin1)) of
undefined -> #{type => string};
#{} = OtherSpec -> OtherSpec
end;
#{} ->
#{type => atom}
end,
KeysProps = [begin
Key1 = case FullSpec of
% Этот хак только для #27111 для случая, когда мы удачно спроектировали
% число как ключ для объекта, что недопустимо в жсоне
#{'x-key-type' := <<"network_port">>} when is_binary(Key) ->
case c2_reader:to_int(Key) of
{ok, _, IntVal} -> IntVal;
_ -> Key
end;
_ ->
Key
end,
{visit(Ctx, Key1, KeyTypeSpec, Path),Value,Spec}
end || {Key,Value} <- maps:to_list(Object)],
visit_object_fields(Ctx, KeysProps, #{}, Path);
visit_object(_Ctx, #{} = Object, #{}, _Path) ->
Object.
visit_object_fields(_Ctx, [{{error, E},_Value,_Spec}|_], _, _Path) ->
{error, E};
visit_object_fields(Ctx, [{Key,Value,Spec}|Keys], Object, Path) ->
case Spec of
undefined when (Key == '$delete' orelse Key == <<"$delete">>) andalso Value == true ->
visit_object_fields(Ctx, Keys, Object#{to_a(Key) => Value}, Path);
undefined when (Key == '$reset' orelse Key == <<"$reset">>) andalso Value == true ->
visit_object_fields(Ctx, Keys, Object#{to_a(Key) => Value}, Path);
undefined when (Key == '$index' orelse Key == <<"$index">>) andalso is_integer(Value) ->
visit_object_fields(Ctx, Keys, Object#{to_a(Key) => Value}, Path);
undefined when (Key == '$position' orelse Key == <<"$position">>) andalso is_integer(Value) ->
visit_object_fields(Ctx, Keys, Object#{to_a(Key) => Value}, Path);
undefined when Ctx#ctx.drop_unknown ->
visit_object_fields(Ctx, Keys, Object, Path);
undefined when Ctx#ctx.allow_forced_undefined andalso (Value =:= undefined orelse Value =:= null) ->
visit_object_fields(Ctx, Keys, Object#{to_a(Key) => undefined}, Path);
undefined when not Ctx#ctx.drop_unknown ->
{error, #{error => unknown_key, path => Path ++ [Key]}};
#{} ->
case visit(Ctx, Value, Spec, Path ++ [Key]) of
{error, E} ->
{error, E};
% FIXME: Буду рад предложениям, как обойтись без такого хака
Value2 when Key == session_keys ->
case server_config:session_key(Value2) of
{ok, Value3} ->
visit_object_fields(Ctx, Keys, Object#{Key => Value3}, Path);
{error, E2} ->
{error, E2}
end;
Value2 ->
visit_object_fields(Ctx, Keys, Object#{Key => Value2}, Path)
end
end;
visit_object_fields(_Ctx, [], Object, _) ->
Object.
to_a(Bin) when is_binary(Bin) -> binary_to_atom(Bin,latin1);
to_a(A) when is_atom(A) -> A.
visit_array(Ctx, [Value|List], Spec, Path, Acc) ->
case visit(Ctx, Value, Spec, Path ++ [length(Acc)]) of
{error, E} ->
{error, E};
Value1 ->
visit_array(Ctx, List, Spec, Path, [Value1|Acc])
end;
visit_array(_Ctx, [], _, _, Acc) ->
lists:reverse(Acc).
visit_anyOf(Ctx, Value, [Spec|Types], Path) ->
case visit(Ctx, Value, Spec, Path) of
{error, E} ->
% Эта хитрость здесь для того, чтобы не брать ошибки от энума, а брать от следующих
% То, что что-то не попало в энум означает, что нам следующие ошибки интереснее
IsEnum = case Spec of
#{enum := _} -> true;
#{oneOf := [#{const := _}|_]} -> true;
_ -> false
end,
case visit_anyOf(Ctx, Value, Types, Path) of
{error, E1} when IsEnum -> {error, E1#{what => Value}};
{error, _} when is_binary(Value) -> {error, E#{what => Value}};
{error, _} -> {error, E};
Value1 -> Value1
end;
Value2 ->
Value2
end;
visit_anyOf(_Ctx, _, [], Path) ->
{error, #{error => unmatched_type, path => Path}}.
visit_anyOf_with_pattern_discriminator(Ctx, Value, [Spec|Types], Path, DKey, BinDKey, DValue) ->
case visit(Ctx, #{DKey => DValue}, Spec, Path) of
#{DKey := _} ->
visit(Ctx, Value, Spec, Path);
{error, #{error := nomatch_pattern}} ->
visit_anyOf_with_pattern_discriminator(Ctx, Value, Types, Path, DKey, BinDKey, DValue);
Err ->
Err
end;
visit_anyOf_with_pattern_discriminator(_Ctx, _, [], Path, DKey, BinDKey, DValue) ->
{error, #{error => <<"unmatched_", BinDKey/binary>>, path => Path, matched_by => DKey, what => DValue}}.
enumerate(Key, Config) ->
case Config of
#{Key := Streams} when is_list(Streams) ->
Streams1 = lists:zipwith(fun(I,S) -> S#{position => I} end,
lists:seq(0,length(Streams)-1), Streams),
Config#{Key => Streams1};
_ ->
Config
end.
add_compat(#ctx{add_compat = AddCompat}, #{} = Object, #{'x-compat-map' := OldNew}) ->
Object2 = lists:foldl(fun({Old,New}, R) ->
case R of
#{New := Value} when AddCompat -> R#{Old => Value};
#{New := _} -> maps:remove(Old,R);
#{Old := Value} when AddCompat -> R#{New => Value};
#{Old := Value} -> (maps:remove(Old,R))#{New => Value};
#{} -> R
end
end, Object, maps:to_list(OldNew)),
Object2;
add_compat(_Ctx, Else, _Spec) ->
Else.
record2json(#ctx{records = Records},Tuple) ->
Type = element(1,Tuple),
Keys = maps:get(Type, Records),
Values = lists:sublist(tl(tuple_to_list(Tuple)),length(Keys)),
case config2_schema:schema(Type) of
#{properties := Props} ->
maps:from_list([{K,V} || {K,V} <- lists:zip(Keys,Values), V =/= undefined, is_map_key(K,Props)]);
#{discriminator := #{propertyName := DKey, mapping := Mapping}} ->
%% Нетривиальный случай -- в зависимости от content разные наборы полей имеют значение.
%% В схеме это описано через дискриминатор с сопоставлением значения нужному типу
J0 = maps:from_list([{K,V} || {K,V} <- lists:zip(Keys,Values), V =/= undefined]),
DValue = maps:get(DKey, J0),
<<"#/components/schemas/", DType/binary>> = maps:get(DValue, Mapping),
#{properties := Props} = config2_schema:schema(binary_to_atom(DType)),
maps:with(maps:keys(Props), J0)
end.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment