lib/exonerate/remote.ex

defmodule Exonerate.Remote do
  @moduledoc """
  Adapter for fetching remote content.

  Use the `fetch_remote!/2` for creating your own adapters if the default
  adapter is inappropriate for your use case.

  Note that this module also contains overall logic for managing remote
  schemas.
  """

  alias Exonerate.Cache
  alias Exonerate.Tools
  alias Exonerate.Schema

  @spec ensure_resource_loaded!(URI.t(), Env.t(), keyword) :: URI.t()
  @doc false
  # Ensures the resource represented by the URI exists in the cache.
  #
  # If it doesn't exist in the cache, then attempt to fetch it from Exonerate's
  # priv directory (or app pointed to by options).
  #
  # If it doesn't exist there, prompt the user that the remote json will be
  # loaded. If the user picks yes, then download it.
  #
  # ### Options
  #
  # - `:cache_app`: specifies the otp app whose priv directory cached remote
  #  JSONs are stored.
  #
  #  Defaults to `:exonerate`.
  #
  # - `:cache_path`: specifies the subdirectory of priv where cached remote
  #  JSONs are stored.
  #
  #  Defaults to `/`.
  #
  # - `:remote_fetch_adapter`: specifies the module that exposes the public
  #  function `fetch_remote!/2`.  This function should take a URI and
  #  the options passed to `ensure_resource_loaded!/2` and raise if failures
  #  occur, or return :ok if it succeeds.
  #
  #  Defaults to `#{__MODULE__}`.

  def ensure_resource_loaded!(uri, caller, opts) do
    if Cache.has_schema?(caller.module, Tools.uri_to_resource(uri)) do
      :ok
    else
      load_cache(caller, uri, opts)
    end

    uri
  end

  defp load_cache(_, uri = %{scheme: "function"}, _) do
    raise "function resources can't loaded, (tried to load #{uri})"
  end

  defp load_cache(caller, uri, opts) do
    remote_fetch_adapter = Keyword.get(opts, :remote_fetch_adapter, __MODULE__)
    resource = Tools.uri_to_resource(uri)

    case File.read(path_for(uri, opts)) do
      {:ok, binary} ->
        # TODO: support YAML here.
        Schema.ingest(binary, caller, resource, opts)

      {:error, :enoent} ->
        proxied_uri = maybe_proxy(uri, opts)
        guard_fetch!(proxied_uri, opts)
        ensure_priv_directory!(opts)

        {body, encoding} = remote_fetch_adapter.fetch_remote!(proxied_uri, opts)

        encoding = encoding || Tools.encoding_from_extension(proxied_uri, opts)

        opts = Keyword.put(opts, :encoding, encoding)

        if Keyword.get(opts, :cache, true) do
          uri
          |> path_for(opts)
          |> File.write!(body)

          load_cache(caller, resource, opts)
        else
          Schema.ingest(body, caller, resource, opts)
        end
    end

    :ok
  end

  defp guard_fetch!(resource, opts) do
    unless opts[:force_remote] do
      response =
        IO.gets(
          IO.ANSI.yellow() <>
            "Exonerate would like to fetch a schema from #{resource}" <>
            IO.ANSI.reset() <> "\nOk? (y/n) "
        )

      case response do
        <<yes, _::binary>> when yes in ~C'Yy' ->
          :ok

        _ ->
          raise IO.ANSI.red() <>
                  "fetch rejected for online content #{resource}" <> IO.ANSI.reset()
      end
    end
  end

  @spec fetch_remote!(URI.t(), keyword) ::
          {encoded_json :: String.t(), content_type :: String.t() | nil}
  @doc """
  uses the `Req` library to fetch remote content.

  Note that this function fails if the remote resource does not return 200, and
  raises for any connection errors.
  """
  def fetch_remote!(resource, _opts) do
    Application.ensure_all_started(:req)

    %{status: 200, body: body, headers: headers} =
      resource
      |> Map.put(:fragment, nil)
      |> to_string
      |> Req.get!(decode_body: false)

    content_type =
      if List.keyfind(headers, "content-type", 0) do
        List.keyfind(headers, "content-type", 0)
        |> elem(1)
        |> String.split(";")
        |> List.first()
      end

    {body, content_type}
  end

  # utilities

  defp maybe_proxy(uri, opts) do
    if proxy_mapping = opts[:proxy] do
      proxy_mapping
      |> Enum.reduce_while(
        to_string(uri),
        fn {from, to}, uri_string ->
          if String.starts_with?(uri_string, from) do
            {:halt, String.replace_prefix(uri_string, from, to)}
          else
            {:cont, uri_string}
          end
        end
      )
      |> URI.parse()
    else
      uri
    end
  end

  defp priv_dir(opts) do
    opts
    |> Keyword.get(:cache_app, :exonerate)
    |> :code.priv_dir()
  end

  defp path_for(uri, opts) do
    priv_path = Keyword.get(opts, :cache_path, "/")

    file_name =
      uri
      |> Map.put(:fragment, nil)
      |> to_string()
      |> URI.encode_www_form()

    opts
    |> priv_dir
    |> Path.join(priv_path)
    |> Path.join(file_name)
  end

  defp ensure_priv_directory!(opts) do
    priv_dir = priv_dir(opts)
    if File.dir?(priv_dir), do: :ok, else: File.mkdir_p!(priv_dir)
  end
end