diff --git a/README.md b/README.md index 6173dfb..bf883ed 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,12 @@ Suppose you have following resource in your schema: The `phoenix_swagger` provides `PhoenixSwagger.Validator.parse_swagger_schema/1` API to load a swagger schema by the given path or list of paths. This API should be called during application startup to parse/load a swagger schema. -After this, the `PhoenixSwagger.Validator.validate/2` can be used to validate resources. +After this, use one of the following to validate resources: +* the function `PhoenixSwagger.Validator.validate/2` using request path and parameters +* the default Plug `PhoenixSwagger.Plug.Validate` +* the function `PhoenixSwagger.ConnValidate.validate/1` using `conn` + +### `Validator.validate/2` For example: @@ -321,10 +326,10 @@ iex(2)> Validator.validate("/history", %{"limit" => 10, "offset" => 100}) :ok ``` -Besides `validate/2` API, the `phoenix_swagger` validator can be used via Plug to validate -intput parameters of your controllers. -Just add `PhoenixSwagger.Plug.Validate` plug to your router: +### Default Plug + +To validate input parameters of your controllers with the default Plug, just add `PhoenixSwagger.Plug.Validate` to your router: ```elixir pipeline :api do @@ -338,9 +343,26 @@ scope "/api", MyApp do end ``` +On validation errors, the default Plug returns `400` with the following body: +```json +{ + "error": { + "path": "#/path/to/schema", + "message": "Expected integer, got null" + } +} +``` + +The return code for validation errors is configurable via `:validation_failed_status` parameter. +If `conn.private[:phoenix_swagger][:valid]` is set to `true`, the Plug will skip validation. + The current minimal version of elixir should be `1.3` and in this case you must add `phoenix_swagger` application to the application list in your `mix.exs`. +### `ConnValidator.validate/1` + +Use `ConnValidator.validate/1` to build your own Plugs. It accepts a `conn` and returns `:ok` on validation success. Refer to source for error cases. + ## Test Response Validator PhoenixSwagger also includes a testing helper module `PhoenixSwagger.SchemaTest` to conveniently assert that responses diff --git a/lib/phoenix_swagger/conn_validator.ex b/lib/phoenix_swagger/conn_validator.ex new file mode 100644 index 0000000..4eb3eb0 --- /dev/null +++ b/lib/phoenix_swagger/conn_validator.ex @@ -0,0 +1,108 @@ +defmodule PhoenixSwagger.ConnValidator do + alias PhoenixSwagger.Validator + + @table :validator_table + + @doc """ + Validate a request. Feel free to use it in your own Plugs. Returns: + * `{:ok, conn}` on success + * `{:error, :no_matching_path}` if the request path could not be mapped to a schema + * `{:error, message, path}` if the request was mapped but failed validation + * `{:error, [{message, path}], path}` if more than one validation error has been detected + """ + def validate(conn) do + with {:ok, path} <- find_matching_path(conn), + :ok <- validate_body_params(path, conn), + :ok <- validate_query_params(path, conn), + do: {:ok, conn} + end + + defp find_matching_path(conn) do + found = Enum.find(:ets.tab2list(@table), fn({path, base_path, _}) -> + base_path_segments = String.split(base_path || "", "/") |> tl + path_segments = String.split(path, "/") |> tl + path_info_without_base = remove_base_path(conn.path_info, base_path_segments) + req_path_segments = [String.downcase(conn.method) | path_info_without_base] + equal_paths?(path_segments, req_path_segments) + end) + + case found do + nil -> {:error, :no_matching_path} + {path, _, _} -> {:ok, path} + end + end + + defp validate_boolean(_name, value, parameters) when value in ["true", "false"] do + validate_query_params(parameters) + end + defp validate_boolean(name, _value, _parameters) do + {:error, "Type mismatch. Expected Boolean but got String.", "#/#{name}"} + end + + defp validate_integer(name, value, parameters) do + _ = String.to_integer(value) + validate_query_params(parameters) + rescue ArgumentError -> + {:error, "Type mismatch. Expected Integer but got String.", "#/#{name}"} + end + + defp validate_query_params([]), do: :ok + defp validate_query_params([{_type, _name, nil, false} | parameters]) do + validate_query_params(parameters) + end + defp validate_query_params([{_type, name, nil, true} | _]) do + {:error, "Required property #{name} was not present.", "#"} + end + defp validate_query_params([{"string", _name, _val, _} | parameters]) do + validate_query_params(parameters) + end + defp validate_query_params([{"integer", name, val, _} | parameters]) do + validate_integer(name, val, parameters) + end + defp validate_query_params([{"boolean", name, val, _} | parameters]) do + validate_boolean(name, val, parameters) + end + defp validate_query_params(path, conn) do + [{_path, _basePath, schema}] = :ets.lookup(@table, path) + parameters = + for parameter <- schema.schema["parameters"], + parameter["type"] != nil, + parameter["in"] in ["query", "path"] do + {parameter["type"], parameter["name"], get_param_value(conn.params, parameter["name"]), parameter["required"]} + end + validate_query_params(parameters) + end + + defp get_in_nested(params = nil, _), do: params + defp get_in_nested(params, nil), do: params + defp get_in_nested(params, nested_map) when map_size(nested_map) == 1 do + [{key, child_nested_map}] = Map.to_list(nested_map) + + get_in_nested(params[key], child_nested_map) + end + + defp get_param_value(params, nested_name) when is_binary(nested_name) do + nested_map = Plug.Conn.Query.decode(nested_name) + get_in_nested(params, nested_map) + end + + defp validate_body_params(path, conn) do + case Validator.validate(path, conn.body_params) do + :ok -> :ok + {:error, [{error, error_path} | _], _path} -> {:error, error, error_path} + {:error, error, error_path} -> {:error, error, error_path} + end + end + + defp equal_paths?([], []), do: true + defp equal_paths?([head | orig_path_rest], [head | req_path_rest]), do: equal_paths?(orig_path_rest, req_path_rest) + defp equal_paths?(["{" <> _ | orig_path_rest], [_ | req_path_rest]), do: equal_paths?(orig_path_rest, req_path_rest) + defp equal_paths?(_, _), do: false + + # It is pretty safe to strip request path by base path. They can't be + # non-equal. In this way, the router even will not execute this plug. + defp remove_base_path(path, []), do: path + defp remove_base_path([_path | rest], [_base_path | base_path_rest]) do + remove_base_path(rest, base_path_rest) + end +end diff --git a/lib/phoenix_swagger/plug/validate_plug.ex b/lib/phoenix_swagger/plug/validate_plug.ex index 02a6a4e..d0c5efa 100644 --- a/lib/phoenix_swagger/plug/validate_plug.ex +++ b/lib/phoenix_swagger/plug/validate_plug.ex @@ -1,8 +1,14 @@ defmodule PhoenixSwagger.Plug.Validate do - import Plug.Conn - alias PhoenixSwagger.Validator + @moduledoc """ + A plug to automatically validate all requests in a given scope. Please make + sure to: - @table :validator_table + * load Swagger specs at appliction start with + `PhoenixSwagger.Validator.parse_swagger_schema/1` + * set `conn.private.phoenix_swagger.valid` to `true` to skip validation + """ + import Plug.Conn + alias PhoenixSwagger.ConnValidator @doc """ Plug.init callback @@ -13,40 +19,23 @@ defmodule PhoenixSwagger.Plug.Validate do """ def init(opts), do: opts + + def call(%Plug.Conn{private: %{phoenix_swagger: %{valid: true}}} = conn, _opts), do: conn def call(conn, opts) do validation_failed_status = Keyword.get(opts, :validation_failed_status, 400) - result = - with {:ok, path} <- find_matching_path(conn), - :ok <- validate_body_params(path, conn), - :ok <- validate_query_params(path, conn), - do: {:ok, conn} - - case result do + case ConnValidator.validate(conn) do {:ok, conn} -> - conn + conn |> put_private(:phoenix_swagger, %{valid: true}) {:error, :no_matching_path} -> send_error_response(conn, 404, "API does not provide resource", conn.request_path) + {:error, [{message, path} | _], _path} -> + send_error_response(conn, validation_failed_status, message, path) {:error, message, path} -> send_error_response(conn, validation_failed_status, message, path) end end - defp find_matching_path(conn) do - found = Enum.find(:ets.tab2list(@table), fn({path, base_path, _}) -> - base_path_segments = String.split(base_path || "", "/") |> tl - path_segments = String.split(path, "/") |> tl - path_info_without_base = remove_base_path(conn.path_info, base_path_segments) - req_path_segments = [String.downcase(conn.method) | path_info_without_base] - equal_paths?(path_segments, req_path_segments) - end) - - case found do - nil -> {:error, :no_matching_path} - {path, _, _} -> {:ok, path} - end - end - defp send_error_response(conn, status, message, path) do response = %{ error: %{ @@ -60,78 +49,4 @@ defmodule PhoenixSwagger.Plug.Validate do |> send_resp(status, Poison.encode!(response)) |> halt() end - - defp validate_boolean(_name, value, parameters) when value in ["true", "false"] do - validate_query_params(parameters) - end - defp validate_boolean(name, _value, _parameters) do - {:error, "Type mismatch. Expected Boolean but got String.", "#/#{name}"} - end - - defp validate_integer(name, value, parameters) do - _ = String.to_integer(value) - validate_query_params(parameters) - rescue ArgumentError -> - {:error, "Type mismatch. Expected Integer but got String.", "#/#{name}"} - end - - defp validate_query_params([]), do: :ok - defp validate_query_params([{_type, _name, nil, false} | parameters]) do - validate_query_params(parameters) - end - defp validate_query_params([{_type, name, nil, true} | _]) do - {:error, "Required property #{name} was not present.", "#"} - end - defp validate_query_params([{"string", _name, _val, _} | parameters]) do - validate_query_params(parameters) - end - defp validate_query_params([{"integer", name, val, _} | parameters]) do - validate_integer(name, val, parameters) - end - defp validate_query_params([{"boolean", name, val, _} | parameters]) do - validate_boolean(name, val, parameters) - end - defp validate_query_params(path, conn) do - [{_path, _basePath, schema}] = :ets.lookup(@table, path) - parameters = - for parameter <- schema.schema["parameters"], - parameter["type"] != nil, - parameter["in"] in ["query", "path"] do - {parameter["type"], parameter["name"], get_param_value(conn.params, parameter["name"]), parameter["required"]} - end - validate_query_params(parameters) - end - - defp get_in_nested(params = nil, _), do: params - defp get_in_nested(params, nil), do: params - defp get_in_nested(params, nested_map) when map_size(nested_map) == 1 do - [{key, child_nested_map}] = Map.to_list(nested_map) - - get_in_nested(params[key], child_nested_map) - end - - defp get_param_value(params, nested_name) when is_binary(nested_name) do - nested_map = Plug.Conn.Query.decode(nested_name) - get_in_nested(params, nested_map) - end - - defp validate_body_params(path, conn) do - case Validator.validate(path, conn.body_params) do - :ok -> :ok - {:error, [{error, error_path} | _], _path} -> {:error, error, error_path} - {:error, error, error_path} -> {:error, error, error_path} - end - end - - defp equal_paths?([], []), do: true - defp equal_paths?([head | orig_path_rest], [head | req_path_rest]), do: equal_paths?(orig_path_rest, req_path_rest) - defp equal_paths?(["{" <> _ | orig_path_rest], [_ | req_path_rest]), do: equal_paths?(orig_path_rest, req_path_rest) - defp equal_paths?(_, _), do: false - - # It is pretty safe to strip request path by base path. They can't be - # non-equal. In this way, the router even will not execute this plug. - defp remove_base_path(path, []), do: path - defp remove_base_path([_path | rest], [_base_path | base_path_rest]) do - remove_base_path(rest, base_path_rest) - end end diff --git a/test/validate_plug_test.exs b/test/validate_plug_test.exs new file mode 100644 index 0000000..327ffba --- /dev/null +++ b/test/validate_plug_test.exs @@ -0,0 +1,64 @@ +defmodule ValidatePlugTest do + use ExUnit.Case + use Plug.Test + require IEx + + alias PhoenixSwagger.Plug.Validate + alias PhoenixSwagger.Validator + alias Plug.Conn + + @table :validator_table + + setup do + schema = Validator.parse_swagger_schema(["test/test_spec/swagger_test_spec.json", "test/test_spec/swagger_test_spec_2.json"]) + on_exit fn -> + :ets.delete_all_objects(@table) + end + {:ok, schema} + end + + test "init" do + opts = [foo: :bar, bar: 123] + assert opts == Validate.init(opts) + end + + test "validation successful on a valid request" do + test_conn = init_conn(:get, "/api/pets") + test_conn = Validate.call(test_conn, []) + assert is_nil test_conn.status + assert is_nil test_conn.resp_body + assert test_conn.private[:phoenix_swagger][:valid] + end + + test "validation fails on an invalid request" do + test_conn = init_conn(:post, "/v1/products", %{foo: :bar}) + test_conn = Validate.call(test_conn, []) + assert {400, _, _} = sent_resp(test_conn) + end + + test "validation fails on an invalid path" do + test_conn = init_conn(:get, "foo", %{foo: :bar}) + test_conn = Validate.call(test_conn, []) + assert {404, _, _} = sent_resp(test_conn) + end + + test "validation fails with custom code" do + test_conn = init_conn(:post, "/v1/products", %{foo: :bar}) + test_conn = Validate.call(test_conn, [validation_failed_status: 422]) + assert {422, _, _} = sent_resp(test_conn) + end + + test "validation skipped if valid flag is already set" do + test_conn = init_conn(:get, "foo", %{foo: :bar}) + test_conn = Conn.put_private(test_conn, :phoenix_swagger, %{valid: true}) + assert test_conn == Validate.call(test_conn, []) + end + + defp init_conn(verb, path, body_params \\ %{}, path_params \\ %{}) do + conn(verb, path) + |> Map.put(:body_params, body_params) + |> Map.put(:path_params, path_params) + |> Map.put(:params, Map.merge(path_params, body_params)) + |> Conn.fetch_query_params + end +end