refactor(authn api): use config schemas for request validations (#5999)

This commit is contained in:
Ilya Averyanov 2021-11-01 12:52:03 +03:00 committed by GitHub
parent d9cb0283f3
commit 800b4b32c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1228 additions and 1895 deletions

View File

@ -268,4 +268,3 @@ dir(ChainName, ID) when is_binary(ID) ->
binary:replace(iolist_to_binary([to_bin(ChainName), "-", ID]), <<":">>, <<"-">>);
dir(ChainName, Config) when is_map(Config) ->
dir(ChainName, authenticator_id(Config)).

View File

@ -352,10 +352,13 @@ listener_id(Type, ListenerName) ->
list_to_atom(lists:append([str(Type), ":", str(ListenerName)])).
parse_listener_id(Id) ->
[Type, Name] = string:split(str(Id), ":", leading),
case string:split(str(Id), ":", leading) of
[Type, Name] ->
case lists:member(Type, ?TYPES_STRING) of
true -> {list_to_existing_atom(Type), list_to_atom(Name)};
false -> {error, {invalid_listener_id, Id}}
end;
_ -> {error, {invalid_listener_id, Id}}
end.
zone(Opts) ->

File diff suppressed because it is too large Load Diff

View File

@ -21,12 +21,11 @@
-export([ common_fields/0
, roots/0
, fields/1
, authenticator_type/0
]).
%% only for doc generation
roots() -> [{authenticator_config,
#{type => hoconsc:union(config_refs([Module || {_AuthnType, Module} <- emqx_authn:providers()]))
}}].
roots() -> [{authenticator_config, hoconsc:mk(authenticator_type())}].
fields(_) -> [].
@ -38,5 +37,8 @@ enable(type) -> boolean();
enable(default) -> true;
enable(_) -> undefined.
authenticator_type() ->
hoconsc:union(config_refs([Module || {_AuthnType, Module} <- emqx_authn:providers()])).
config_refs(Modules) ->
lists:append([Module:refs() || Module <- Modules]).

View File

