%%-------------------------------------------------------------------- %% Copyright (c) 2021-2024 EMQ Technologies Co., Ltd. All Rights Reserved. %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. %% You may obtain a copy of the License at %% %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, software %% distributed under the License is distributed on an "AS IS" BASIS, %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. %%-------------------------------------------------------------------- -module(emqx_dashboard_swagger). -include_lib("typerefl/include/types.hrl"). -include_lib("hocon/include/hoconsc.hrl"). -define(BASE_PATH, "/api/v5"). %% API -export([spec/1, spec/2]). -export([namespace/0, namespace/1, fields/1]). -export([schema_with_example/2, schema_with_examples/2]). -export([error_codes/1, error_codes/2]). -export([file_schema/1]). -export([base_path/0]). -export([relative_uri/1, get_relative_uri/1]). -export([compose_filters/2]). -export([validate_content_type_json/2, validate_content_type/3]). -export([ filter_check_request/2, filter_check_request_and_translate_body/2, gen_api_schema_json_iodata/3 ]). -ifdef(TEST). -export([ parse_spec_ref/3, components/2 ]). -endif. -define(METHODS, [get, post, put, head, delete, patch, options, trace]). -define(DEFAULT_FIELDS, [ example, allowReserved, style, format, readOnly, explode, maxLength, allowEmptyValue, deprecated, minimum, maximum, %% is_template is a type property, %% but some exceptions are made for them to be field property %% for example, HTTP headers (which is a map type) is_template ]). -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(NO_I18N, undefined). -define(MAX_ROW_LIMIT, 10000). -define(DEFAULT_ROW, 100). -type request() :: #{bindings => map(), query_string => map(), body => map()}. -type request_meta() :: #{ module := module(), path := string(), method := atom(), %% API Operation specification override. %% Takes precedence over the API specification defined in the module. apispec => map() }. %% More exact types are defined in minirest.hrl, but we don't want to include it %% because it defines a lot of types and they may clash with the types declared locally. -type status_code() :: pos_integer(). -type error_code() :: atom() | binary(). -type error_message() :: binary(). -type response_body() :: term(). -type headers() :: map(). -type response() :: status_code() | {status_code()} | {status_code(), response_body()} | {status_code(), headers(), response_body()} | {status_code(), error_code(), error_message()}. -type filter_result() :: {ok, request()} | response(). -type filter() :: emqx_maybe:t(fun((request(), request_meta()) -> filter_result())). -type spec_opts() :: #{ check_schema => boolean() | filter(), translate_body => boolean(), schema_converter => fun((hocon_schema:schema(), Module :: atom()) -> map()), i18n_lang => atom() | string() | binary(), filter => filter() }. -type route_path() :: string() | binary(). -type route_methods() :: map(). -type route_handler() :: atom(). -type route_options() :: #{filter => filter()}. -type api_spec_entry() :: {route_path(), route_methods(), route_handler(), route_options()}. -type api_spec_component() :: map(). %%------------------------------------------------------------------------------ %% API %%------------------------------------------------------------------------------ %% @equiv spec(Module, #{check_schema => false}) -spec spec(module()) -> {list(api_spec_entry()), list(api_spec_component())}. spec(Module) -> spec(Module, #{check_schema => false}). -spec spec(module(), spec_opts()) -> {list(api_spec_entry()), list(api_spec_component())}. spec(Module, Options) -> Paths = apply(Module, paths, []), {ApiSpec, AllRefs} = lists:foldl( fun(Path, {AllAcc, AllRefsAcc}) -> {OperationId, Specs, Refs, RouteOpts} = parse_spec_ref(Module, Path, Options), { [{filename:join("/", Path), Specs, OperationId, RouteOpts} | AllAcc], Refs ++ AllRefsAcc } end, {[], []}, Paths ), {ApiSpec, components(lists:usort(AllRefs), Options)}. validate_content_type_json(Params, Meta) -> validate_content_type(Params, Meta, <<"application/json">>). %% tip: Skip content-type check if body is empty. validate_content_type( #{body := Body, headers := Headers} = Params, #{method := Method}, Expect ) when (Method =:= put orelse Method =:= post orelse method =:= patch) andalso Body =/= #{} -> ExpectSize = byte_size(Expect), case maps:get(<<"content-type">>, Headers, undefined) of <> -> {ok, Params}; _ -> {415, 'UNSUPPORTED_MEDIA_TYPE', <<"content-type:", Expect/binary, " Required">>} end; validate_content_type(Params, _Meta, _Expect) -> {ok, Params}. -spec namespace() -> hocon_schema:name(). namespace() -> "public". -spec fields(hocon_schema:name()) -> hocon_schema:fields(). fields(page) -> Desc = <<"Page number of the results to fetch.">>, Meta = #{in => query, desc => Desc, default => 1, example => 1}, [{page, hoconsc:mk(pos_integer(), Meta)}]; fields(limit) -> Desc = iolist_to_binary([ <<"Results per page(max ">>, integer_to_binary(?MAX_ROW_LIMIT), <<")">> ]), Meta = #{in => query, desc => Desc, default => ?DEFAULT_ROW, example => 50}, [{limit, hoconsc:mk(range(1, ?MAX_ROW_LIMIT), Meta)}]; fields(cursor) -> Desc = <<"Opaque value representing the current iteration state.">>, Meta = #{default => none, in => query, desc => Desc}, [{cursor, hoconsc:mk(hoconsc:union([none, binary()]), Meta)}]; fields(cursor_response) -> Desc = <<"Opaque value representing the current iteration state.">>, Meta = #{desc => Desc, required => false}, [{cursor, hoconsc:mk(binary(), Meta)}]; fields(count) -> Desc = << "Total number of records matching the query.
" "Note: this field is present only if the query can be optimized and does " "not require a full table scan." >>, Meta = #{desc => Desc, required => false}, [{count, hoconsc:mk(non_neg_integer(), Meta)}]; fields(hasnext) -> Desc = << "Flag indicating whether there are more results available on next pages." >>, Meta = #{desc => Desc, required => true}, [{hasnext, hoconsc:mk(boolean(), Meta)}]; fields(position) -> Desc = << "An opaque token that can then be in subsequent requests to get " " the next chunk of results: \"?position={prev_response.meta.position}\"
" "It is used instead of \"page\" parameter to traverse highly volatile data.
" "Can be omitted or set to \"none\" to get the first chunk of data." >>, Meta = #{ in => query, desc => Desc, required => false, example => <<"none">> }, [{position, hoconsc:mk(hoconsc:union([none, end_of_data, binary()]), Meta)}]; fields(start) -> Desc = <<"The position of the current first element of the data collection.">>, Meta = #{ desc => Desc, required => true, example => <<"none">> }, [{start, hoconsc:mk(hoconsc:union([none, binary()]), Meta)}]; fields(meta) -> fields(page) ++ fields(limit) ++ fields(count) ++ fields(hasnext); fields(meta_with_cursor) -> fields(count) ++ fields(hasnext) ++ fields(cursor_response); fields(continuation_meta) -> fields(start) ++ fields(position). -spec schema_with_example(hocon_schema:type(), term()) -> hocon_schema:field_schema(). schema_with_example(Type, Example) -> hoconsc:mk(Type, #{examples => #{<<"example">> => Example}}). -spec schema_with_examples(hocon_schema:type(), map() | list(tuple())) -> hocon_schema:field_schema(). schema_with_examples(Type, Examples) -> hoconsc:mk(Type, #{examples => #{<<"examples">> => Examples}}). -spec error_codes(list(atom())) -> hocon_schema:fields(). error_codes(Codes) -> error_codes(Codes, <<"Error code to troubleshoot problems.">>). -spec error_codes(nonempty_list(atom()), binary() | {desc, module(), term()}) -> hocon_schema:fields(). error_codes(Codes = [_ | _], MsgDesc) -> [ {code, hoconsc:mk(hoconsc:enum(Codes))}, {message, hoconsc:mk(string(), #{ desc => MsgDesc })} ]. -spec base_path() -> uri_string:uri_string(). base_path() -> ?BASE_PATH. -spec relative_uri(uri_string:uri_string()) -> uri_string:uri_string(). relative_uri(Uri) -> base_path() ++ Uri. -spec get_relative_uri(uri_string:uri_string()) -> {ok, uri_string:uri_string()} | error. get_relative_uri(<>) -> {ok, Path}; get_relative_uri(_Path) -> error. file_schema(FileName) -> #{ content => #{ 'multipart/form-data' => #{ schema => #{ type => object, properties => #{ FileName => #{type => string, format => binary} } } } } }. gen_api_schema_json_iodata(SchemaMod, SchemaInfo, Converter) -> {ApiSpec0, Components0} = spec( SchemaMod, #{ schema_converter => Converter, i18n_lang => ?NO_I18N } ), ApiSpec = lists:foldl( fun({Path, Spec, _, _}, Acc) -> NewSpec = maps:fold( fun(Method, #{responses := Responses}, SubAcc) -> case Responses of #{ <<"200">> := #{ <<"content">> := #{ <<"application/json">> := #{<<"schema">> := Schema} } } } -> SubAcc#{Method => Schema}; _ -> SubAcc end end, #{}, Spec ), Acc#{list_to_atom(Path) => NewSpec} end, #{}, ApiSpec0 ), Components = lists:foldl(fun(M, Acc) -> maps:merge(M, Acc) end, #{}, Components0), emqx_utils_json:encode( #{ info => SchemaInfo, paths => ApiSpec, components => #{schemas => Components} }, [pretty, force_utf8] ). -spec compose_filters(filter(), filter()) -> filter(). compose_filters(undefined, Filter2) -> Filter2; compose_filters(Filter1, undefined) -> Filter1; compose_filters(Filter1, Filter2) -> [Filter1, Filter2]. %%------------------------------------------------------------------------------ %% 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, ReqMeta = #{module := Module}, CheckFun) -> Spec = find_req_apispec(ReqMeta), try Params = maps:get(parameters, Spec, []), Body = maps:get('requestBody', Spec, []), {Bindings, QueryStr} = check_parameters(Request, Params, Module), case check_request_body(Request, Body, Module, CheckFun, hoconsc:is_schema(Body)) of {ok, NewBody} -> {ok, Request#{bindings => Bindings, query_string => QueryStr, body => NewBody}}; Error -> Error end catch throw:HoconError -> Msg = hocon_error_msg(HoconError), {400, 'BAD_REQUEST', Msg} end. find_req_apispec(#{apispec := Spec}) -> Spec; find_req_apispec(#{module := Module, path := Path, method := Method}) -> #{Method := Spec} = apply(Module, schema, [Path]), Spec. check_and_translate(Schema, Map, Opts) -> hocon_tconf:check_plain(Schema, Map, Opts). check_only(Schema, Map, Opts) -> _ = hocon_tconf:check_plain(Schema, Map, Opts), Map. filter(Options) -> CheckSchemaFilter = check_schema_filter(Options), CustomFilter = custom_filter(Options), compose_filters(CheckSchemaFilter, CustomFilter). custom_filter(Options) -> maps:get(filter, Options, undefined). check_schema_filter(#{check_schema := true, translate_body := true}) -> fun ?MODULE:filter_check_request_and_translate_body/2; check_schema_filter(#{check_schema := true}) -> fun ?MODULE:filter_check_request/2; check_schema_filter(#{check_schema := Filter}) when is_function(Filter, 2) -> Filter; check_schema_filter(_) -> undefined. parse_spec_ref(Module, Path, Options) -> Schema = try erlang:apply(Module, schema, [Path]) catch Error:Reason:Stacktrace -> failed_to_generate_swagger_spec(Module, Path, Error, Reason, Stacktrace) end, OperationId = maps:get('operationId', Schema), {Specs, Refs} = maps:fold( fun(Method, Meta, {Acc, RefsAcc}) -> (not lists:member(Method, ?METHODS)) andalso throw({error, #{module => Module, path => Path, method => Method}}), {Spec, SubRefs} = meta_to_spec(Meta, Module, Options), {Acc#{Method => Spec}, SubRefs ++ RefsAcc} end, {#{}, []}, maps:without(['operationId', 'filter'], Schema) ), RouteOpts = generate_route_opts(Schema, Options), {OperationId, Specs, Refs, RouteOpts}. -ifdef(TEST). -spec failed_to_generate_swagger_spec(_, _, _, _, _) -> no_return(). failed_to_generate_swagger_spec(Module, Path, Error, Reason, Stacktrace) -> error({failed_to_generate_swagger_spec, Module, Path, Error, Reason, Stacktrace}). -else. -spec failed_to_generate_swagger_spec(_, _, _, _, _) -> no_return(). failed_to_generate_swagger_spec(Module, Path, Error, Reason, Stacktrace) -> %% This error is intended to fail the build %% hence print to standard_error io:format( standard_error, "Failed to generate swagger for path ~p in module ~p~n" "error:~p~nreason:~p~n~p~n", [Module, Path, Error, Reason, Stacktrace] ), error({failed_to_generate_swagger_spec, Module, Path}). -endif. generate_route_opts(Schema, Options) -> #{filter => compose_filters(filter(Options), custom_filter(Schema))}. check_parameters(Request, Spec, Module) -> #{bindings := Bindings, query_string := QueryStr} = Request, BindingsBin = maps:fold( fun(Key, Value, Acc) -> Acc#{atom_to_binary(Key) => Value} end, #{}, Bindings ), check_parameter(Spec, BindingsBin, QueryStr, Module, #{}, #{}). check_parameter([?REF(Fields) | Spec], Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc) -> check_parameter( [?R_REF(LocalMod, Fields) | Spec], Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc ); check_parameter( [?R_REF(Module, Fields) | Spec], Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc ) -> Params = apply(Module, fields, [Fields]), check_parameter(Params ++ Spec, Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc); check_parameter([], _Bindings, _QueryStr, _Module, NewBindings, NewQueryStr) -> {NewBindings, NewQueryStr}; check_parameter([{Name, Type} | Spec], Bindings, QueryStr, Module, BindingsAcc, QueryStrAcc) -> case hocon_schema:field_schema(Type, in) of path -> Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]}, Option = #{atom_key => true}, NewBindings = hocon_tconf:check_plain(Schema, Bindings, Option), NewBindingsAcc = maps:merge(BindingsAcc, NewBindings), check_parameter(Spec, Bindings, QueryStr, Module, NewBindingsAcc, QueryStrAcc); query -> Type1 = maybe_wrap_array_qs_param(Type), Schema = ?INIT_SCHEMA#{roots => [{Name, Type1}]}, Option = #{}, NewQueryStr = hocon_tconf:check_plain(Schema, QueryStr, Option), NewQueryStrAcc = maps:merge(QueryStrAcc, NewQueryStr), check_parameter(Spec, Bindings, QueryStr, Module, BindingsAcc, NewQueryStrAcc) end. %% Compatibility layer for minirest 1.4.0 that parses repetitive QS params into lists. %% Previous minirest releases dropped all but the last repetitive params. maybe_wrap_array_qs_param(FieldSchema) -> Conv = hocon_schema:field_schema(FieldSchema, converter), Type = hocon_schema:field_schema(FieldSchema, type), case array_or_single_qs_param(Type, Conv) of any -> FieldSchema; array -> override_conv(FieldSchema, fun wrap_array_conv/2, Conv); single -> override_conv(FieldSchema, fun unwrap_array_conv/2, Conv) end. array_or_single_qs_param(?ARRAY(_Type), undefined) -> array; %% Qs field schema is an array and defines a converter: %% don't change (wrap/unwrap) the original value, and let the converter handle it. %% For example, it can be a CSV list. array_or_single_qs_param(?ARRAY(_Type), _Conv) -> any; array_or_single_qs_param(?UNION(Types), _Conv) -> HasArray = lists:any( fun (?ARRAY(_)) -> true; (_) -> false end, Types ), case HasArray of true -> any; false -> single end; array_or_single_qs_param(_, _Conv) -> single. override_conv(FieldSchema, NewConv, OldConv) -> Conv = compose_converters(NewConv, OldConv), hocon_schema:override(FieldSchema, FieldSchema#{converter => Conv}). compose_converters(NewFun, undefined = _OldFun) -> NewFun; compose_converters(NewFun, OldFun) -> case erlang:fun_info(OldFun, arity) of {_, 2} -> fun(V, Opts) -> OldFun(NewFun(V, Opts), Opts) end; {_, 1} -> fun(V, Opts) -> OldFun(NewFun(V, Opts)) end end. wrap_array_conv(Val, _Opts) when is_list(Val); Val =:= undefined -> Val; wrap_array_conv(SingleVal, _Opts) -> [SingleVal]. unwrap_array_conv([HVal | _], _Opts) -> HVal; unwrap_array_conv(SingleVal, _Opts) -> SingleVal. check_request_body(#{body := Body}, Schema, Module, CheckFun, true) -> %% the body was already being decoded %% if the content-type header specified application/json. case is_binary(Body) of false -> Type0 = hocon_schema:field_schema(Schema, type), Type = case Type0 of ?REF(StructName) -> ?R_REF(Module, StructName); _ -> Type0 end, Validations = case hocon_schema:field_schema(Schema, validator) of undefined -> []; Fun when is_function(Fun) -> [{validator, fun(#{<<"root">> := B}) -> Fun(B) end}] end, NewSchema = ?INIT_SCHEMA#{roots => [{root, Type}], validations => Validations}, Option = #{required => false}, #{<<"root">> := NewBody} = CheckFun(NewSchema, #{<<"root">> => Body}, Option), {ok, NewBody}; true -> {415, 'UNSUPPORTED_MEDIA_TYPE', <<"content-type:application/json Required">>} end; %% TODO not support nest object check yet, please use ref! %% 'requestBody' = [ {per_page, mk(integer(), #{}}, %% {nest_object, [ %% {good_nest_1, mk(integer(), #{})}, %% {good_nest_2, mk(ref(?MODULE, good_ref), #{})} %% ]} %% ] check_request_body(#{body := Body}, Spec, _Module, CheckFun, false) when is_list(Spec) -> {ok, lists:foldl( fun({Name, Type}, Acc) -> Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]}, maps:merge(Acc, CheckFun(Schema, Body, #{})) end, #{}, Spec )}; %% requestBody => #{content => #{ 'application/octet-stream' => %% #{schema => #{ type => string, format => binary}}} check_request_body(#{body := Body}, Spec, _Module, _CheckFun, false) when is_map(Spec) -> {ok, Body}. %% tags, description, summary, security, deprecated meta_to_spec(Meta, Module, Options) -> {Params, Refs1} = parameters(maps:get(parameters, Meta, []), Module, Options), {RequestBody, Refs2} = request_body(maps:get('requestBody', Meta, []), Module, Options), {Responses, Refs3} = responses(maps:get(responses, Meta, #{}), Module, Options), { generate_method_desc(to_spec(Meta, Params, RequestBody, Responses), Options), lists:usort(Refs1 ++ Refs2 ++ Refs3) }. to_spec(Meta, Params, [], Responses) -> Spec = maps:without([parameters, 'requestBody', responses], Meta), Spec#{parameters => Params, responses => Responses}; to_spec(Meta, Params, RequestBody, Responses) -> Spec = to_spec(Meta, Params, [], Responses), maps:put('requestBody', RequestBody, Spec). generate_method_desc(Spec = #{desc := _Desc}, Options) -> Spec1 = trans_description(maps:remove(desc, Spec), Spec, Options), trans_tags(Spec1); generate_method_desc(Spec = #{description := _Desc}, Options) -> Spec1 = trans_description(Spec, Spec, Options), trans_tags(Spec1); generate_method_desc(Spec, _Options) -> trans_tags(Spec). trans_tags(Spec = #{tags := Tags}) -> Spec#{tags => [string:titlecase(to_bin(Tag)) || Tag <- Tags]}; trans_tags(Spec) -> Spec. parameters(Params, Module, Options) -> {SpecList, AllRefs} = lists:foldl( fun(Param, {Acc, RefsAcc}) -> case Param of ?REF(StructName) -> to_ref(Module, StructName, Acc, RefsAcc); ?R_REF(RModule, StructName) -> to_ref(RModule, StructName, Acc, RefsAcc); {Name, Type} -> In = hocon_schema:field_schema(Type, in), In =:= undefined andalso throw({error, <<"missing in:path/query field in parameters">>}), Required = hocon_schema:field_schema(Type, required), Default = hocon_schema:field_schema(Type, default), HoconType = hocon_schema:field_schema(Type, type), SchemaExtras = hocon_extract_map([enum, default], Type), Meta = init_meta(Default), {ParamType, Refs} = hocon_schema_to_spec(HoconType, Module), Schema = maps:merge(maps:merge(ParamType, Meta), SchemaExtras), Spec0 = init_prop( [required | ?DEFAULT_FIELDS], #{schema => Schema, name => Name, in => In}, Type ), Spec1 = trans_required(Spec0, Required, In), Spec2 = trans_description(Spec1, Type, Options), {[Spec2 | Acc], Refs ++ RefsAcc} end end, {[], []}, Params ), {lists:reverse(SpecList), AllRefs}. hocon_extract_map(Keys, Type) -> lists:foldl( fun(K, M) -> case hocon_schema:field_schema(Type, K) of undefined -> M; V -> M#{K => V} end end, #{}, Keys ). init_meta(undefined) -> #{}; init_meta(Default) -> #{default => Default}. init_prop(Keys, Init, Type) -> lists:foldl( fun(Key, Acc) -> case hocon_schema:field_schema(Type, Key) of undefined -> Acc; Schema -> Acc#{Key => format_prop(Key, Schema)} end end, Init, Keys ). format_prop(deprecated, Value) when is_boolean(Value) -> Value; format_prop(deprecated, _) -> true; format_prop(default, []) -> []; format_prop(_, Schema) -> to_bin(Schema). trans_required(Spec, true, _) -> Spec#{required => true}; trans_required(Spec, _, path) -> Spec#{required => true}; trans_required(Spec, _, _) -> Spec. trans_description(Spec, Hocon, Options) -> Desc = case desc_struct(Hocon) of undefined -> undefined; ?DESC(_, _) = Struct -> get_i18n(<<"desc">>, Struct, undefined, Options); Text -> to_bin(Text) end, case Desc =:= undefined of true -> Spec; false -> Desc1 = binary:replace(Desc, [<<"\n">>], <<"
">>, [global]), Spec#{description => Desc1} end. get_i18n(Tag, ?DESC(Namespace, Id), Default, Options) -> Lang = get_lang(Options), case Lang of ?NO_I18N -> undefined; _ -> get_i18n_text(Lang, Namespace, Id, Tag, Default) end. get_i18n_text(Lang, Namespace, Id, Tag, Default) -> case emqx_dashboard_desc_cache:lookup(Lang, Namespace, Id, Tag) of undefined -> Default; Text -> Text end. %% So far i18n_lang in options is only used at build time. %% At runtime, it's still the global config which controls the language. get_lang(#{i18n_lang := Lang}) -> Lang; get_lang(_) -> emqx:get_config([dashboard, i18n_lang]). desc_struct(Hocon) -> R = case hocon_schema:field_schema(Hocon, desc) of undefined -> case hocon_schema:field_schema(Hocon, description) of undefined -> get_ref_desc(Hocon); Struct1 -> Struct1 end; Struct -> Struct end, ensure_bin(R). ensure_bin(undefined) -> undefined; ensure_bin(?DESC(_Namespace, _Id) = Desc) -> Desc; ensure_bin(Text) -> to_bin(Text). get_ref_desc(?R_REF(Mod, Name)) -> case erlang:function_exported(Mod, desc, 1) of true -> Mod:desc(Name); false -> undefined end; get_ref_desc(_) -> undefined. request_body(#{content := _} = Content, _Module, _Options) -> {Content, []}; request_body([], _Module, _Options) -> {[], []}; request_body(Schema, Module, Options) -> {{Props, Refs}, Examples} = case hoconsc:is_schema(Schema) of true -> HoconSchema = hocon_schema:field_schema(Schema, type), SchemaExamples = hocon_schema:field_schema(Schema, examples), {hocon_schema_to_spec(HoconSchema, Module), SchemaExamples}; false -> {parse_object(Schema, Module, Options), undefined} end, {#{<<"content">> => content(Props, Examples)}, Refs}. responses(Responses, Module, Options) -> {Spec, Refs, _, _} = maps:fold(fun response/3, {#{}, [], Module, Options}, Responses), {Spec, Refs}. response(Status, ?DESC(_Mod, _Id) = Schema, {Acc, RefsAcc, Module, Options}) -> Desc = trans_description(#{}, #{desc => Schema}, Options), {Acc#{integer_to_binary(Status) => Desc}, RefsAcc, Module, Options}; response(Status, Bin, {Acc, RefsAcc, Module, Options}) when is_binary(Bin) -> {Acc#{integer_to_binary(Status) => #{description => Bin}}, RefsAcc, Module, Options}; %% Support swagger raw object(file download). %% TODO: multi type response(i.e. Support both 'application/json' and 'plain/text') response(Status, #{content := _} = Content, {Acc, RefsAcc, Module, Options}) -> {Acc#{integer_to_binary(Status) => Content}, RefsAcc, Module, Options}; response(Status, ?REF(StructName), {Acc, RefsAcc, Module, Options}) -> response(Status, ?R_REF(Module, StructName), {Acc, RefsAcc, Module, Options}); response(Status, ?R_REF(_Mod, _Name) = RRef, {Acc, RefsAcc, Module, Options}) -> SchemaToSpec = get_schema_converter(Options), {Spec, Refs} = SchemaToSpec(RRef, Module), Content = content(Spec), { Acc#{ integer_to_binary(Status) => #{<<"content">> => Content} }, Refs ++ RefsAcc, Module, Options }; response(Status, Schema, {Acc, RefsAcc, Module, Options}) -> case hoconsc:is_schema(Schema) of true -> Hocon = hocon_schema:field_schema(Schema, type), Examples = hocon_schema:field_schema(Schema, examples), {Spec, Refs} = hocon_schema_to_spec(Hocon, Module), Init = trans_description(#{}, Schema, Options), Content = content(Spec, Examples), { Acc#{integer_to_binary(Status) => Init#{<<"content">> => Content}}, Refs ++ RefsAcc, Module, Options }; false -> {Props, Refs} = parse_object(Schema, Module, Options), Init = trans_description(#{}, Schema, Options), Content = Init#{<<"content">> => content(Props)}, {Acc#{integer_to_binary(Status) => Content}, Refs ++ RefsAcc, Module, Options} end. components(Refs, Options) -> lists:sort( maps:fold( fun(K, V, Acc) -> [#{K => V} | Acc] end, [], components(Options, Refs, #{}, []) ) ). components(_Options, [], SpecAcc, []) -> SpecAcc; components(Options, [], SpecAcc, SubRefAcc) -> components(Options, SubRefAcc, SpecAcc, []); components(Options, [{Module, Field} | Refs], SpecAcc, SubRefsAcc) -> Props = hocon_schema_fields(Module, Field), Namespace = namespace(Module), {Object, SubRefs} = parse_object(Props, Module, Options), NewSpecAcc = SpecAcc#{?TO_REF(Namespace, Field) => Object}, components(Options, Refs, NewSpecAcc, SubRefs ++ SubRefsAcc); %% parameters in ref only have one value, not array components(Options, [{Module, Field, parameter} | Refs], SpecAcc, SubRefsAcc) -> Props = hocon_schema_fields(Module, Field), {[Param], SubRefs} = parameters(Props, Module, Options), Namespace = namespace(Module), NewSpecAcc = SpecAcc#{?TO_REF(Namespace, Field) => Param}, components(Options, Refs, NewSpecAcc, SubRefs ++ SubRefsAcc). hocon_schema_fields(Module, StructName) -> case apply(Module, fields, [StructName]) of #{fields := Fields, desc := _} -> %% evil here, as it's match hocon_schema's internal representation %% TODO: make use of desc ? Fields; Other -> Other end. %% Semantic error at components.schemas.xxx:xx:xx %% Component names can only contain the characters A-Z a-z 0-9 - . _ %% So replace ':' by '-'. namespace(Module) -> case hocon_schema:namespace(Module) of undefined -> Module; NameSpace -> re:replace(to_bin(NameSpace), ":", "-", [global]) end. hocon_schema_to_spec(?R_REF(Module, StructName), _LocalModule) -> {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(Module, StructName)}, [{Module, StructName}]}; hocon_schema_to_spec(?REF(StructName), LocalModule) -> {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(LocalModule, StructName)}, [{LocalModule, StructName}]}; hocon_schema_to_spec(Type, LocalModule) when ?IS_TYPEREFL(Type) -> {typename_to_spec(lists:flatten(typerefl:name(Type)), LocalModule), []}; hocon_schema_to_spec(?ARRAY(Item), LocalModule) -> {Schema, Refs} = hocon_schema_to_spec(Item, LocalModule), {#{type => array, items => Schema}, Refs}; hocon_schema_to_spec(?ENUM(Items), _LocalModule) -> {#{type => string, enum => Items}, []}; hocon_schema_to_spec(?MAP(Name, Type), LocalModule) -> {Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule), { #{ <<"type">> => object, <<"properties">> => #{<<"$", (to_bin(Name))/binary>> => Schema} }, SubRefs }; hocon_schema_to_spec(?UNION(Types, _DisplayName), LocalModule) -> {OneOf, Refs} = lists:foldl( fun(Type, {Acc, RefsAcc}) -> {Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule), {[Schema | Acc], SubRefs ++ RefsAcc} end, {[], []}, hoconsc:union_members(Types) ), {#{<<"oneOf">> => OneOf}, Refs}; hocon_schema_to_spec(Atom, _LocalModule) when is_atom(Atom) -> {#{type => string, enum => [Atom]}, []}. typename_to_spec(TypeStr, Module) -> emqx_conf_schema_types:readable_swagger(Module, TypeStr). to_bin(List) when is_list(List) -> case io_lib:printable_list(List) of true -> unicode:characters_to_binary(List); false -> List end; to_bin(Boolean) when is_boolean(Boolean) -> Boolean; to_bin(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8); to_bin({Type, Args}) -> unicode:characters_to_binary(io_lib:format("~ts-~p", [Type, Args])); to_bin(X) -> X. parse_object(PropList = [_ | _], Module, Options) when is_list(PropList) -> {Props, Required, Refs} = parse_object_loop(PropList, Module, Options), Object = #{<<"type">> => object, <<"properties">> => fix_empty_props(Props)}, case Required of [] -> {Object, Refs}; _ -> {maps:put(required, Required, Object), Refs} end; parse_object(Other, Module, Options) -> erlang:throw( {error, #{ msg => <<"Object only supports non-empty fields list">>, args => Other, module => Module, options => Options }} ). parse_object_loop(PropList0, Module, Options) -> PropList = filter_hidden_key(PropList0, Module), parse_object_loop(PropList, Module, Options, _Props = [], _Required = [], _Refs = []). filter_hidden_key(PropList0, Module) -> {PropList1, _} = lists:foldr( fun({Key, Hocon} = Prop, {PropAcc, KeyAcc}) -> NewKeyAcc = assert_no_duplicated_key(Key, KeyAcc, Module), case hoconsc:is_schema(Hocon) andalso is_hidden(Hocon) of true -> {PropAcc, NewKeyAcc}; false -> {[Prop | PropAcc], NewKeyAcc} end end, {[], []}, PropList0 ), PropList1. assert_no_duplicated_key(Key, Keys, Module) -> KeyBin = emqx_utils_conv:bin(Key), case lists:member(KeyBin, Keys) of true -> throw({duplicated_key, #{module => Module, key => KeyBin, keys => Keys}}); false -> [KeyBin | Keys] end. parse_object_loop([], _Module, _Options, Props, Required, Refs) -> {lists:reverse(Props), lists:usort(Required), Refs}; parse_object_loop([{Name, Hocon} | Rest], Module, Options, Props, Required, Refs) -> NameBin = to_bin(Name), case hoconsc:is_schema(Hocon) of true -> HoconType = hocon_schema:field_schema(Hocon, type), Init0 = init_prop([default | ?DEFAULT_FIELDS], #{}, Hocon), SchemaToSpec = get_schema_converter(Options), Init = maps:remove( summary, trans_description(Init0, Hocon, Options) ), {Prop, Refs1} = SchemaToSpec(HoconType, Module), NewRequiredAcc = case is_required(Hocon) of true -> [NameBin | Required]; false -> Required end, parse_object_loop( Rest, Module, Options, [{NameBin, maps:merge(Prop, Init)} | Props], NewRequiredAcc, Refs1 ++ Refs ); false -> %% TODO: there is only a handful of such %% refactor the schema to unify the two cases {SubObject, SubRefs} = parse_object(Hocon, Module, Options), parse_object_loop( Rest, Module, Options, [{NameBin, SubObject} | Props], Required, SubRefs ++ Refs ) end. %% return true if the field has 'importance' set to 'hidden' is_hidden(Hocon) -> hocon_schema:is_hidden(Hocon, #{include_importance_up_from => ?IMPORTANCE_LOW}). is_required(Hocon) -> hocon_schema:field_schema(Hocon, required) =:= true. fix_empty_props([]) -> #{}; fix_empty_props(Props) -> Props. content(ApiSpec) -> content(ApiSpec, undefined). content(ApiSpec, undefined) -> #{<<"application/json">> => #{<<"schema">> => ApiSpec}}; content(ApiSpec, Examples) when is_map(Examples) -> #{<<"application/json">> => Examples#{<<"schema">> => ApiSpec}}. to_ref(Mod, StructName, Acc, RefsAcc) -> Ref = #{<<"$ref">> => ?TO_COMPONENTS_PARAM(Mod, StructName)}, {[Ref | Acc], [{Mod, StructName, parameter} | RefsAcc]}. get_schema_converter(Options) -> maps:get(schema_converter, Options, fun hocon_schema_to_spec/2). hocon_error_msg(Reason) -> emqx_utils:readable_error_msg(Reason).