Skip to main content

lib/rocksky/builder.ex

defmodule Rocksky.Builder do
  @moduledoc """
  `use Rocksky.Builder` turns a module into a chainable, pipe-friendly request
  builder for a single XRPC procedure.

  ## Example

      defmodule Rocksky.Scrobble.Builder do
        use Rocksky.Builder,
          nsid: "app.rocksky.scrobble.createScrobble",
          required: [:title, :artist],
          optional: [:album, :duration, :mbId, :isrc, :albumArt, :timestamp]
      end

      alias Rocksky.Scrobble.Builder, as: Scrobble

      Scrobble.new(title: "In Bloom", artist: "Nirvana")
      |> Scrobble.album("Nevermind")
      |> Scrobble.timestamp(System.system_time(:second))
      |> Scrobble.submit(client)

  ## What you get

  For each field listed in `:required` or `:optional` the macro generates a
  `field/2` setter that returns the updated builder (snake-cased: a `:mbId`
  field becomes `mb_id/2`).

  In addition:

    * `new/1` — build from a keyword list / map. Returns a `%__MODULE__{}`.
    * `put/2` — generic batch setter that accepts a keyword list or map.
    * `submit/2` — issue the underlying XRPC procedure with the current builder
      as the JSON body. Returns `{:ok, body} | {:error, Rocksky.Error.t()}`.
      If any `:required` field is `nil`, returns
      `{:error, %Rocksky.Error{reason: :missing_fields, ...}}` without making
      a network call.
    * `to_body/1` — return the body that would be sent, with `nil` fields
      stripped. Useful for inspection in tests.
  """

  defmacro __using__(opts) do
    nsid = Keyword.fetch!(opts, :nsid)
    required = Keyword.get(opts, :required, [])
    optional = Keyword.get(opts, :optional, [])
    fields = required ++ optional

    if fields == [] do
      raise ArgumentError, "Rocksky.Builder requires at least one :required or :optional field"
    end

    # snake_case → original camelCase atom (and identity for already-camel keys).
    # Lets new/1 and put/2 accept either form.
    field_aliases =
      for field <- fields, reduce: %{} do
        acc ->
          snake = field |> Atom.to_string() |> Macro.underscore() |> String.to_atom()
          acc |> Map.put(field, field) |> Map.put(snake, field)
      end

    setters =
      for field <- fields do
        snake = field |> Atom.to_string() |> Macro.underscore() |> String.to_atom()

        quote do
          @doc "Set `#{unquote(field)}` on the builder."
          def unquote(snake)(%__MODULE__{} = builder, value) do
            Map.put(builder, unquote(field), value)
          end
        end
      end

    quote do
      @rocksky_nsid unquote(nsid)
      @rocksky_required unquote(required)
      @rocksky_field_aliases unquote(Macro.escape(field_aliases))

      defstruct unquote(fields)

      @type t :: %__MODULE__{}

      @doc """
      Build a new request from a keyword list or map of fields.

      Accepts either the canonical lexicon key (`:mbId`) or its snake-cased
      equivalent (`:mb_id`). Unknown keys raise.
      """
      @spec new(keyword() | map()) :: t()
      def new(attrs \\ []) do
        struct!(__MODULE__, normalize_keys(attrs))
      end

      @doc """
      Batch-set fields. `attrs` is a keyword list or map. Accepts the same
      key forms as `new/1`.
      """
      @spec put(t(), keyword() | map()) :: t()
      def put(%__MODULE__{} = builder, attrs) when is_list(attrs) or is_map(attrs) do
        struct!(builder, normalize_keys(attrs))
      end

      defp normalize_keys(attrs) do
        Map.new(attrs, fn {k, v} ->
          {Map.get(@rocksky_field_aliases, k, k), v}
        end)
      end

      @doc "Return the JSON body that would be sent (nil fields stripped)."
      @spec to_body(t()) :: map()
      def to_body(%__MODULE__{} = builder) do
        builder
        |> Map.from_struct()
        |> Enum.reject(fn {_k, v} -> is_nil(v) end)
        |> Map.new()
      end

      @doc """
      Submit the builder. Returns `{:ok, body}` on success or
      `{:error, %Rocksky.Error{}}` on failure.

      Returns `{:error, %Rocksky.Error{reason: :missing_fields}}` without
      making a network call when any required field is `nil`.
      """
      @spec submit(t(), Rocksky.Client.t()) ::
              {:ok, term()} | {:error, Rocksky.Error.t()}
      def submit(%__MODULE__{} = builder, %Rocksky.Client{} = client) do
        case missing_required(builder) do
          [] ->
            Rocksky.HTTP.procedure(client, @rocksky_nsid, [], to_body(builder))

          missing ->
            {:error,
             %Rocksky.Error{
               status: nil,
               reason: :missing_fields,
               message:
                 "missing required field(s): " <>
                   Enum.map_join(missing, ", ", &Atom.to_string/1),
               body: %{missing: missing}
             }}
        end
      end

      defp missing_required(%__MODULE__{} = builder) do
        Enum.filter(@rocksky_required, fn field -> is_nil(Map.fetch!(builder, field)) end)
      end

      unquote(setters)
    end
  end
end