Skip to main content

lib/core/http/base_router.ex

defmodule Core.HTTP.BaseRouter do
  @moduledoc """
  Composable macros for building custom routers on top of Elixir Server Core.

  ## Usage

      defmodule MyApp.Router do
        use Plug.Router
        require Logger

        plug Plug.Logger, log: :info
        plug :match
        plug Plug.Parsers, parsers: [:json], pass: ["application/json"], json_decoder: Jason
        plug Plug.Telemetry, event_prefix: [:server, :http]
        plug :dispatch

        import Core.HTTP.BaseRouter

        add_root_route()
        add_health_route()
        add_job_routes()

        get "/my-domain" do
          send_resp(conn, 200, "custom")
        end

        match _ do
          send_resp(conn, 404, "Not Found")
        end
      end
  """

  @doc """
  Injects a GET / route returning a plain-text status message.
  """
  defmacro add_root_route do
    quote do
      get "/" do
        send_resp(conn, 200, "Server is running")
      end
    end
  end

  @doc """
  Injects GET /health — returns JSON {status: "OK"} or {status: "DEGRADED"} (503).
  """
  defmacro add_health_route do
    quote do
      get "/health" do
        alive = Process.whereis(Core.Workers.JobQueue) != nil
        {status, code} = if alive, do: {"OK", 200}, else: {"DEGRADED", 503}

        conn
        |> put_resp_content_type("application/json")
        |> send_resp(code, Jason.encode!(%{status: status}))
      end
    end
  end

  @doc """
  Injects GET /stats — returns job counts by status.
  """
  defmacro add_stats_route do
    quote do
      get "/stats" do
        conn
        |> put_resp_content_type("application/json")
        |> send_resp(200, Jason.encode!(Core.Workers.JobQueue.stats()))
      end
    end
  end

  @doc """
  Injects POST /jobs, POST /jobs/schedule, GET /jobs, GET /jobs/:id.

  GET /jobs supports query params:
    - status=queued|running|done|failed
    - page=N  (default 1)
    - per_page=N (default 50, max 200)
  """
  defmacro add_job_routes do
    quote do
      post "/jobs" do
        case conn.body_params do
          %{"payload" => payload} when is_map(payload) ->
            opts = []

            opts =
              if conn.body_params["max_attempts"],
                do: Keyword.put(opts, :max_attempts, conn.body_params["max_attempts"]),
                else: opts

            {:ok, id} = Core.Workers.JobQueue.submit(payload, opts)

            conn
            |> put_resp_content_type("application/json")
            |> send_resp(202, Jason.encode!(%{message: "Job accepted", job_id: id}))

          %{"payload" => _} ->
            conn
            |> put_resp_content_type("application/json")
            |> send_resp(400, Jason.encode!(%{error: "'payload' must be a JSON object"}))

          _ ->
            conn
            |> put_resp_content_type("application/json")
            |> send_resp(400, Jason.encode!(%{error: "Missing 'payload' field"}))
        end
      end

      post "/jobs/schedule" do
        with %{"payload" => payload, "run_at" => run_at_str} <- conn.body_params,
             {:ok, run_at, _} <- DateTime.from_iso8601(run_at_str) do
          {:ok, id} = Core.Workers.JobQueue.submit_at(payload, run_at)

          conn
          |> put_resp_content_type("application/json")
          |> send_resp(
            202,
            Jason.encode!(%{message: "Job scheduled", job_id: id, run_at: run_at_str})
          )
        else
          _ ->
            conn
            |> put_resp_content_type("application/json")
            |> send_resp(
              400,
              Jason.encode!(%{error: "Required: payload (object), run_at (ISO8601 string)"})
            )
        end
      end

      get "/jobs" do
        params = conn.query_params

        opts =
          []
          |> then(fn o ->
            case params["status"] do
              nil -> o
              s -> Keyword.put(o, :status, String.to_existing_atom(s))
            end
          end)
          |> then(fn o ->
            case params["page"] do
              nil -> o
              p -> Keyword.put(o, :page, String.to_integer(p))
            end
          end)
          |> then(fn o ->
            case params["per_page"] do
              nil -> o
              pp -> Keyword.put(o, :per_page, String.to_integer(pp))
            end
          end)

        jobs = Core.Workers.JobQueue.all(opts)

        conn
        |> put_resp_content_type("application/json")
        |> send_resp(200, Jason.encode!(jobs))
      rescue
        ArgumentError ->
          conn
          |> put_resp_content_type("application/json")
          |> send_resp(
            400,
            Jason.encode!(%{error: "Invalid status. Valid: queued, running, done, failed"})
          )
      end

      get "/jobs/:id" do
        with {int_id, ""} <- Integer.parse(id),
             {:ok, job} <- Core.Workers.JobQueue.get(int_id) do
          conn
          |> put_resp_content_type("application/json")
          |> send_resp(200, Jason.encode!(job))
        else
          :error ->
            conn
            |> put_resp_content_type("application/json")
            |> send_resp(400, Jason.encode!(%{error: "Job ID must be an integer"}))

          {:error, :not_found} ->
            conn
            |> put_resp_content_type("application/json")
            |> send_resp(404, Jason.encode!(%{error: "Job not found"}))
        end
      end
    end
  end
end