lib/exvcr/mock.ex

defmodule ExVCR.Mock do
  @moduledoc """
  Provides macro to record HTTP request/response.
  """

  alias ExVCR.Recorder

  defmacro __using__(opts) do
    adapter = opts[:adapter] || ExVCR.Adapter.IBrowse
    options = opts[:options]

    quote do
      import ExVCR.Mock
      :application.start(unquote(adapter).module_name())
      use unquote(adapter)

      def adapter_method() do
        unquote(adapter)
      end

      def options_method() do
        unquote(options)
      end
    end
  end

  @doc """
  Provides macro to trigger recording/replaying http interactions.

  ## Options

  - `:match_requests_on` A list of request properties to match on when
    finding a matching response. Valid values include `:query`, `:headers`,
    and `:request_body`

  """
  defmacro use_cassette(:stub, options, test) do
    quote do
      stub_fixture = "stub_fixture_#{ExVCR.Util.uniq_id()}"
      stub = prepare_stub_records(unquote(options), adapter_method())
      recorder = Recorder.start([fixture: stub_fixture, stub: stub, adapter: adapter_method()])

      try do
        mock_methods(recorder, adapter_method())
        [do: return_value] = unquote(test)
        return_value
      after
        module_name = adapter_method().module_name()
        unload(module_name)
        ExVCR.MockLock.release_lock()
      end
    end
  end

  defmacro use_cassette(fixture, options, test) do
    quote do
      recorder = start_cassette(unquote(fixture), unquote(options))

      try do
        [do: return_value] = unquote(test)
        return_value
      after
        stop_cassette(recorder)
      end
    end
  end

  defmacro use_cassette(fixture, test) do
    quote do
      use_cassette(unquote(fixture), [], unquote(test))
    end
  end

  defmacro start_cassette(fixture, options) when fixture != :stub do
    quote do
      recorder =
        Recorder.start(
          unquote(options) ++
            [fixture: normalize_fixture(unquote(fixture)), adapter: adapter_method()]
        )

      mock_methods(recorder, adapter_method())
      recorder
    end
  end

  defmacro stop_cassette(recorder) do
    quote do
      recorder_result = Recorder.save(unquote(recorder))

      module_name = adapter_method().module_name()
      unload(module_name)
      ExVCR.MockLock.release_lock()

      recorder_result
    end
  end

  @doc false
  defp load(adapter, recorder) do
    if ExVCR.Application.global_mock_enabled?() do
      ExVCR.Actor.CurrentRecorder.set(recorder)
    else
      module_name    = adapter.module_name()
      target_methods = adapter.target_methods(recorder)
      Enum.each(target_methods, fn({function, callback}) ->
        :meck.expect(module_name, function, callback)
      end)
    end
  end

  @doc false
  def unload(module_name) do
    if ExVCR.Application.global_mock_enabled?() do
      ExVCR.Actor.CurrentRecorder.default_state()
      |> ExVCR.Actor.CurrentRecorder.set()
    else
      :meck.unload(module_name)
    end
  end

  @doc """
  Mock methods pre-defined for the specified adapter.
  """
  def mock_methods(recorder, adapter) do
    parent_pid = self()
    Task.async(fn ->
      ExVCR.MockLock.ensure_started()
      ExVCR.MockLock.request_lock(self(), parent_pid)
      receive do
        :lock_granted ->
          load(adapter, recorder)
      end
    end)
    |> Task.await(:infinity)
  end

  @doc """
  Prepare stub records
  """
  def prepare_stub_records(options, adapter) do
    if Keyword.keyword?(options) do
      prepare_stub_record(options, adapter)
    else
      Enum.flat_map(options, &prepare_stub_record(&1, adapter))
    end
  end

  @doc """
  Prepare stub record based on specified option parameters.
  """
  def prepare_stub_record(options, adapter) do
    method        = (options[:method] || "get") |> to_string
    url           = (options[:url] || "~r/.+/") |> to_string
    body          = (options[:body] || "Hello World") |> to_string
    # REVIEW: would be great to have "~r/.+/" as default request_body
    request_body  = (options[:request_body] || "") |> to_string

    headers     = options[:headers] || adapter.default_stub_params(:headers)
    status_code = options[:status_code] || adapter.default_stub_params(:status_code)

    record = %{ "request"  => %{"method" => method, "url" => url, "request_body" => request_body},
                "response" => %{"body" => body, "headers"  => headers, "status_code" => status_code} }

    [adapter.convert_from_string(record)]
  end

  @doc """
  Normalize fixture name for using as json file names, which removes whitespaces and align case.
  """
  def normalize_fixture(fixture) do
    fixture |> String.replace(~r/\s/, "_") |> String.downcase
  end
end