@ -18,7 +18,7 @@
-compile(nowarn_export_all).
-compile(export_all).
-include("emqx_authz.hrl").
-include("emqx_authn.hrl").
-include_lib("eunit/include/eunit.hrl").
-include_lib("common_test/include/ct.hrl").
@ -27,12 +27,27 @@
-define(API_VERSION, "v5").
-define(BASE_PATH, "api").
-define(TCP_DEFAULT, 'tcp:default').
-define(
assertAuthenticatorsMatch(Guard, Path),
(fun() ->
{ok, 200, Response} = request(get, uri(Path)),
?assertMatch(Guard, jiffy:decode(Response, [return_maps]))
end)()).
all() ->
emqx_common_test_helpers:all(?MODULE).
groups() ->
[].
init_per_testcase(_, Config) ->
delete_authenticators([authentication], ?GLOBAL),
delete_authenticators([listeners, tcp, default, authentication], ?TCP_DEFAULT),
{atomic, ok} = mria:clear_table(emqx_authn_mnesia),
Config.
init_per_suite(Config) ->
ok = emqx_common_test_helpers:start_apps([emqx_authn, emqx_dashboard], fun set_special_configs/1),
Config.
@ -55,10 +70,379 @@ set_special_configs(emqx_dashboard) ->
set_special_configs(_App) ->
ok.
t_create_http_authn(_) ->
{ok, 200, _} = request(post, uri(["authentication"]),
emqx_authn_test_lib:http_example()),
{ok, 200, _} = request(get, uri(["authentication"])).
%%------------------------------------------------------------------------------
%% Tests
%%------------------------------------------------------------------------------
t_invalid_listener(_) ->
{ok, 404, _} = request(get, uri(["listeners", "invalid", "authentication"])),
{ok, 404, _} = request(get, uri(["listeners", "in:valid", "authentication"])).
t_authenticators(_) ->
test_authenticators([]).
t_authenticator(_) ->
test_authenticator([]).
t_authenticator_users(_) ->
test_authenticator_users([]).
t_authenticator_user(_) ->
test_authenticator_user([]).
t_authenticator_move(_) ->
test_authenticator_move([]).
t_authenticator_import_users(_) ->
test_authenticator_import_users([]).
t_listener_authenticators(_) ->
test_authenticators(["listeners", ?TCP_DEFAULT]).
t_listener_authenticator(_) ->
test_authenticator(["listeners", ?TCP_DEFAULT]).
t_listener_authenticator_users(_) ->
test_authenticator_users(["listeners", ?TCP_DEFAULT]).
t_listener_authenticator_user(_) ->
test_authenticator_user(["listeners", ?TCP_DEFAULT]).
t_listener_authenticator_move(_) ->
test_authenticator_move(["listeners", ?TCP_DEFAULT]).
t_listener_authenticator_import_users(_) ->
test_authenticator_import_users(["listeners", ?TCP_DEFAULT]).
test_authenticators(PathPrefix) ->
ValidConfig = emqx_authn_test_lib:http_example(),
{ok, 200, _} = request(
post,
uri(PathPrefix ++ ["authentication"]),
ValidConfig),
InvalidConfig = ValidConfig#{method => <<"delete">>},
{ok, 400, _} = request(
post,
uri(PathPrefix ++ ["authentication"]),
InvalidConfig),
?assertAuthenticatorsMatch(
[#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>}],
PathPrefix ++ ["authentication"]).
test_authenticator(PathPrefix) ->
ValidConfig0 = emqx_authn_test_lib:http_example(),
{ok, 200, _} = request(
post,
uri(PathPrefix ++ ["authentication"]),
ValidConfig0),
{ok, 200, _} = request(
get,
uri(PathPrefix ++ ["authentication", "password-based:http"])),
{ok, 404, _} = request(
get,
uri(PathPrefix ++ ["authentication", "password-based:redis"])),
{ok, 404, _} = request(
put,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database"]),
emqx_authn_test_lib:built_in_database_example()),
InvalidConfig0 = ValidConfig0#{method => <<"delete">>},
{ok, 400, _} = request(
put,
uri(PathPrefix ++ ["authentication", "password-based:http"]),
InvalidConfig0),
ValidConfig1 = ValidConfig0#{pool_size => 9},
{ok, 200, _} = request(
put,
uri(PathPrefix ++ ["authentication", "password-based:http"]),
ValidConfig1),
{ok, 404, _} = request(
delete,
uri(PathPrefix ++ ["authentication", "password-based:redis"])),
{ok, 204, _} = request(
delete,
uri(PathPrefix ++ ["authentication", "password-based:http"])),
?assertAuthenticatorsMatch([], PathPrefix ++ ["authentication"]).
test_authenticator_users(PathPrefix) ->
{ok, 200, _} = request(
post,
uri(PathPrefix ++ ["authentication"]),
emqx_authn_test_lib:built_in_database_example()),
InvalidUsers = [
#{clientid => <<"u1">>, password => <<"p1">>},
#{user_id => <<"u2">>},
#{user_id => <<"u3">>, password => <<"p3">>, foobar => <<"foobar">>}],
lists:foreach(
fun(User) ->
{ok, 400, _} = request(
post,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]),
User)
end,
InvalidUsers),
ValidUsers = [
#{user_id => <<"u1">>, password => <<"p1">>},
#{user_id => <<"u2">>, password => <<"p2">>, is_superuser => true},
#{user_id => <<"u3">>, password => <<"p3">>}],
lists:foreach(
fun(User) ->
{ok, 201, _} = request(
post,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]),
User)
end,
ValidUsers),
{ok, 200, Page1Data} =
request(
get,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]) ++ "?page=1&limit=2"),
Page1Users = response_data(Page1Data),
{ok, 200, Page2Data} =
request(
get,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]) ++ "?page=2&limit=2"),
Page2Users = response_data(Page2Data),
?assertEqual(2, length(Page1Users)),
?assertEqual(1, length(Page2Users)),
?assertEqual(
[<<"u1">>, <<"u2">>, <<"u3">>],
lists:usort([ UserId || #{<<"user_id">> := UserId} <- Page1Users ++ Page2Users])).
test_authenticator_user(PathPrefix) ->
{ok, 200, _} = request(
post,
uri(PathPrefix ++ ["authentication"]),
emqx_authn_test_lib:built_in_database_example()),
User = #{user_id => <<"u1">>, password => <<"p1">>},
{ok, 201, _} = request(
post,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]),
User),
{ok, 404, _} = request(
get,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u123"])),
{ok, 409, _} = request(
post,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]),
User),
{ok, 200, UserData} = request(
get,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u1"])),
FetchedUser = jiffy:decode(UserData, [return_maps]),
?assertMatch(#{<<"user_id">> := <<"u1">>}, FetchedUser),
?assertNotMatch(#{<<"password">> := _}, FetchedUser),
ValidUserUpdates = [
#{password => <<"p1">>},
#{password => <<"p1">>, is_superuser => true}],
lists:foreach(
fun(UserUpdate) ->
{ok, 200, _} = request(
put,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u1"]),
UserUpdate)
end,
ValidUserUpdates),
InvalidUserUpdates = [
#{user_id => <<"u1">>, password => <<"p1">>},
#{is_superuser => true}],
lists:foreach(
fun(UserUpdate) ->
{ok, 400, _} = request(
put,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u1"]),
UserUpdate)
end,
InvalidUserUpdates),
{ok, 404, _} = request(
delete,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u123"])),
{ok, 204, _} = request(
delete,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u1"])).
test_authenticator_move(PathPrefix) ->
AuthenticatorConfs = [
emqx_authn_test_lib:http_example(),
emqx_authn_test_lib:jwt_example(),
emqx_authn_test_lib:built_in_database_example()
],
lists:foreach(
fun(Conf) ->
{ok, 200, _} = request(
post,
uri(PathPrefix ++ ["authentication"]),
Conf)
end,
AuthenticatorConfs),
?assertAuthenticatorsMatch(
[
#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>},
#{<<"mechanism">> := <<"jwt">>},
#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"built-in-database">>}
],
PathPrefix ++ ["authentication"]),
% Invalid moves
{ok, 400, _} = request(
post,
uri(PathPrefix ++ ["authentication", "jwt", "move"]),
#{position => <<"up">>}),
{ok, 400, _} = request(
post,
uri(PathPrefix ++ ["authentication", "jwt", "move"]),
#{}),
{ok, 404, _} = request(
post,
uri(PathPrefix ++ ["authentication", "jwt", "move"]),
#{position => <<"before:invalid">>}),
{ok, 404, _} = request(
post,
uri(PathPrefix ++ ["authentication", "jwt", "move"]),
#{position => <<"before:password-based:redis">>}),
{ok, 404, _} = request(
post,
uri(PathPrefix ++ ["authentication", "jwt", "move"]),
#{position => <<"before:password-based:redis">>}),
% Valid moves
{ok, 204, _} = request(
post,
uri(PathPrefix ++ ["authentication", "jwt", "move"]),
#{position => <<"top">>}),
?assertAuthenticatorsMatch(
[
#{<<"mechanism">> := <<"jwt">>},
#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>},
#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"built-in-database">>}
],
PathPrefix ++ ["authentication"]),
{ok, 204, _} = request(
post,
uri(PathPrefix ++ ["authentication", "jwt", "move"]),
#{position => <<"bottom">>}),
?assertAuthenticatorsMatch(
[
#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>},
#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"built-in-database">>},
#{<<"mechanism">> := <<"jwt">>}
],
PathPrefix ++ ["authentication"]),
{ok, 204, _} = request(
post,
uri(PathPrefix ++ ["authentication", "jwt", "move"]),
#{position => <<"before:password-based:built-in-database">>}),
?assertAuthenticatorsMatch(
[
#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>},
#{<<"mechanism">> := <<"jwt">>},
#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"built-in-database">>}
],
PathPrefix ++ ["authentication"]).
test_authenticator_import_users(PathPrefix) ->
{ok, 200, _} = request(
post,
uri(PathPrefix ++ ["authentication"]),
emqx_authn_test_lib:built_in_database_example()),
{ok, 400, _} = request(
post,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]),
#{}),
{ok, 400, _} = request(
post,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]),
#{filename => <<"/etc/passwd">>}),
{ok, 400, _} = request(
post,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]),
#{filename => <<"/not_exists.csv">>}),
Dir = code:lib_dir(emqx_authn, test),
JSONFileName = filename:join([Dir, <<"data/user-credentials.json">>]),
CSVFileName = filename:join([Dir, <<"data/user-credentials.csv">>]),
{ok, 204, _} = request(
post,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]),
#{filename => JSONFileName}),
{ok, 204, _} = request(
post,
uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]),
#{filename => CSVFileName}).
%%------------------------------------------------------------------------------
%% Helpers
%%------------------------------------------------------------------------------
delete_authenticators(Path, Chain) ->
case emqx_authentication:list_authenticators(Chain) of
{error, _} -> ok;
{ok, Authenticators} ->
lists:foreach(
fun(#{id := ID}) ->
emqx:update_config(
Path,
{delete_authenticator, Chain, ID},
#{rawconf_with_defaults => true})
end,
Authenticators)
end.
response_data(Response) ->
#{<<"data">> := Data} = jiffy:decode(Response, [return_maps]),
Data.
request(Method, Url) ->
request(Method, Url, []).
@ -83,10 +467,7 @@ request(Method, Url, Body) ->
uri() -> uri([]).
uri(Parts) when is_list(Parts) ->
NParts = [E || E <- Parts],
?HOST ++ filename:join([?BASE_PATH, ?API_VERSION | NParts]).
get_sources(Result) -> jsx:decode(Result).
?HOST ++ filename:join([?BASE_PATH, ?API_VERSION | Parts]).
auth_header() ->
Username = <<"admin">>,
@ -94,6 +475,5 @@ auth_header() ->
{ok, Token} = emqx_dashboard_admin:sign_token(Username, Password),
{"Authorization", "Bearer " ++ binary_to_list(Token)}.
to_json(Hocon) ->
{ok, Map} =hocon:binary(Hocon),
to_json(Map) ->
jiffy:encode(Map).

View File

@ -19,20 +19,15 @@
-compile(nowarn_export_all).
-compile(export_all).
authenticator_example(Id) ->
#{Id := #{value := Example}} = emqx_authn_api:authenticator_examples(),
Example.
http_example() ->
"""
{
mechanism = \"password-based\"
backend = http
method = post
url = \"http://127.0.0.2:8080\"
headers = {\"content-type\" = \"application/json\"}
body = {username = \"${username}\",
password = \"${password}\"}
pool_size = 8
connect_timeout = 5000
request_timeout = 5000
enable_pipelining = true
ssl = {enable = false}
}
""".
authenticator_example('password-based:http').
built_in_database_example() ->
authenticator_example('password-based:built-in-database').
jwt_example() ->
authenticator_example(jwt).

View File

@ -41,7 +41,7 @@
namespace() -> "dashboard".
api_spec() ->
emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}).
emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true, translate_body => true}).
paths() -> ["/login", "/logout", "/users",
"/users/:username", "/users/:username/change_pwd"].

View File

@ -5,16 +5,16 @@
%% API
-export([spec/1, spec/2]).
-export([translate_req/2]).
-export([namespace/0, fields/1]).
-export([schema_with_example/2, schema_with_examples/2]).
-export([error_codes/1, error_codes/2]).
-define(MAX_ROW_LIMIT, 100).
%% API
-ifdef(TEST).
-compile(export_all).
-compile(nowarn_export_all).
-export([
parse_spec_ref/2,
components/1,
filter_check_request/2,
filter_check_request_and_translate_body/2]).
-endif.
-define(METHODS, [get, post, put, head, delete, patch, options, trace]).
@ -22,33 +22,39 @@
-define(DEFAULT_FIELDS, [example, allowReserved, style,
explode, maxLength, allowEmptyValue, deprecated, minimum, maximum]).
-define(DEFAULT_FILTER, #{filter => fun ?MODULE:translate_req/2}).
-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(MAX_ROW_LIMIT, 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()}).
-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({Path, Specs, OperationId, Options}), list(Component)} when
Path :: string()|binary(),
Specs :: map(),
OperationId :: atom(),
Options :: #{filter => fun((map(),
#{module => module(), path => string(), method => atom()}) -> map())},
Component :: map()).
-spec(spec(module()) -> {list(api_spec_entry()), list(api_spec_component())}).
spec(Module) -> spec(Module, #{check_schema => false}).
-spec(spec(module(), #{check_schema => boolean()}) ->
{list({Path, Specs, OperationId, Options}), list(Component)} when
Path :: string()|binary(),
Specs :: map(),
OperationId :: atom(),
Options :: #{filter => fun((map(),
#{module => module(), path => string(), method => atom()}) -> map())},
Component :: map()).
-spec(spec(module(), spec_opts()) -> {list(api_spec_entry()), list(api_spec_component())}).
spec(Module, Options) ->
Paths = apply(Module, paths, []),
{ApiSpec, AllRefs} =
@ -60,26 +66,10 @@ spec(Module, Options) ->
end, {[], []}, Paths),
{ApiSpec, components(lists:usort(AllRefs))}.
-spec(translate_req(#{binding => list(), query_string => list(), body => map()},
#{module => module(), path => string(), method => atom()}) ->
{ok, #{binding => list(), query_string => list(), body => map()}}|
{400, 'BAD_REQUEST', binary()}).
translate_req(Request, #{module := Module, path := Path, method := Method}) ->
#{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_requestBody(Request, Body, Module, hoconsc:is_schema(Body)),
{ok, Request#{bindings => Bindings, query_string => QueryStr, body => NewBody}}
catch throw:Error ->
{_, [{validation_error, ValidErr}]} = Error,
#{path := Key, reason := Reason} = ValidErr,
{400, 'BAD_REQUEST', iolist_to_binary(io_lib:format("~ts : ~p", [Key, Reason]))}
end.
-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},
@ -90,9 +80,19 @@ fields(limit) ->
Meta = #{in => query, desc => Desc, default => ?MAX_ROW_LIMIT, example => 50},
[{limit, hoconsc:mk(range(1, ?MAX_ROW_LIMIT), Meta)}].
-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()) -> hocon_schema:fields()).
error_codes(Codes = [_ | _], MsgExample) ->
[
{code, hoconsc:mk(hoconsc:enum(Codes))},
@ -102,9 +102,45 @@ error_codes(Codes = [_ | _], MsgExample) ->
})}
].
support_check_schema(#{check_schema := true}) -> ?DEFAULT_FILTER;
support_check_schema(#{check_schema := Func}) when is_function(Func, 2) -> #{filter => Func};
support_check_schema(_) -> #{filter => undefined}.
%%------------------------------------------------------------------------------
%% 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_requestBody(Request, Body, Module, CheckFun, hoconsc:is_schema(Body)),
{ok, Request#{bindings => Bindings, query_string => QueryStr, body => NewBody}}
catch throw:Error ->
{_, [{validation_error, ValidErr}]} = Error,
#{path := Key, reason := Reason} = ValidErr,
{400, 'BAD_REQUEST', iolist_to_binary(io_lib:format("~ts : ~p", [Key, Reason]))}
end.
check_and_translate(Schema, Map, Opts) ->
hocon_schema:check_plain(Schema, Map, Opts).
check_only(Schema, Map, Opts) ->
_ = hocon_schema:check_plain(Schema, Map, Opts),
Map.
support_check_schema(#{check_schema := true, translate_body := true}) ->
#{filter => fun filter_check_request_and_translate_body/2};
support_check_schema(#{check_schema := true}) ->
#{filter => fun 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) ->
Schema =
@ -143,10 +179,10 @@ check_parameter([{Name, Type} | Spec], Bindings, QueryStr, Module, BindingsAcc,
query ->
NewQueryStr = hocon_schema:check_plain(Schema, QueryStr, #{override_env => false}),
NewQueryStrAcc = maps:merge(QueryStrAcc, NewQueryStr),
check_parameter(Spec, Bindings, QueryStr, Module, BindingsAcc, NewQueryStrAcc)
check_parameter(Spec, Bindings, QueryStr, Module,BindingsAcc, NewQueryStrAcc)
end.
check_requestBody(#{body := Body}, Schema, Module, true) ->
check_requestBody(#{body := Body}, Schema, Module, CheckFun, true) ->
Type0 = hocon_schema:field_schema(Schema, type),
Type =
case Type0 of
@ -154,7 +190,7 @@ check_requestBody(#{body := Body}, Schema, Module, true) ->
_ -> Type0
end,
NewSchema = ?INIT_SCHEMA#{roots => [{root, Type}]},
#{<<"root">> := NewBody} = hocon_schema:check_plain(NewSchema, #{<<"root">> => Body}, #{override_env => false}),
#{<<"root">> := NewBody} = CheckFun(NewSchema, #{<<"root">> => Body}, #{override_env => false}),
NewBody;
%% TODO not support nest object check yet, please use ref!
%% RequestBody = [ {per_page, mk(integer(), #{}},
@ -163,10 +199,10 @@ check_requestBody(#{body := Body}, Schema, Module, true) ->
%% {good_nest_2, mk(ref(?MODULE, good_ref), #{})}
%% ]}
%% ]
check_requestBody(#{body := Body}, Spec, _Module, false) ->
check_requestBody(#{body := Body}, Spec, _Module, CheckFun, false) ->
lists:foldl(fun({Name, Type}, Acc) ->
Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]},
maps:merge(Acc, hocon_schema:check_plain(Schema, Body))
maps:merge(Acc, CheckFun(Schema, Body, #{}))
end, #{}, Spec).
%% tags, description, summary, security, deprecated
@ -244,14 +280,15 @@ trans_desc(Spec, Hocon) ->
requestBody([], _Module) -> {[], []};
requestBody(Schema, Module) ->
{Props, Refs} =
{{Props, Refs}, Examples} =
case hoconsc:is_schema(Schema) of
true ->
HoconSchema = hocon_schema:field_schema(Schema, type),
hocon_schema_to_spec(HoconSchema, Module);
false -> parse_object(Schema, Module)
SchemaExamples = hocon_schema:field_schema(Schema, examples),
{hocon_schema_to_spec(HoconSchema, Module), SchemaExamples};
false -> {parse_object(Schema, Module), undefined}
end,
{#{<<"content">> => #{<<"application/json">> => #{<<"schema">> => Props}}},
{#{<<"content">> => content(Props, Examples)},
Refs}.
responses(Responses, Module) ->
@ -264,19 +301,20 @@ response(Status, ?REF(StructName), {Acc, RefsAcc, Module}) ->
response(Status, ?R_REF(Module, StructName), {Acc, RefsAcc, Module});
response(Status, ?R_REF(_Mod, _Name) = RRef, {Acc, RefsAcc, Module}) ->
{Spec, Refs} = hocon_schema_to_spec(RRef, Module),
Content = #{<<"application/json">> => #{<<"schema">> => Spec}},
Content = content(Spec),
{Acc#{integer_to_binary(Status) => #{<<"content">> => Content}}, Refs ++ RefsAcc, Module};
response(Status, Schema, {Acc, RefsAcc, Module}) ->
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_desc(#{}, Schema),
Content = #{<<"application/json">> => #{<<"schema">> => Spec}},
Content = content(Spec, Examples),
{Acc#{integer_to_binary(Status) => Init#{<<"content">> => Content}}, Refs ++ RefsAcc, Module};
false ->
{Props, Refs} = parse_object(Schema, Module),
Content = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => Props}}},
Content = #{<<"content">> => content(Props)},
{Acc#{integer_to_binary(Status) => Content}, Refs ++ RefsAcc, Module}
end.
@ -467,3 +505,11 @@ parse_object(Other, Module) ->
is_required(Hocon) ->
hocon_schema:field_schema(Hocon, required) =:= true orelse
hocon_schema:field_schema(Hocon, nullable) =:= false.
content(ApiSpec) ->
content(ApiSpec, undefined).
content(ApiSpec, undefined) ->
#{<<"application/json">> => #{<<"schema">> => ApiSpec}};
content(ApiSpec, Examples) when is_map(Examples) ->
#{<<"application/json">> => Examples#{<<"schema">> => ApiSpec}}.

View File

@ -209,14 +209,32 @@ t_in_mix_trans_error(_Config) ->
ok.
t_api_spec(_Config) ->
{Spec, _Components} = emqx_dashboard_swagger:spec(?MODULE),
Filter = fun(V, S) -> lists:all(fun({_, _, _, #{filter := Filter}}) -> Filter =:= V end, S) end,
?assertEqual(true, Filter(undefined, Spec)),
{Spec1, _Components1} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}),
?assertEqual(true, Filter(fun emqx_dashboard_swagger:translate_req/2, Spec1)),
{Spec2, _Components2} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => fun emqx_dashboard_swagger:translate_req/2}),
?assertEqual(true, Filter(fun emqx_dashboard_swagger:translate_req/2, Spec2)),
ok.
{Spec0, _} = emqx_dashboard_swagger:spec(?MODULE),
assert_all_filters_equal(Spec0, undefined),
{Spec1, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false}),
assert_all_filters_equal(Spec1, undefined),
CustomFilter = fun(Request, _RequestMeta) -> {ok, Request} end,
{Spec2, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => CustomFilter}),
assert_all_filters_equal(Spec2, CustomFilter),
{Spec3, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}),
Path = "/test/in/:filter",
Filter = filter(Spec3, Path),
Bindings = #{filter => <<"created">>},
?assertMatch(
{ok, #{bindings := #{filter := created}}},
trans_parameters(Path, Bindings, #{}, Filter)).
assert_all_filters_equal(Spec, Filter) ->
lists:foreach(
fun({_, _, _, #{filter := F}}) ->
?assertEqual(Filter, F)
end,
Spec).
validate(Path, ExpectParams) ->
{OperationId, Spec, Refs} = emqx_dashboard_swagger:parse_spec_ref(?MODULE, Path),
@ -226,10 +244,17 @@ validate(Path, ExpectParams) ->
?assertEqual([], Refs),
Spec.
filter(ApiSpec, Path) ->
[Filter] = [F || {P, _, _, #{filter := F}} <- ApiSpec, P =:= Path],
Filter.
trans_parameters(Path, Bindings, QueryStr) ->
trans_parameters(Path, Bindings, QueryStr, fun emqx_dashboard_swagger:filter_check_request/2).
trans_parameters(Path, Bindings, QueryStr, Filter) ->
Meta = #{module => ?MODULE, method => post, path => Path},
Request = #{bindings => Bindings, query_string => QueryStr, body => #{}},
emqx_dashboard_swagger:translate_req(Request, Meta).
Filter(Request, Meta).
api_spec() -> emqx_dashboard_swagger:spec(?MODULE).

View File

@ -10,7 +10,7 @@
t_ref_array_with_key/1, t_ref_array_without_key/1
]).
-export([
t_object_trans/1, t_nest_object_trans/1, t_local_ref_trans/1,
t_object_trans/1, t_object_notrans/1, t_nest_object_trans/1, t_local_ref_trans/1,
t_remote_ref_trans/1, t_nest_ref_trans/1,
t_ref_array_with_key_trans/1, t_ref_array_without_key_trans/1,
t_ref_trans_error/1, t_object_trans_error/1
@ -32,7 +32,7 @@ groups() -> [
t_ref_array_with_key, t_ref_array_without_key, t_nest_ref]},
{validation, [parallel],
[
t_object_trans, t_local_ref_trans, t_remote_ref_trans,
t_object_trans, t_object_notrans, t_local_ref_trans, t_remote_ref_trans,
t_ref_array_with_key_trans, t_ref_array_without_key_trans, t_nest_ref_trans,
t_ref_trans_error, t_object_trans_error
%% t_nest_object_trans,
@ -173,8 +173,29 @@ t_ref_array_without_key(_Config) ->
ok.
t_api_spec(_Config) ->
emqx_dashboard_swagger:spec(?MODULE),
ok.
{Spec0, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}),
Path = "/object",
Body = #{
<<"per_page">> => 1,
<<"timeout">> => <<"infinity">>,
<<"inner_ref">> => #{
<<"webhook-host">> => <<"127.0.0.1:80">>,
<<"log_dir">> => <<"var/log/test">>,
<<"tag">> => <<"god_tag">>
}
},
Filter0 = filter(Spec0, Path),
?assertMatch(
{ok, #{body := ActualBody}},
trans_requestBody(Path, Body, Filter0)),
{Spec1, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true, translate_body => true}),
Filter1 = filter(Spec1, Path),
?assertMatch(
{ok, #{body := #{<<"timeout">> := infinity}}},
trans_requestBody(Path, Body, Filter1)).
t_object_trans(_Config) ->
Path = "/object",
@ -205,6 +226,21 @@ t_object_trans(_Config) ->
?assertEqual(Expect, ActualBody),
ok.
t_object_notrans(_Config) ->
Path = "/object",
Body = #{
<<"per_page">> => 1,
<<"timeout">> => <<"infinity">>,
<<"inner_ref">> => #{
<<"webhook-host">> => <<"127.0.0.1:80">>,
<<"log_dir">> => <<"var/log/test">>,
<<"tag">> => <<"god_tag">>
}
},
{ok, #{body := ActualBody}} = trans_requestBody(Path, Body, fun emqx_dashboard_swagger:filter_check_request/2),
?assertEqual(Body, ActualBody),
ok.
t_nest_object_trans(_Config) ->
Path = "/nest/object",
Body = #{
@ -337,6 +373,7 @@ t_ref_array_with_key_trans(_Config) ->
{ok, NewRequest} = trans_requestBody(Path, Body),
?assertEqual(Expect, NewRequest),
ok.
t_ref_array_without_key_trans(_Config) ->
Path = "/ref/array/without/key",
Body = [#{
@ -401,10 +438,18 @@ validate(Path, ExpectSpec, ExpectRefs) ->
?assertEqual(ExpectRefs, Refs),
{Spec, emqx_dashboard_swagger:components(Refs)}.
filter(ApiSpec, Path) ->
[Filter] = [F || {P, _, _, #{filter := F}} <- ApiSpec, P =:= Path],
Filter.
trans_requestBody(Path, Body) ->
trans_requestBody(Path, Body, fun emqx_dashboard_swagger:filter_check_request_and_translate_body/2).
trans_requestBody(Path, Body, Filter) ->
Meta = #{module => ?MODULE, method => post, path => Path},
Request = #{bindings => #{}, query_string => #{}, body => Body},
emqx_dashboard_swagger:translate_req(Request, Meta).
Filter(Request, Meta).
api_spec() -> emqx_dashboard_swagger:spec(?MODULE).
paths() ->

View File

@ -33,7 +33,7 @@
]).
api_spec() ->
emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}).
emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true, translate_body => true}).
paths() ->
["/mqtt/topic_rewrite"].