diff --git a/lib/mimicry/mock_api.ex b/lib/mimicry/mock_api.ex index ff50280..2ee083b 100644 --- a/lib/mimicry/mock_api.ex +++ b/lib/mimicry/mock_api.ex @@ -21,8 +21,6 @@ defmodule Mimicry.MockAPI do conn |> get_req_header("x-mimicry-expect-status") |> List.first() - # TODO: For JSON, all keys are strings, always - |> ensure_numerical() |> fallback("default") expected_example = @@ -94,13 +92,4 @@ defmodule Mimicry.MockAPI do body: value } end - - defp ensure_numerical(nil), do: nil - - defp ensure_numerical(maybe_number) do - case Integer.parse(maybe_number) do - :error -> nil - {number, _} -> number - end - end end diff --git a/lib/mimicry/open_api/parser.ex b/lib/mimicry/open_api/parser.ex index 454225d..7b664ea 100644 --- a/lib/mimicry/open_api/parser.ex +++ b/lib/mimicry/open_api/parser.ex @@ -18,9 +18,11 @@ defmodule Mimicry.OpenAPI.Parser do def json(str), do: parse(str, :json) @doc """ - parses a given string depending on its extension + parses a given string based on the extension to a Mimicry.Specifaction + + If you need a map instead of the specification, see parse_to_map/2 """ - @spec parse(String.t(), :yaml | :json) :: Specification.t() + @spec parse(term(), :yaml | :json) :: Specification.t() def parse(str, atom) do str |> decoder(atom).() @@ -35,10 +37,28 @@ defmodule Mimicry.OpenAPI.Parser do end end + @doc """ + parses a given string to a map + """ + @spec parse_to_map(any(), :yaml | :json) :: :error | any() + def parse_to_map(str, atom) do + str + |> decoder(atom).() + |> case do + {:ok, decoded} -> + decoded + + {:error, err} -> + Logger.warn("Could not decode #{atom |> to_string() |> String.upcase()} specification") + Logger.error(err) + :error + end + end + @doc """ builds a new Specification from inputs given """ - @spec build_specification(map()) :: Specification.t() + @spec build_specification(any()) :: Specification.t() def build_specification( parsed = %{ "info" => info, @@ -65,6 +85,7 @@ defmodule Mimicry.OpenAPI.Parser do # NOTE: This cannot read multiple specifications in a YAML file as of yet :yaml -> &YamlElixir.read_from_string/1 :json -> &Jason.decode/1 + _ -> raise RuntimeError, "Unknown extension: #{atom}" end end end diff --git a/lib/mimicry/open_api/response.ex b/lib/mimicry/open_api/response.ex index 4ecbb2b..177b62a 100644 --- a/lib/mimicry/open_api/response.ex +++ b/lib/mimicry/open_api/response.ex @@ -2,5 +2,5 @@ defmodule Mimicry.OpenAPI.Response do @moduledoc """ A struct to represent a potential response based on the `Mimicry.OpenAPI.Specification` powering the `MockServer`. """ - defstruct [:content_type, :schema, :description, examples: %{}, status: 200] + defstruct [:content_type, :schema, :description, examples: %{}, status: "200"] end diff --git a/lib/mimicry_api/controllers/proxy_controller.ex b/lib/mimicry_api/controllers/proxy_controller.ex index 4df4d2c..8657277 100644 --- a/lib/mimicry_api/controllers/proxy_controller.ex +++ b/lib/mimicry_api/controllers/proxy_controller.ex @@ -41,7 +41,7 @@ defmodule MimicryApi.ProxyController do conn |> clean_default_headers() |> merge_resp_headers(headers) - |> put_status(status) + |> put_status(status |> as_integer()) |> json(body) end @@ -63,4 +63,11 @@ defmodule MimicryApi.ProxyController do end defp get_host(%Specification{}), do: nil + + defp as_integer(val) do + case Integer.parse(val) do + {num, _} -> num + _ -> raise RuntimeError, message: "Cannot convert #{val} to proper response code" + end + end end diff --git a/lib/mimicry_api/controllers/server_controller.ex b/lib/mimicry_api/controllers/server_controller.ex index f130db2..d1e5174 100644 --- a/lib/mimicry_api/controllers/server_controller.ex +++ b/lib/mimicry_api/controllers/server_controller.ex @@ -3,33 +3,60 @@ defmodule MimicryApi.ServerController do alias Mimicry.{MockServer, MockServerList} alias Mimicry.OpenAPI.Parser, as: SpecParser + alias MimicryApi.Errors def index(conn, _params) do conn |> json(%{servers: MockServerList.list_servers()}) end - def create(conn, %{"spec" => definition}) do - definition |> SpecParser.build_specification() + def create(conn, %{"json" => raw_definition}) do + spec = raw_definition |> SpecParser.parse_to_map(:json) - with spec <- definition |> SpecParser.build_specification(), + _create(conn, spec) + end + + def create(conn, %{"yaml" => raw_definition}) do + spec = raw_definition |> SpecParser.parse_to_map(:yaml) + _create(conn, spec) + end + + def create(conn, _) do + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Pass the raw spec either via 'yaml' or 'json' properties"}) + end + + defp _create(conn, definition) do + with :ok <- definition |> OpenAPIv3Validator.validate(), + spec <- definition |> SpecParser.build_specification(), {:ok, pid} <- MockServerList.create_server(spec), {:ok, %{id: id} = response} <- pid |> MockServer.get_details() do conn |> put_resp_header("x-mimicry-server-id", id |> to_string()) |> json(response) else + {:error, errors} -> + conn + |> put_status(:bad_request) + |> json(%{ + message: "Invalid specification", + errors: errors |> Errors.make_errors_from_openapi_validation() + }) + {:error, :invalid_spec} -> conn |> put_status(:bad_request) |> json(%{message: "Invalid specification!"}) {:error, _} -> conn |> put_status(:bad_request) |> json(%{message: "Invalid JSON"}) end - end - - def create(conn, _) do - conn - |> put_status(:bad_request) - |> json(%{message: "Missing 'spec' property!"}) + rescue + FunctionClauseError -> + conn + |> put_status(:unprocessable_entity) + |> json(%{ + message: + "Could not parse specification. Please use strings for response codes instead of integers." + }) end def spec(conn = %Plug.Conn{req_headers: headers}, _params) do diff --git a/lib/mimicry_api/errors.ex b/lib/mimicry_api/errors.ex new file mode 100644 index 0000000..b5e2753 --- /dev/null +++ b/lib/mimicry_api/errors.ex @@ -0,0 +1,8 @@ +defmodule MimicryApi.Errors do + @moduledoc """ + Provides functions for transforming errors from libs into something usable to the API + """ + def make_errors_from_openapi_validation(errors) do + errors |> Enum.map(fn {msg, path} -> %{path => msg} end) + end +end diff --git a/mix.exs b/mix.exs index b6bf52b..afd08d9 100644 --- a/mix.exs +++ b/mix.exs @@ -41,6 +41,7 @@ defmodule Mimicry.MixProject do {:phoenix, "~> 1.5"}, {:plug_cowboy, "~> 2.4"}, {:yaml_elixir, "~> 2.6"}, + {:openapiv3_validator, "~> 0.1.0"}, # dev {:mix_test_watch, "~> 1.0", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 3884209..67ae9bb 100644 --- a/mix.lock +++ b/mix.lock @@ -10,6 +10,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.15", "b29e8e729f4aa4a00436580dcc2c9c5c51890613457c193cc8525c388ccb2f06", [:mix], [], "hexpm", "044523d6438ea19c1b8ec877ec221b008661d3c27e3b848f4c879f500421ca5c"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.25.2", "4f1cae793c4d132e06674b282f1d9ea3bf409bcca027ddb2fe177c4eed6a253f", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5b0c172e87ac27f14dfd152d52a145238ec71a95efbf29849550278c58a393d6"}, + "ex_json_schema": {:hex, :ex_json_schema, "0.8.1", "d4cf78f318e7eea9ed173b4bb74b24a2d1601a7da5b5a857c07fe3d5da8f7fb6", [:mix], [], "hexpm", "4c870cfd422f6f5f4a6944cffde8eff029ba27b7f4783d94ed0f97ace27811d1"}, "excoveralls": {:hex, :excoveralls, "0.14.2", "f9f5fd0004d7bbeaa28ea9606251bb643c313c3d60710bad1f5809c845b748f0", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ca6fd358621cb4d29311b29d4732c4d47dac70e622850979bc54ed9a3e50f3e1"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, @@ -25,6 +26,7 @@ "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, + "openapiv3_validator": {:hex, :openapiv3_validator, "0.1.0", "ebcfe821bb29ab6259612286aba72795264a9e5b68d7f8f02117fb0459cfda6d", [:mix], [{:ex_json_schema, "~> 0.8.1", [hex: :ex_json_schema, repo: "hexpm", optional: false]}], "hexpm", "ed3f63aad49415c93ad3cd980cb12942f18de19b2a247ad3e0ca83d4c0a327fb"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "phoenix": {:hex, :phoenix, "1.5.12", "75fddb14c720388eea93d33886166a690416a7ff8633fbd93f364355b6fe1166", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8f0ae6734fcc18bbaa646c161e2febc46fb899eae43f82679b92530983324113"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, diff --git a/test/fixtures/specs/simple-with-broken-integer.yaml b/test/fixtures/specs/simple-with-broken-integer.yaml new file mode 100644 index 0000000..d56b239 --- /dev/null +++ b/test/fixtures/specs/simple-with-broken-integer.yaml @@ -0,0 +1,66 @@ +openapi: 3.0.3 +info: + title: 'Test YAML' + version: '1.0' + license: + name: MIT + description: 'A simple API' +servers: + - url: https://simple-api.testing.com + description: production +paths: + '/': + get: + responses: + default: + description: 'The default response for this endpoint' + content: + application/json: + schema: + $ref: '#/components/schemas/SimpleMessage' + examples: + simple-reference: + $ref: '#/components/examples/simple-message-example' + simple-embedded: + summary: 'An embedded example' + value: + message: 'embedded message example' + simple-combined-example: + $ref: '#/components/examples/simple-message-example' + summary: 'simple example with overriden summary' + 404: # <- this is the problem, while technically correct YAML, this will break the parser, since it need compat with JSON + description: The response for when something is not found + content: + application/json: + schema: + $ref: '#/components/schemas/SimpleError' + examples: + simple-error: + $ref: '#/components/examples/simple-error' + +components: + schemas: + SimpleMessage: + title: 'A simple message' + description: 'an example of a simple message' + type: object + properties: + message: + type: string + SimpleError: + title: 'A simple Error' + description: 'A simple error model' + type: object + properties: + code: + type: integer + examples: + simple-message-example: + summary: 'a simple message' + value: + message: 'foobar' + simple-error: + summary: 'a simple error' + value: + code: 42 + diff --git a/test/fixtures/specs/simple.json b/test/fixtures/specs/simple.json index 4f90b3a..32e0cba 100644 --- a/test/fixtures/specs/simple.json +++ b/test/fixtures/specs/simple.json @@ -1,7 +1,7 @@ { - "openapi": "3.0.0", + "openapi": "3.0.3", "info": { - "title": "Test JSON", + "title": "Test YAML", "version": "1.0", "license": { "name": "MIT" @@ -10,9 +10,94 @@ }, "servers": [ { - "url": "https://simple-api.com", + "url": "https://simple-api.testing.com", "description": "production" } ], - "paths": [] -} + "paths": { + "/": { + "get": { + "responses": { + "404": { + "description": "The response for when something is not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SimpleError" + }, + "examples": { + "simple-error": { + "$ref": "#/components/examples/simple-error" + } + } + } + } + }, + "default": { + "description": "The default response for this endpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SimpleMessage" + }, + "examples": { + "simple-reference": { + "$ref": "#/components/examples/simple-message-example" + }, + "simple-embedded": { + "summary": "An embedded example", + "value": { + "message": "embedded message example" + } + }, + "simple-combined-example": { + "$ref": "#/components/examples/simple-message-example", + "summary": "simple example with overriden summary" + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "SimpleMessage": { + "title": "A simple message", + "description": "an example of a simple message", + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "SimpleError": { + "title": "A simple Error", + "description": "A simple error model", + "type": "object", + "properties": { + "code": { + "type": "integer" + } + } + } + }, + "examples": { + "simple-message-example": { + "summary": "a simple message", + "value": { + "message": "foobar" + } + }, + "simple-error": { + "summary": "a simple error", + "value": { + "code": 42 + } + } + } + } +} \ No newline at end of file diff --git a/test/fixtures/specs/simple.yaml b/test/fixtures/specs/simple.yaml index 02d1fb5..47da7be 100644 --- a/test/fixtures/specs/simple.yaml +++ b/test/fixtures/specs/simple.yaml @@ -28,7 +28,7 @@ paths: simple-combined-example: $ref: '#/components/examples/simple-message-example' summary: 'simple example with overriden summary' - 404: + '404': description: The response for when something is not found content: application/json: diff --git a/test/mimicry/mock_api_test.exs b/test/mimicry/mock_api_test.exs index 515fd70..9cdf885 100644 --- a/test/mimicry/mock_api_test.exs +++ b/test/mimicry/mock_api_test.exs @@ -7,7 +7,7 @@ defmodule Mimicry.MockAPITest do describe "respond/2" do @tag specification: "products-with-examples.yaml" test "will respond with a specific path", %{conn: conn, specification: spec} do - %{status: 200, body: _, headers: _} = conn |> get("/products") |> MockAPI.respond(spec) + %{status: "200", body: _, headers: _} = conn |> get("/products") |> MockAPI.respond(spec) end @tag specification: "products-with-examples.yaml" @@ -15,7 +15,7 @@ defmodule Mimicry.MockAPITest do conn: conn, specification: spec } do - %{status: 200, body: product, headers: _} = + %{status: "200", body: product, headers: _} = conn |> get("/products/foobar") |> MockAPI.respond(spec) assert product == %{ diff --git a/test/mimicry_api/controllers/proxy_controller_test.exs b/test/mimicry_api/controllers/proxy_controller_test.exs index 975583b..79e8817 100644 --- a/test/mimicry_api/controllers/proxy_controller_test.exs +++ b/test/mimicry_api/controllers/proxy_controller_test.exs @@ -69,6 +69,7 @@ defmodule MimicryApi.ProxyControllerTest do end @tag server: "simple.yaml" + @tag :focus test "GET / with an expected 404", %{conn: conn} do conn = conn diff --git a/test/mimicry_api/controllers/server_controller_test.exs b/test/mimicry_api/controllers/server_controller_test.exs index 6451003..fd7f8d3 100644 --- a/test/mimicry_api/controllers/server_controller_test.exs +++ b/test/mimicry_api/controllers/server_controller_test.exs @@ -2,6 +2,8 @@ defmodule MimicryApi.ServerControllerTest do use MimicryApi.ConnCase use Mimicry.MockServerCase + import Fixtures + @tag server: "simple.yaml" test "GET /__mimicry/", %{conn: conn} do conn = conn |> get(Routes.server_path(conn, :index)) @@ -23,29 +25,87 @@ defmodule MimicryApi.ServerControllerTest do describe "POST /" do test "when the payload is not a spec", %{conn: conn} do conn = conn |> post(Routes.server_path(conn, :create, %{})) - assert %{"message" => message} = conn |> json_response(:bad_request) - assert message =~ "Missing" + assert %{"message" => message} = conn |> json_response(:unprocessable_entity) + assert message =~ "Pass the raw spec" - conn = conn |> post(Routes.server_path(conn, :create, %{"spec" => %{"foobar" => "foo"}})) + conn = conn |> post(Routes.server_path(conn, :create, %{yaml: "foobar"})) assert %{"message" => message} = conn |> json_response(:bad_request) assert message =~ "Invalid" end - test "when the payload is a spec", %{conn: conn} do - spec = %{ - "openapi" => "3.0.0", - "info" => %{"title" => "myFreshNewApi", "version" => "1.0.0alpha"}, - "servers" => [ - %{"url" => "https://fresh-api.testing.com"} - ], - "paths" => [%{"/" => %{}}] - } - - conn = conn |> post(Routes.server_path(conn, :create, %{"spec" => spec})) + test "when the payload is a YAML spec", %{conn: conn} do + spec = load_fixture("simple.yaml") + + conn = + conn + |> post( + Routes.server_path(conn, :create, %{ + "yaml" => spec + }) + ) + assert _ = conn |> json_response(:ok) assert [_server_id] = conn |> get_resp_header("x-mimicry-server-id") end + + test "when the payload is a JSON spec", %{conn: conn} do + spec = load_fixture("simple.json") + + conn = + conn + |> post( + Routes.server_path(conn, :create, %{ + "json" => spec + }) + ) + + assert _ = conn |> json_response(:ok) + assert [_server_id] = conn |> get_resp_header("x-mimicry-server-id") + end + + test "when the payload is an incomplete YAML spec", %{conn: conn} do + spec = "openapi: 3.0.0" + + conn = + conn + |> post(Routes.server_path(conn, :create, %{yaml: spec})) + + assert %{"message" => message, "errors" => errors} = conn |> json_response(:bad_request) + assert message =~ "Invalid" + assert errors |> length() > 0 + end + + test "when the payload is an incomplete JSON spec", %{conn: conn} do + spec = ''' + { "openapi": "3.0.0" } + ''' + + conn = + conn + |> post(Routes.server_path(conn, :create, %{json: spec})) + + assert %{"message" => message, "errors" => errors} = conn |> json_response(:bad_request) + assert message =~ "Invalid" + assert errors |> length() > 0 + end + + test "when the payload is YAML and contains integers as response codes", %{conn: conn} do + spec = load_fixture("simple-with-broken-integer.yaml") + + conn = + conn + |> post( + Routes.server_path(conn, :create, %{ + "yaml" => spec + }) + ) + + assert %{"message" => message} = conn |> json_response(:unprocessable_entity) + + assert message =~ + "Could not parse specification" + end end describe "DELETE /:id" do @@ -55,23 +115,16 @@ defmodule MimicryApi.ServerControllerTest do end test "when deleting an existing server", %{conn: conn} do - spec = %{ - "openapi" => "3.0.0", - "info" => %{"title" => "mySuperDeletableApi", "version" => "1.0.0alpha"}, - "paths" => [%{"/" => %{"get" => %{"responses" => %{"valid" => "spec"}}}}], - "servers" => [ - %{"url" => "https://fresh-deletable-api.testing.com"} - ] - } - - conn = conn |> post(Routes.server_path(conn, :create, %{"spec" => spec})) + spec = load_fixture("simple.yaml") + + conn = conn |> post(Routes.server_path(conn, :create, %{"yaml" => spec})) assert _ = conn |> json_response(:ok) assert [server_id] = conn |> get_resp_header("x-mimicry-server-id") conn = conn |> delete(Routes.server_path(conn, :delete, server_id)) assert response = conn |> json_response(:ok) - assert response == spec + assert response == YamlElixir.read_from_string!(spec) end end end diff --git a/test/mimicry_api/errors_test.exs b/test/mimicry_api/errors_test.exs new file mode 100644 index 0000000..8f7cbec --- /dev/null +++ b/test/mimicry_api/errors_test.exs @@ -0,0 +1,16 @@ +defmodule MimicryApi.ErrorsTest do + use ExUnit.Case + + alias MimicryApi.Errors + + describe "make_errors_from_openapi_validation/2" do + test "will transform a list of tuples" do + tuples = [{"foobar", "#"}, {"barfoo", "#baz"}] + + assert Errors.make_errors_from_openapi_validation(tuples) == [ + %{"#" => "foobar"}, + %{"#baz" => "barfoo"} + ] + end + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex new file mode 100644 index 0000000..f543bf1 --- /dev/null +++ b/test/support/fixtures.ex @@ -0,0 +1,12 @@ +defmodule Fixtures do + @moduledoc false + + alias Mimicry.Utils.{SpecificationFileReader, SpecificationFolder} + + def load_fixture(file) do + case SpecificationFolder.base_path() |> Path.join(file) |> SpecificationFileReader.read() do + {:ok, content, _extension} -> content + _ -> raise RuntimeError, message: "Fixture #{file} not found!" + end + end +end