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