Skip to main content

lib/rpc_elixir/resolution.ex

defmodule RpcElixir.Resolution do
  @moduledoc """
  Envelope threaded through the middleware pipeline for a single procedure
  invocation. Middleware receive and return a `%Resolution{}`.

  ## Field ownership

  Different fields have different writers — keeping the struct single but
  partitioning who touches what (mirroring `Plug.Conn`):

    * `:ctx` — middleware and the dispatcher both write. Use `put_ctx/3`,
      `assign/3` (application data), or `put_private/3` (framework data on
      the context). Read by handlers.
    * `:params`, `:private` — middleware-owned. Use `put_private/3` for
      framework-internal scratch data.
    * `:state` — set to `:halted` only via `halt/2`. Never set directly.
    * `:result` — **dispatcher-owned.** Middleware should not write to it
      directly; use `halt/2` to short-circuit with an error. Direct writes
      pre-dispatch are clobbered by the handler step.
    * `:procedure` — set at construction time, never mutated.
    * `:resp_cookies` — middleware-writable. Use `put_resp_cookie/4` or
      `delete_resp_cookie/3`. The transport adapter drains this onto the
      response after dispatch. Format: `%{name => {value, opts}}` for set,
      `%{name => {:delete, opts}}` for delete.
    * `:resp_headers` — middleware-writable. Use `put_resp_header/3`. The
      transport adapter appends these to the response. Duplicates are allowed
      (e.g. multiple `Set-Cookie` values). Format: `[{name, value}]`.
    * `:resp_session` — middleware-writable. Use `put_session/3`,
      `delete_session/2`, or `clear_session/1`. The transport adapter drains
      this into the Plug session after dispatch. Requires `Plug.Session` to be
      configured earlier in the pipeline.

  ## Halting

  Calling `halt/2` short-circuits remaining middleware and the handler. The
  dispatcher returns the resolution unchanged from that point.
  """

  alias RpcElixir.{Context, RpcError}

  @typedoc "Pipeline execution state."
  @type state :: :continue | :halted

  @typedoc "Cookie entry in resp_cookies — either a set or delete instruction."
  @type resp_cookie_entry :: {String.t(), keyword()} | {:delete, keyword()}

  @typedoc "Session entry in resp_session — either a value to set or `:delete`."
  @type resp_session_entry :: term() | :delete

  @typedoc "Resolution envelope."
  @type t :: %__MODULE__{
          ctx: Context.t(),
          params: map(),
          state: state(),
          result: {:ok, term()} | {:error, RpcError.t()} | nil,
          private: map(),
          procedure: String.t() | nil,
          resp_cookies: %{optional(String.t()) => resp_cookie_entry()},
          resp_headers: [{String.t(), String.t()}],
          resp_session: %{optional(term()) => resp_session_entry()},
          resp_session_clear: boolean()
        }

  @enforce_keys [:procedure]
  defstruct ctx: %Context{},
            params: %{},
            state: :continue,
            result: nil,
            private: %{},
            procedure: nil,
            resp_cookies: %{},
            resp_headers: [],
            resp_session: %{},
            resp_session_clear: false

  @doc """
  Halts the pipeline with an error.

  Accepts either a fully-formed `%RpcError{}` or any other term, which is
  wrapped under `code: :middleware_halted` with the original term stored at
  `details.reason`. After halting, downstream middleware and the handler are
  skipped.
  """
  @spec halt(t(), RpcError.t() | term()) :: t()
  def halt(%__MODULE__{} = res, %RpcError{} = err) do
    %{res | state: :halted, result: {:error, %{err | source: err.source || :middleware}}}
  end

  def halt(%__MODULE__{} = res, reason) do
    err =
      RpcError.framework(:middleware_halted, "middleware halted the pipeline", %{reason: reason})

    halt(res, %{err | source: :middleware})
  end

  @doc """
  Replaces a known field on the nested `%Context{}` struct.

  Raises `KeyError` if `key` is not a field on the context struct. To store
  arbitrary request-scoped data, use `assign/3` (application data) or
  `put_private/3` (framework/library data) instead.
  """
  @spec put_ctx(t(), atom(), term()) :: t()
  def put_ctx(%__MODULE__{ctx: ctx} = res, key, value) do
    %{res | ctx: Map.replace!(ctx, key, value)}
  end

  @doc """
  Stores framework/library-private request-scoped data under `key` in the
  resolution's `:private` map. For application data, use `assign/3` instead.
  """
  @spec put_private(t(), atom(), term()) :: t()
  def put_private(%__MODULE__{private: private} = res, key, value) do
    %{res | private: Map.put(private, key, value)}
  end

  @doc """
  Stores application data under `key` in the context's `:assigns` map — the
  primary way middleware passes request-scoped data to handlers.
  """
  @spec assign(t(), atom(), term()) :: t()
  def assign(%__MODULE__{} = res, key, value) do
    update_in(res, [Access.key!(:ctx), Access.key!(:assigns)], &Map.put(&1, key, value))
  end

  @doc """
  Stores a cookie to be set on the response.

  `opts` are forwarded directly to `Plug.Conn.put_resp_cookie/4` by the
  transport adapter. Supported options include `:http_only`, `:secure`,
  `:same_site`, `:max_age`, `:path`, `:domain`, `:sign`, `:encrypt`.

  Safe to call from middleware.
  """
  @spec put_resp_cookie(t(), String.t(), String.t(), keyword()) :: t()
  def put_resp_cookie(%__MODULE__{resp_cookies: cookies} = res, name, value, opts \\ []) do
    %{res | resp_cookies: Map.put(cookies, name, {value, opts})}
  end

  @doc """
  Marks a cookie for deletion on the response.

  `opts` are forwarded to `Plug.Conn.delete_resp_cookie/3` by the transport
  adapter.

  Safe to call from middleware.
  """
  @spec delete_resp_cookie(t(), String.t(), keyword()) :: t()
  def delete_resp_cookie(%__MODULE__{resp_cookies: cookies} = res, name, opts \\ []) do
    %{res | resp_cookies: Map.put(cookies, name, {:delete, opts})}
  end

  @doc """
  Appends a response header. Duplicates are preserved (useful for headers like
  `set-cookie` that must be set separately per cookie).

  Safe to call from middleware.
  """
  @spec put_resp_header(t(), String.t(), String.t()) :: t()
  def put_resp_header(%__MODULE__{resp_headers: headers} = res, name, value) do
    %{res | resp_headers: headers ++ [{name, value}]}
  end

  @doc """
  Stores a session key-value pair to be set on the response.

  Requires `Plug.Session` to be configured in the Plug pipeline. The transport
  adapter drains this into the session after dispatch.

  Safe to call from middleware.
  """
  @spec put_session(t(), term(), term()) :: t()
  def put_session(%__MODULE__{resp_session: session} = res, key, value) do
    %{res | resp_session: Map.put(session, key, value)}
  end

  @doc """
  Marks a session key for deletion on the response.

  Safe to call from middleware.
  """
  @spec delete_session(t(), term()) :: t()
  def delete_session(%__MODULE__{resp_session: session} = res, key) do
    %{res | resp_session: Map.put(session, key, :delete)}
  end

  @doc """
  Marks the entire session to be cleared on the response.

  The transport adapter calls `Plug.Conn.clear_session/1` when it encounters
  this sentinel. Any other `resp_session` entries are ignored after a clear.

  Safe to call from middleware.
  """
  @spec clear_session(t()) :: t()
  def clear_session(%__MODULE__{} = res) do
    %{res | resp_session_clear: true}
  end
end