diff --git a/apps/emqx/rebar.config b/apps/emqx/rebar.config index 7a43249a4..54735360b 100644 --- a/apps/emqx/rebar.config +++ b/apps/emqx/rebar.config @@ -16,7 +16,7 @@ , {esockd, {git, "https://github.com/emqx/esockd", {tag, "5.8.2"}}} , {ekka, {git, "https://github.com/emqx/ekka", {tag, "0.10.8"}}} , {gen_rpc, {git, "https://github.com/emqx/gen_rpc", {tag, "2.5.1"}}} - , {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.17.1"}}} + , {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.19.0"}}} , {pbkdf2, {git, "https://github.com/emqx/erlang-pbkdf2.git", {tag, "2.0.4"}}} , {recon, {git, "https://github.com/ferd/recon", {tag, "2.5.1"}}} , {snabbkaffe, {git, "https://github.com/kafka4beam/snabbkaffe.git", {tag, "0.14.1"}}} diff --git a/apps/emqx/src/emqx_schema.erl b/apps/emqx/src/emqx_schema.erl index d7caeb971..66db17e81 100644 --- a/apps/emqx/src/emqx_schema.erl +++ b/apps/emqx/src/emqx_schema.erl @@ -156,11 +156,11 @@ fields("stats") -> fields("authorization") -> [ {"no_match", - sc(union(allow, deny), + sc(hoconsc:union([allow, deny]), #{ default => allow })} , {"deny_action", - sc(union(ignore, disconnect), + sc(hoconsc:union([ignore, disconnect]), #{ default => ignore })} , {"cache", diff --git a/apps/emqx_dashboard/rebar.config b/apps/emqx_dashboard/rebar.config index bdb491bcb..d0a1fbde4 100644 --- a/apps/emqx_dashboard/rebar.config +++ b/apps/emqx_dashboard/rebar.config @@ -13,3 +13,4 @@ {cover_enabled, true}. {cover_opts, [verbose]}. {cover_export_enabled, true}. +{eunit_first_files, ["test/emqx_swagger_remote_schema.erl"]}. diff --git a/apps/emqx_dashboard/src/emqx_dashboard_api.erl b/apps/emqx_dashboard/src/emqx_dashboard_api.erl index 68c737488..874d2bf2b 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_api.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_api.erl @@ -29,135 +29,132 @@ -behaviour(minirest_api). -include("emqx_dashboard.hrl"). +-include_lib("typerefl/include/types.hrl"). +-import(hoconsc, [mk/2, ref/2, array/1, enum/1]). --import(emqx_mgmt_util, [ schema/1 - , object_schema/1 - , object_schema/2 - , object_array_schema/1 - , bad_request/0 - , properties/1 - ]). - --export([api_spec/0]). - --export([ login/2 - , logout/2 - , users/2 - , user/2 - , change_pwd/2 - ]). +-export([api_spec/0, fields/1, paths/0, schema/1, namespace/0]). +-export([login/2, logout/2, users/2, user/2, change_pwd/2]). -define(EMPTY(V), (V == undefined orelse V == <<>>)). - -define(ERROR_USERNAME_OR_PWD, 'ERROR_USERNAME_OR_PWD'). +namespace() -> "dashboard". + api_spec() -> - {[ login_api() - , logout_api() - , users_api() - , user_api() - , change_pwd_api() - ], - []}. + emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}). -login_api() -> - AuthProps = properties([{username, string, <<"Username">>}, - {password, string, <<"Password">>}]), +paths() -> ["/login", "/logout", "/users", + "/users/:username", "/users/:username/change_pwd"]. - TokenProps = properties([{token, string, <<"JWT Token">>}, - {license, object, [{edition, string, <<"License">>, [community, enterprise]}]}, - {version, string}]), - Metadata = #{ +schema("/login") -> + #{ + operationId => login, post => #{ - tags => [dashboard], + tags => [<<"dashboard">>], description => <<"Dashboard Auth">>, - 'requestBody' => object_schema(AuthProps), + summary => <<"Dashboard Auth">>, + requestBody => + [ + {username, mk(binary(), + #{desc => <<"The User for which to create the token.">>, + maxLength => 100, example => <<"admin">>})}, + {password, mk(binary(), + #{desc => "password", example => "public"})} + ], responses => #{ - <<"200">> => - object_schema(TokenProps, <<"Dashboard Auth successfully">>), - <<"401">> => unauthorized_request() + 200 => [ + {token, mk(string(), #{desc => <<"JWT Token">>})}, + {license, [{edition, + mk(enum([community, enterprise]), #{desc => <<"license">>, + example => "community"})}]}, + {version, mk(string(), #{desc => <<"version">>, example => <<"5.0.0">>})}], + 401 => [ + {code, mk(string(), #{example => 'ERROR_USERNAME_OR_PWD'})}, + {message, mk(string(), #{example => "Unauthorized"})}] }, security => [] - } - }, - {"/login", Metadata, login}. - -logout_api() -> - LogoutProps = properties([{username, string, <<"Username">>}]), - Metadata = #{ + }}; +schema("/logout") -> + #{ + operationId => logout, post => #{ - tags => [dashboard], - description => <<"Dashboard Auth">>, - 'requestBody' => object_schema(LogoutProps), + tags => [<<"dashboard">>], + description => <<"Dashboard User logout">>, + requestBody => [ + {username, mk(binary(), + #{desc => <<"The User for which to create the token.">>, + maxLength => 100, example => <<"admin">>})} + ], responses => #{ - <<"200">> => schema(<<"Dashboard Auth successfully">>) + 200 => <<"Dashboard logout successfully">> } } - }, - {"/logout", Metadata, logout}. - -users_api() -> - BaseProps = properties([{username, string, <<"Username">>}, - {password, string, <<"Password">>}, - {tag, string, <<"Tag">>}]), - Metadata = #{ + }; +schema("/users") -> + #{ + operationId => users, get => #{ - tags => [dashboard], + tags => [<<"dashboard">>], description => <<"Get dashboard users">>, responses => #{ - <<"200">> => object_array_schema(maps:without([password], BaseProps)) - } - }, - post => #{ - tags => [dashboard], - description => <<"Create dashboard users">>, - 'requestBody' => object_schema(BaseProps), - responses => #{ - <<"200">> => schema(<<"Create Users successfully">>), - <<"400">> => bad_request() + 200 => mk(array(ref(?MODULE, user)), + #{desc => "User lists"}) } } - }, - {"/users", Metadata, users}. + }; -user_api() -> - Metadata = #{ - delete => #{ - tags => [dashboard], - description => <<"Delete dashboard users">>, - parameters => parameters(), - responses => #{ - <<"200">> => schema(<<"Delete User successfully">>), - <<"400">> => bad_request() - } - }, +schema("/users/:username") -> + #{ + operationId => user, put => #{ - tags => [dashboard], + tags => [<<"dashboard">>], description => <<"Update dashboard users">>, - parameters => parameters(), - 'requestBody' => object_schema(properties([{tag, string, <<"Tag">>}])), + parameters => [{username, mk(binary(), + #{in => path, example => <<"admin">>})}], + requestBody => [{tag, mk(binary(), #{desc => <<"Tag">>})}], responses => #{ - <<"200">> => schema(<<"Update Users successfully">>), - <<"400">> => bad_request() - } - } - }, - {"/users/:username", Metadata, user}. - -change_pwd_api() -> - Metadata = #{ + 200 => <<"Update User successfully">>, + 400 => [{code, mk(string(), #{example => 'UPDATE_FAIL'})}, + {message, mk(string(), #{example => "Update Failed unknown"})}]}}, + delete => #{ + tags => [<<"dashboard">>], + description => <<"Delete dashboard users">>, + parameters => [{username, mk(binary(), + #{in => path, example => <<"admin">>})}], + responses => #{ + 200 => <<"Delete User successfully">>, + 400 => [ + {code, mk(string(), #{example => 'CANNOT_DELETE_ADMIN'})}, + {message, mk(string(), #{example => "CANNOT DELETE ADMIN"})}]}} + }; +schema("/users/:username/change_pwd") -> + #{ + operationId => change_pwd, put => #{ - tags => [dashboard], + tags => [<<"dashboard">>], description => <<"Update dashboard users password">>, - parameters => parameters(), - 'requestBody' => object_schema(properties([old_pwd, new_pwd])), + parameters => [{username, mk(binary(), + #{in => path, required => true, example => <<"admin">>})}], + requestBody => [ + {old_pwd, mk(binary(), #{required => true})}, + {new_pwd, mk(binary(), #{required => true})} + ], responses => #{ - <<"200">> => schema(<<"Update Users password successfully">>), - <<"400">> => bad_request() - } - } - }, - {"/users/:username/change_pwd", Metadata, change_pwd}. + 200 => <<"Update user password successfully">>, + 400 => [ + {code, mk(string(), #{example => 'UPDATE_FAIL'})}, + {message, mk(string(), #{example => "Failed Reason"})}]}} + }. + +fields(user) -> + [ + {tag, + mk(string(), + #{desc => <<"tag">>, example => "administrator"})}, + {username, + mk(string(), + #{desc => <<"username">>, example => "emqx"})} + ]. login(post, #{body := Params}) -> Username = maps:get(<<"username">>, Params), @@ -171,7 +168,7 @@ login(post, #{body := Params}) -> end. logout(_, #{body := #{<<"username">> := Username}, - headers := #{<<"authorization">> := <<"Bearer ", Token/binary>>}}) -> + headers := #{<<"authorization">> := <<"Bearer ", Token/binary>>}}) -> case emqx_dashboard_admin:destroy_token_by_username(Username, Token) of ok -> 200; @@ -187,9 +184,9 @@ users(post, #{body := Params}) -> Username = maps:get(<<"username">>, Params), Password = maps:get(<<"password">>, Params), case ?EMPTY(Username) orelse ?EMPTY(Password) of - true -> + true -> {400, #{code => <<"CREATE_USER_FAIL">>, - message => <<"Username or password undefined">>}}; + message => <<"Username or password undefined">>}}; false -> case emqx_dashboard_admin:add_user(Username, Password, Tag) of ok -> {200}; @@ -208,8 +205,8 @@ user(put, #{bindings := #{username := Username}, body := Params}) -> user(delete, #{bindings := #{username := Username}}) -> case Username == <<"admin">> of - true -> {400, #{code => <<"CONNOT_DELETE_ADMIN">>, - message => <<"Cannot delete admin">>}}; + true -> {400, #{code => <<"CANNOT_DELETE_ADMIN">>, + message => <<"Cannot delete admin">>}}; false -> _ = emqx_dashboard_admin:remove_user(Username), {200} @@ -226,20 +223,3 @@ change_pwd(put, #{bindings := #{username := Username}, body := Params}) -> row(#mqtt_admin{username = Username, tags = Tag}) -> #{username => Username, tag => Tag}. - -parameters() -> - [#{ - name => username, - in => path, - required => true, - schema => #{type => string}, - example => <<"admin">> - }]. - -unauthorized_request() -> - object_schema( - properties([{message, string}, - {code, string, <<"Resp Code">>, [?ERROR_USERNAME_OR_PWD]} - ]), - <<"Unauthorized">> - ). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_schema.erl b/apps/emqx_dashboard/src/emqx_dashboard_schema.erl index 3ba3dc803..94cfaddad 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_schema.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_schema.erl @@ -18,8 +18,10 @@ -include_lib("typerefl/include/types.hrl"). -export([ roots/0 - , fields/1]). + , fields/1 + ,namespace/0]). +namespace() -> <<"dashboard">>. roots() -> ["emqx_dashboard"]. fields("emqx_dashboard") -> diff --git a/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl b/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl new file mode 100644 index 000000000..9033a9b40 --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl @@ -0,0 +1,345 @@ +-module(emqx_dashboard_swagger). + +-include_lib("typerefl/include/types.hrl"). +-include_lib("hocon/include/hoconsc.hrl"). + +%% API +-export([spec/1, spec/2]). +-export([translate_req/2]). + +-ifdef(TEST). +-compile(export_all). +-compile(nowarn_export_all). +-endif. + +-define(METHODS, [get, post, put, head, delete, patch, options, trace]). + +-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(_M_, _F_), iolist_to_binary([<<"#/components/schemas/">>, ?TO_REF(namespace(_M_), _F_)])). + +%% @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(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(Module, Options) -> + Paths = apply(Module, paths, []), + {ApiSpec, AllRefs} = + lists:foldl(fun(Path, {AllAcc, AllRefsAcc}) -> + {OperationId, Specs, Refs} = parse_spec_ref(Module, Path), + CheckSchema = support_check_schema(Options), + {[{Path, Specs, OperationId, CheckSchema} | AllAcc], + Refs ++ AllRefsAcc} + 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), + 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("~s : ~p", [Key, Reason]))} + end. + +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}. + +parse_spec_ref(Module, Path) -> + Schema = + try + erlang:apply(Module, schema, [Path]) + catch error: Reason -> %% better error message + throw({error, #{mfa => {Module, schema, [Path]}, reason => Reason}}) + 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), + {Acc#{Method => Spec}, SubRefs ++ RefsAcc} + end, {#{}, []}, + maps:without([operationId], Schema)), + {maps:get(operationId, Schema), Specs, Refs}. + +check_parameters(Request, Spec) -> + #{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, #{}, #{}). + +check_parameter([], _Bindings, _QueryStr, NewBindings, NewQueryStr) -> {NewBindings, NewQueryStr}; +check_parameter([{Name, Type} | Spec], Bindings, QueryStr, BindingsAcc, QueryStrAcc) -> + Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]}, + case hocon_schema:field_schema(Type, in) of + path -> + NewBindings = hocon_schema:check_plain(Schema, Bindings, #{atom_key => true, override_env => false}), + NewBindingsAcc = maps:merge(BindingsAcc, NewBindings), + check_parameter(Spec, Bindings, QueryStr, NewBindingsAcc, QueryStrAcc); + query -> + NewQueryStr = hocon_schema:check_plain(Schema, QueryStr, #{override_env => false}), + NewQueryStrAcc = maps:merge(QueryStrAcc, NewQueryStr), + check_parameter(Spec, Bindings, QueryStr, BindingsAcc, NewQueryStrAcc) + end. + +check_requestBody(#{body := Body}, Schema, Module, 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}]}, + #{<<"root">> := NewBody} = hocon_schema:check_plain(NewSchema, #{<<"root">> => Body}, #{override_env => false}), + 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_requestBody(#{body := Body}, Spec, _Module, false) -> + lists:foldl(fun({Name, Type}, Acc) -> + Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]}, + maps:merge(Acc, hocon_schema:check_plain(Schema, Body)) + end, #{}, Spec). + +%% tags, description, summary, security, deprecated +meta_to_spec(Meta, Module) -> + {Params, Refs1} = parameters(maps:get(parameters, Meta, []), Module), + {RequestBody, Refs2} = requestBody(maps:get(requestBody, Meta, []), Module), + {Responses, Refs3} = responses(maps:get(responses, Meta, #{}), Module), + { + to_spec(Meta, Params, RequestBody, Responses), + 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). + +parameters(Params, Module) -> + {SpecList, AllRefs} = + lists:foldl(fun({Name, Type}, {Acc, RefsAcc}) -> + In = hocon_schema:field_schema(Type, in), + In =:= undefined andalso throw({error, <<"missing in:path/query field in parameters">>}), + Nullable = hocon_schema:field_schema(Type, nullable), + Default = hocon_schema:field_schema(Type, default), + HoconType = hocon_schema:field_schema(Type, type), + Meta = init_meta(Nullable, Default), + {ParamType, Refs} = hocon_schema_to_spec(HoconType, Module), + Spec0 = init_prop([required | ?DEFAULT_FIELDS], + #{schema => maps:merge(ParamType, Meta), name => Name, in => In}, Type), + Spec1 = trans_required(Spec0, Nullable, In), + Spec2 = trans_desc(Spec1, Type), + {[Spec2 | Acc], Refs ++ RefsAcc} + end, {[], []}, Params), + {lists:reverse(SpecList), AllRefs}. + +init_meta(Nullable, Default) -> + Init = + case Nullable of + true -> #{nullable => true}; + _ -> #{} + end, + case Default =:= undefined of + true -> Init; + false -> Init#{default => Default} + end. + +init_prop(Keys, Init, Type) -> + lists:foldl(fun(Key, Acc) -> + case hocon_schema:field_schema(Type, Key) of + undefined -> Acc; + Schema -> Acc#{Key => to_bin(Schema)} + end + end, Init, Keys). + +trans_required(Spec, false, _) -> Spec#{required => true}; +trans_required(Spec, _, path) -> Spec#{required => true}; +trans_required(Spec, _, _) -> Spec. + +trans_desc(Spec, Hocon) -> + case hocon_schema:field_schema(Hocon, desc) of + undefined -> Spec; + Desc -> Spec#{description => Desc} + end. + +requestBody([], _Module) -> {[], []}; +requestBody(Schema, Module) -> + {Props, Refs} = + 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) + end, + {#{<<"content">> => #{<<"application/json">> => #{<<"schema">> => Props}}}, + Refs}. + +responses(Responses, Module) -> + {Spec, Refs, _} = maps:fold(fun response/3, {#{}, [], Module}, Responses), + {Spec, Refs}. + +response(Status, Bin, {Acc, RefsAcc, Module}) when is_binary(Bin) -> + {Acc#{integer_to_binary(Status) => #{description => Bin}}, RefsAcc, Module}; +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}}, + {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), + {Spec, Refs} = hocon_schema_to_spec(Hocon, Module), + Init = trans_desc(#{}, Schema), + Content = #{<<"application/json">> => #{<<"schema">> => Spec}}, + {Acc#{integer_to_binary(Status) => Init#{<<"content">> => Content}}, Refs ++ RefsAcc, Module}; + false -> + {Props, Refs} = parse_object(Schema, Module), + Content = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => Props}}}, + {Acc#{integer_to_binary(Status) => Content}, Refs ++ RefsAcc, Module} + end. + +components(Refs) -> + lists:sort(maps:fold(fun(K, V, Acc) -> [#{K => V} | Acc] end, [], + components(Refs, #{}, []))). + +components([], SpecAcc, []) -> SpecAcc; +components([], SpecAcc, SubRefAcc) -> components(SubRefAcc, SpecAcc, []); +components([{Module, Field} | Refs], SpecAcc, SubRefsAcc) -> + Props = apply(Module, fields, [Field]), + Namespace = namespace(Module), + {Object, SubRefs} = parse_object(Props, Module), + NewSpecAcc = SpecAcc#{?TO_REF(Namespace, Field) => Object}, + components(Refs, NewSpecAcc, SubRefs ++ SubRefsAcc). + +namespace(Module) -> + case hocon_schema:namespace(Module) of + undefined -> Module; + NameSpace -> NameSpace + end. + +hocon_schema_to_spec(?R_REF(Module, StructName), _LocalModule) -> + {#{<<"$ref">> => ?TO_COMPONENTS(Module, StructName)}, + [{Module, StructName}]}; +hocon_schema_to_spec(?REF(StructName), LocalModule) -> + {#{<<"$ref">> => ?TO_COMPONENTS(LocalModule, StructName)}, + [{LocalModule, StructName}]}; +hocon_schema_to_spec(Type, _LocalModule) when ?IS_TYPEREFL(Type) -> + {typename_to_spec(typerefl:name(Type)), []}; +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(?UNION(Types), LocalModule) -> + {OneOf, Refs} = lists:foldl(fun(Type, {Acc, RefsAcc}) -> + {Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule), + {[Schema | Acc], SubRefs ++ RefsAcc} + end, {[], []}, Types), + {#{<<"oneOf">> => OneOf}, Refs}; +hocon_schema_to_spec(Atom, _LocalModule) when is_atom(Atom) -> + {#{type => string, enum => [Atom]}, []}. + +typename_to_spec("boolean()") -> #{type => boolean, example => true}; +typename_to_spec("binary()") -> #{type => string, example =><<"binary example">>}; +typename_to_spec("float()") -> #{type =>number, example =>3.14159}; +typename_to_spec("integer()") -> #{type =>integer, example =>100}; +typename_to_spec("number()") -> #{type =>number, example =>42}; +typename_to_spec("string()") -> #{type =>string, example =><<"string example">>}; +typename_to_spec("atom()") -> #{type =>string, example =>atom}; +typename_to_spec("duration()") -> #{type =>string, example =><<"12m">>}; +typename_to_spec("duration_s()") -> #{type =>string, example =><<"1h">>}; +typename_to_spec("duration_ms()") -> #{type =>string, example =><<"32s">>}; +typename_to_spec("percent()") -> #{type =>number, example =><<"12%">>}; +typename_to_spec("file()") -> #{type =>string, example =><<"/path/to/file">>}; +typename_to_spec("ip_port()") -> #{type => string, example =><<"127.0.0.1:80">>}; +typename_to_spec(Name) -> + case string:split(Name, "..") of + [MinStr, MaxStr] -> %% 1..10 + {Min, []} = string:to_integer(MinStr), + {Max, []} = string:to_integer(MaxStr), + #{type => integer, example => Min, minimum => Min, maximum => Max}; + _ -> %% Module:Type(). + case string:split(Name, ":") of + [_Module, Type] -> typename_to_spec(Type); + _ -> throw({error, #{msg => <<"Unsupport Type">>, type => Name}}) + end + end. + +to_bin(List) when is_list(List) -> list_to_binary(List); +to_bin(B) when is_boolean(B) -> B; +to_bin(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8); +to_bin(X) -> X. + +parse_object(PropList = [_|_], Module) when is_list(PropList) -> + {Props, Required, Refs} = + lists:foldl(fun({Name, Hocon}, {Acc, RequiredAcc, RefsAcc}) -> + 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), + Init = trans_desc(Init0, Hocon), + {Prop, Refs1} = hocon_schema_to_spec(HoconType, Module), + NewRequiredAcc = + case is_required(Hocon) of + true -> [NameBin | RequiredAcc]; + false -> RequiredAcc + end, + {[{NameBin, maps:merge(Prop, Init)} | Acc], NewRequiredAcc, Refs1 ++ RefsAcc}; + false -> + {SubObject, SubRefs} = parse_object(Hocon, Module), + {[{NameBin, SubObject} | Acc], RequiredAcc, SubRefs ++ RefsAcc} + end + end, {[], [], []}, PropList), + Object = #{<<"type">> => object, <<"properties">> => lists:reverse(Props)}, + case Required of + [] -> {Object, Refs}; + _ -> {maps:put(required, Required, Object), Refs} + end; +parse_object(Other, Module) -> + erlang:throw({error, + #{msg => <<"Object only supports not empty proplists">>, + args => Other, module => Module}}). + +is_required(Hocon) -> + hocon_schema:field_schema(Hocon, required) =:= true orelse + hocon_schema:field_schema(Hocon, nullable) =:= false. diff --git a/apps/emqx_dashboard/src/emqx_swagger_util.erl b/apps/emqx_dashboard/src/emqx_swagger_util.erl new file mode 100644 index 000000000..e2f279941 --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_swagger_util.erl @@ -0,0 +1,13 @@ +%%%------------------------------------------------------------------- +%%% @author zhongwen +%%% @copyright (C) 2021, +%%% @doc +%%% +%%% @end +%%% Created : 22. 9月 2021 13:38 +%%%------------------------------------------------------------------- +-module(emqx_swagger_util). +-author("zhongwen"). + +%% API +-export([]). diff --git a/apps/emqx_dashboard/test/emqx_swagger_parameter_SUITE.erl b/apps/emqx_dashboard/test/emqx_swagger_parameter_SUITE.erl new file mode 100644 index 000000000..a5c458ffa --- /dev/null +++ b/apps/emqx_dashboard/test/emqx_swagger_parameter_SUITE.erl @@ -0,0 +1,267 @@ +-module(emqx_swagger_parameter_SUITE). +-behaviour(minirest_api). +-behaviour(hocon_schema). + +%% API +-export([paths/0, api_spec/0, schema/1]). +-export([t_in_path/1, t_in_query/1, t_in_mix/1, t_without_in/1]). +-export([t_require/1, t_nullable/1, t_method/1, t_api_spec/1]). +-export([t_in_path_trans/1, t_in_query_trans/1, t_in_mix_trans/1]). +-export([t_in_path_trans_error/1, t_in_query_trans_error/1, t_in_mix_trans_error/1]). +-export([all/0, suite/0, groups/0]). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("typerefl/include/types.hrl"). +-include_lib("hocon/include/hoconsc.hrl"). +-import(hoconsc, [mk/2]). + +-define(METHODS, [get, post, put, head, delete, patch, options, trace]). + +all() -> [{group, spec}, {group, validation}]. +suite() -> [{timetrap, {minutes, 1}}]. +groups() -> [ + {spec, [parallel], [t_api_spec, t_in_path, t_in_query, t_in_mix, + t_without_in, t_require, t_nullable, t_method]}, + {validation, [parallel], [t_in_path_trans, t_in_query_trans, t_in_mix_trans, + t_in_path_trans_error, t_in_query_trans_error, t_in_mix_trans_error]} +]. + +t_in_path(_Config) -> + Expect = + [#{description => <<"Indicates which sorts of issues to return">>, + example => <<"all">>, in => path, name => filter, + required => true, + schema => #{enum => [assigned, created, mentioned, all], type => string}} + ], + validate("/test/in/:filter", Expect), + ok. + +t_in_query(_Config) -> + Expect = + [#{description => <<"results per page (max 100)">>, + example => 1, in => query, name => per_page, + schema => #{example => 1, maximum => 100, minimum => 1, type => integer}}], + validate("/test/in/query", Expect), + ok. + +t_in_mix(_Config) -> + Expect = + [#{description => <<"Indicates which sorts of issues to return">>, + example => <<"all">>,in => query,name => filter, + schema => #{enum => [assigned,created,mentioned,all],type => string}}, + #{description => <<"Indicates the state of the issues to return.">>, + example => <<"12m">>,in => path,name => state,required => true, + schema => #{example => <<"1h">>,type => string}}, + #{example => 10,in => query,name => per_page, required => false, + schema => #{default => 5,example => 1,maximum => 50,minimum => 1, type => integer}}, + #{in => query,name => is_admin, schema => #{example => true,type => boolean}}, + #{in => query,name => timeout, + schema => #{<<"oneOf">> => [#{enum => [infinity],type => string}, + #{example => 30,maximum => 60,minimum => 30, type => integer}]}}], + ExpectMeta = #{ + tags => [tags, good], + description => <<"good description">>, + summary => <<"good summary">>, + security => [], + deprecated => true, + responses => #{<<"200">> => #{description => <<"ok">>}}}, + GotSpec = validate("/test/in/mix/:state", Expect), + ?assertEqual(ExpectMeta, maps:without([parameters], maps:get(post, GotSpec))), + ok. + +t_without_in(_Config) -> + ?assertThrow({error, <<"missing in:path/query field in parameters">>}, + emqx_dashboard_swagger:parse_spec_ref(?MODULE, "/test/without/in")), + ok. + +t_require(_Config) -> + ExpectSpec = [#{ + in => query,name => userid, required => false, + schema => #{example => <<"binary example">>, type => string}}], + validate("/required/false", ExpectSpec), + ok. + +t_nullable(_Config) -> + NullableFalse = [#{in => query,name => userid, required => true, + schema => #{example => <<"binary example">>, type => string}}], + NullableTrue = [#{in => query,name => userid, + schema => #{example => <<"binary example">>, type => string, + nullable => true}}], + validate("/nullable/false", NullableFalse), + validate("/nullable/true", NullableTrue), + ok. + +t_method(_Config) -> + PathOk = "/method/ok", + PathError = "/method/error", + {test, Spec, []} = emqx_dashboard_swagger:parse_spec_ref(?MODULE, PathOk), + ?assertEqual(lists:sort(?METHODS), lists:sort(maps:keys(Spec))), + ?assertThrow({error, #{module := ?MODULE, path := PathError, method := bar}}, + emqx_dashboard_swagger:parse_spec_ref(?MODULE, PathError)), + ok. + +t_in_path_trans(_Config) -> + Path = "/test/in/:filter", + Bindings = #{filter => <<"created">>}, + Expect = {ok,#{bindings => #{filter => created}, + body => #{}, query_string => #{}}}, + ?assertEqual(Expect, trans_parameters(Path, Bindings, #{})), + ok. + +t_in_query_trans(_Config) -> + Path = "/test/in/query", + Expect = {ok, #{bindings => #{},body => #{}, + query_string => #{<<"per_page">> => 100}}}, + ?assertEqual(Expect, trans_parameters(Path, #{}, #{<<"per_page">> => 100})), + ok. + +t_in_mix_trans(_Config) -> + Path = "/test/in/mix/:state", + Bindings = #{ + state => <<"12m">>, + per_page => <<"1">> + }, + Query = #{ + <<"filter">> => <<"created">>, + <<"is_admin">> => true, + <<"timeout">> => <<"34">> + }, + Expect = {ok, + #{body => #{}, + bindings => #{state => 720}, + query_string => #{<<"filter">> => created,<<"is_admin">> => true, <<"per_page">> => 5,<<"timeout">> => 34}}}, + ?assertEqual(Expect, trans_parameters(Path, Bindings, Query)), + ok. + +t_in_path_trans_error(_Config) -> + Path = "/test/in/:filter", + Bindings = #{filter => <<"created1">>}, + Expect = {400,'BAD_REQUEST', <<"filter : unable_to_convert_to_enum_symbol">>}, + ?assertEqual(Expect, trans_parameters(Path, Bindings, #{})), + ok. + +t_in_query_trans_error(_Config) -> + Path = "/test/in/query", + {400,'BAD_REQUEST', Reason} = trans_parameters(Path, #{}, #{<<"per_page">> => 101}), + ?assertNotEqual(nomatch, binary:match(Reason, [<<"per_page">>])), + ok. + +t_in_mix_trans_error(_Config) -> + Path = "/test/in/mix/:state", + Bindings = #{ + state => <<"1d2m">>, + per_page => <<"1">> + }, + Query = #{ + <<"filter">> => <<"cdreated">>, + <<"is_admin">> => true, + <<"timeout">> => <<"34">> + }, + Expect = {400,'BAD_REQUEST', <<"filter : unable_to_convert_to_enum_symbol">>}, + ?assertEqual(Expect, trans_parameters(Path, Bindings, Query)), + 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. + +validate(Path, ExpectParams) -> + {OperationId, Spec, Refs} = emqx_dashboard_swagger:parse_spec_ref(?MODULE, Path), + ?assertEqual(test, OperationId), + Params = maps:get(parameters, maps:get(post, Spec)), + ?assertEqual(ExpectParams, Params), + ?assertEqual([], Refs), + Spec. + +trans_parameters(Path, Bindings, QueryStr) -> + Meta = #{module => ?MODULE, method => post, path => Path}, + Request = #{bindings => Bindings, query_string => QueryStr, body => #{}}, + emqx_dashboard_swagger:translate_req(Request, Meta). + +api_spec() -> emqx_dashboard_swagger:spec(?MODULE). + +paths() -> ["/test/in/:filter", "/test/in/query", "/test/in/mix/:state", + "/required/false", "/nullable/false", "/nullable/true", "/method/ok"]. + +schema("/test/in/:filter") -> + #{ + operationId => test, + post => #{ + parameters => [ + {filter, + mk(hoconsc:enum([assigned, created, mentioned, all]), + #{in => path, desc => <<"Indicates which sorts of issues to return">>, example => "all"})} + ], + responses => #{200 => <<"ok">>} + } + }; +schema("/test/in/query") -> + #{ + operationId => test, + post => #{ + parameters => [ + {per_page, + mk(range(1, 100), + #{in => query, desc => <<"results per page (max 100)">>, example => 1})} + ], + responses => #{200 => <<"ok">>} + } + }; +schema("/test/in/mix/:state") -> + #{ + operationId => test, + post => #{ + tags => [tags, good], + description => <<"good description">>, + summary => <<"good summary">>, + security => [], + deprecated => true, + parameters => [ + {filter, hoconsc:mk(hoconsc:enum([assigned, created, mentioned, all]), + #{in => query, desc => <<"Indicates which sorts of issues to return">>, example => "all"})}, + {state, mk(emqx_schema:duration_s(), + #{in => path, required => true, example => "12m", desc => <<"Indicates the state of the issues to return.">>})}, + {per_page, mk(range(1, 50), + #{in => query, required => false, example => 10, default => 5})}, + {is_admin, mk(boolean(), #{in => query})}, + {timeout, mk(hoconsc:union([range(30, 60), infinity]), #{in => query})} + ], + responses => #{200 => <<"ok">>} + } + }; +schema("/test/without/in") -> + #{ + operationId => test, + post => #{ + parameters => [ + {'x-request-id', mk(binary(), #{})} + ], + responses => #{200 => <<"ok">>} + } + }; +schema("/required/false") -> + to_schema([{'userid', mk(binary(), #{in => query, required => false})}]); +schema("/nullable/false") -> + to_schema([{'userid', mk(binary(), #{in => query, nullable => false})}]); +schema("/nullable/true") -> + to_schema([{'userid', mk(binary(), #{in => query, nullable => true})}]); +schema("/method/ok") -> + Response = #{responses => #{200 => <<"ok">>}}, + lists:foldl(fun(Method, Acc) -> Acc#{Method => Response} end, + #{operationId => test}, ?METHODS); +schema("/method/error") -> + #{operationId => test, bar => #{200 => <<"ok">>}}. +to_schema(Params) -> + #{ + operationId => test, + post => #{ + parameters => Params, + responses => #{200 => <<"ok">>} + } + }. diff --git a/apps/emqx_dashboard/test/emqx_swagger_remote_schema.erl b/apps/emqx_dashboard/test/emqx_swagger_remote_schema.erl new file mode 100644 index 000000000..91ef5a557 --- /dev/null +++ b/apps/emqx_dashboard/test/emqx_swagger_remote_schema.erl @@ -0,0 +1,59 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020-2021 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_swagger_remote_schema). + +-include_lib("typerefl/include/types.hrl"). + +-export([ roots/0, fields/1]). +-import(hoconsc, [mk/2]). +roots() -> ["root"]. + +fields("root") -> + [ + {listeners, hoconsc:array(hoconsc:union([hoconsc:ref(?MODULE, "ref1"), + hoconsc:ref(?MODULE, "ref2")]))}, + {default_username, fun default_username/1}, + {default_password, fun default_password/1}, + {sample_interval, mk(emqx_schema:duration_s(), #{default => "10s"})}, + {token_expired_time, mk(emqx_schema:duration(), #{default => "30m"})} + ]; + +fields("ref1") -> + [ + {"protocol", hoconsc:enum([http, https])}, + {"port", mk(integer(), #{default => 18083})} + ]; + +fields("ref2") -> + [ + {page, mk(range(1,100), #{desc => <<"good page">>})}, + {another_ref, hoconsc:ref(?MODULE, "ref3")} + ]; +fields("ref3") -> + [ + {ip, mk(emqx_schema:ip_port(), #{desc => <<"IP:Port">>, example => "127.0.0.1:80"})}, + {version, mk(string(), #{desc => "a good version", example => "1.0.0"})} + ]. + +default_username(type) -> string(); +default_username(default) -> "admin"; +default_username(nullable) -> false; +default_username(_) -> undefined. + +default_password(type) -> string(); +default_password(default) -> "public"; +default_password(nullable) -> false; +default_password(_) -> undefined. diff --git a/apps/emqx_dashboard/test/emqx_swagger_requestBody_SUITE.erl b/apps/emqx_dashboard/test/emqx_swagger_requestBody_SUITE.erl new file mode 100644 index 000000000..49dca926f --- /dev/null +++ b/apps/emqx_dashboard/test/emqx_swagger_requestBody_SUITE.erl @@ -0,0 +1,471 @@ +-module(emqx_swagger_requestBody_SUITE). + +-behaviour(minirest_api). +-behaviour(hocon_schema). + +%% API +-export([paths/0, api_spec/0, schema/1, fields/1]). +-export([t_object/1, t_nest_object/1, t_api_spec/1, + t_local_ref/1, t_remote_ref/1, t_bad_ref/1, t_none_ref/1, t_nest_ref/1, + 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_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 +]). +-export([all/0, suite/0, groups/0]). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("typerefl/include/types.hrl"). +-include_lib("hocon/include/hoconsc.hrl"). +-import(hoconsc, [mk/2]). + +all() -> [{group, spec}, {group, validation}]. + +suite() -> [{timetrap, {minutes, 1}}]. +groups() -> [ + {spec, [parallel], [ + t_api_spec, t_object, t_nest_object, + t_local_ref, t_remote_ref, t_bad_ref, t_none_ref, + 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_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, + ]} +]. + +t_object(_Config) -> + Spec = #{ + post => #{parameters => [], + requestBody => #{<<"content">> => + #{<<"application/json">> => + #{<<"schema">> => + #{required => [<<"timeout">>, <<"per_page">>], + <<"properties">> =>[ + {<<"per_page">>, #{description => <<"good per page desc">>, example => 1, maximum => 100, minimum => 1, type => integer}}, + {<<"timeout">>, #{default => 5, <<"oneOf">> => [#{example => <<"1h">>, type => string}, #{enum => [infinity], type => string}]}}, + {<<"inner_ref">>, #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_requestBody_SUITE.good_ref">>}}], + <<"type">> => object}}}}, + responses => #{<<"200">> => #{description => <<"ok">>}}}}, + Refs = [{?MODULE, good_ref}], + validate("/object", Spec, Refs), + ok. + +t_nest_object(_Config) -> + Spec = #{ + post => #{parameters => [], + requestBody => #{<<"content">> => #{<<"application/json">> => + #{<<"schema">> => + #{required => [<<"timeout">>], + <<"properties">> => + [{<<"per_page">>, #{description => <<"good per page desc">>, example => 1, maximum => 100, minimum => 1, type => integer}}, + {<<"timeout">>, #{default => 5, <<"oneOf">> => + [#{example => <<"1h">>, type => string}, #{enum => [infinity], type => string}]}}, + {<<"nest_object">>, + #{<<"properties">> => + [{<<"good_nest_1">>, #{example => 100, type => integer}}, + {<<"good_nest_2">>, #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_requestBody_SUITE.good_ref">>}}],<<"type">> => object}}, + {<<"inner_ref">>, #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_requestBody_SUITE.good_ref">>}}], + <<"type">> => object}}}}, + responses => #{<<"200">> => #{description => <<"ok">>}}}}, + Refs = [{?MODULE, good_ref}], + validate("/nest/object", Spec, Refs), + ok. + +t_local_ref(_Config) -> + Spec = #{ + post => #{parameters => [], + requestBody => #{<<"content">> => #{<<"application/json">> => + #{<<"schema">> => #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_requestBody_SUITE.good_ref">>}}}}, + responses => #{<<"200">> => #{description => <<"ok">>}}}}, + Refs = [{?MODULE, good_ref}], + validate("/ref/local", Spec, Refs), + ok. + +t_remote_ref(_Config) -> + Spec = #{ + post => #{parameters => [], + requestBody => #{<<"content">> => #{<<"application/json">> => + #{<<"schema">> => #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_remote_schema.ref2">>}}}}, + responses => #{<<"200">> => #{description => <<"ok">>}}}}, + Refs = [{emqx_swagger_remote_schema, "ref2"}], + {_, Components} = validate("/ref/remote", Spec, Refs), + ExpectComponents = [ + #{<<"emqx_swagger_remote_schema.ref2">> => #{<<"properties">> => [ + {<<"page">>, #{description => <<"good page">>,example => 1, maximum => 100,minimum => 1,type => integer}}, + {<<"another_ref">>, #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_remote_schema.ref3">>}}], <<"type">> => object}}, + #{<<"emqx_swagger_remote_schema.ref3">> => #{<<"properties">> => [ + {<<"ip">>, #{description => <<"IP:Port">>, example => <<"127.0.0.1:80">>,type => string}}, + {<<"version">>, #{description => "a good version", example => <<"1.0.0">>,type => string}}], + <<"type">> => object}}], + ?assertEqual(ExpectComponents, Components), + ok. + +t_nest_ref(_Config) -> + Spec = #{ + post => #{parameters => [], + requestBody => #{<<"content">> => #{<<"application/json">> => + #{<<"schema">> => #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_requestBody_SUITE.nest_ref">>}}}}, + responses => #{<<"200">> => #{description => <<"ok">>}}}}, + Refs = [{?MODULE, nest_ref}], + ExpectComponents = lists:sort([ + #{<<"emqx_swagger_requestBody_SUITE.nest_ref">> => #{<<"properties">> => [ + {<<"env">>, #{enum => [test,dev,prod],type => string}}, + {<<"another_ref">>, #{description => "nest ref", <<"$ref">> => <<"#/components/schemas/emqx_swagger_requestBody_SUITE.good_ref">>}}], + <<"type">> => object}}, + #{<<"emqx_swagger_requestBody_SUITE.good_ref">> => #{<<"properties">> => [ + {<<"webhook-host">>, #{default => <<"127.0.0.1:80">>, example => <<"127.0.0.1:80">>,type => string}}, + {<<"log_dir">>, #{example => <<"var/log/emqx">>,type => string}}, + {<<"tag">>, #{description => <<"tag">>, example => <<"binary example">>,type => string}}], + <<"type">> => object}}]), + {_, Components} = validate("/ref/nest/ref", Spec, Refs), + ?assertEqual(ExpectComponents, Components), + ok. + +t_none_ref(_Config) -> + Path = "/ref/none", + ?assertThrow({error, #{mfa := {?MODULE, schema, [Path]}}}, + emqx_dashboard_swagger:parse_spec_ref(?MODULE, Path)), + ok. + +t_bad_ref(_Config) -> + Path = "/ref/bad", + Spec = #{ + post => #{parameters => [], + requestBody => #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => + #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_requestBody_SUITE.bad_ref">>}}}}, + responses => #{<<"200">> => #{description => <<"ok">>}}}}, + Refs = [{?MODULE, bad_ref}], + Fields = fields(bad_ref), + ?assertThrow({error, #{msg := <<"Object only supports not empty proplists">>, args := Fields}}, + validate(Path, Spec, Refs)), + ok. + +t_ref_array_with_key(_Config) -> + Spec = #{ + post => #{parameters => [], + requestBody => #{<<"content">> => #{<<"application/json">> => + #{<<"schema">> => #{required => [<<"timeout">>], + <<"type">> => object, <<"properties">> => + [ + {<<"per_page">>, #{description => <<"good per page desc">>, example => 1, maximum => 100, minimum => 1, type => integer}}, + {<<"timeout">>, #{default => 5, <<"oneOf">> => [#{example => <<"1h">>, type => string}, #{enum => [infinity], type => string}]}}, + {<<"array_refs">>, #{items => #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_requestBody_SUITE.good_ref">>}, type => array}} + ]}}}}, + responses => #{<<"200">> => #{description => <<"ok">>}}}}, + Refs = [{?MODULE, good_ref}], + validate("/ref/array/with/key", Spec, Refs), + ok. + +t_ref_array_without_key(_Config) -> + Spec = #{ + post => #{parameters => [], + requestBody => #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => + #{items => #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_requestBody_SUITE.good_ref">>}, type => array}}}}, + responses => #{<<"200">> => #{description => <<"ok">>}}}}, + Refs = [{?MODULE, good_ref}], + validate("/ref/array/without/key", Spec, Refs), + ok. + +t_api_spec(_Config) -> + emqx_dashboard_swagger:spec(?MODULE), + ok. + +t_object_trans(_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">> + } + }, + Expect = + #{ + bindings => #{}, + query_string => #{}, + body => + #{ + <<"per_page">> => 1, + <<"timeout">> => infinity, + <<"inner_ref">> => #{ + <<"log_dir">> => "var/log/test", + <<"tag">> => <<"god_tag">>, + <<"webhook-host">> => {{127, 0, 0, 1}, 80}} + } + }, + {ok, ActualBody} = trans_requestBody(Path, Body), + ?assertEqual(Expect, ActualBody), + ok. + +t_nest_object_trans(_Config) -> + Path = "/nest/object", + Body = #{ + <<"timeout">> => "10m", + <<"per_page">> => 10, + <<"inner_ref">> => #{ + <<"webhook-host">> => <<"127.0.0.1:80">>, + <<"log_dir">> => <<"var/log/test">>, + <<"tag">> => <<"god_tag">> + }, + <<"nest_object">> => #{ + <<"good_nest_1">> => 1, + <<"good_nest_2">> => #{ + <<"webhook-host">> => <<"127.0.0.1:80">>, + <<"log_dir">> => <<"var/log/test">>, + <<"tag">> => <<"god_tag">> + } + } + }, + Expect = #{ + bindings => #{}, + query_string => #{}, + body => #{<<"per_page">> => 10, + <<"timeout">> => 600} + }, + {ok, NewRequest} = trans_requestBody(Path, Body), + ?assertEqual(Expect, NewRequest), + ok. + +t_local_ref_trans(_Config) -> + Path = "/ref/local", + Body = #{ + <<"webhook-host">> => <<"127.0.0.1:80">>, + <<"log_dir">> => <<"var/log/test">>, + <<"tag">> => <<"A">> + }, + Expect = #{ + bindings => #{}, + query_string => #{}, + body => #{ + <<"log_dir">> => "var/log/test", + <<"tag">> => <<"A">>, + <<"webhook-host">> => {{127, 0, 0, 1}, 80} + } + }, + {ok, NewRequest} = trans_requestBody(Path, Body), + ?assertEqual(Expect, NewRequest), + ok. + +t_remote_ref_trans(_Config) -> + Path = "/ref/remote", + Body = #{ + <<"page">> => 10, + <<"another_ref">> => #{ + <<"version">> => "2.1.0", + <<"ip">> => <<"198.12.2.1:89">>} + }, + Expect = #{ + bindings => #{}, + query_string => #{}, + body => #{ + <<"page">> => 10, + <<"another_ref">> => #{ + <<"version">> => "2.1.0", + <<"ip">> => {{198,12,2,1}, 89}} + } + }, + {ok, NewRequest} = trans_requestBody(Path, Body), + ?assertEqual(Expect, NewRequest), + ok. + +t_nest_ref_trans(_Config) -> + Path = "/ref/nest/ref", + Body = #{<<"env">> => <<"prod">>, + <<"another_ref">> => #{ + <<"log_dir">> => "var/log/dev", + <<"tag">> => <<"A">>, + <<"webhook-host">> => "127.0.0.1:80" + }}, + Expect = #{ + bindings => #{}, + query_string => #{}, + body => #{ + <<"another_ref">> => #{ + <<"log_dir">> => "var/log/dev", <<"tag">> => <<"A">>, + <<"webhook-host">> => {{127, 0, 0, 1}, 80}}, + <<"env">> => prod} + }, + {ok, NewRequest} = trans_requestBody(Path, Body), + ?assertEqual(Expect, NewRequest), + ok. + +t_ref_array_with_key_trans(_Config) -> + Path = "/ref/array/with/key", + Body = #{ + <<"per_page">> => 100, + <<"timeout">> => "100m", + <<"array_refs">> => [ + #{ + <<"log_dir">> => "var/log/dev", + <<"tag">> => <<"A">>, + <<"webhook-host">> => "127.0.0.1:80" + }, + #{ + <<"log_dir">> => "var/log/test", + <<"tag">> => <<"B">>, + <<"webhook-host">> => "127.0.0.1:81" + }] + }, + Expect = #{ + bindings => #{}, + query_string => #{}, + body => #{ + <<"per_page">> => 100, + <<"timeout">> => 6000, + <<"array_refs">> => [ + #{ + <<"log_dir">> => "var/log/dev", + <<"tag">> => <<"A">>, + <<"webhook-host">> => {{127, 0, 0, 1}, 80} + }, + #{ + <<"log_dir">> => "var/log/test", + <<"tag">> => <<"B">>, + <<"webhook-host">> => {{127, 0, 0, 1}, 81} + } + ] + } + }, + {ok, NewRequest} = trans_requestBody(Path, Body), + ?assertEqual(Expect, NewRequest), + ok. +t_ref_array_without_key_trans(_Config) -> + Path = "/ref/array/without/key", + Body = [#{ + <<"log_dir">> => "var/log/dev", + <<"tag">> => <<"A">>, + <<"webhook-host">> => "127.0.0.1:80" + }, + #{ + <<"log_dir">> => "var/log/test", + <<"tag">> => <<"B">>, + <<"webhook-host">> => "127.0.0.1:81" + }], + Expect = #{ + bindings => #{}, + query_string => #{}, + body => [ + #{ + <<"log_dir">> => "var/log/dev", + <<"tag">> => <<"A">>, + <<"webhook-host">> => {{127, 0, 0, 1}, 80} + }, + #{ + <<"log_dir">> => "var/log/test", + <<"tag">> => <<"B">>, + <<"webhook-host">> => {{127, 0, 0, 1}, 81} + }] + }, + {ok, NewRequest} = trans_requestBody(Path, Body), + ?assertEqual(Expect, NewRequest), + ok. + +t_ref_trans_error(_Config) -> + Path = "/ref/nest/ref", + Body = #{<<"env">> => <<"prod">>, + <<"another_ref">> => #{ + <<"log_dir">> => "var/log/dev", + <<"tag">> => <<"A">>, + <<"webhook-host">> => "127.0..0.1:80" + }}, + {400, 'BAD_REQUEST', _} = trans_requestBody(Path, Body), + ok. + +t_object_trans_error(_Config) -> + Path = "/object", + Body = #{ + <<"per_page">> => 99, + <<"timeout">> => <<"infinity">>, + <<"inner_ref">> => #{ + <<"webhook-host">> => <<"127.0.0..1:80">>, + <<"log_dir">> => <<"var/log/test">>, + <<"tag">> => <<"god_tag">> + } + }, + {400, 'BAD_REQUEST', Reason} = trans_requestBody(Path, Body), + ?assertNotEqual(nomatch, binary:match(Reason, [<<"webhook-host">>])), + ok. + +validate(Path, ExpectSpec, ExpectRefs) -> + {OperationId, Spec, Refs} = emqx_dashboard_swagger:parse_spec_ref(?MODULE, Path), + ?assertEqual(test, OperationId), + ?assertEqual(ExpectSpec, Spec), + ?assertEqual(ExpectRefs, Refs), + {Spec, emqx_dashboard_swagger:components(Refs)}. + +trans_requestBody(Path, Body) -> + Meta = #{module => ?MODULE, method => post, path => Path}, + Request = #{bindings => #{}, query_string => #{}, body => Body}, + emqx_dashboard_swagger:translate_req(Request, Meta). + +api_spec() -> emqx_dashboard_swagger:spec(?MODULE). +paths() -> + ["/object", "/nest/object", "/ref/local", "/ref/nest/ref", "/ref/array/with/key", "/ref/array/without/key"]. + +schema("/object") -> + to_schema([ + {per_page, mk(range(1, 100), #{nullable => false, desc => <<"good per page desc">>})}, + {timeout, mk(hoconsc:union([infinity, emqx_schema:duration_s()]), + #{default => 5, nullable => false})}, + {inner_ref, mk(hoconsc:ref(?MODULE, good_ref), #{})} + ]); +schema("/nest/object") -> + to_schema([ + {per_page, mk(range(1, 100), #{desc => <<"good per page desc">>})}, + {timeout, mk(hoconsc:union([infinity, emqx_schema:duration_s()]), + #{default => 5, nullable => false})}, + {nest_object, [ + {good_nest_1, mk(integer(), #{})}, + {good_nest_2, mk(hoconsc:ref(?MODULE, good_ref), #{})} + ]}, + {inner_ref, mk(hoconsc:ref(?MODULE, good_ref), #{})} + ]); +schema("/ref/local") -> + to_schema(mk(hoconsc:ref(good_ref), #{})); +schema("/ref/remote") -> + to_schema(mk(hoconsc:ref(emqx_swagger_remote_schema, "ref2"), #{})); +schema("/ref/bad") -> + to_schema(mk(hoconsc:ref(?MODULE, bad_ref), #{})); +schema("/ref/nest/ref") -> + to_schema(mk(hoconsc:ref(?MODULE, nest_ref), #{})); +schema("/ref/array/with/key") -> + to_schema([ + {per_page, mk(range(1, 100), #{desc => <<"good per page desc">>})}, + {timeout, mk(hoconsc:union([infinity, emqx_schema:duration_s()]), + #{default => 5, required => true})}, + {array_refs, mk(hoconsc:array(hoconsc:ref(?MODULE, good_ref)), #{})} + ]); +schema("/ref/array/without/key") -> + to_schema(mk(hoconsc:array(hoconsc:ref(?MODULE, good_ref)), #{})). + +to_schema(Body) -> + #{ + operationId => test, + post => #{requestBody => Body, responses => #{200 => <<"ok">>}} + }. + +fields(good_ref) -> + [ + {'webhook-host', mk(emqx_schema:ip_port(), #{default => "127.0.0.1:80"})}, + {log_dir, mk(emqx_schema:file(), #{example => "var/log/emqx"})}, + {tag, mk(binary(), #{desc => <<"tag">>})} + ]; +fields(nest_ref) -> + [ + {env, mk(hoconsc:enum([test, dev, prod]), #{})}, + {another_ref, mk(hoconsc:ref(good_ref), #{desc => "nest ref"})} + ]; + +fields(bad_ref) -> %% don't support maps + #{ + username => mk(string(), #{}), + is_admin => mk(boolean(), #{}) + }. diff --git a/apps/emqx_dashboard/test/emqx_swagger_response_SUITE.erl b/apps/emqx_dashboard/test/emqx_swagger_response_SUITE.erl new file mode 100644 index 000000000..c2140d2c0 --- /dev/null +++ b/apps/emqx_dashboard/test/emqx_swagger_response_SUITE.erl @@ -0,0 +1,292 @@ +-module(emqx_swagger_response_SUITE). + +-behaviour(minirest_api). +-behaviour(hocon_schema). + +-include_lib("eunit/include/eunit.hrl"). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("typerefl/include/types.hrl"). +-include_lib("hocon/include/hoconsc.hrl"). +-import(hoconsc, [mk/2]). + +-export([all/0, suite/0, groups/0]). +-export([paths/0, api_spec/0, schema/1, fields/1]). +-export([t_simple_binary/1, t_object/1, t_nest_object/1, t_empty/1, + t_raw_local_ref/1, t_raw_remote_ref/1, t_hocon_schema_function/1, + t_local_ref/1, t_remote_ref/1, t_bad_ref/1, t_none_ref/1, t_nest_ref/1, + t_ref_array_with_key/1, t_ref_array_without_key/1, t_api_spec/1]). + +all() -> [{group, spec}]. +suite() -> [{timetrap, {minutes, 1}}]. +groups() -> [ + {spec, [parallel], [ + t_api_spec, t_simple_binary, t_object, t_nest_object, + t_raw_local_ref, t_raw_remote_ref, t_empty, t_hocon_schema_function, + t_local_ref, t_remote_ref, t_bad_ref, t_none_ref, + t_ref_array_with_key, t_ref_array_without_key, t_nest_ref]} +]. + +t_simple_binary(_config) -> + Path = "/simple/bin", + ExpectSpec = #{description => <<"binary ok">>}, + ExpectRefs = [], + validate(Path, ExpectSpec, ExpectRefs), + ok. + +t_object(_config) -> + Path = "/object", + Object = + #{<<"content">> => #{<<"application/json">> => + #{<<"schema">> => #{required => [<<"timeout">>, <<"per_page">>], + <<"properties">> => [ + {<<"per_page">>, #{description => <<"good per page desc">>, example => 1, maximum => 100, minimum => 1, type => integer}}, + {<<"timeout">>, #{default => 5, <<"oneOf">> => [#{example => <<"1h">>, type => string}, #{enum => [infinity], type => string}]}}, + {<<"inner_ref">>, #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_response_SUITE.good_ref">>}}], + <<"type">> => object}}}}, + ExpectRefs = [{?MODULE, good_ref}], + validate(Path, Object, ExpectRefs), + ok. + +t_nest_object(_Config) -> + Path = "/nest/object", + Object = + #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => + #{required => [<<"timeout">>], <<"type">> => object, <<"properties">> => [ + {<<"per_page">>, #{description => <<"good per page desc">>, example => 1, maximum => 100, minimum => 1, type => integer}}, + {<<"timeout">>, #{default => 5, <<"oneOf">> => + [#{example => <<"1h">>, type => string}, #{enum => [infinity], type => string}]}}, + {<<"nest_object">>, #{<<"type">> => object, <<"properties">> => [ + {<<"good_nest_1">>, #{example => 100, type => integer}}, + {<<"good_nest_2">>, #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_response_SUITE.good_ref">>} + }]}}, + {<<"inner_ref">>, #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_response_SUITE.good_ref">>}}] + }}}}, + ExpectRefs = [{?MODULE, good_ref}], + validate(Path, Object, ExpectRefs), + ok. + +t_empty(_Config) -> + ?assertThrow({error, + #{msg := <<"Object only supports not empty proplists">>, + args := [], module := ?MODULE}}, validate("/empty", error, [])), + ok. + +t_raw_local_ref(_Config) -> + Path = "/raw/ref/local", + Object = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => #{ + <<"$ref">> => <<"#/components/schemas/emqx_swagger_response_SUITE.good_ref">>}}}}, + ExpectRefs = [{?MODULE, good_ref}], + validate(Path, Object, ExpectRefs), + ok. + +t_raw_remote_ref(_Config) -> + Path = "/raw/ref/remote", + Object = #{<<"content">> => + #{<<"application/json">> => #{<<"schema">> => #{ + <<"$ref">> => <<"#/components/schemas/emqx_swagger_remote_schema.ref1">>}}}}, + ExpectRefs = [{emqx_swagger_remote_schema, "ref1"}], + validate(Path, Object, ExpectRefs), + ok. + +t_local_ref(_Config) -> + Path = "/ref/local", + Object = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => #{ + <<"$ref">> => <<"#/components/schemas/emqx_swagger_response_SUITE.good_ref">>}}}}, + ExpectRefs = [{?MODULE, good_ref}], + validate(Path, Object, ExpectRefs), + ok. + +t_remote_ref(_Config) -> + Path = "/ref/remote", + Object = #{<<"content">> => + #{<<"application/json">> => #{<<"schema">> => #{ + <<"$ref">> => <<"#/components/schemas/emqx_swagger_remote_schema.ref1">>}}}}, + ExpectRefs = [{emqx_swagger_remote_schema, "ref1"}], + validate(Path, Object, ExpectRefs), + ok. + +t_bad_ref(_Config) -> + Path = "/ref/bad", + Object = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => + #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_response_SUITE.bad_ref">>}}}}, + ExpectRefs = [{?MODULE, bad_ref}], + ?assertThrow({error, #{module := ?MODULE, msg := <<"Object only supports not empty proplists">>}}, + validate(Path, Object, ExpectRefs)), + ok. + +t_none_ref(_Config) -> + Path = "/ref/none", + ?assertThrow({error, #{mfa := {?MODULE, schema, ["/ref/none"]}, + reason := function_clause}}, validate(Path, #{}, [])), + ok. + +t_nest_ref(_Config) -> + Path = "/ref/nest/ref", + Object = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => #{ + <<"$ref">> => <<"#/components/schemas/emqx_swagger_response_SUITE.nest_ref">>}}}}, + ExpectRefs = [{?MODULE, nest_ref}], + validate(Path, Object, ExpectRefs), + ok. + +t_ref_array_with_key(_Config) -> + Path = "/ref/array/with/key", + Object = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => #{ + required => [<<"timeout">>], <<"type">> => object, <<"properties">> => [ + {<<"per_page">>, #{description => <<"good per page desc">>, example => 1, maximum => 100, minimum => 1, type => integer}}, + {<<"timeout">>, #{default => 5, <<"oneOf">> => + [#{example => <<"1h">>, type => string}, #{enum => [infinity], type => string}]}}, + {<<"assert">>, #{description => <<"money">>, example => 3.14159,type => number}}, + {<<"number_ex">>, #{description => <<"number example">>, example => 42,type => number}}, + {<<"percent_ex">>, #{description => <<"percent example">>, example => <<"12%">>,type => number}}, + {<<"duration_ms_ex">>, #{description => <<"duration ms example">>, example => <<"32s">>,type => string}}, + {<<"atom_ex">>, #{description => <<"atom ex">>, example => atom, type => string}}, + {<<"array_refs">>, #{items => #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_response_SUITE.good_ref">>}, type => array}} + ]} + }}}, + ExpectRefs = [{?MODULE, good_ref}], + validate(Path, Object, ExpectRefs), + ok. + +t_ref_array_without_key(_Config) -> + Path = "/ref/array/without/key", + Object = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => #{ + items => #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_response_SUITE.good_ref">>}, + type => array}}}}, + ExpectRefs = [{?MODULE, good_ref}], + validate(Path, Object, ExpectRefs), + ok. +t_hocon_schema_function(_Config) -> + Path = "/ref/hocon/schema/function", + Object = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => + #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_remote_schema.root">>}}}}, + ExpectComponents = [ + #{<<"emqx_swagger_remote_schema.ref1">> => #{<<"type">> => object, + <<"properties">> => [ + {<<"protocol">>, #{enum => [http, https], type => string}}, + {<<"port">>, #{default => 18083, example => 100, type => integer}}] + }}, + #{<<"emqx_swagger_remote_schema.ref2">> => #{<<"type">> => object, + <<"properties">> => [ + {<<"page">>, #{description => <<"good page">>, example => 1, maximum => 100, minimum => 1, type => integer}}, + {<<"another_ref">>, #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_remote_schema.ref3">>}} + ] + }}, + #{<<"emqx_swagger_remote_schema.ref3">> => #{<<"type">> => object, + <<"properties">> => [ + {<<"ip">>, #{description => <<"IP:Port">>, example => <<"127.0.0.1:80">>,type => string}}, + {<<"version">>, #{description => "a good version", example => <<"1.0.0">>, type => string}}] + }}, + #{<<"emqx_swagger_remote_schema.root">> => #{required => [<<"default_password">>, <<"default_username">>], + <<"properties">> => [{<<"listeners">>, #{items => + #{<<"oneOf">> => + [#{<<"$ref">> => <<"#/components/schemas/emqx_swagger_remote_schema.ref2">>}, + #{<<"$ref">> => <<"#/components/schemas/emqx_swagger_remote_schema.ref1">>}]}, type => array}}, + {<<"default_username">>, + #{default => <<"admin">>, example => <<"string example">>, type => string}}, + {<<"default_password">>, #{default => <<"public">>, example => <<"string example">>, type => string}}, + {<<"sample_interval">>, #{default => <<"10s">>, example => <<"1h">>, type => string}}, + {<<"token_expired_time">>, #{default => <<"30m">>, example => <<"12m">>, type => string}}], + <<"type">> => object}}], + ExpectRefs = [{emqx_swagger_remote_schema, "root"}], + {_, Components} = validate(Path, Object, ExpectRefs), + ?assertEqual(ExpectComponents, Components), + ok. + +t_api_spec(_Config) -> + emqx_dashboard_swagger:spec(?MODULE), + ok. + +api_spec() -> emqx_dashboard_swagger:spec(?MODULE). + +paths() -> + ["/simple/bin", "/object", "/nest/object", "/ref/local", + "/ref/nest/ref", "/raw/ref/local", "/raw/ref/remote", + "/ref/array/with/key", "/ref/array/without/key", + "/ref/hocon/schema/function"]. + +schema("/simple/bin") -> + to_schema(<<"binary ok">>); +schema("/object") -> + Object = [ + {per_page, mk(range(1, 100), #{nullable => false, desc => <<"good per page desc">>})}, + {timeout, mk(hoconsc:union([infinity, emqx_schema:duration_s()]), + #{default => 5, nullable => false})}, + {inner_ref, mk(hoconsc:ref(?MODULE, good_ref), #{})} + ], + to_schema(Object); +schema("/nest/object") -> + Response = [ + {per_page, mk(range(1, 100), #{desc => <<"good per page desc">>})}, + {timeout, mk(hoconsc:union([infinity, emqx_schema:duration_s()]), + #{default => 5, nullable => false})}, + {nest_object, [ + {good_nest_1, mk(integer(), #{})}, + {good_nest_2, mk(hoconsc:ref(?MODULE, good_ref), #{})} + ]}, + {inner_ref, mk(hoconsc:ref(?MODULE, good_ref), #{})}], + to_schema(Response); +schema("/empty") -> + to_schema([]); +schema("/raw/ref/local") -> + to_schema(hoconsc:ref(good_ref)); +schema("/raw/ref/remote") -> + to_schema(hoconsc:ref(emqx_swagger_remote_schema, "ref1")); +schema("/ref/local") -> + to_schema(mk(hoconsc:ref(good_ref), #{})); +schema("/ref/remote") -> + to_schema(mk(hoconsc:ref(emqx_swagger_remote_schema, "ref1"), #{})); +schema("/ref/bad") -> + to_schema(mk(hoconsc:ref(?MODULE, bad_ref), #{})); +schema("/ref/nest/ref") -> + to_schema(mk(hoconsc:ref(?MODULE, nest_ref), #{})); +schema("/ref/array/with/key") -> + to_schema([ + {per_page, mk(range(1, 100), #{desc => <<"good per page desc">>})}, + {timeout, mk(hoconsc:union([infinity, emqx_schema:duration_s()]), + #{default => 5, required => true})}, + {assert, mk(float(), #{desc => <<"money">>})}, + {number_ex, mk(number(), #{desc => <<"number example">>})}, + {percent_ex, mk(emqx_schema:percent(), #{desc => <<"percent example">>})}, + {duration_ms_ex, mk(emqx_schema:duration_ms(), #{desc => <<"duration ms example">>})}, + {atom_ex, mk(atom(), #{desc => <<"atom ex">>})}, + {array_refs, mk(hoconsc:array(hoconsc:ref(?MODULE, good_ref)), #{})} + ]); +schema("/ref/array/without/key") -> + to_schema(mk(hoconsc:array(hoconsc:ref(?MODULE, good_ref)), #{})); +schema("/ref/hocon/schema/function") -> + to_schema(mk(hoconsc:ref(emqx_swagger_remote_schema, "root"), #{})). + +validate(Path, ExpectObject, ExpectRefs) -> + {OperationId, Spec, Refs} = emqx_dashboard_swagger:parse_spec_ref(?MODULE, Path), + ?assertEqual(test, OperationId), + Response = maps:get(responses, maps:get(post, Spec)), + ?assertEqual(ExpectObject, maps:get(<<"200">>, Response)), + ?assertEqual(ExpectObject, maps:get(<<"201">>, Response)), + ?assertEqual(#{}, maps:without([<<"201">>, <<"200">>], Response)), + ?assertEqual(ExpectRefs, Refs), + {Spec, emqx_dashboard_swagger:components(Refs)}. + +to_schema(Object) -> + #{ + operationId => test, + post => #{responses => #{200 => Object, 201 => Object}} + }. + +fields(good_ref) -> + [ + {'webhook-host', mk(emqx_schema:ip_port(), #{default => "127.0.0.1:80"})}, + {log_dir, mk(emqx_schema:file(), #{example => "var/log/emqx"})}, + {tag, mk(binary(), #{desc => <<"tag">>})} + ]; +fields(nest_ref) -> + [ + {env, mk(hoconsc:enum([test, dev, prod]), #{})}, + {another_ref, mk(hoconsc:ref(good_ref), #{desc => "nest ref"})} + ]; + +fields(bad_ref) -> %% don't support maps + #{ + username => mk(string(), #{}), + is_admin => mk(boolean(), #{}) + }. diff --git a/apps/emqx_machine/test/emqx_cluster_rpc_SUITE.erl b/apps/emqx_machine/test/emqx_cluster_rpc_SUITE.erl index cb07db37e..d39dc3c6e 100644 --- a/apps/emqx_machine/test/emqx_cluster_rpc_SUITE.erl +++ b/apps/emqx_machine/test/emqx_cluster_rpc_SUITE.erl @@ -179,14 +179,23 @@ t_del_stale_mfa(_Config) -> t_skip_failed_commit(_Config) -> emqx_cluster_rpc:reset(), {atomic, []} = emqx_cluster_rpc:status(), - {ok, _, ok} = emqx_cluster_rpc:multicall(io, format, ["test~n"], all, 1000), + {ok, 1, ok} = emqx_cluster_rpc:multicall(io, format, ["test~n"], all, 1000), + {atomic, List1} = emqx_cluster_rpc:status(), + Node = node(), + ?assertEqual([{Node, 1}, {{Node, ?NODE2}, 1}, {{Node, ?NODE3}, 1}], + tnx_ids(List1)), {M, F, A} = {?MODULE, failed_on_node, [erlang:whereis(?NODE1)]}, {ok, _, ok} = emqx_cluster_rpc:multicall(M, F, A, 1, 1000), ok = gen_server:call(?NODE2, skip_failed_commit, 5000), - {atomic, List} = emqx_cluster_rpc:status(), - ?assertEqual([1, 2, 2], lists:sort(lists:map(fun(#{tnx_id := TnxId}) -> TnxId end, List))), + {atomic, List2} = emqx_cluster_rpc:status(), + ?assertEqual([{Node, 2}, {{Node, ?NODE2}, 2}, {{Node, ?NODE3}, 1}], + tnx_ids(List2)), ok. +tnx_ids(Status) -> + lists:sort(lists:map(fun(#{tnx_id := TnxId, node := Node}) -> + {Node, TnxId} end, Status)). + start() -> {ok, Pid1} = emqx_cluster_rpc:start_link(), {ok, Pid2} = emqx_cluster_rpc:start_link({node(), ?NODE2}, ?NODE2, 500), diff --git a/rebar.config b/rebar.config index 310cd82ec..54e6d23f8 100644 --- a/rebar.config +++ b/rebar.config @@ -52,7 +52,7 @@ , {esockd, {git, "https://github.com/emqx/esockd", {tag, "5.8.2"}}} , {ekka, {git, "https://github.com/emqx/ekka", {tag, "0.10.8"}}} , {gen_rpc, {git, "https://github.com/emqx/gen_rpc", {tag, "2.5.1"}}} - , {minirest, {git, "https://github.com/emqx/minirest", {tag, "1.2.3"}}} + , {minirest, {git, "https://github.com/emqx/minirest", {tag, "1.2.4"}}} , {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.1"}}} , {replayq, "0.3.3"} , {pbkdf2, {git, "https://github.com/emqx/erlang-pbkdf2.git", {tag, "2.0.4"}}} @@ -61,7 +61,7 @@ , {observer_cli, "1.7.1"} % NOTE: depends on recon 2.5.x , {getopt, "1.0.2"} , {snabbkaffe, {git, "https://github.com/kafka4beam/snabbkaffe.git", {tag, "0.14.1"}}} - , {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.17.1"}}} + , {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.19.0"}}} , {emqx_http_lib, {git, "https://github.com/emqx/emqx_http_lib.git", {tag, "0.4.1"}}} , {esasl, {git, "https://github.com/emqx/esasl", {tag, "0.2.0"}}} , {jose, {git, "https://github.com/potatosalad/erlang-jose", {tag, "1.11.1"}}}