From a35d6f7acc5a1acf1ed67e58ab562b0ed74c1c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Sat, 9 May 2026 18:15:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20Swagger=20https://git.sabilin.com/EventHub/EventHubBac?= =?UTF-8?q?k/issues/20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 1 + rebar.config | 3 +- src/config/sys.config | 3 + src/eventhub.app.src | 4 +- src/eventhub_app.erl | 63 ++++--- .../admin/admin_handler_event_by_id.erl | 154 +++++++++++++++++- src/handlers/admin/admin_handler_events.erl | 121 +++++++++++--- src/handlers/swagger_docs_handler.erl | 96 +++++++++++ src/swagger/trails.erl | 25 +++ 9 files changed, 419 insertions(+), 51 deletions(-) create mode 100644 src/handlers/swagger_docs_handler.erl create mode 100644 src/swagger/trails.erl diff --git a/Makefile b/Makefile index 9777bff..a058242 100644 --- a/Makefile +++ b/Makefile @@ -263,6 +263,7 @@ docker-compose-up: ## Запустить кластер (3 ноды) @echo "ObserverWeb: http://localhost:4000/observer/" @echo "Traefik: http://localhost:8080" @echo "LogLynx: http://localhost:6123" + @echo "Swagger UI: http://localhost:8447" docker-compose-down: ## Остановить кластер @echo "Остановка кластера..." diff --git a/rebar.config b/rebar.config index 3a8a0b9..53634de 100644 --- a/rebar.config +++ b/rebar.config @@ -9,7 +9,8 @@ {argon2, "1.2.0"}, {meck, "0.9.2"}, {gun, "2.2.0"}, - {prometheus_cowboy, "0.2.0"} + {prometheus_cowboy, "0.2.0"}, + {cowboy_swagger, "2.8.0"} ]}. {shell, [ diff --git a/src/config/sys.config b/src/config/sys.config index 2c32a2a..0e1a1b3 100644 --- a/src/config/sys.config +++ b/src/config/sys.config @@ -13,5 +13,8 @@ #{formatter => {logger_formatter, #{template => [time, " ", level, ": ", msg, "\n"]}}} } ]} + ]}, + { cowboy_swagger, [ + { static_files, "./_build/default/lib/cowboy_swagger/priv/swagger" } ]} ]. \ No newline at end of file diff --git a/src/eventhub.app.src b/src/eventhub.app.src index 50e1145..03c11d0 100644 --- a/src/eventhub.app.src +++ b/src/eventhub.app.src @@ -9,7 +9,9 @@ mnesia, crypto, cowboy, - jsx + jsx, + trails, + cowboy_swagger ]}, {env, [ {http_port, 8080}, diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 6e12767..ee308be 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -10,29 +10,29 @@ start(_StartType, _StartArgs) -> {ok, Pid} -> % Определяем список узлов кластера, если режим CLUSTER_MODE=true Nodes = case os:getenv("CLUSTER_MODE", "false") of - "true" -> - DnsName = os:getenv("DNS_NAME", "eventhub-node"), - try inet:getaddrs(DnsName, inet) of - {ok, IPs} when is_list(IPs), IPs /= [] -> - % Получаем имена всех узлов Erlang, зарегистрированных в EPMD - AllNodes = lists:flatmap(fun(IP) -> - case erl_epmd:names(IP) of - {ok, Names} -> - [list_to_atom(Name ++ "@" ++ Name) || {Name, _Port} <- Names, - lists:prefix("eventhub-node", Name)]; - _ -> [] - end - end, IPs), - % Исключаем свой узел, чтобы не подключаться к самому себе - AllNodes -- [node()]; - _ -> [] - catch - _:_ -> - io:format("DNS lookup failed, starting as first node~n"), - [] - end; - _ -> [] - end, + "true" -> + DnsName = os:getenv("DNS_NAME", "eventhub-node"), + try inet:getaddrs(DnsName, inet) of + {ok, IPs} when is_list(IPs), IPs /= [] -> + % Получаем имена всех узлов Erlang, зарегистрированных в EPMD + AllNodes = lists:flatmap(fun(IP) -> + case erl_epmd:names(IP) of + {ok, Names} -> + [list_to_atom(Name ++ "@" ++ Name) || {Name, _Port} <- Names, + lists:prefix("eventhub-node", Name)]; + _ -> [] + end + end, IPs), + % Исключаем свой узел, чтобы не подключаться к самому себе + AllNodes -- [node()]; + _ -> [] + catch + _:_ -> + io:format("DNS lookup failed, starting as first node~n"), + [] + end; + _ -> [] + end, case Nodes of [] -> io:format("Cluster: no nodes found or first node~n"); @@ -45,6 +45,7 @@ start(_StartType, _StartArgs) -> calendar_html_renderer:init_cache(), start_http(), % Пользовательский API (8080) start_admin_http(), % Административный API (8445) + start_swagger_http(), % Swagger UI и спецификация (8447) application:ensure_all_started(prometheus), application:ensure_all_started(prometheus_cowboy), init_default_admins(), @@ -153,6 +154,22 @@ start_admin_http() -> io:format("WebSocket started on ports ~p (user) and ~p (admin)~n", [PortWs, PortAdminWs]). +%% =================================================================== +%% Swagger HTTP (порт 8447) — документация API +%% =================================================================== +start_swagger_http() -> + PortSwagger = get_env_int(swagger_http_port, 8447), + Dispatch = cowboy_router:compile([ + {'_', [ + {"/", swagger_docs_handler, []}, + {"/[...]", swagger_docs_handler, []} + ]} + ]), + Middlewares = [cowboy_router, cowboy_handler], + Env = #{dispatch => Dispatch}, + cowboy:start_clear(swagger_http, [{port, PortSwagger}], #{env => Env, middlewares => Middlewares}), + io:format("Swagger HTTP server started on port ~p~n", [PortSwagger]). + %% ---------- Инициализация администраторов ---------- init_default_admins() -> case core_admin:list_all() of diff --git a/src/handlers/admin/admin_handler_event_by_id.erl b/src/handlers/admin/admin_handler_event_by_id.erl index cea85fb..9c1546a 100644 --- a/src/handlers/admin/admin_handler_event_by_id.erl +++ b/src/handlers/admin/admin_handler_event_by_id.erl @@ -2,16 +2,146 @@ -behaviour(cowboy_handler). -export([init/2]). +-export([trails/0]). + -include("records.hrl"). +%%%=================================================================== +%%% cowboy_handler callback +%%%=================================================================== + init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> get_event(Req); - <<"PUT">> -> update_event(Req); + <<"GET">> -> get_event(Req); + <<"PUT">> -> update_event(Req); <<"DELETE">> -> delete_event(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> send_error(Req, 405, <<"Method not allowed">>) end. +%%%=================================================================== +%%% Swagger / Trails metadata +%%%=================================================================== + +trails() -> + Path = <<"/v1/admin/events/:id">>, + BaseParams = [ + #{ + name => <<"id">>, + in => <<"path">>, + description => <<"Event ID">>, + required => true, + schema => #{type => string} + } + ], + [ + %% GET + #{ + path => Path, + method => <<"GET">>, + handler => ?MODULE, + tags => [<<"Events: id">>], + description => <<"Get event by ID (admin)">>, + parameters => BaseParams, + responses => #{ + 200 => #{ + description => <<"Event details">>, + content => #{ + <<"application/json">> => #{ + schema => event_schema() + } + } + } + } + }, + %% PUT + #{ + path => Path, + method => <<"PUT">>, + handler => ?MODULE, + tags => [<<"Events: id">>], + description => <<"Update event (admin)">>, + parameters => BaseParams, + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => event_update_schema() + } + } + }, + responses => #{ + 200 => #{description => <<"Updated event">>} + } + }, + %% DELETE + #{ + path => Path, + method => <<"DELETE">>, + handler => ?MODULE, + tags => [<<"Events: id">>], + description => <<"Soft-delete event (admin)">>, + parameters => BaseParams, + responses => #{ + 200 => #{description => <<"Event status set to deleted">>} + } + } + ]. + +event_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + calendar_id => #{type => string}, + title => #{type => string}, + description => #{type => string}, + event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, + start_time => #{type => string, format => <<"date-time">>}, + duration => #{type => integer}, + recurrence => #{type => object, nullable => true}, + master_id => #{type => string, nullable => true}, + is_instance => #{type => boolean}, + specialist_id => #{type => string, nullable => true}, + location => #{type => object, nullable => true}, + tags => #{type => array, items => #{type => string}}, + capacity => #{type => integer, nullable => true}, + online_link => #{type => string, nullable => true}, + status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, + rating_avg => #{type => number, format => float}, + rating_count => #{type => integer}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } + }. + +event_update_schema() -> + #{ + type => object, + properties => #{ + title => #{type => string}, + description => #{type => string}, + start_time => #{type => string, format => <<"date-time">>}, + duration => #{type => integer}, + status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, + specialist_id => #{type => string}, + location => #{ + type => object, + properties => #{ + address => #{type => string}, + lat => #{type => number, format => float}, + lon => #{type => number, format => float} + } + }, + tags => #{type => array, items => #{type => string}}, + capacity => #{type => integer}, + online_link => #{type => string} + } + }. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + %% GET /v1/admin/events/:id get_event(Req) -> case auth_admin(Req) of @@ -73,7 +203,9 @@ delete_event(Req) -> send_error(Req1, Code, Msg) end. -%% --- Вспомогательные функции (идентичны handler_event_by_id.erl) --- +%%-------------------------------------------------------------------- +%% Auth helpers +%%-------------------------------------------------------------------- auth_admin(Req) -> case handler_auth:authenticate(Req) of @@ -86,6 +218,10 @@ auth_admin(Req) -> {error, Code, Msg, Req1} end. +%%-------------------------------------------------------------------- +%% Field conversion (from binary keys/values to internal atoms) +%%-------------------------------------------------------------------- + convert_fields(Updates) -> lists:map(fun convert_field/1, Updates). @@ -117,10 +253,14 @@ convert_field({<<"status">>, Val}) -> try binary_to_existing_atom(Val, utf8) of Atom -> {status, Atom} catch - error:badarg -> {status, Val} % fallback, но лучше залогировать + error:badarg -> {status, Val} end; convert_field(Other) -> Other. +%%-------------------------------------------------------------------- +%% JSON / datetime helpers +%%-------------------------------------------------------------------- + event_to_json(Event) -> LocationJson = case Event#event.location of undefined -> null; @@ -185,6 +325,10 @@ parse_datetime(Str) -> catch _:_ -> {error, invalid_format} end. +%%-------------------------------------------------------------------- +%% Response helpers +%%-------------------------------------------------------------------- + send_json(Req, Status, Data) -> Body = jsx:encode(Data), Headers = #{<<"content-type">> => <<"application/json">>}, diff --git a/src/handlers/admin/admin_handler_events.erl b/src/handlers/admin/admin_handler_events.erl index 698f222..13b3207 100644 --- a/src/handlers/admin/admin_handler_events.erl +++ b/src/handlers/admin/admin_handler_events.erl @@ -2,14 +2,92 @@ -behaviour(cowboy_handler). -export([init/2]). +-export([trails/0]). + -include("records.hrl"). +%%%=================================================================== +%%% cowboy_handler callbacks +%%%=================================================================== + init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> list_all_events(Req); _ -> send_error(Req, 405, <<"Method not allowed">>) end. +%%%=================================================================== +%%% Swagger / Trails metadata +%%%=================================================================== + +trails() -> + [ + #{ + path => <<"/v1/admin/events">>, + method => <<"GET">>, + handler => ?MODULE, + tags => [<<"Events">>], + description => <<"Search and list events (admin)">>, + parameters => [ + #{name => <<"from">>, in => <<"query">>, description => <<"ISO8601 start datetime">>, required => false, schema => #{type => string}}, + #{name => <<"to">>, in => <<"query">>, description => <<"ISO8601 end datetime">>, required => false, schema => #{type => string}}, + #{name => <<"status">>, in => <<"query">>, description => <<"active, cancelled, completed, or all">>, required => false, schema => #{type => string}}, + #{name => <<"calendar_id">>, in => <<"query">>, description => <<"Filter by calendar ID">>, required => false, schema => #{type => string}}, + #{name => <<"title">>, in => <<"query">>, description => <<"Exact title match">>, required => false, schema => #{type => string}}, + #{name => <<"q">>, in => <<"query">>, description => <<"Substring search in title/description">>, required => false, schema => #{type => string}}, + #{name => <<"limit">>, in => <<"query">>, description => <<"Page size (max 200)">>, required => false, schema => #{type => integer}}, + #{name => <<"offset">>, in => <<"query">>, description => <<"Offset">>, required => false, schema => #{type => integer}}, + #{name => <<"sort">>, in => <<"query">>, description => <<"created_at, start_time, title, status">>, required => false, schema => #{type => string}}, + #{name => <<"order">>, in => <<"query">>, description => <<"asc or desc">>, required => false, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}} + ], + responses => #{ + 200 => #{ + description => <<"Array of events with Content-Range header">>, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => array, + items => event_schema() + } + } + } + }, + 405 => #{description => <<"Method not allowed">>} + } + } + ]. + +event_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + calendar_id => #{type => string}, + title => #{type => string}, + description => #{type => string}, + event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, + start_time => #{type => string, format => <<"date-time">>}, + duration => #{type => integer}, + recurrence => #{type => object, nullable => true}, + master_id => #{type => string, nullable => true}, + is_instance => #{type => boolean}, + specialist_id => #{type => string, nullable => true}, + location => #{type => object, nullable => true}, + tags => #{type => array, items => #{type => string}}, + capacity => #{type => integer, nullable => true}, + online_link => #{type => string, nullable => true}, + status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, + rating_avg => #{type => number, format => float}, + rating_count => #{type => integer}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } + }. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + list_all_events(Req) -> case auth_admin(Req) of {ok, _AdminId, Req1} -> @@ -26,9 +104,7 @@ list_all_events(Req) -> <<"x-total-count">> => integer_to_binary(Total), <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> }, - Body = jsx:encode(Json), - cowboy_req:reply(200, Headers, Body, Req1), - {ok, Body, []}; + send_json(Req1, 200, Json, Headers); {error, Code, Msg, Req1} -> send_error(Req1, Code, Msg) end. @@ -48,9 +124,6 @@ parse_admin_event_search(Req) -> order => proplists:get_value(<<"order">>, Qs, <<"desc">>) }. -%%-------------------------------------------------------------------- -%% Вспомогательные функции -%%-------------------------------------------------------------------- auth_admin(Req) -> case handler_auth:authenticate(Req) of {ok, AdminId, Req1} -> @@ -70,6 +143,22 @@ parse_datetime_qs(undefined) -> undefined; parse_datetime_qs(Bin) -> case parse_datetime(Bin) of {ok, Dt} -> Dt; _ -> undefined end. +parse_datetime(Str) -> + try + [DateStr, TimeStr] = string:split(Str, "T"), + TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), + [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), + [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), + Year = binary_to_integer(list_to_binary(YearStr)), + Month = binary_to_integer(list_to_binary(MonthStr)), + Day = binary_to_integer(list_to_binary(DayStr)), + Hour = binary_to_integer(list_to_binary(HourStr)), + Minute = binary_to_integer(list_to_binary(MinuteStr)), + Second = binary_to_integer(list_to_binary(SecondStr)), + {ok, {{Year, Month, Day}, {Hour, Minute, Second}}} + catch _:_ -> {error, invalid_format} + end. + event_to_json(Event) -> LocationJson = case Event#event.location of undefined -> null; @@ -118,21 +207,11 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> datetime_to_iso8601(undefined) -> undefined. -parse_datetime(Str) -> - try - [DateStr, TimeStr] = string:split(Str, "T"), - TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), - [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), - [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), - Year = binary_to_integer(list_to_binary(YearStr)), - Month = binary_to_integer(list_to_binary(MonthStr)), - Day = binary_to_integer(list_to_binary(DayStr)), - Hour = binary_to_integer(list_to_binary(HourStr)), - Minute = binary_to_integer(list_to_binary(MinuteStr)), - Second = binary_to_integer(list_to_binary(SecondStr)), - {ok, {{Year, Month, Day}, {Hour, Minute, Second}}} - catch _:_ -> {error, invalid_format} - end. +send_json(Req, Status, Data, ExtraHeaders) -> + Body = jsx:encode(Data), + Headers = maps:merge(#{<<"content-type">> => <<"application/json">>}, ExtraHeaders), + cowboy_req:reply(Status, Headers, Body, Req), + {ok, Body, []}. send_error(Req, Status, Message) -> Body = jsx:encode(#{error => Message}), diff --git a/src/handlers/swagger_docs_handler.erl b/src/handlers/swagger_docs_handler.erl new file mode 100644 index 0000000..e53ac42 --- /dev/null +++ b/src/handlers/swagger_docs_handler.erl @@ -0,0 +1,96 @@ +-module(swagger_docs_handler). +-behaviour(cowboy_handler). + +-export([init/2]). + +init(Req, _Opts) -> + Path = cowboy_req:path(Req), + handle(Path, Req). + +handle(<<"/">>, Req) -> + serve_index(Req); +handle(<<"/admin">>, Req) -> + redirect_to_slash(<<"/admin/">>, Req); +handle(<<"/admin/">>, Req) -> + serve_ui(admin, Req); +handle(<<"/admin/swagger.json">>, Req) -> + serve_json(admin, Req); +handle(<<"/client">>, Req) -> + redirect_to_slash(<<"/client/">>, Req); +handle(<<"/client/">>, Req) -> + serve_ui(client, Req); +handle(<<"/client/swagger.json">>, Req) -> + serve_json(client, Req); +handle(_, Req) -> + cowboy_req:reply(404, #{}, <<"Not Found">>, Req), + {ok, [], []}. + +%% Главная страница с выбором API +serve_index(Req) -> + Html = <<" + +EventHub API Docs + +

