Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/mimicry/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule Mimicry.Application do
"""
use Application
alias Mimicry.MockServerList
alias Mimicry.Utils.SpecificationFileObserver

def start(_type, _args) do
# List all child processes to be supervised
Expand All @@ -17,7 +18,9 @@ defmodule Mimicry.Application do
# Start a dynamic supervisor for creating additional servers
MockServerList,
# starts one task to trigger the initial seeds given in ./specs
{Task, &MockServerList.load_specifications_on_startup/0}
{Task, &MockServerList.load_specifications_on_startup/0},
# starts a file observer that watches the configured spec folder for changes
SpecificationFileObserver
]

# See https://hexdocs.pm/elixir/Supervisor.html
Expand Down
45 changes: 0 additions & 45 deletions lib/mimicry/mock_repo.ex

This file was deleted.

25 changes: 22 additions & 3 deletions lib/mimicry/mock_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,37 @@ defmodule Mimicry.MockServer do
@doc """
gets the internal state of a mock server
"""
@spec get_details(pid()) :: map()
def get_details(pid) do
pid |> GenServer.call(:details)
end

@doc """
Makes a request against a mock server process, returning a map with the details for the
actual response
"""
@spec request(pid, Plug.Conn.t(), map()) :: map()
def request(pid, conn = %Plug.Conn{}, _params = %{}) do
pid |> GenServer.call({:request, conn})
end

def child_spec(id, openapi_spec) do
@doc """
Creates a child spec for the `Mimicry.MockServerList`
"""
@spec child_spec(atom(), Specification.t(), String.t()) :: map()
def child_spec(id, openapi_spec, path \\ "") do
%{
id: id,
start: {__MODULE__, :start_link, [[spec: openapi_spec, id: id]]},
start: {__MODULE__, :start_link, [[spec: openapi_spec, id: id, path: path]]},
type: :worker
}
end

def start_link(state = [spec: _spec, id: id]) do
def update_server_specification(pid, spec = %Specification{}) do
pid |> GenServer.call({:update_spec, spec})
end

def start_link(state = [spec: _spec, id: id, path: _path]) do
GenServer.start_link(__MODULE__, state, name: id)
end

Expand Down Expand Up @@ -61,4 +75,9 @@ defmodule Mimicry.MockServer do
spec = state |> Keyword.get(:spec, nil)
{:reply, conn |> MockAPI.respond(spec), state}
end

@impl true
def handle_call({:update_spec, specification}, _from, state) do
{:reply, :ok, state |> Keyword.merge(spec: specification)}
end
end
47 changes: 34 additions & 13 deletions lib/mimicry/mock_server_list.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ defmodule Mimicry.MockServerList do

Idempotent, this will not create a duplicate for the same combination of `title` + `version`.
"""
@spec create_server(Specification.t()) ::
@spec create_server(Specification.t(), String.t()) ::
{:ok, pid()} | {:error, :invalid_specification} | {:error, :unknown}
def create_server(spec) do
case start_mock_server(spec) do
def create_server(spec, file \\ "") do
case spec |> start_mock_server(file) do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caller must adhere to expected argument format (single triple, no 2 args)

Suggested change
case spec |> start_mock_server(file) do
case start_mock_server({:ok, spec, file}) do

