Skip to main content

lib/miosa/sandbox_templates.ex

defmodule Miosa.SandboxTemplates do
  @moduledoc """
  Sandbox template management — define reusable base images for sandboxes.

  Templates are built from a `build_spec` (a declarative definition of the
  base image). Use `build_spec_schema/1` to discover the schema, `validate/2`
  to check a spec before creating a template, and `create_build/2` to trigger
  a build.

  Mutating calls (create, create_build) send an `Idempotency-Key` header
  automatically.

  ## Example

      client = Miosa.client(System.fetch_env!("MIOSA_API_KEY"))

      {:ok, schema} = Miosa.SandboxTemplates.build_spec_schema(client)

      {:ok, tmpl} = Miosa.SandboxTemplates.create(client, %{
        name: "node-20-base",
        build_spec: %{runtime: "node", version: "20", packages: ["curl"]}
      })

      {:ok, build} = Miosa.SandboxTemplates.create_build(client, tmpl["id"], %{})
  """

  alias Miosa.Client

  # ── CRUD ─────────────────────────────────────────────────────────────────────

  @doc """
  List sandbox templates for the authenticated tenant.

  Options:
    * `:include_aliases` — Include template alias names. Defaults to `false`.
  """
  @spec list(Client.t(), keyword() | map()) :: Client.result(map())
  def list(client, opts \\ []) do
    query = opts |> normalize() |> build_query()
    Client.get(client, "/sandbox-templates" <> query)
  end

  @doc "Fetch a sandbox template by ID."
  @spec get(Client.t(), String.t()) :: Client.result(map())
  def get(client, template_id) when is_binary(template_id) do
    Client.get(client, "/sandbox-templates/" <> template_id)
  end

  @doc """
  Create a sandbox template.

  Required: `:name`, `:build_spec` (map). Optional: `:description`, `:metadata`,
  `:idempotency_key`.
  """
  @spec create(Client.t(), map()) :: Client.result(map())
  def create(client, attrs) when is_map(attrs) do
    idem = pop_idempotency(attrs)

    Client.post(client, "/sandbox-templates", strip_nil(attrs),
      headers: [{"idempotency-key", idem}]
    )
  end

  # ── Build spec ───────────────────────────────────────────────────────────────

  @doc "Get the JSON schema for sandbox build specs."
  @spec build_spec_schema(Client.t()) :: Client.result(map())
  def build_spec_schema(client) do
    Client.get(client, "/sandbox-templates/build-spec")
  end

  @doc """
  Validate a build spec without creating a template.

  Returns validation errors or `{:ok, result}` with the normalized spec.
  """
  @spec validate(Client.t(), map()) :: Client.result(map())
  def validate(client, build_spec) when is_map(build_spec) do
    Client.post(client, "/sandbox-templates/validate", %{build_spec: build_spec})
  end

  # ── Builds ───────────────────────────────────────────────────────────────────

  @doc "List builds for a sandbox template."
  @spec list_builds(Client.t(), String.t()) :: Client.result(map())
  def list_builds(client, template_id) when is_binary(template_id) do
    Client.get(client, "/sandbox-templates/#{template_id}/builds")
  end

  @doc """
  Trigger a new build for a sandbox template.

  Optional `attrs` may include build-time overrides. Pass `:idempotency_key`
  to supply your own idempotency key.
  """
  @spec create_build(Client.t(), String.t(), map()) :: Client.result(map())
  def create_build(client, template_id, attrs \\ %{})
      when is_binary(template_id) and is_map(attrs) do
    idem = pop_idempotency(attrs)

    Client.post(
      client,
      "/sandbox-templates/#{template_id}/builds",
      strip_nil(attrs),
      headers: [{"idempotency-key", idem}]
    )
  end

  # ── Helpers ──────────────────────────────────────────────────────────────────

  defp pop_idempotency(attrs) do
    cond do
      is_map(attrs) -> Map.get(attrs, :idempotency_key) || Map.get(attrs, "idempotency_key")
      Keyword.keyword?(attrs) -> Keyword.get(attrs, :idempotency_key)
      true -> nil
    end || generate_idempotency_key()
  end

  defp generate_idempotency_key do
    16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
  end

  defp strip_nil(map) when is_map(map) do
    map
    |> Map.delete(:idempotency_key)
    |> Map.delete("idempotency_key")
    |> Enum.reject(fn {_k, v} -> is_nil(v) end)
    |> Map.new()
  end

  defp normalize(filters) when is_list(filters), do: Map.new(filters)
  defp normalize(filters) when is_map(filters), do: filters

  defp build_query(filters) when filters == %{}, do: ""

  defp build_query(filters) do
    "?" <>
      (filters
       |> Enum.reject(fn {_k, v} -> is_nil(v) end)
       |> Enum.map(fn {k, v} -> "#{k}=#{URI.encode_www_form(to_string(v))}" end)
       |> Enum.join("&"))
  end
end