Skip to content

Commit

Permalink
Merge pull request #129 from meltwater/path_filtering_in_validation_plug
Browse files Browse the repository at this point in the history
Expose `conn` validation for reuse in custom plugs
  • Loading branch information
mbuhot authored Feb 17, 2018
2 parents e35c5c0 + 84dd479 commit 6223736
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 104 deletions.
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand All @@ -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
Expand Down
108 changes: 108 additions & 0 deletions lib/phoenix_swagger/conn_validator.ex
Original file line number Diff line number Diff line change
@@ -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
115 changes: 15 additions & 100 deletions lib/phoenix_swagger/plug/validate_plug.ex
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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: %{
Expand All @@ -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
64 changes: 64 additions & 0 deletions test/validate_plug_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 6223736

Please sign in to comment.