EventHub API Documentation

+ + +">>, + cowboy_req:reply(200, #{<<"content-type">> => <<"text/html">>}, Html, Req), + {ok, Html, []}. + +%% Swagger UI для конкретного API +serve_ui(Api, Req) -> + {Title, SpecUrl} = case Api of + admin -> {<<"EventHub Admin API">>, <<"/admin/swagger.json">>}; + client -> {<<"EventHub Client API">>, <<"/client/swagger.json">>} + end, + Html = iolist_to_binary([ + "", Title, + "", + "
", + "", + "", + "" + ]), + cowboy_req:reply(200, #{<<"content-type">> => <<"text/html">>}, Html, Req), + {ok, Html, []}. + +%% Генерация OpenAPI JSON +serve_json(Api, Req) -> + Trails = case Api of + admin -> trails:admin(); + client -> trails:client() + end, + OpenApi = #{ + openapi => <<"3.0.3">>, + info => #{ + title => case Api of + admin -> <<"EventHub Admin API">>; + client -> <<"EventHub Client API">> + end, + version => <<"1.0.0">> + }, + servers => [#{url => <<"http://localhost:8445">>, description => <<"API server">>}], + paths => build_paths(Trails) + }, + Json = jsx:encode(OpenApi), + cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Json, Req), + {ok, Json, []}. + +build_paths(Trails) -> + lists:foldl(fun(Trail, Acc) -> + Path = maps:get(path, Trail, <<"/">>), + Method0 = maps:get(method, Trail, <<"get">>), + Method = string:lowercase(Method0), + TrailData = maps:without([path, method], Trail), + PathItem = #{Method => TrailData}, + maps:merge_with(fun(_, V1, V2) -> maps:merge(V1, V2) end, Acc, #{Path => PathItem}) + end, #{}, Trails). + +redirect_to_slash(Location, Req) -> + cowboy_req:reply(301, #{<<"location">> => Location}, <<>>, Req), + {ok, [], []}. \ No newline at end of file diff --git a/src/swagger/trails.erl b/src/swagger/trails.erl new file mode 100644 index 0000000..3de761c --- /dev/null +++ b/src/swagger/trails.erl @@ -0,0 +1,25 @@ +-module(trails). +-export([admin/0, client/0, all/0]). + +admin() -> + Modules = [ + admin_handler_events, + admin_handler_event_by_id + %% другие админские обработчики с trails/0 + ], + lists:flatmap(fun trails_from_module/1, Modules). + +client() -> + Modules = [ + %% пока пусто; добавьте handler_events, handler_event_by_id и др. + ], + lists:flatmap(fun trails_from_module/1, Modules). + +all() -> + admin() ++ client(). + +trails_from_module(Module) -> + try Module:trails() of + Trails when is_list(Trails) -> Trails + catch error:undef -> [] + end. \ No newline at end of file