5.9 KiB · text History 173e2a6
defmodule GitGud.Packages.Format do
  @moduledoc """
  The one thing that actually differs between package registries.

  PyPI, Hex, Cargo and apt share everything that was expensive to
  build: blob storage (`GitGud.Storage`), namespacing (`Package` /
  `PackageVersion` / `PackageFile`, per repository), auth tokens
  (`GitGud.Packages.RegistryToken`), and a UI. What differs is the
  *index shape* a client fetches and the *upload framing* a publish
  tool sends. That is this behaviour, and there is one implementation
  per format — the same move `GitGud.Storage` makes for backends.

  Everything is served under `/pkg/:format/:owner/:name/*rest`;
  `GitGudWeb.PackageRegistryController` resolves the repository,
  authenticates, and hands the remaining path to the adapter.

  ## `index/2`

  Serves every read: index documents, metadata, and artifact
  downloads. The `ctx` carries the path below the repository root plus
  the raw `Plug.Conn`, because several formats negotiate on request
  headers — PEP 691 picks a serialisation off `Accept`, Cargo's sparse
  index revalidates with `If-None-Match`.

  ## `store/3`

  Serves every write, not just uploads: the `ctx` carries the HTTP
  method, so Cargo's `DELETE …/yank` and `PUT …/unyank` land here too.
  The third argument is the already-extracted upload body — either a
  `%Plug.Upload{}` (twine's multipart) or a raw binary (Cargo's
  length-prefixed octet-stream).

  ## `content_type/1`

  The media type to serve a stored artifact with, given its filename.
  Clients rarely care, but `pip` and `cargo` both log it.
  """

  alias GitGud.Repositories.Repository

  @formats ~w(oci pypi hex cargo deb)

  # Format identifier → adapter module. `oci` is absent on purpose: zot
  # is the authoritative store for container images and serves the
  # distribution API itself, so `oci` exists only as a `format` value
  # the packages UI can list alongside everything else. Each remaining
  # format appears here as its adapter lands.
  @default_adapters %{
    "pypi" => GitGud.Packages.Formats.Pypi,
    "hex" => GitGud.Packages.Formats.Hex,
    "cargo" => GitGud.Packages.Formats.Cargo
  }

  @typedoc """
  Request context handed to an adapter.

    * `:path` — path segments below `/pkg/:format/:owner/:name`
    * `:conn` — the live `Plug.Conn`, for header negotiation
    * `:method` — `"GET"`, `"PUT"`, `"POST"`, `"DELETE"`
    * `:trailing_slash?` — whether the raw request path ended in `/`
      (Plug's segment list drops it, and PEP 503 makes it load-bearing)
    * `:base_url` — absolute URL of this repository's registry root,
      no trailing slash
    * `:principal` — the authenticated principal, see
      `GitGud.Packages.Auth`
  """
  @type ctx :: %{
          path: [String.t()],
          conn: Plug.Conn.t(),
          method: String.t(),
          trailing_slash?: boolean(),
          base_url: String.t(),
          principal: term()
        }

  @typedoc """
  What an adapter hands back from `index/2`.

    * `{:ok, content_type, body}` — render it
    * `{:ok, content_type, body, headers}` — …with extra response
      headers (`ETag`, `Cache-Control`)
    * `{:blob, package_file}` — serve the stored artifact
    * `{:redirect, url_or_path}` — 301
    * `:not_modified` — 304, the client's validator still holds
    * `{:error, reason}` — mapped to a status by the controller
  """
  @type index_result ::
          {:ok, String.t(), iodata()}
          | {:ok, String.t(), iodata(), [{String.t(), String.t()}]}
          | {:blob, GitGud.Packages.PackageFile.t()}
          | {:redirect, String.t()}
          | :not_modified
          | {:error, term()}

  @typedoc """
  What an adapter hands back from `store/3`. The body is the
  format's own success document — Cargo wants a `warnings` object,
  twine is happy with an empty 200.
  """
  @type store_result ::
          {:ok, String.t(), iodata()}
          | {:reply, integer(), String.t(), iodata()}
          | {:error, term()}

  @callback index(Repository.t(), ctx()) :: index_result()
  @callback store(Repository.t(), ctx(), term()) :: store_result()
  @callback content_type(String.t()) :: String.t()

  @doc """
  The ecosystem's canonical form of a package name. Optional — a
  format that doesn't specify one gets case folding.
  """
  @callback normalize(String.t()) :: String.t()

  @optional_callbacks normalize: 1

  @doc "Format identifiers this forge knows about."
  def formats, do: @formats

  @doc """
  The live adapter table. Defaults to every format this build knows
  how to serve; a deployment that only wants, say, Hex can narrow it:

      config :git_gud, :package_formats, %{"hex" => GitGud.Packages.Formats.Hex}

  A format that isn't in the table 404s, which is a better answer than
  serving an index nobody meant to publish.
  """
  def adapters, do: Application.get_env(:git_gud, :package_formats, @default_adapters)

  @doc "Resolve a format identifier to its adapter module."
  def adapter(format) when is_binary(format) do
    case Map.fetch(adapters(), format) do
      {:ok, mod} -> {:ok, mod}
      :error -> :error
    end
  end

  def adapter(_), do: :error

  @doc """
  Canonical form of a package name for `format`, used for every
  lookup. Each ecosystem specifies its own; where one doesn't, case
  folding is the safe floor.
  """
  def normalize(format, name) do
    case adapter(format) do
      {:ok, mod} ->
        if Code.ensure_loaded?(mod) and function_exported?(mod, :normalize, 1),
          do: mod.normalize(name),
          else: String.downcase(name)

      :error ->
        String.downcase(name)
    end
  end

  @doc """
  Media type to serve `filename` with, asked of `format`'s adapter.
  Falls back to `application/octet-stream`, which is what every client
  here treats an unknown artifact as anyway.
  """
  def adapter_content_type(format, filename) do
    case adapter(format) do
      {:ok, mod} -> mod.content_type(filename)
      :error -> "application/octet-stream"
    end
  end
end