From 0127205d1065a048db9ef36d3e02cc175a1d3393 Mon Sep 17 00:00:00 2001 From: Florian Kraft Date: Thu, 17 Jun 2021 21:51:41 +0200 Subject: [PATCH 1/5] documents extension/1 --- lib/mimicry/utils/specification_file_reader.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/mimicry/utils/specification_file_reader.ex b/lib/mimicry/utils/specification_file_reader.ex index 9cba91b..27bfb58 100644 --- a/lib/mimicry/utils/specification_file_reader.ex +++ b/lib/mimicry/utils/specification_file_reader.ex @@ -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 @@ -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 From e3b3ef21ccc5f4279d322b80893b54b16f2ca470 Mon Sep 17 00:00:00 2001 From: Florian Kraft Date: Thu, 17 Jun 2021 21:52:48 +0200 Subject: [PATCH 2/5] starts a dummy file observer for the spec folder --- lib/mimicry/application.ex | 5 +- .../utils/specification_file_observer.ex | 65 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 lib/mimicry/utils/specification_file_observer.ex diff --git a/lib/mimicry/application.ex b/lib/mimicry/application.ex index 1acb529..bd20e70 100644 --- a/lib/mimicry/application.ex +++ b/lib/mimicry/application.ex @@ -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 @@ -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 diff --git a/lib/mimicry/utils/specification_file_observer.ex b/lib/mimicry/utils/specification_file_observer.ex new file mode 100644 index 0000000..cfe5a6e --- /dev/null +++ b/lib/mimicry/utils/specification_file_observer.ex @@ -0,0 +1,65 @@ +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.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()]) + FileSystem.subscribe(watcher_pid) + {:ok, %{watcher_pid: watcher_pid}} + end + + @impl true + def handle_info({:file_event, _watcher_pid, {path, events}}, state) do + case SpecificationFileReader.extension(path) do + extension when extension in [:yaml, :json] -> + path |> handle_file_event(events) + {:noreply, state} + + _ -> + Logger.info("Saw new file in #{path}, but not a specification!") + {:noreply, state} + end + end + + @impl true + def handle_info({:file_event, _watcher_pid, :stop}, state) do + {:noreply, state} + end + + defp handle_file_event(path, [:deleted]) do + IO.puts("Deleted #{path}") + end + + defp handle_file_event(path, [:created]) do + IO.puts("Created #{path}") + end + + defp handle_file_event(path, [:modified, :closed]) do + IO.puts("Modified #{path}") + end + + defp handle_file_event(path, [:attribute]) do + IO.puts("New content #{path}") + end + + defp handle_file_event(path, [:moved_to]) do + IO.puts("Moved file: #{path}") + end + + defp handle_file_event(_path, unsupported_events) do + unsupported_events |> IO.inspect() + end +end From 799841b7db966d27d4cd77c85a24306ff2bfdb95 Mon Sep 17 00:00:00 2001 From: Florian Kraft Date: Thu, 17 Jun 2021 22:51:59 +0200 Subject: [PATCH 3/5] removes mock repo --- lib/mimicry/mock_repo.ex | 45 ---------------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 lib/mimicry/mock_repo.ex diff --git a/lib/mimicry/mock_repo.ex b/lib/mimicry/mock_repo.ex deleted file mode 100644 index a98eade..0000000 --- a/lib/mimicry/mock_repo.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Mimicry.MockRepo do - @moduledoc """ - The MockRepo can be used for fetch entities from the example section within `components -> schemas` of the spec given - """ - - alias Mimicry.OpenAPI.Specification - require Logger - - @doc """ - Given a speicification, find an example for a given reference - """ - def example(spec = %Specification{content: content}, reference) do - end - - @doc """ - For a set of entities, retrieve one, either by a given ID or at random - """ - @spec get(list(), keyword() | :random) :: - {:ok, any()} | {:error, :not_found} | {:error, :bad_param} - def get(entities, param \\ :random) - def get([], _param), do: {:error, :not_found} - - def get(entities, param) do - case param do - :random -> - entity = entities |> Enum.shuffle() |> hd() - {:ok, entity} - - {name, given_value} -> - entities |> find_by_name_and_given_value(name, given_value) - - _ -> - Logger.warn("param given to MockRepo.get/2 must be :random or a keyword list") - {:error, :bad_param} - end - end - - defp find_by_name_and_given_value(entities, name, given_value) do - # NOTE: not all data in the example are necessarily strings - case entities |> Enum.find(fn {key, val} -> key == name && val == given_value end) do - nil -> {:error, :not_found} - entity -> {:ok, entity} - end - end -end From 39ad2cc9de4fb887694afb15a4e151bd528decbd Mon Sep 17 00:00:00 2001 From: Florian Kraft Date: Thu, 17 Jun 2021 23:21:24 +0200 Subject: [PATCH 4/5] observes files to delete and upsert Only if necessary, of course, this is quite naive as of yet, as I am not super familiar with the FileSystem implementation. --- lib/mimicry/mock_server.ex | 25 ++++++++- lib/mimicry/mock_server_list.ex | 47 +++++++++++----- lib/mimicry/open_api/parser.ex | 3 +- lib/mimicry/open_api/path.ex | 3 - lib/mimicry/open_api/specification.ex | 2 +- .../utils/specification_file_observer.ex | 56 ++++++++++++++----- lib/mimicry/utils/specification_folder.ex | 13 +++-- 7 files changed, 107 insertions(+), 42 deletions(-) diff --git a/lib/mimicry/mock_server.ex b/lib/mimicry/mock_server.ex index 7cab803..4b0bd6f 100644 --- a/lib/mimicry/mock_server.ex +++ b/lib/mimicry/mock_server.ex @@ -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 @@ -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 diff --git a/lib/mimicry/mock_server_list.ex b/lib/mimicry/mock_server_list.ex index d42ae27..f7443f8 100644 --- a/lib/mimicry/mock_server_list.ex +++ b/lib/mimicry/mock_server_list.ex @@ -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 {:ok, pid} -> {:ok, pid} {:error, {:already_started, pid}} -> {:ok, pid} {:error, :invalid_specification} -> {:error, :invalid_specification} @@ -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 """ @@ -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} defp children do DynamicSupervisor.which_children(__MODULE__) @@ -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 diff --git a/lib/mimicry/open_api/parser.ex b/lib/mimicry/open_api/parser.ex index 454225d..f7a3b10 100644 --- a/lib/mimicry/open_api/parser.ex +++ b/lib/mimicry/open_api/parser.ex @@ -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 diff --git a/lib/mimicry/open_api/path.ex b/lib/mimicry/open_api/path.ex index e23d8ca..2de2ecd 100644 --- a/lib/mimicry/open_api/path.ex +++ b/lib/mimicry/open_api/path.ex @@ -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, diff --git a/lib/mimicry/open_api/specification.ex b/lib/mimicry/open_api/specification.ex index 660e49d..85fcdfd 100644 --- a/lib/mimicry/open_api/specification.ex +++ b/lib/mimicry/open_api/specification.ex @@ -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 diff --git a/lib/mimicry/utils/specification_file_observer.ex b/lib/mimicry/utils/specification_file_observer.ex index cfe5a6e..f88f7be 100644 --- a/lib/mimicry/utils/specification_file_observer.ex +++ b/lib/mimicry/utils/specification_file_observer.ex @@ -5,6 +5,8 @@ defmodule Mimicry.Utils.SpecificationFileObserver do """ alias Mimicry.Utils.{SpecificationFileReader, SpecificationFolder} + alias Mimicry.MockServerList + alias Mimicry.OpenAPI.Parser require Logger @@ -17,19 +19,20 @@ defmodule Mimicry.Utils.SpecificationFileObserver do @impl true def init(_args) do {:ok, watcher_pid} = FileSystem.start_link(dirs: [SpecificationFolder.base_path()]) - FileSystem.subscribe(watcher_pid) + 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} - _ -> - Logger.info("Saw new file in #{path}, but not a specification!") + :unsupported -> {:noreply, state} end end @@ -39,27 +42,50 @@ defmodule Mimicry.Utils.SpecificationFileObserver do {:noreply, state} end - defp handle_file_event(path, [:deleted]) do - IO.puts("Deleted #{path}") - 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() - defp handle_file_event(path, [:created]) do - IO.puts("Created #{path}") + _ -> + nil + end end defp handle_file_event(path, [:modified, :closed]) do - IO.puts("Modified #{path}") + path + |> parse() + |> upsert(path) end - defp handle_file_event(path, [:attribute]) do - IO.puts("New content #{path}") + defp handle_file_event(path, [:moved_to]) do + path + |> parse() + |> upsert(path) end - defp handle_file_event(path, [:moved_to]) do - IO.puts("Moved file: #{path}") + defp handle_file_event(_path, _events) do + Logger.info("Unknown") end - defp handle_file_event(_path, unsupported_events) do - unsupported_events |> IO.inspect() + 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) + end end end diff --git a/lib/mimicry/utils/specification_folder.ex b/lib/mimicry/utils/specification_folder.ex index 5fd0f85..146678f 100644 --- a/lib/mimicry/utils/specification_folder.ex +++ b/lib/mimicry/utils/specification_folder.ex @@ -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() @@ -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 @@ -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 @@ -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 "#{title}-#{version}" end From f143a0d65160919433f079dda478a56a372148f3 Mon Sep 17 00:00:00 2001 From: Florian Kraft Date: Thu, 17 Jun 2021 23:28:58 +0200 Subject: [PATCH 5/5] cleans up --- lib/mimicry/utils/specification_file_observer.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mimicry/utils/specification_file_observer.ex b/lib/mimicry/utils/specification_file_observer.ex index f88f7be..4fab8b0 100644 --- a/lib/mimicry/utils/specification_file_observer.ex +++ b/lib/mimicry/utils/specification_file_observer.ex @@ -4,9 +4,9 @@ defmodule Mimicry.Utils.SpecificationFileObserver do the specifications currently running in mimicry """ - alias Mimicry.Utils.{SpecificationFileReader, SpecificationFolder} alias Mimicry.MockServerList alias Mimicry.OpenAPI.Parser + alias Mimicry.Utils.{SpecificationFileReader, SpecificationFolder} require Logger