refactor(authn api): use config schemas for request validations (#5999)
This commit is contained in:
parent
d9cb0283f3
commit
800b4b32c7
|
@ -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)).
|
||||
|
||||
|
|
|
@ -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 lists:member(Type, ?TYPES_STRING) of
|
||||
true -> {list_to_existing_atom(Type), list_to_atom(Name)};
|
||||
false -> {error, {invalid_listener_id, Id}}
|
||||
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
|
@ -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]).
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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"].
|
||||
|
|
|
@ -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}}.
|
||||
|
|
|
@ -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).
|
||||
|
||||
|
|
|
@ -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() ->
|
||||
|
|
|
@ -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"].
|
||||
|
|
Loading…
Reference in New Issue