Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose conn validation for reuse in custom plugs #129

Merged
Merged
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
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