api specs
This commit is contained in:
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal file
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal file
@@ -0,0 +1,221 @@
|
||||
defmodule ShootingEventPhxWeb.ApiController do
|
||||
use ShootingEventPhxWeb, :controller
|
||||
alias ShootingEventPhx.{Auth, Events}
|
||||
alias ShootingEventPhx.Auth.SessionStore
|
||||
alias ShootingEventPhx.Domain.{EventData, Settings, Stages, StateService}
|
||||
alias ShootingEventPhx.AI.Gemini
|
||||
|
||||
def health(conn, _params) do
|
||||
json(conn, %{"status" => "ok"})
|
||||
end
|
||||
|
||||
def state(conn, _params) do
|
||||
state = StateService.read_state(Auth.admin_request?(conn))
|
||||
json(conn, state)
|
||||
end
|
||||
|
||||
def admin_state(conn, _params) do
|
||||
json(conn, StateService.read_state(true))
|
||||
end
|
||||
|
||||
def admin_login(conn, params) do
|
||||
username = Map.get(params, "username", "")
|
||||
password = Map.get(params, "password", "")
|
||||
|
||||
if Auth.verify_admin(username, password) do
|
||||
{:ok, token, expires_at} = SessionStore.create_token()
|
||||
|
||||
json(conn, %{
|
||||
"token" => token,
|
||||
"expiresAt" => DateTime.to_iso8601(expires_at),
|
||||
"username" => Auth.admin_user()
|
||||
})
|
||||
else
|
||||
error(conn, :unauthorized, "invalid credentials")
|
||||
end
|
||||
end
|
||||
|
||||
def admin_logout(conn, _params) do
|
||||
case Auth.bearer_token(conn) do
|
||||
nil -> :ok
|
||||
token -> SessionStore.delete_token(token)
|
||||
end
|
||||
|
||||
json(conn, %{"ok" => true})
|
||||
end
|
||||
|
||||
def update_settings(conn, params) do
|
||||
case Settings.update_settings(params) do
|
||||
:ok ->
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
|
||||
{:error, message} ->
|
||||
error(conn, :bad_request, message)
|
||||
end
|
||||
end
|
||||
|
||||
def create_player(conn, params) do
|
||||
case EventData.create_player(params) do
|
||||
:ok ->
|
||||
Events.broadcast()
|
||||
conn |> put_status(:created) |> json(StateService.read_state(true))
|
||||
|
||||
{:error, :bad_request, message} ->
|
||||
error(conn, :bad_request, message)
|
||||
|
||||
{:error, _, message} ->
|
||||
error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def update_player(conn, %{"id" => id} = params) do
|
||||
with {player_id, ""} <- Integer.parse(to_string(id)),
|
||||
result <- EventData.update_player(player_id, params),
|
||||
:ok <- result do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
{:error, :bad_request, message} -> error(conn, :bad_request, message)
|
||||
{:error, :not_found, message} -> error(conn, :not_found, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_player(conn, %{"id" => id}) do
|
||||
with {player_id, ""} <- Integer.parse(to_string(id)),
|
||||
:ok <- EventData.delete_player(player_id) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
{:error, :not_found, message} -> error(conn, :not_found, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def auto_group_players(conn, params) do
|
||||
groups = Map.get(params, "groups", [])
|
||||
|
||||
case EventData.auto_group_players(groups) do
|
||||
:ok ->
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
|
||||
{:error, :bad_request, message} ->
|
||||
error(conn, :bad_request, message)
|
||||
|
||||
{:error, _, message} ->
|
||||
error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def update_score(conn, %{"stage" => stage, "id" => id} = params) do
|
||||
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||
score <- parse_score(Map.get(params, "score")),
|
||||
:ok <- EventData.update_score(normalized_stage, player_id, score) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
{:error, message} -> error(conn, :bad_request, message)
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
{:invalid_score, message} -> error(conn, :bad_request, message)
|
||||
{:error, :bad_request, message} -> error(conn, :bad_request, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def reset_stage(conn, %{"stage" => stage} = params) do
|
||||
with {:ok, target_stages} <- Stages.resolve_reset_stages(stage),
|
||||
reset_proofs <- truthy?(Map.get(params, "resetProofs")),
|
||||
:ok <- EventData.reset_stage_scores(target_stages, reset_proofs) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
{:error, message} -> error(conn, :bad_request, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def update_score_proof(conn, %{"stage" => stage, "id" => id} = params) do
|
||||
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||
image_data when is_binary(image_data) <- Map.get(params, "imageData"),
|
||||
:ok <- EventData.update_score_proof(normalized_stage, player_id, image_data) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
{:error, message} -> error(conn, :bad_request, message)
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
nil -> error(conn, :bad_request, "imageData is required")
|
||||
{:error, :bad_request, message} -> error(conn, :bad_request, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_score_proof(conn, %{"stage" => stage, "id" => id}) do
|
||||
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||
:ok <- EventData.delete_score_proof(normalized_stage, player_id) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
{:error, message} -> error(conn, :bad_request, message)
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def score_advice(conn, %{"stage" => stage, "id" => id}) do
|
||||
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||
{:ok, image_data} <- EventData.load_score_proof_image(normalized_stage, player_id),
|
||||
{:ok, current_score} <- EventData.load_current_score(normalized_stage, player_id),
|
||||
{:ok, advice} <-
|
||||
Gemini.generate_score_advice(normalized_stage, player_id, image_data, current_score) do
|
||||
json(conn, advice)
|
||||
else
|
||||
{:error, :not_found} -> error(conn, :bad_request, "no proof image found for this score")
|
||||
{:error, :service_unavailable, message} -> error(conn, :service_unavailable, message)
|
||||
{:error, :bad_gateway, message} -> error(conn, :bad_gateway, message)
|
||||
{:error, message} when is_binary(message) -> error(conn, :bad_request, message)
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_score(value) do
|
||||
cond do
|
||||
is_integer(value) ->
|
||||
value
|
||||
|
||||
is_binary(value) ->
|
||||
case Integer.parse(String.trim(value)) do
|
||||
{v, ""} -> v
|
||||
_ -> {:invalid_score, "invalid request body"}
|
||||
end
|
||||
|
||||
true ->
|
||||
{:invalid_score, "invalid request body"}
|
||||
end
|
||||
end
|
||||
|
||||
defp truthy?(value) do
|
||||
case value do
|
||||
true -> true
|
||||
1 -> true
|
||||
"1" -> true
|
||||
"true" -> true
|
||||
"yes" -> true
|
||||
"on" -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp error(conn, status, message) do
|
||||
conn
|
||||
|> put_status(status)
|
||||
|> json(%{"message" => message})
|
||||
end
|
||||
end
|
||||
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal file
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal file
@@ -0,0 +1,21 @@
|
||||
defmodule ShootingEventPhxWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# If you want to customize a particular status code,
|
||||
# you may add your own clauses, such as:
|
||||
#
|
||||
# def render("500.json", _assigns) do
|
||||
# %{errors: %{detail: "Internal Server Error"}}
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.json" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
defmodule ShootingEventPhxWeb.EventsController do
|
||||
use ShootingEventPhxWeb, :controller
|
||||
alias ShootingEventPhx.Events
|
||||
|
||||
def stream(conn, _params) do
|
||||
conn =
|
||||
conn
|
||||
|> put_resp_header("content-type", "text/event-stream")
|
||||
|> put_resp_header("cache-control", "no-cache, no-transform")
|
||||
|> put_resp_header("connection", "keep-alive")
|
||||
|> put_resp_header("x-accel-buffering", "no")
|
||||
|> send_chunked(200)
|
||||
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||
{:ok, conn} = chunk(conn, "event: ready\ndata: {\"ts\":\"#{now}\"}\n\n")
|
||||
|
||||
:ok = Events.subscribe()
|
||||
loop_stream(conn)
|
||||
end
|
||||
|
||||
defp loop_stream(conn) do
|
||||
receive do
|
||||
{:state, ts} ->
|
||||
case chunk(conn, "event: state\ndata: {\"ts\":\"#{ts}\"}\n\n") do
|
||||
{:ok, conn} -> loop_stream(conn)
|
||||
{:error, :closed} -> conn
|
||||
{:error, _} -> conn
|
||||
end
|
||||
after
|
||||
20_000 ->
|
||||
now_unix = DateTime.utc_now() |> DateTime.to_unix()
|
||||
|
||||
case chunk(conn, ": ping #{now_unix}\n\n") do
|
||||
{:ok, conn} -> loop_stream(conn)
|
||||
{:error, :closed} -> conn
|
||||
{:error, _} -> conn
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
defmodule ShootingEventPhxWeb.FrontendController do
|
||||
use ShootingEventPhxWeb, :controller
|
||||
|
||||
def serve(conn, %{"path" => segments}) do
|
||||
request_path =
|
||||
segments
|
||||
|> List.wrap()
|
||||
|> Enum.join("/")
|
||||
|> String.trim_leading("/")
|
||||
|
||||
if String.starts_with?(request_path, "api") do
|
||||
send_resp(conn, 404, "Not Found")
|
||||
else
|
||||
web_dir = web_dir()
|
||||
requested = if request_path == "", do: "index.html", else: request_path
|
||||
requested_file = safe_file_path(web_dir, requested)
|
||||
|
||||
cond do
|
||||
requested_file != nil and File.regular?(requested_file) ->
|
||||
send_static_file(conn, requested_file)
|
||||
|
||||
File.regular?(Path.join(web_dir, "index.html")) ->
|
||||
send_static_file(conn, Path.join(web_dir, "index.html"))
|
||||
|
||||
true ->
|
||||
send_resp(conn, 404, "frontend build not found. run make build")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp web_dir do
|
||||
System.get_env("WEB_DIR") || Path.expand("../frontend/dist", File.cwd!())
|
||||
end
|
||||
|
||||
defp safe_file_path(web_dir, request_path) do
|
||||
cleaned = request_path |> Path.expand(web_dir)
|
||||
root = Path.expand(web_dir)
|
||||
|
||||
if String.starts_with?(cleaned, root) do
|
||||
cleaned
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp send_static_file(conn, file_path) do
|
||||
content_type =
|
||||
case MIME.from_path(file_path) do
|
||||
"" -> "application/octet-stream"
|
||||
type -> type
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_resp_content_type(content_type)
|
||||
|> send_file(200, file_path)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user