405 lines
16 KiB
Erlang
405 lines
16 KiB
Erlang
%%%-------------------------------------------------------------------
|
||
%%% @doc Общие утилиты для HTTP-обработчиков.
|
||
%%% Содержит повторяющиеся функции, которые раньше копировались
|
||
%%% в каждый обработчик: аутентификация, отправка ответов,
|
||
%%% парсинг параметров, сериализация записей и генерация трейлов.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-module(handler_utils).
|
||
|
||
-export([
|
||
auth_admin/1,
|
||
auth_user/1,
|
||
send_json/3,
|
||
send_json/4,
|
||
send_error/3,
|
||
parse_pagination_params/1,
|
||
parse_int_qs/2,
|
||
parse_datetime_qs/1,
|
||
parse_datetime/1,
|
||
datetime_to_iso8601/1,
|
||
event_to_json/1,
|
||
user_to_json/1,
|
||
review_to_json/1,
|
||
report_to_json/1,
|
||
ticket_to_json/1,
|
||
calendar_to_json/1,
|
||
subscription_to_json/1,
|
||
trails_for_crud/4,
|
||
is_superadmin/1]).
|
||
|
||
-include("records.hrl").
|
||
|
||
%%%===================================================================
|
||
%%% Аутентификация и авторизация
|
||
%%%===================================================================
|
||
|
||
%% @doc Проверяет, что запрос содержит валидный токен администратора.
|
||
-spec auth_admin(cowboy_req:req()) ->
|
||
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
|
||
auth_admin(Req) ->
|
||
case handler_auth:authenticate(Req) of
|
||
{ok, UserId, Req1} ->
|
||
case admin_utils:is_admin(UserId) of
|
||
true -> {ok, UserId, Req1};
|
||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||
end;
|
||
{error, Code, Msg, Req1} ->
|
||
{error, Code, Msg, Req1}
|
||
end.
|
||
|
||
%% @doc Проверяет, что запрос выполняет суперадмин.
|
||
-spec is_superadmin(cowboy_req:req()) ->
|
||
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
|
||
is_superadmin(Req) ->
|
||
case handler_utils:auth_admin(Req) of
|
||
{ok, AdminId, Req1} ->
|
||
case admin_utils:is_superadmin(AdminId) of
|
||
true -> {ok, AdminId, Req1};
|
||
false -> {error, 403, <<"Only superadmin allowed">>, Req1}
|
||
end;
|
||
Error -> Error
|
||
end.
|
||
|
||
%% @doc Проверяет, что запрос содержит валидный токен пользователя.
|
||
-spec auth_user(cowboy_req:req()) ->
|
||
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
|
||
auth_user(Req) ->
|
||
handler_auth:authenticate(Req).
|
||
|
||
%%%===================================================================
|
||
%%% HTTP‑ответы
|
||
%%%===================================================================
|
||
|
||
%% @doc Отправляет JSON-ответ с указанным статусом и стандартным заголовком.
|
||
-spec send_json(cowboy_req:req(), cowboy:http_status(), jsx:json_term()) ->
|
||
{ok, binary(), cowboy_req:req()}.
|
||
send_json(Req, Status, Data) ->
|
||
send_json(Req, Status, Data, #{}).
|
||
|
||
%% @doc Отправляет JSON-ответ с указанным статусом и дополнительными заголовками.
|
||
%% ExtraHeaders вставляются поверх стандартного `content-type`.
|
||
-spec send_json(cowboy_req:req(), cowboy:http_status(), jsx:json_term(), map()) ->
|
||
{ok, binary(), cowboy_req:req()}.
|
||
send_json(Req, Status, Data, ExtraHeaders) ->
|
||
Body = jsx:encode(Data),
|
||
BaseHeaders = #{<<"content-type">> => <<"application/json">>},
|
||
Headers = maps:merge(BaseHeaders, ExtraHeaders),
|
||
Req1 = cowboy_req:reply(Status, Headers, Body, Req),
|
||
{ok, Body, Req1}.
|
||
|
||
%% @doc Отправляет JSON-ошибку.
|
||
-spec send_error(cowboy_req:req(), cowboy:http_status(), binary()) ->
|
||
{ok, binary(), cowboy_req:req()}.
|
||
send_error(Req, Status, Message) ->
|
||
Body = jsx:encode(#{error => Message}),
|
||
Headers = #{<<"content-type">> => <<"application/json">>},
|
||
Req1 = cowboy_req:reply(Status, Headers, Body, Req),
|
||
{ok, Body, Req1}.
|
||
|
||
%%%===================================================================
|
||
%%% Парсинг параметров запроса
|
||
%%%===================================================================
|
||
|
||
%% @doc Извлекает стандартные параметры пагинации/сортировки.
|
||
-spec parse_pagination_params(cowboy_req:req()) ->
|
||
#{limit => integer(), offset => integer(), sort => binary(), order => binary()}.
|
||
parse_pagination_params(Req) ->
|
||
Qs = cowboy_req:parse_qs(Req),
|
||
#{
|
||
limit => parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50),
|
||
offset => parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0),
|
||
sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>),
|
||
order => proplists:get_value(<<"order">>, Qs, <<"desc">>)
|
||
}.
|
||
|
||
-spec parse_int_qs(binary() | undefined, integer()) -> integer().
|
||
parse_int_qs(undefined, Default) -> Default;
|
||
parse_int_qs(Bin, Default) ->
|
||
try binary_to_integer(Bin) catch _:_ -> Default end.
|
||
|
||
%% @doc Преобразует бинарный ISO8601 параметр в datetime().
|
||
-spec parse_datetime_qs(binary() | undefined) -> calendar:datetime() | undefined.
|
||
parse_datetime_qs(undefined) -> undefined;
|
||
parse_datetime_qs(Bin) ->
|
||
case parse_datetime(Bin) of
|
||
{ok, Dt} -> Dt;
|
||
_ -> undefined
|
||
end.
|
||
|
||
%% @doc Разбирает ISO8601 строку в datetime().
|
||
-spec parse_datetime(binary()) -> {ok, calendar:datetime()} | {error, invalid_format}.
|
||
parse_datetime(Str) ->
|
||
try
|
||
%% Убираем завершающий 'Z', если он есть
|
||
Clean = case binary:last(Str) of
|
||
$Z -> binary_part(Str, 0, byte_size(Str) - 1);
|
||
_ -> Str
|
||
end,
|
||
%% Разделяем дату и время
|
||
[DatePart, TimePart] = binary:split(Clean, <<"T">>),
|
||
%% Парсим дату YYYY-MM-DD
|
||
[YearStr, MonthStr, DayStr] = binary:split(DatePart, <<"-">>, [global]),
|
||
%% Убираем дробные секунды, если есть
|
||
TimeMain = case binary:split(TimePart, <<".">>) of
|
||
[Main, _] -> Main;
|
||
[Main] -> Main
|
||
end,
|
||
%% Парсим время HH:MM:SS
|
||
[HourStr, MinuteStr, SecondStr] = binary:split(TimeMain, <<":">>, [global]),
|
||
Year = binary_to_integer(YearStr),
|
||
Month = binary_to_integer(MonthStr),
|
||
Day = binary_to_integer(DayStr),
|
||
Hour = binary_to_integer(HourStr),
|
||
Minute = binary_to_integer(MinuteStr),
|
||
Second = binary_to_integer(SecondStr),
|
||
{ok, {{Year, Month, Day}, {Hour, Minute, Second}}}
|
||
catch
|
||
_:_ -> {error, invalid_format}
|
||
end.
|
||
|
||
%%%===================================================================
|
||
%%% Сериализация записей (все поля согласно records.hrl)
|
||
%%%===================================================================
|
||
|
||
%% @doc Преобразует #event{} в JSON-карту.
|
||
-spec event_to_json(#event{}) -> map().
|
||
event_to_json(Event) ->
|
||
LocationJson = case Event#event.location of
|
||
undefined -> null;
|
||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
||
#{address => Addr, lat => Lat, lon => Lon}
|
||
end,
|
||
RecurrenceJson = case Event#event.recurrence_rule of
|
||
undefined -> null;
|
||
Rule -> try jsx:decode(Rule, [return_maps]) of
|
||
Map when is_map(Map) -> Map;
|
||
_ -> null
|
||
catch _:_ -> null end
|
||
end,
|
||
#{
|
||
id => Event#event.id,
|
||
calendar_id => Event#event.calendar_id,
|
||
title => Event#event.title,
|
||
description => Event#event.description,
|
||
event_type => Event#event.event_type,
|
||
start_time => datetime_to_iso8601(Event#event.start_time),
|
||
duration => Event#event.duration,
|
||
recurrence => RecurrenceJson,
|
||
master_id => Event#event.master_id,
|
||
is_instance => Event#event.is_instance,
|
||
specialist_id => Event#event.specialist_id,
|
||
location => LocationJson,
|
||
tags => Event#event.tags,
|
||
capacity => Event#event.capacity,
|
||
online_link => Event#event.online_link,
|
||
status => Event#event.status,
|
||
reason => Event#event.reason,
|
||
rating_avg => Event#event.rating_avg,
|
||
rating_count => Event#event.rating_count,
|
||
attachments => Event#event.attachments,
|
||
edit_history => Event#event.edit_history,
|
||
created_at => datetime_to_iso8601(Event#event.created_at),
|
||
updated_at => datetime_to_iso8601(Event#event.updated_at)
|
||
}.
|
||
|
||
%% @doc Преобразует #user{} в JSON-карту.
|
||
-spec user_to_json(#user{}) -> map().
|
||
user_to_json(User) ->
|
||
#{
|
||
id => User#user.id,
|
||
email => User#user.email,
|
||
role => User#user.role,
|
||
status => User#user.status,
|
||
reason => User#user.reason,
|
||
nickname => User#user.nickname,
|
||
avatar_url => User#user.avatar_url,
|
||
timezone => User#user.timezone,
|
||
language => User#user.language,
|
||
social_links => User#user.social_links,
|
||
phone => User#user.phone,
|
||
preferences => User#user.preferences,
|
||
last_login => datetime_to_iso8601(User#user.last_login),
|
||
created_at => datetime_to_iso8601(User#user.created_at),
|
||
updated_at => datetime_to_iso8601(User#user.updated_at)
|
||
}.
|
||
|
||
%% @doc Преобразует #review{} в JSON-карту.
|
||
-spec review_to_json(#review{}) -> map().
|
||
review_to_json(Review) ->
|
||
#{
|
||
id => Review#review.id,
|
||
user_id => Review#review.user_id,
|
||
target_type => Review#review.target_type,
|
||
target_id => Review#review.target_id,
|
||
rating => Review#review.rating,
|
||
comment => Review#review.comment,
|
||
status => Review#review.status,
|
||
reason => Review#review.reason,
|
||
likes => Review#review.likes,
|
||
dislikes => Review#review.dislikes,
|
||
created_at => datetime_to_iso8601(Review#review.created_at),
|
||
updated_at => datetime_to_iso8601(Review#review.updated_at)
|
||
}.
|
||
|
||
%% @doc Преобразует #report{} в JSON-карту.
|
||
-spec report_to_json(#report{}) -> map().
|
||
report_to_json(Report) ->
|
||
#{
|
||
id => Report#report.id,
|
||
reporter_id => Report#report.reporter_id,
|
||
target_type => Report#report.target_type,
|
||
target_id => Report#report.target_id,
|
||
reason => Report#report.reason,
|
||
status => Report#report.status,
|
||
created_at => datetime_to_iso8601(Report#report.created_at),
|
||
resolved_at => datetime_to_iso8601(Report#report.resolved_at),
|
||
resolved_by => Report#report.resolved_by
|
||
}.
|
||
|
||
%% @doc Преобразует #ticket{} в JSON-карту.
|
||
-spec ticket_to_json(#ticket{}) -> map().
|
||
ticket_to_json(Ticket) ->
|
||
#{
|
||
id => Ticket#ticket.id,
|
||
reporter_id => Ticket#ticket.reporter_id,
|
||
error_hash => Ticket#ticket.error_hash,
|
||
error_message => Ticket#ticket.error_message,
|
||
stacktrace => Ticket#ticket.stacktrace,
|
||
context => Ticket#ticket.context,
|
||
count => Ticket#ticket.count,
|
||
first_seen => datetime_to_iso8601(Ticket#ticket.first_seen),
|
||
last_seen => datetime_to_iso8601(Ticket#ticket.last_seen),
|
||
status => Ticket#ticket.status,
|
||
assigned_to => Ticket#ticket.assigned_to,
|
||
resolution_note => Ticket#ticket.resolution_note
|
||
}.
|
||
|
||
%% @doc Преобразует #calendar{} в JSON-карту.
|
||
-spec calendar_to_json(#calendar{}) -> map().
|
||
calendar_to_json(Calendar) ->
|
||
#{
|
||
id => Calendar#calendar.id,
|
||
owner_id => Calendar#calendar.owner_id,
|
||
title => Calendar#calendar.title,
|
||
description => Calendar#calendar.description,
|
||
short_name => Calendar#calendar.short_name,
|
||
category => Calendar#calendar.category,
|
||
color => Calendar#calendar.color,
|
||
image_url => Calendar#calendar.image_url,
|
||
settings => Calendar#calendar.settings,
|
||
tags => Calendar#calendar.tags,
|
||
type => Calendar#calendar.type,
|
||
confirmation => Calendar#calendar.confirmation,
|
||
rating_avg => Calendar#calendar.rating_avg,
|
||
rating_count => Calendar#calendar.rating_count,
|
||
status => Calendar#calendar.status,
|
||
reason => Calendar#calendar.reason,
|
||
created_at => datetime_to_iso8601(Calendar#calendar.created_at),
|
||
updated_at => datetime_to_iso8601(Calendar#calendar.updated_at)
|
||
}.
|
||
|
||
%% @doc Преобразует #subscription{} в JSON-карту.
|
||
-spec subscription_to_json(#subscription{}) -> map().
|
||
subscription_to_json(Subscription) ->
|
||
#{
|
||
id => Subscription#subscription.id,
|
||
user_id => Subscription#subscription.user_id,
|
||
plan => Subscription#subscription.plan,
|
||
status => Subscription#subscription.status,
|
||
trial_used => Subscription#subscription.trial_used,
|
||
started_at => datetime_to_iso8601(Subscription#subscription.started_at),
|
||
expires_at => datetime_to_iso8601(Subscription#subscription.expires_at),
|
||
created_at => datetime_to_iso8601(Subscription#subscription.created_at),
|
||
updated_at => datetime_to_iso8601(Subscription#subscription.updated_at)
|
||
}.
|
||
|
||
%%%===================================================================
|
||
%%% Вспомогательные внутренние функции
|
||
%%%===================================================================
|
||
|
||
%% @private
|
||
-spec datetime_to_iso8601(calendar:datetime() | undefined) -> binary() | undefined.
|
||
datetime_to_iso8601(undefined) -> undefined;
|
||
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
|
||
iolist_to_binary(
|
||
io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
|
||
[Year, Month, Day, Hour, Minute, Second])).
|
||
|
||
%%%===================================================================
|
||
%%% Генерация Swagger-трейлов для типового CRUD-ресурса
|
||
%%%===================================================================
|
||
|
||
%% @doc Генерирует трейлы для GET (list), GET /:id, POST, PUT, DELETE.
|
||
-spec trails_for_crud(binary(), binary(), map(), map()) -> [map()].
|
||
trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
|
||
IdParam = #{
|
||
name => <<"id">>,
|
||
in => <<"path">>,
|
||
description => <<"Resource ID">>,
|
||
required => true,
|
||
schema => #{type => string}
|
||
},
|
||
[
|
||
#{ % GET list
|
||
path => Path,
|
||
method => <<"GET">>,
|
||
description => <<"List all records">>,
|
||
parameters => [
|
||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
|
||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
|
||
],
|
||
responses => #{
|
||
200 => #{
|
||
description => <<"Array of records">>,
|
||
content => #{<<"application/json">> => #{schema => GetSchema}}
|
||
}
|
||
}
|
||
},
|
||
#{ % GET by id
|
||
path => <<Path/binary, "/:id">>,
|
||
method => <<"GET">>,
|
||
description => <<"Get record by ID">>,
|
||
parameters => [IdParam],
|
||
responses => #{
|
||
200 => #{
|
||
description => <<"Record details">>,
|
||
content => #{<<"application/json">> => #{schema => GetSchema}}
|
||
}
|
||
}
|
||
},
|
||
#{ % POST
|
||
path => Path,
|
||
method => <<"POST">>,
|
||
description => <<"Create a new record">>,
|
||
requestBody => #{
|
||
required => true,
|
||
content => #{<<"application/json">> => #{schema => UpdateSchema}}
|
||
},
|
||
responses => #{
|
||
201 => #{description => <<"Record created">>}
|
||
}
|
||
},
|
||
#{ % PUT
|
||
path => <<Path/binary, "/:id">>,
|
||
method => <<"PUT">>,
|
||
description => <<"Update record by ID">>,
|
||
parameters => [IdParam],
|
||
requestBody => #{
|
||
required => true,
|
||
content => #{<<"application/json">> => #{schema => UpdateSchema}}
|
||
},
|
||
responses => #{
|
||
200 => #{description => <<"Record updated">>}
|
||
}
|
||
},
|
||
#{ % DELETE
|
||
path => <<Path/binary, "/:id">>,
|
||
method => <<"DELETE">>,
|
||
description => <<"Delete record by ID">>,
|
||
parameters => [IdParam],
|
||
responses => #{
|
||
200 => #{description => <<"Record deleted">>}
|
||
}
|
||
}
|
||
]. |