{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
{:error, :invalid_specification} -> {:error, :invalid_specification}
Expand Down Expand Up @@ -99,6 +99,24 @@ defmodule Mimicry.MockServerList do
end
end

def find_server_by_specification_path(path) do
children()
|> Enum.find(fn pid ->
pid |> :sys.get_state() |> Keyword.get(:path) == path
end)
|> case do
nil ->
{:error, :not_found}

pid ->
{:ok, pid}
end
end

def update_server_specification(pid, spec = %Specification{}) do
pid |> MockServer.update_server_specification(spec)
end

@doc """
used to seed servers given via the spec folder upon startup
"""
Expand All @@ -121,20 +139,23 @@ defmodule Mimicry.MockServerList do
DynamicSupervisor.init(strategy: :one_for_one)
end

defp start_mock_server(_spec = %Specification{supported: false}) do
defp start_mock_server({_, _spec = %Specification{supported: false}, _}) do
{:error, :unsupported_spec}
end

defp start_mock_server(spec = %Specification{}) do
child_spec = spec |> MockServer.create_id() |> MockServer.child_spec(spec)
DynamicSupervisor.start_child(__MODULE__, child_spec)
rescue
e in RuntimeError ->
Logger.error(e.message, spec: spec)
{:error, :invalid_specification}
defp start_mock_server({:ok, spec = %Mimicry.OpenAPI.Specification{}, file_path}) do
child_spec = spec |> MockServer.create_id() |> MockServer.child_spec(spec, file_path)

case DynamicSupervisor.start_child(__MODULE__, child_spec) do
{:error, _} ->
:error

value ->
value
end
end

defp start_mock_server(_), do: {:error, :invalid_specification}
defp start_mock_server(_, _), do: {:error, :invalid_specification}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only a single triple is passed.

Suggested change
defp start_mock_server(_, _), do: {:error, :invalid_specification}
defp start_mock_server(_), do: {:error, :invalid_specification}


defp children do
DynamicSupervisor.which_children(__MODULE__)
Expand All @@ -146,7 +167,7 @@ defmodule Mimicry.MockServerList do
pid |> :sys.get_state() |> Keyword.take([:id, :entities, :spec]) |> Enum.into(%{})
end

defp do_load_specification_on_startup(true) do
defp do_load_specification_on_startup(enabled?) when enabled? == true do
SpecFolder.load_all()
|> Enum.each(&start_mock_server/1)
end
Expand Down
3 changes: 1 addition & 2 deletions lib/mimicry/open_api/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ defmodule Mimicry.OpenAPI.Parser do
}
end

def build_specification(spec = _) do
Logger.warn("Specification most likely invalid", specification: spec)
def build_specification(_) do
Specification.unsupported()
end

Expand Down
3 changes: 0 additions & 3 deletions lib/mimicry/open_api/path.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ defmodule Mimicry.OpenAPI.Path do

alias Mimicry.OpenAPI.{Response, Specification}

def extract_response do
end

