1009 lines
36 KiB
Erlang
1009 lines
36 KiB
Erlang
%%--------------------------------------------------------------------
|
||
%% Copyright (c) 2021-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
|
||
%%
|
||
%% Licensed 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.
|
||
%%--------------------------------------------------------------------
|
||
|
||
-module(emqx_dashboard_swagger).
|
||
|
||
-include_lib("typerefl/include/types.hrl").
|
||
-include_lib("hocon/include/hoconsc.hrl").
|
||
|
||
%% API
|
||
-export([spec/1, spec/2]).
|
||
-export([namespace/0, namespace/1, fields/1]).
|
||
-export([schema_with_example/2, schema_with_examples/2]).
|
||
-export([error_codes/1, error_codes/2]).
|
||
-export([file_schema/1]).
|
||
|
||
-export([
|
||
filter_check_request/2,
|
||
filter_check_request_and_translate_body/2,
|
||
gen_api_schema_json_iodata/3
|
||
]).
|
||
|
||
-ifdef(TEST).
|
||
-export([
|
||
parse_spec_ref/3,
|
||
components/2
|
||
]).
|
||
-endif.
|
||
|
||
-define(METHODS, [get, post, put, head, delete, patch, options, trace]).
|
||
|
||
-define(DEFAULT_FIELDS, [
|
||
example,
|
||
allowReserved,
|
||
style,
|
||
format,
|
||
readOnly,
|
||
explode,
|
||
maxLength,
|
||
allowEmptyValue,
|
||
deprecated,
|
||
minimum,
|
||
maximum
|
||
]).
|
||
|
||
-define(INIT_SCHEMA, #{
|
||
fields => #{},
|
||
translations => #{},
|
||
validations => [],
|
||
namespace => undefined
|
||
}).
|
||
|
||
-define(TO_REF(_N_, _F_), iolist_to_binary([to_bin(_N_), ".", to_bin(_F_)])).
|
||
-define(TO_COMPONENTS_SCHEMA(_M_, _F_),
|
||
iolist_to_binary([
|
||
<<"#/components/schemas/">>,
|
||
?TO_REF(namespace(_M_), _F_)
|
||
])
|
||
).
|
||
-define(TO_COMPONENTS_PARAM(_M_, _F_),
|
||
iolist_to_binary([
|
||
<<"#/components/parameters/">>,
|
||
?TO_REF(namespace(_M_), _F_)
|
||
])
|
||
).
|
||
|
||
-define(SPECIAL_LANG_MSGID, <<"$msgid">>).
|
||
|
||
-define(MAX_ROW_LIMIT, 1000).
|
||
-define(DEFAULT_ROW, 100).
|
||
|
||
-type request() :: #{bindings => map(), query_string => map(), body => map()}.
|
||
-type request_meta() :: #{module => module(), path => string(), method => atom()}.
|
||
|
||
-type filter_result() :: {ok, request()} | {400, 'BAD_REQUEST', binary()}.
|
||
-type filter() :: fun((request(), request_meta()) -> filter_result()).
|
||
|
||
-type spec_opts() :: #{
|
||
check_schema => boolean() | filter(),
|
||
translate_body => boolean(),
|
||
schema_converter => fun((hocon_schema:schema(), Module :: atom()) -> map()),
|
||
i18n_lang => atom() | string() | binary()
|
||
}.
|
||
|
||
-type route_path() :: string() | binary().
|
||
-type route_methods() :: map().
|
||
-type route_handler() :: atom().
|
||
-type route_options() :: #{filter => filter() | undefined}.
|
||
|
||
-type api_spec_entry() :: {route_path(), route_methods(), route_handler(), route_options()}.
|
||
-type api_spec_component() :: map().
|
||
|
||
%%------------------------------------------------------------------------------
|
||
%% API
|
||
%%------------------------------------------------------------------------------
|
||
|
||
%% @equiv spec(Module, #{check_schema => false})
|
||
-spec spec(module()) -> {list(api_spec_entry()), list(api_spec_component())}.
|
||
spec(Module) -> spec(Module, #{check_schema => false}).
|
||
|
||
-spec spec(module(), spec_opts()) -> {list(api_spec_entry()), list(api_spec_component())}.
|
||
spec(Module, Options) ->
|
||
Paths = apply(Module, paths, []),
|
||
{ApiSpec, AllRefs} =
|
||
lists:foldl(
|
||
fun(Path, {AllAcc, AllRefsAcc}) ->
|
||
{OperationId, Specs, Refs} = parse_spec_ref(Module, Path, Options),
|
||
CheckSchema = support_check_schema(Options),
|
||
{
|
||
[{filename:join("/", Path), Specs, OperationId, CheckSchema} | AllAcc],
|
||
Refs ++ AllRefsAcc
|
||
}
|
||
end,
|
||
{[], []},
|
||
Paths
|
||
),
|
||
{ApiSpec, components(lists:usort(AllRefs), Options)}.
|
||
|
||
-spec namespace() -> hocon_schema:name().
|
||
namespace() -> "public".
|
||
|
||
-spec fields(hocon_schema:name()) -> hocon_schema:fields().
|
||
fields(page) ->
|
||
Desc = <<"Page number of the results to fetch.">>,
|
||
Meta = #{in => query, desc => Desc, default => 1, example => 1},
|
||
[{page, hoconsc:mk(pos_integer(), Meta)}];
|
||
fields(limit) ->
|
||
Desc = iolist_to_binary([
|
||
<<"Results per page(max ">>,
|
||
integer_to_binary(?MAX_ROW_LIMIT),
|
||
<<")">>
|
||
]),
|
||
Meta = #{in => query, desc => Desc, default => ?DEFAULT_ROW, example => 50},
|
||
[{limit, hoconsc:mk(range(1, ?MAX_ROW_LIMIT), Meta)}];
|
||
fields(count) ->
|
||
Desc = <<
|
||
"Total number of records matching the query.<br/>"
|
||
"Note: this field is present only if the query can be optimized and does "
|
||
"not require a full table scan."
|
||
>>,
|
||
Meta = #{desc => Desc, required => false},
|
||
[{count, hoconsc:mk(non_neg_integer(), Meta)}];
|
||
fields(hasnext) ->
|
||
Desc = <<
|
||
"Flag indicating whether there are more results available on next pages."
|
||
>>,
|
||
Meta = #{desc => Desc, required => true},
|
||
[{hasnext, hoconsc:mk(boolean(), Meta)}];
|
||
fields(meta) ->
|
||
fields(page) ++ fields(limit) ++ fields(count) ++ fields(hasnext).
|
||
|
||
-spec schema_with_example(hocon_schema:type(), term()) -> hocon_schema:field_schema_map().
|
||
schema_with_example(Type, Example) ->
|
||
hoconsc:mk(Type, #{examples => #{<<"example">> => Example}}).
|
||
|
||
-spec schema_with_examples(hocon_schema:type(), map()) -> hocon_schema:field_schema_map().
|
||
schema_with_examples(Type, Examples) ->
|
||
hoconsc:mk(Type, #{examples => #{<<"examples">> => Examples}}).
|
||
|
||
-spec error_codes(list(atom())) -> hocon_schema:fields().
|
||
error_codes(Codes) ->
|
||
error_codes(Codes, <<"Error code to troubleshoot problems.">>).
|
||
|
||
-spec error_codes(nonempty_list(atom()), binary() | {desc, module(), term()}) ->
|
||
hocon_schema:fields().
|
||
error_codes(Codes = [_ | _], MsgDesc) ->
|
||
[
|
||
{code, hoconsc:mk(hoconsc:enum(Codes))},
|
||
{message,
|
||
hoconsc:mk(string(), #{
|
||
desc => MsgDesc
|
||
})}
|
||
].
|
||
|
||
file_schema(FileName) ->
|
||
#{
|
||
content => #{
|
||
'multipart/form-data' => #{
|
||
schema => #{
|
||
type => object,
|
||
properties => #{
|
||
FileName => #{type => string, format => binary}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}.
|
||
|
||
gen_api_schema_json_iodata(SchemaMod, SchemaInfo, Converter) ->
|
||
{ApiSpec0, Components0} = emqx_dashboard_swagger:spec(
|
||
SchemaMod,
|
||
#{
|
||
schema_converter => Converter,
|
||
i18n_lang => ?SPECIAL_LANG_MSGID
|
||
}
|
||
),
|
||
ApiSpec = lists:foldl(
|
||
fun({Path, Spec, _, _}, Acc) ->
|
||
NewSpec = maps:fold(
|
||
fun(Method, #{responses := Responses}, SubAcc) ->
|
||
case Responses of
|
||
#{
|
||
<<"200">> :=
|
||
#{
|
||
<<"content">> := #{
|
||
<<"application/json">> := #{<<"schema">> := Schema}
|
||
}
|
||
}
|
||
} ->
|
||
SubAcc#{Method => Schema};
|
||
_ ->
|
||
SubAcc
|
||
end
|
||
end,
|
||
#{},
|
||
Spec
|
||
),
|
||
Acc#{list_to_atom(Path) => NewSpec}
|
||
end,
|
||
#{},
|
||
ApiSpec0
|
||
),
|
||
Components = lists:foldl(fun(M, Acc) -> maps:merge(M, Acc) end, #{}, Components0),
|
||
emqx_utils_json:encode(
|
||
#{
|
||
info => SchemaInfo,
|
||
paths => ApiSpec,
|
||
components => #{schemas => Components}
|
||
},
|
||
[pretty, force_utf8]
|
||
).
|
||
|
||
%%------------------------------------------------------------------------------
|
||
%% Private functions
|
||
%%------------------------------------------------------------------------------
|
||
|
||
filter_check_request_and_translate_body(Request, RequestMeta) ->
|
||
translate_req(Request, RequestMeta, fun check_and_translate/3).
|
||
|
||
filter_check_request(Request, RequestMeta) ->
|
||
translate_req(Request, RequestMeta, fun check_only/3).
|
||
|
||
translate_req(Request, #{module := Module, path := Path, method := Method}, CheckFun) ->
|
||
#{Method := Spec} = apply(Module, schema, [Path]),
|
||
try
|
||
Params = maps:get(parameters, Spec, []),
|
||
Body = maps:get('requestBody', Spec, []),
|
||
{Bindings, QueryStr} = check_parameters(Request, Params, Module),
|
||
NewBody = check_request_body(Request, Body, Module, CheckFun, hoconsc:is_schema(Body)),
|
||
{ok, Request#{bindings => Bindings, query_string => QueryStr, body => NewBody}}
|
||
catch
|
||
throw:HoconError ->
|
||
Msg = hocon_error_msg(HoconError),
|
||
{400, 'BAD_REQUEST', Msg}
|
||
end.
|
||
|
||
check_and_translate(Schema, Map, Opts) ->
|
||
hocon_tconf:check_plain(Schema, Map, Opts).
|
||
|
||
check_only(Schema, Map, Opts) ->
|
||
_ = hocon_tconf:check_plain(Schema, Map, Opts),
|
||
Map.
|
||
|
||
support_check_schema(#{check_schema := true, translate_body := true}) ->
|
||
#{filter => fun ?MODULE:filter_check_request_and_translate_body/2};
|
||
support_check_schema(#{check_schema := true}) ->
|
||
#{filter => fun ?MODULE:filter_check_request/2};
|
||
support_check_schema(#{check_schema := Filter}) when is_function(Filter, 2) ->
|
||
#{filter => Filter};
|
||
support_check_schema(_) ->
|
||
#{filter => undefined}.
|
||
|
||
parse_spec_ref(Module, Path, Options) ->
|
||
Schema =
|
||
try
|
||
erlang:apply(Module, schema, [Path])
|
||
%% better error message
|
||
catch
|
||
error:Reason:Stacktrace ->
|
||
%% raise a new error with the same stacktrace.
|
||
%% it's a bug if this happens.
|
||
%% i.e. if a path is listed in the spec but the module doesn't
|
||
%% implement it or crashes when trying to build the schema.
|
||
erlang:raise(
|
||
error,
|
||
#{mfa => {Module, schema, [Path]}, reason => Reason},
|
||
Stacktrace
|
||
)
|
||
end,
|
||
{Specs, Refs} = maps:fold(
|
||
fun(Method, Meta, {Acc, RefsAcc}) ->
|
||
(not lists:member(Method, ?METHODS)) andalso
|
||
throw({error, #{module => Module, path => Path, method => Method}}),
|
||
{Spec, SubRefs} = meta_to_spec(Meta, Module, Options),
|
||
{Acc#{Method => Spec}, SubRefs ++ RefsAcc}
|
||
end,
|
||
{#{}, []},
|
||
maps:without(['operationId'], Schema)
|
||
),
|
||
{maps:get('operationId', Schema), Specs, Refs}.
|
||
|
||
check_parameters(Request, Spec, Module) ->
|
||
#{bindings := Bindings, query_string := QueryStr} = Request,
|
||
BindingsBin = maps:fold(
|
||
fun(Key, Value, Acc) ->
|
||
Acc#{atom_to_binary(Key) => Value}
|
||
end,
|
||
#{},
|
||
Bindings
|
||
),
|
||
check_parameter(Spec, BindingsBin, QueryStr, Module, #{}, #{}).
|
||
|
||
check_parameter([?REF(Fields) | Spec], Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc) ->
|
||
check_parameter(
|
||
[?R_REF(LocalMod, Fields) | Spec],
|
||
Bindings,
|
||
QueryStr,
|
||
LocalMod,
|
||
BindingsAcc,
|
||
QueryStrAcc
|
||
);
|
||
check_parameter(
|
||
[?R_REF(Module, Fields) | Spec],
|
||
Bindings,
|
||
QueryStr,
|
||
LocalMod,
|
||
BindingsAcc,
|
||
QueryStrAcc
|
||
) ->
|
||
Params = apply(Module, fields, [Fields]),
|
||
check_parameter(Params ++ Spec, Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc);
|
||
check_parameter([], _Bindings, _QueryStr, _Module, NewBindings, NewQueryStr) ->
|
||
{NewBindings, NewQueryStr};
|
||
check_parameter([{Name, Type} | Spec], Bindings, QueryStr, Module, BindingsAcc, QueryStrAcc) ->
|
||
Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]},
|
||
case hocon_schema:field_schema(Type, in) of
|
||
path ->
|
||
Option = #{atom_key => true},
|
||
NewBindings = hocon_tconf:check_plain(Schema, Bindings, Option),
|
||
NewBindingsAcc = maps:merge(BindingsAcc, NewBindings),
|
||
check_parameter(Spec, Bindings, QueryStr, Module, NewBindingsAcc, QueryStrAcc);
|
||
query ->
|
||
Option = #{},
|
||
NewQueryStr = hocon_tconf:check_plain(Schema, QueryStr, Option),
|
||
NewQueryStrAcc = maps:merge(QueryStrAcc, NewQueryStr),
|
||
check_parameter(Spec, Bindings, QueryStr, Module, BindingsAcc, NewQueryStrAcc)
|
||
end.
|
||
|
||
check_request_body(#{body := Body}, Schema, Module, CheckFun, true) ->
|
||
Type0 = hocon_schema:field_schema(Schema, type),
|
||
Type =
|
||
case Type0 of
|
||
?REF(StructName) -> ?R_REF(Module, StructName);
|
||
_ -> Type0
|
||
end,
|
||
NewSchema = ?INIT_SCHEMA#{roots => [{root, Type}]},
|
||
Option = #{required => false},
|
||
#{<<"root">> := NewBody} = CheckFun(NewSchema, #{<<"root">> => Body}, Option),
|
||
NewBody;
|
||
%% TODO not support nest object check yet, please use ref!
|
||
%% 'requestBody' = [ {per_page, mk(integer(), #{}},
|
||
%% {nest_object, [
|
||
%% {good_nest_1, mk(integer(), #{})},
|
||
%% {good_nest_2, mk(ref(?MODULE, good_ref), #{})}
|
||
%% ]}
|
||
%% ]
|
||
check_request_body(#{body := Body}, Spec, _Module, CheckFun, false) when is_list(Spec) ->
|
||
lists:foldl(
|
||
fun({Name, Type}, Acc) ->
|
||
Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]},
|
||
maps:merge(Acc, CheckFun(Schema, Body, #{}))
|
||
end,
|
||
#{},
|
||
Spec
|
||
);
|
||
%% requestBody => #{content => #{ 'application/octet-stream' =>
|
||
%% #{schema => #{ type => string, format => binary}}}
|
||
check_request_body(#{body := Body}, Spec, _Module, _CheckFun, false) when is_map(Spec) ->
|
||
Body.
|
||
|
||
%% tags, description, summary, security, deprecated
|
||
meta_to_spec(Meta, Module, Options) ->
|
||
{Params, Refs1} = parameters(maps:get(parameters, Meta, []), Module, Options),
|
||
{RequestBody, Refs2} = request_body(maps:get('requestBody', Meta, []), Module, Options),
|
||
{Responses, Refs3} = responses(maps:get(responses, Meta, #{}), Module, Options),
|
||
{
|
||
generate_method_desc(to_spec(Meta, Params, RequestBody, Responses), Options),
|
||
lists:usort(Refs1 ++ Refs2 ++ Refs3)
|
||
}.
|
||
|
||
to_spec(Meta, Params, [], Responses) ->
|
||
Spec = maps:without([parameters, 'requestBody', responses], Meta),
|
||
Spec#{parameters => Params, responses => Responses};
|
||
to_spec(Meta, Params, RequestBody, Responses) ->
|
||
Spec = to_spec(Meta, Params, [], Responses),
|
||
maps:put('requestBody', RequestBody, Spec).
|
||
|
||
generate_method_desc(Spec = #{desc := _Desc}, Options) ->
|
||
Spec1 = trans_description(maps:remove(desc, Spec), Spec, Options),
|
||
trans_tags(Spec1);
|
||
generate_method_desc(Spec = #{description := _Desc}, Options) ->
|
||
Spec1 = trans_description(Spec, Spec, Options),
|
||
trans_tags(Spec1);
|
||
generate_method_desc(Spec, _Options) ->
|
||
trans_tags(Spec).
|
||
|
||
trans_tags(Spec = #{tags := Tags}) ->
|
||
Spec#{tags => [string:titlecase(to_bin(Tag)) || Tag <- Tags]};
|
||
trans_tags(Spec) ->
|
||
Spec.
|
||
|
||
parameters(Params, Module, Options) ->
|
||
{SpecList, AllRefs} =
|
||
lists:foldl(
|
||
fun(Param, {Acc, RefsAcc}) ->
|
||
case Param of
|
||
?REF(StructName) ->
|
||
to_ref(Module, StructName, Acc, RefsAcc);
|
||
?R_REF(RModule, StructName) ->
|
||
to_ref(RModule, StructName, Acc, RefsAcc);
|
||
{Name, Type} ->
|
||
In = hocon_schema:field_schema(Type, in),
|
||
In =:= undefined andalso
|
||
throw({error, <<"missing in:path/query field in parameters">>}),
|
||
Required = hocon_schema:field_schema(Type, required),
|
||
Default = hocon_schema:field_schema(Type, default),
|
||
HoconType = hocon_schema:field_schema(Type, type),
|
||
SchemaExtras = hocon_extract_map([enum, default], Type),
|
||
Meta = init_meta(Default),
|
||
{ParamType, Refs} = hocon_schema_to_spec(HoconType, Module),
|
||
Schema = maps:merge(maps:merge(ParamType, Meta), SchemaExtras),
|
||
Spec0 = init_prop(
|
||
[required | ?DEFAULT_FIELDS],
|
||
#{schema => Schema, name => Name, in => In},
|
||
Type
|
||
),
|
||
Spec1 = trans_required(Spec0, Required, In),
|
||
Spec2 = trans_description(Spec1, Type, Options),
|
||
{[Spec2 | Acc], Refs ++ RefsAcc}
|
||
end
|
||
end,
|
||
{[], []},
|
||
Params
|
||
),
|
||
{lists:reverse(SpecList), AllRefs}.
|
||
|
||
hocon_extract_map(Keys, Type) ->
|
||
lists:foldl(
|
||
fun(K, M) ->
|
||
case hocon_schema:field_schema(Type, K) of
|
||
undefined -> M;
|
||
V -> M#{K => V}
|
||
end
|
||
end,
|
||
#{},
|
||
Keys
|
||
).
|
||
|
||
init_meta(undefined) -> #{};
|
||
init_meta(Default) -> #{default => Default}.
|
||
|
||
init_prop(Keys, Init, Type) ->
|
||
lists:foldl(
|
||
fun(Key, Acc) ->
|
||
case hocon_schema:field_schema(Type, Key) of
|
||
undefined -> Acc;
|
||
Schema -> Acc#{Key => format_prop(Key, Schema)}
|
||
end
|
||
end,
|
||
Init,
|
||
Keys
|
||
).
|
||
|
||
format_prop(deprecated, Value) when is_boolean(Value) -> Value;
|
||
format_prop(deprecated, _) -> true;
|
||
format_prop(_, Schema) -> to_bin(Schema).
|
||
|
||
trans_required(Spec, true, _) -> Spec#{required => true};
|
||
trans_required(Spec, _, path) -> Spec#{required => true};
|
||
trans_required(Spec, _, _) -> Spec.
|
||
|
||
trans_desc(Init, Hocon, Func, Name, Options) ->
|
||
Spec0 = trans_description(Init, Hocon, Options),
|
||
case Func =:= fun hocon_schema_to_spec/2 of
|
||
true ->
|
||
Spec0;
|
||
false ->
|
||
Spec1 = trans_label(Spec0, Hocon, Name, Options),
|
||
case Spec1 of
|
||
#{description := _} -> Spec1;
|
||
_ -> Spec1#{description => <<Name/binary, " Description">>}
|
||
end
|
||
end.
|
||
|
||
trans_description(Spec, Hocon, Options) ->
|
||
Desc =
|
||
case desc_struct(Hocon) of
|
||
undefined -> undefined;
|
||
?DESC(_, _) = Struct -> get_i18n(<<"desc">>, Struct, undefined, Options);
|
||
Text -> to_bin(Text)
|
||
end,
|
||
case Desc of
|
||
undefined ->
|
||
Spec;
|
||
Desc ->
|
||
Desc1 = binary:replace(Desc, [<<"\n">>], <<"<br/>">>, [global]),
|
||
maybe_add_summary_from_label(Spec#{description => Desc1}, Hocon, Options)
|
||
end.
|
||
|
||
maybe_add_summary_from_label(Spec, Hocon, Options) ->
|
||
Label =
|
||
case desc_struct(Hocon) of
|
||
?DESC(_, _) = Struct -> get_i18n(<<"label">>, Struct, undefined, Options);
|
||
_ -> undefined
|
||
end,
|
||
case Label of
|
||
undefined -> Spec;
|
||
_ -> Spec#{summary => Label}
|
||
end.
|
||
|
||
get_i18n(Tag, ?DESC(Namespace, Id), Default, Options) ->
|
||
Lang = get_lang(Options),
|
||
case Lang of
|
||
?SPECIAL_LANG_MSGID ->
|
||
make_msgid(Namespace, Id, Tag);
|
||
_ ->
|
||
get_i18n_text(Lang, Namespace, Id, Tag, Default)
|
||
end.
|
||
|
||
get_i18n_text(Lang, Namespace, Id, Tag, Default) ->
|
||
case emqx_dashboard_desc_cache:lookup(Lang, Namespace, Id, Tag) of
|
||
undefined ->
|
||
Default;
|
||
Text ->
|
||
Text
|
||
end.
|
||
|
||
%% Format:$msgid:Namespace.Id.Tag
|
||
%% e.g. $msgid:emqx_schema.key.desc
|
||
%% $msgid:emqx_schema.key.label
|
||
%% if needed, the consumer of this schema JSON can use this msgid to
|
||
%% resolve the text in the i18n database.
|
||
make_msgid(Namespace, Id, Tag) ->
|
||
iolist_to_binary(["$msgid:", to_bin(Namespace), ".", to_bin(Id), ".", Tag]).
|
||
|
||
%% So far i18n_lang in options is only used at build time.
|
||
%% At runtime, it's still the global config which controls the language.
|
||
get_lang(#{i18n_lang := Lang}) -> Lang;
|
||
get_lang(_) -> emqx:get_config([dashboard, i18n_lang]).
|
||
|
||
trans_label(Spec, Hocon, Default, Options) ->
|
||
Label =
|
||
case desc_struct(Hocon) of
|
||
?DESC(_, _) = Struct -> get_i18n(<<"label">>, Struct, Default, Options);
|
||
_ -> Default
|
||
end,
|
||
Spec#{label => Label}.
|
||
|
||
desc_struct(Hocon) ->
|
||
R =
|
||
case hocon_schema:field_schema(Hocon, desc) of
|
||
undefined ->
|
||
case hocon_schema:field_schema(Hocon, description) of
|
||
undefined -> get_ref_desc(Hocon);
|
||
Struct1 -> Struct1
|
||
end;
|
||
Struct ->
|
||
Struct
|
||
end,
|
||
ensure_bin(R).
|
||
|
||
ensure_bin(undefined) -> undefined;
|
||
ensure_bin(?DESC(_Namespace, _Id) = Desc) -> Desc;
|
||
ensure_bin(Text) -> to_bin(Text).
|
||
|
||
get_ref_desc(?R_REF(Mod, Name)) ->
|
||
case erlang:function_exported(Mod, desc, 1) of
|
||
true -> Mod:desc(Name);
|
||
false -> undefined
|
||
end;
|
||
get_ref_desc(_) ->
|
||
undefined.
|
||
|
||
request_body(#{content := _} = Content, _Module, _Options) ->
|
||
{Content, []};
|
||
request_body([], _Module, _Options) ->
|
||
{[], []};
|
||
request_body(Schema, Module, Options) ->
|
||
{{Props, Refs}, Examples} =
|
||
case hoconsc:is_schema(Schema) of
|
||
true ->
|
||
HoconSchema = hocon_schema:field_schema(Schema, type),
|
||
SchemaExamples = hocon_schema:field_schema(Schema, examples),
|
||
{hocon_schema_to_spec(HoconSchema, Module), SchemaExamples};
|
||
false ->
|
||
{parse_object(Schema, Module, Options), undefined}
|
||
end,
|
||
{#{<<"content">> => content(Props, Examples)}, Refs}.
|
||
|
||
responses(Responses, Module, Options) ->
|
||
{Spec, Refs, _, _} = maps:fold(fun response/3, {#{}, [], Module, Options}, Responses),
|
||
{Spec, Refs}.
|
||
|
||
response(Status, ?DESC(_Mod, _Id) = Schema, {Acc, RefsAcc, Module, Options}) ->
|
||
Desc = trans_description(#{}, #{desc => Schema}, Options),
|
||
{Acc#{integer_to_binary(Status) => Desc}, RefsAcc, Module, Options};
|
||
response(Status, Bin, {Acc, RefsAcc, Module, Options}) when is_binary(Bin) ->
|
||
{Acc#{integer_to_binary(Status) => #{description => Bin}}, RefsAcc, Module, Options};
|
||
%% Support swagger raw object(file download).
|
||
%% TODO: multi type response(i.e. Support both 'application/json' and 'plain/text')
|
||
response(Status, #{content := _} = Content, {Acc, RefsAcc, Module, Options}) ->
|
||
{Acc#{integer_to_binary(Status) => Content}, RefsAcc, Module, Options};
|
||
response(Status, ?REF(StructName), {Acc, RefsAcc, Module, Options}) ->
|
||
response(Status, ?R_REF(Module, StructName), {Acc, RefsAcc, Module, Options});
|
||
response(Status, ?R_REF(_Mod, _Name) = RRef, {Acc, RefsAcc, Module, Options}) ->
|
||
SchemaToSpec = schema_converter(Options),
|
||
{Spec, Refs} = SchemaToSpec(RRef, Module),
|
||
Content = content(Spec),
|
||
{
|
||
Acc#{
|
||
integer_to_binary(Status) =>
|
||
#{<<"content">> => Content}
|
||
},
|
||
Refs ++ RefsAcc,
|
||
Module,
|
||
Options
|
||
};
|
||
response(Status, Schema, {Acc, RefsAcc, Module, Options}) ->
|
||
case hoconsc:is_schema(Schema) of
|
||
true ->
|
||
Hocon = hocon_schema:field_schema(Schema, type),
|
||
Examples = hocon_schema:field_schema(Schema, examples),
|
||
{Spec, Refs} = hocon_schema_to_spec(Hocon, Module),
|
||
Init = trans_description(#{}, Schema, Options),
|
||
Content = content(Spec, Examples),
|
||
{
|
||
Acc#{integer_to_binary(Status) => Init#{<<"content">> => Content}},
|
||
Refs ++ RefsAcc,
|
||
Module,
|
||
Options
|
||
};
|
||
false ->
|
||
{Props, Refs} = parse_object(Schema, Module, Options),
|
||
Init = trans_description(#{}, Schema, Options),
|
||
Content = Init#{<<"content">> => content(Props)},
|
||
{Acc#{integer_to_binary(Status) => Content}, Refs ++ RefsAcc, Module, Options}
|
||
end.
|
||
|
||
components(Refs, Options) ->
|
||
lists:sort(
|
||
maps:fold(
|
||
fun(K, V, Acc) -> [#{K => V} | Acc] end,
|
||
[],
|
||
components(Options, Refs, #{}, [])
|
||
)
|
||
).
|
||
|
||
components(_Options, [], SpecAcc, []) ->
|
||
SpecAcc;
|
||
components(Options, [], SpecAcc, SubRefAcc) ->
|
||
components(Options, SubRefAcc, SpecAcc, []);
|
||
components(Options, [{Module, Field} | Refs], SpecAcc, SubRefsAcc) ->
|
||
Props = hocon_schema_fields(Module, Field),
|
||
Namespace = namespace(Module),
|
||
{Object, SubRefs} = parse_object(Props, Module, Options),
|
||
NewSpecAcc = SpecAcc#{?TO_REF(Namespace, Field) => Object},
|
||
components(Options, Refs, NewSpecAcc, SubRefs ++ SubRefsAcc);
|
||
%% parameters in ref only have one value, not array
|
||
components(Options, [{Module, Field, parameter} | Refs], SpecAcc, SubRefsAcc) ->
|
||
Props = hocon_schema_fields(Module, Field),
|
||
{[Param], SubRefs} = parameters(Props, Module, Options),
|
||
Namespace = namespace(Module),
|
||
NewSpecAcc = SpecAcc#{?TO_REF(Namespace, Field) => Param},
|
||
components(Options, Refs, NewSpecAcc, SubRefs ++ SubRefsAcc).
|
||
|
||
hocon_schema_fields(Module, StructName) ->
|
||
case apply(Module, fields, [StructName]) of
|
||
#{fields := Fields, desc := _} ->
|
||
%% evil here, as it's match hocon_schema's internal representation
|
||
|
||
%% TODO: make use of desc ?
|
||
Fields;
|
||
Other ->
|
||
Other
|
||
end.
|
||
|
||
%% Semantic error at components.schemas.xxx:xx:xx
|
||
%% Component names can only contain the characters A-Z a-z 0-9 - . _
|
||
%% So replace ':' by '-'.
|
||
namespace(Module) ->
|
||
case hocon_schema:namespace(Module) of
|
||
undefined -> Module;
|
||
NameSpace -> re:replace(to_bin(NameSpace), ":", "-", [global])
|
||
end.
|
||
|
||
hocon_schema_to_spec(?R_REF(Module, StructName), _LocalModule) ->
|
||
{#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(Module, StructName)}, [{Module, StructName}]};
|
||
hocon_schema_to_spec(?REF(StructName), LocalModule) ->
|
||
{#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(LocalModule, StructName)}, [{LocalModule, StructName}]};
|
||
hocon_schema_to_spec(Type, LocalModule) when ?IS_TYPEREFL(Type) ->
|
||
{typename_to_spec(lists:flatten(typerefl:name(Type)), LocalModule), []};
|
||
hocon_schema_to_spec(?ARRAY(Item), LocalModule) ->
|
||
{Schema, Refs} = hocon_schema_to_spec(Item, LocalModule),
|
||
{#{type => array, items => Schema}, Refs};
|
||
hocon_schema_to_spec(?ENUM(Items), _LocalModule) ->
|
||
{#{type => string, enum => Items}, []};
|
||
hocon_schema_to_spec(?MAP(Name, Type), LocalModule) ->
|
||
{Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule),
|
||
{
|
||
#{
|
||
<<"type">> => object,
|
||
<<"properties">> => #{<<"$", (to_bin(Name))/binary>> => Schema}
|
||
},
|
||
SubRefs
|
||
};
|
||
hocon_schema_to_spec(?UNION(Types), LocalModule) ->
|
||
{OneOf, Refs} = lists:foldl(
|
||
fun(Type, {Acc, RefsAcc}) ->
|
||
{Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule),
|
||
{[Schema | Acc], SubRefs ++ RefsAcc}
|
||
end,
|
||
{[], []},
|
||
hoconsc:union_members(Types)
|
||
),
|
||
{#{<<"oneOf">> => OneOf}, Refs};
|
||
hocon_schema_to_spec(Atom, _LocalModule) when is_atom(Atom) ->
|
||
{#{type => string, enum => [Atom]}, []}.
|
||
|
||
typename_to_spec("term()", _Mod) ->
|
||
#{type => string, example => <<"any">>};
|
||
typename_to_spec("boolean()", _Mod) ->
|
||
#{type => boolean};
|
||
typename_to_spec("binary()", _Mod) ->
|
||
#{type => string};
|
||
typename_to_spec("float()", _Mod) ->
|
||
#{type => number};
|
||
typename_to_spec("integer()", _Mod) ->
|
||
#{type => integer};
|
||
typename_to_spec("non_neg_integer()", _Mod) ->
|
||
#{type => integer, minimum => 0};
|
||
typename_to_spec("pos_integer()", _Mod) ->
|
||
#{type => integer, minimum => 1};
|
||
typename_to_spec("number()", _Mod) ->
|
||
#{type => number};
|
||
typename_to_spec("string()", _Mod) ->
|
||
#{type => string};
|
||
typename_to_spec("atom()", _Mod) ->
|
||
#{type => string};
|
||
typename_to_spec("epoch_second()", _Mod) ->
|
||
#{
|
||
<<"oneOf">> => [
|
||
#{type => integer, example => 1640995200, description => <<"epoch-second">>},
|
||
#{type => string, example => <<"2022-01-01T00:00:00.000Z">>, format => <<"date-time">>}
|
||
]
|
||
};
|
||
typename_to_spec("epoch_millisecond()", _Mod) ->
|
||
#{
|
||
<<"oneOf">> => [
|
||
#{type => integer, example => 1640995200000, description => <<"epoch-millisecond">>},
|
||
#{type => string, example => <<"2022-01-01T00:00:00.000Z">>, format => <<"date-time">>}
|
||
]
|
||
};
|
||
typename_to_spec("duration()", _Mod) ->
|
||
#{type => string, example => <<"12m">>};
|
||
typename_to_spec("duration_s()", _Mod) ->
|
||
#{type => string, example => <<"1h">>};
|
||
typename_to_spec("duration_ms()", _Mod) ->
|
||
#{type => string, example => <<"32s">>};
|
||
typename_to_spec("percent()", _Mod) ->
|
||
#{type => number, example => <<"12%">>};
|
||
typename_to_spec("file()", _Mod) ->
|
||
#{type => string, example => <<"/path/to/file">>};
|
||
typename_to_spec("ip_port()", _Mod) ->
|
||
#{type => string, example => <<"127.0.0.1:80">>};
|
||
typename_to_spec("write_syntax()", _Mod) ->
|
||
#{
|
||
type => string,
|
||
example =>
|
||
<<"${topic},clientid=${clientid}", " ", "payload=${payload},",
|
||
"${clientid}_int_value=${payload.int_key}i,", "bool=${payload.bool}">>
|
||
};
|
||
typename_to_spec("url()", _Mod) ->
|
||
#{type => string, example => <<"http://127.0.0.1">>};
|
||
typename_to_spec("connect_timeout()", Mod) ->
|
||
typename_to_spec("timeout()", Mod);
|
||
typename_to_spec("timeout()", _Mod) ->
|
||
#{
|
||
<<"oneOf">> => [
|
||
#{type => string, example => infinity},
|
||
#{type => integer}
|
||
],
|
||
example => infinity
|
||
};
|
||
typename_to_spec("bytesize()", _Mod) ->
|
||
#{type => string, example => <<"32MB">>};
|
||
typename_to_spec("wordsize()", _Mod) ->
|
||
#{type => string, example => <<"1024KB">>};
|
||
typename_to_spec("map()", _Mod) ->
|
||
#{type => object, example => #{}};
|
||
typename_to_spec("service_account_json()", _Mod) ->
|
||
#{type => object, example => #{}};
|
||
typename_to_spec("#{" ++ _, Mod) ->
|
||
typename_to_spec("map()", Mod);
|
||
typename_to_spec("qos()", _Mod) ->
|
||
#{type => integer, minimum => 0, maximum => 2, example => 0};
|
||
typename_to_spec("{binary(), binary()}", _Mod) ->
|
||
#{type => object, example => #{}};
|
||
typename_to_spec("{string(), string()}", _Mod) ->
|
||
#{type => object, example => #{}};
|
||
typename_to_spec("comma_separated_list()", _Mod) ->
|
||
#{type => string, example => <<"item1,item2">>};
|
||
typename_to_spec("comma_separated_binary()", _Mod) ->
|
||
#{type => string, example => <<"item1,item2">>};
|
||
typename_to_spec("comma_separated_atoms()", _Mod) ->
|
||
#{type => string, example => <<"item1,item2">>};
|
||
typename_to_spec("pool_type()", _Mod) ->
|
||
#{type => string, enum => [random, hash]};
|
||
typename_to_spec("log_level()", _Mod) ->
|
||
#{
|
||
type => string,
|
||
enum => [debug, info, notice, warning, error, critical, alert, emergency, all]
|
||
};
|
||
typename_to_spec("rate()", _Mod) ->
|
||
#{type => string, example => <<"10MB">>};
|
||
typename_to_spec("burst()", _Mod) ->
|
||
#{type => string, example => <<"100MB">>};
|
||
typename_to_spec("burst_rate()", _Mod) ->
|
||
%% 0/0s = no burst
|
||
#{type => string, example => <<"10MB">>};
|
||
typename_to_spec("failure_strategy()", _Mod) ->
|
||
#{type => string, example => <<"force">>};
|
||
typename_to_spec("initial()", _Mod) ->
|
||
#{type => string, example => <<"0MB">>};
|
||
typename_to_spec("bucket_name()", _Mod) ->
|
||
#{type => string, example => <<"retainer">>};
|
||
typename_to_spec("json_binary()", _Mod) ->
|
||
#{type => string, example => <<"{\"a\": [1,true]}">>};
|
||
typename_to_spec(Name, Mod) ->
|
||
Spec = range(Name),
|
||
Spec1 = remote_module_type(Spec, Name, Mod),
|
||
Spec2 = typerefl_array(Spec1, Name, Mod),
|
||
Spec3 = integer(Spec2, Name),
|
||
Spec3 =:= nomatch andalso
|
||
throw({error, #{msg => <<"Unsupported Type">>, type => Name, module => Mod}}),
|
||
Spec3.
|
||
|
||
range(Name) ->
|
||
case string:split(Name, "..") of
|
||
%% 1..10 1..inf -inf..10
|
||
[MinStr, MaxStr] ->
|
||
Schema = #{type => integer},
|
||
Schema1 = add_integer_prop(Schema, minimum, MinStr),
|
||
add_integer_prop(Schema1, maximum, MaxStr);
|
||
_ ->
|
||
nomatch
|
||
end.
|
||
|
||
%% Module:Type
|
||
remote_module_type(nomatch, Name, Mod) ->
|
||
case string:split(Name, ":") of
|
||
[_Module, Type] -> typename_to_spec(Type, Mod);
|
||
_ -> nomatch
|
||
end;
|
||
remote_module_type(Spec, _Name, _Mod) ->
|
||
Spec.
|
||
|
||
%% [string()] or [integer()] or [xxx].
|
||
typerefl_array(nomatch, Name, Mod) ->
|
||
case string:trim(Name, leading, "[") of
|
||
Name ->
|
||
nomatch;
|
||
Name1 ->
|
||
case string:trim(Name1, trailing, "]") of
|
||
Name1 ->
|
||
notmatch;
|
||
Name2 ->
|
||
Schema = typename_to_spec(Name2, Mod),
|
||
#{type => array, items => Schema}
|
||
end
|
||
end;
|
||
typerefl_array(Spec, _Name, _Mod) ->
|
||
Spec.
|
||
|
||
%% integer(1)
|
||
integer(nomatch, Name) ->
|
||
case string:to_integer(Name) of
|
||
{Int, []} -> #{type => integer, enum => [Int], default => Int};
|
||
_ -> nomatch
|
||
end;
|
||
integer(Spec, _Name) ->
|
||
Spec.
|
||
|
||
add_integer_prop(Schema, Key, Value) ->
|
||
case string:to_integer(Value) of
|
||
{error, no_integer} -> Schema;
|
||
{Int, []} when Key =:= minimum -> Schema#{Key => Int};
|
||
{Int, []} -> Schema#{Key => Int}
|
||
end.
|
||
|
||
to_bin(List) when is_list(List) ->
|
||
case io_lib:printable_list(List) of
|
||
true -> unicode:characters_to_binary(List);
|
||
false -> List
|
||
end;
|
||
to_bin(Boolean) when is_boolean(Boolean) -> Boolean;
|
||
to_bin(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8);
|
||
to_bin({Type, Args}) ->
|
||
unicode:characters_to_binary(io_lib:format("~ts(~p)", [Type, Args]));
|
||
to_bin(X) ->
|
||
X.
|
||
|
||
parse_object(PropList = [_ | _], Module, Options) when is_list(PropList) ->
|
||
{Props, Required, Refs} = parse_object_loop(PropList, Module, Options),
|
||
Object = #{<<"type">> => object, <<"properties">> => Props},
|
||
case Required of
|
||
[] -> {Object, Refs};
|
||
_ -> {maps:put(required, Required, Object), Refs}
|
||
end;
|
||
parse_object(Other, Module, Options) ->
|
||
erlang:throw(
|
||
{error, #{
|
||
msg => <<"Object only supports not empty proplists">>,
|
||
args => Other,
|
||
module => Module,
|
||
options => Options
|
||
}}
|
||
).
|
||
|
||
parse_object_loop(PropList0, Module, Options) ->
|
||
PropList = lists:filter(
|
||
fun({_, Hocon}) ->
|
||
case hoconsc:is_schema(Hocon) andalso is_hidden(Hocon) of
|
||
true -> false;
|
||
false -> true
|
||
end
|
||
end,
|
||
PropList0
|
||
),
|
||
parse_object_loop(PropList, Module, Options, _Props = [], _Required = [], _Refs = []).
|
||
|
||
parse_object_loop([], _Modlue, _Options, Props, Required, Refs) ->
|
||
{lists:reverse(Props), lists:usort(Required), Refs};
|
||
parse_object_loop([{Name, Hocon} | Rest], Module, Options, Props, Required, Refs) ->
|
||
NameBin = to_bin(Name),
|
||
case hoconsc:is_schema(Hocon) of
|
||
true ->
|
||
HoconType = hocon_schema:field_schema(Hocon, type),
|
||
Init0 = init_prop([default | ?DEFAULT_FIELDS], #{}, Hocon),
|
||
SchemaToSpec = schema_converter(Options),
|
||
Init = trans_desc(Init0, Hocon, SchemaToSpec, NameBin, Options),
|
||
{Prop, Refs1} = SchemaToSpec(HoconType, Module),
|
||
NewRequiredAcc =
|
||
case is_required(Hocon) of
|
||
true -> [NameBin | Required];
|
||
false -> Required
|
||
end,
|
||
parse_object_loop(
|
||
Rest,
|
||
Module,
|
||
Options,
|
||
[{NameBin, maps:merge(Prop, Init)} | Props],
|
||
NewRequiredAcc,
|
||
Refs1 ++ Refs
|
||
);
|
||
false ->
|
||
%% TODO: there is only a handful of such
|
||
%% refactor the schema to unify the two cases
|
||
{SubObject, SubRefs} = parse_object(Hocon, Module, Options),
|
||
parse_object_loop(
|
||
Rest, Module, Options, [{NameBin, SubObject} | Props], Required, SubRefs ++ Refs
|
||
)
|
||
end.
|
||
|
||
%% return true if the field has 'importance' set to 'hidden'
|
||
is_hidden(Hocon) ->
|
||
hocon_schema:is_hidden(Hocon, #{include_importance_up_from => ?IMPORTANCE_LOW}).
|
||
is_required(Hocon) ->
|
||
hocon_schema:field_schema(Hocon, required) =:= true.
|
||
|
||
content(ApiSpec) ->
|
||
content(ApiSpec, undefined).
|
||
|
||
content(ApiSpec, undefined) ->
|
||
#{<<"application/json">> => #{<<"schema">> => ApiSpec}};
|
||
content(ApiSpec, Examples) when is_map(Examples) ->
|
||
#{<<"application/json">> => Examples#{<<"schema">> => ApiSpec}}.
|
||
|
||
to_ref(Mod, StructName, Acc, RefsAcc) ->
|
||
Ref = #{<<"$ref">> => ?TO_COMPONENTS_PARAM(Mod, StructName)},
|
||
{[Ref | Acc], [{Mod, StructName, parameter} | RefsAcc]}.
|
||
|
||
schema_converter(Options) ->
|
||
maps:get(schema_converter, Options, fun hocon_schema_to_spec/2).
|
||
|
||
hocon_error_msg(Reason) ->
|
||
emqx_utils:readable_error_msg(Reason).
|