Skip to main content

lib/npm/registry.ex

defmodule NPM.Registry do
  alias NPM.Security.RegistryPolicy

  @moduledoc """
  HTTP client for the npm registry.

  Fetches abbreviated packuments (version list + deps + dist info)
  using the npm registry API.
  """

  @max_retries 3

  @type packument :: %{
          name: String.t(),
          versions: %{String.t() => version_info()}
        }

  @type version_info :: %{
          dependencies: %{String.t() => String.t()},
          optional_dependencies: %{String.t() => String.t()},
          peer_dependencies: %{String.t() => String.t()},
          peer_dependencies_meta: %{String.t() => map()},
          bin: %{optional(String.t()) => String.t()},
          engines: %{String.t() => String.t()},
          os: [String.t()],
          cpu: [String.t()],
          has_install_script: boolean(),
          deprecated: String.t() | nil,
          created_at: String.t() | nil,
          published_at: String.t() | nil,
          dist: %{
            tarball: String.t(),
            integrity: String.t(),
            file_count: integer() | nil,
            unpacked_size: integer() | nil
          }
        }

  @doc "Get the configured registry URL."
  @spec registry_url :: String.t()
  def registry_url do
    NPM.Config.registry()
  end

  @doc "Fetch the abbreviated packument for a package."
  @spec get_packument(String.t()) :: {:ok, packument()} | {:error, term()}
  def get_packument(package) do
    case NPM.PackumentCache.get(package) do
      {:ok, packument} ->
        {:ok, packument}

      :miss ->
        fetch_packument(package)
    end
  end

  defp fetch_packument(package) do
    url = "#{registry_url()}/#{encode_package(package)}"
    headers = auth_headers() ++ [accept: "application/vnd.npm.install-v1+json"]
    fetch_with_retry(package, url, headers, @max_retries)
  end

  defp fetch_with_retry(package, url, headers, retries_left) do
    RegistryPolicy.validate_url!(url)

    result =
      Req.get(url,
        headers: headers,
        decode_body: false,
        redirect: NPM.Config.allow_registry_redirects?()
      )

    case classify_result(result) do
      {:ok, body} ->
        packument = body |> decode_body() |> parse_packument()
        NPM.PackumentCache.put(package, packument)
        {:ok, packument}

      {:retry, _} when retries_left > 0 ->
        retry(package, url, headers, retries_left)

      {_, error} ->
        error
    end
  end

  defp classify_result({:ok, %{status: 200, body: body}}), do: {:ok, body}
  defp classify_result({:ok, %{status: 404}}), do: {:error, {:error, :not_found}}
  defp classify_result({:ok, %{status: 401}}), do: {:error, {:error, :unauthorized}}
  defp classify_result({:ok, %{status: 403}}), do: {:error, {:error, :forbidden}}
  defp classify_result({:ok, %{status: s}}) when s >= 500, do: {:retry, {:error, {:http, s}}}
  defp classify_result({:ok, %{status: s}}), do: {:error, {:error, {:http, s}}}
  defp classify_result({:error, reason}), do: {:retry, {:error, reason}}

  defp retry(package, url, headers, retries_left) do
    Process.sleep(1000 * (@max_retries - retries_left + 1))
    fetch_with_retry(package, url, headers, retries_left - 1)
  end

  defp auth_headers do
    case NPM.Config.auth_token() do
      nil -> []
      token -> [authorization: "Bearer #{token}"]
    end
  end

  @doc "URL-encode a package name (handles scoped packages)."
  @spec encode_package(String.t()) :: String.t()
  def encode_package(package), do: String.replace(package, "/", "%2f")

  defp decode_body(body) when is_binary(body), do: NPM.JSON.decode!(body)
  defp decode_body(body) when is_map(body), do: body

  defp parse_packument(data) do
    times = Map.get(data, "time", %{})

    versions =
      for {version_str, info} <- Map.get(data, "versions", %{}), into: %{} do
        {version_str, parse_version_info(info, times)}
      end

    %{name: Map.get(data, "name", ""), versions: versions}
  end

  defp parse_version_info(info, times) do
    dist = Map.get(info, "dist", %{})
    tarball = Map.get(dist, "tarball", "")
    RegistryPolicy.validate_url!(tarball)

    %{
      dependencies: Map.get(info, "dependencies", %{}),
      peer_dependencies: Map.get(info, "peerDependencies", %{}),
      peer_dependencies_meta: Map.get(info, "peerDependenciesMeta", %{}),
      optional_dependencies: Map.get(info, "optionalDependencies", %{}),
      bin: parse_bin(info),
      engines: Map.get(info, "engines", %{}),
      os: Map.get(info, "os", []),
      cpu: Map.get(info, "cpu", []),
      has_install_script: Map.get(info, "hasInstallScript", false),
      deprecated: Map.get(info, "deprecated", nil),
      created_at: Map.get(times, "created"),
      published_at: Map.get(times, Map.get(info, "version", "")),
      dist: %{
        tarball: tarball,
        integrity: Map.get(dist, "integrity", ""),
        file_count: Map.get(dist, "fileCount"),
        unpacked_size: Map.get(dist, "unpackedSize")
      }
    }
  end

  defp parse_bin(%{"bin" => bin}) when is_map(bin), do: bin
  defp parse_bin(%{"bin" => bin}) when is_binary(bin), do: %{}
  defp parse_bin(_), do: %{}
end