def extract_response(
spec = %Specification{content: %{"paths" => paths}},
method,
Expand Down
2 changes: 1 addition & 1 deletion lib/mimicry/open_api/specification.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Mimicry.OpenAPI.Specification do
Specification represents a single specification file (either YAML or JSON)
"""
# @derive {Jason.Encoder, only: [:content]}
defstruct [:title, :content, :version, :openapi_version, :servers, supported: true]
defstruct [:title, :content, :version, :openapi_version, :servers, :path, supported: true]

@doc """
returns the representation of an unsupported specification
Expand Down
91 changes: 91 additions & 0 deletions lib/mimicry/utils/specification_file_observer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
defmodule Mimicry.Utils.SpecificationFileObserver do
@moduledoc """
Listens to changes in the folder defined via `Mimicry.Utils.SpecificationFolder.base_path/0` and reloads
the specifications currently running in mimicry
"""

alias Mimicry.MockServerList
alias Mimicry.OpenAPI.Parser
alias Mimicry.Utils.{SpecificationFileReader, SpecificationFolder}

require Logger

use GenServer

def start_link(opts \\ %{}) do
GenServer.start_link(__MODULE__, opts, [])
end

@impl true
def init(_args) do
{:ok, watcher_pid} = FileSystem.start_link(dirs: [SpecificationFolder.base_path()])
watcher_pid |> FileSystem.subscribe()
{:ok, %{watcher_pid: watcher_pid}}
end

@impl true
def handle_info({:file_event, _watcher_pid, {path, events}}, state) do
Logger.metadata(path: path, events: events)

case SpecificationFileReader.extension(path) do
extension when extension in [:yaml, :json] ->
path |> handle_file_event(events)
{:noreply, state}

:unsupported ->
{:noreply, state}
end
end

@impl true
def handle_info({:file_event, _watcher_pid, :stop}, state) do
{:noreply, state}
end

defp handle_file_event(path, [:moved_from]) do
case path |> Path.basename() |> MockServerList.find_server_by_specification_path() do
{:ok, pid} ->
Logger.info("Removing")
pid |> MockServerList.delete_server()

_ ->
nil
end
end
Comment on lines +45 to +54

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is another events list matching deletion: [:deleted]
(and it's the only one being emitted on my system in such cases)

I temporarily solved it with:

  defp handle_file_event(path, [:moved_from]) do
    maybe_delete_server(path)
  end

  defp handle_file_event(path, [:deleted]) do
    maybe_delete_server(path)
  end

# snip

  defp maybe_delete_server(path) do
    case path |> Path.basename() |> MockServerList.find_server_by_specification_path() do
      {:ok, pid} ->
        Logger.info("Removing")
        pid |> MockServerList.delete_server()
      _ ->
        nil
    end
  end


defp handle_file_event(path, [:modified, :closed]) do
path
|> parse()
|> upsert(path)
end

defp handle_file_event(path, [:moved_to]) do
path
|> parse()
|> upsert(path)
end

defp handle_file_event(_path, _events) do
Logger.info("Unknown")
end

defp parse(path) do
{:ok, content, ext} = path |> SpecificationFileReader.read()
Parser.parse(content, ext)
end

defp upsert(spec, path) do
case path
|> Path.basename()
|> MockServerList.find_server_by_specification_path() do
{:ok, pid} ->
Logger.info("Reloading...")

pid |> MockServerList.update_server_specification(spec)

_ ->
Logger.info("Creating...")
spec |> MockServerList.create_server(path)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This solves the creation and deletion on the fly, otherwise a server cannot be removed as paths won't match anymore:

Suggested change
spec |> MockServerList.create_server(path)
spec |> MockServerList.create_server(path |> Path.basename())

Since path |> Path.basename() was already used above, it can be probably extracted into a var if needed.

end
end
end
6 changes: 4 additions & 2 deletions lib/mimicry/utils/specification_file_reader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ defmodule Mimicry.Utils.SpecificationFileReader do
`SpecificationFileReader` provides functions around fiels given to Mimicry.
"""

alias Mimicry.OpenAPI.Specification

@doc """
attempts to read a file from the configured spec directory,
retaining information about the extension of the file
Expand All @@ -22,6 +20,10 @@ defmodule Mimicry.Utils.SpecificationFileReader do
end
end

@doc """
Returns the file extension as an atom in order to determine which parser is used further
down the road.
"""
@spec extension(String.t()) :: atom()
def extension(file) do
case file |> Path.extname() do
Expand Down
13 changes: 8 additions & 5 deletions lib/mimicry/utils/specification_folder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ defmodule Mimicry.Utils.SpecificationFolder do
alias Mimicry.OpenAPI.{Parser, Specification}

@doc """
will attempt to load all specifications in the configured folder
Will attempt to load all specifications in the configured folder
while preserving paths with the specifications
"""
@spec load_all() :: list()
def load_all do
if base_path() |> File.dir?() do
load_all_deduplicated()
Expand All @@ -37,7 +39,7 @@ defmodule Mimicry.Utils.SpecificationFolder do
|> Enum.map(&Path.basename/1)
|> Enum.map(&Task.async(fn -> load(&1) end))
|> Task.await_many()
|> Enum.filter(fn val -> val != :error end)
|> Enum.filter(fn {status, _, _} -> status != :error end)
|> deduplicate()
end

Expand All @@ -57,11 +59,12 @@ defmodule Mimicry.Utils.SpecificationFolder do
defp load(path) do
case load_file(path) do
{content, ext} ->
Parser.parse(content, ext)
specification = Parser.parse(content, ext)
{:ok, specification, path}

nil ->
Logger.warn("Found invalid specification in Specification folder: #{path}")
:error
{:error, nil, path}
end
end

Expand All @@ -73,7 +76,7 @@ defmodule Mimicry.Utils.SpecificationFolder do
|> Enum.uniq_by(&duplicate_condition/1)
end

defp duplicate_condition(%Specification{title: title, version: version}) do
defp duplicate_condition({%Specification{title: title, version: version}, _}) do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First item of the triple is the status; function will not match otherwise

Suggested change
defp duplicate_condition({%Specification{title: title, version: version}, _}) do
defp duplicate_condition({_, %Specification{title: title, version: version}, _}) do

"#{title}-#{version}"
end

Expand Down