Packages

Tools for your phoenix project for validating openapi spec in your application.

Current section

Files

Jump to
openapi_validator lib plugs validate.ex
Raw

lib/plugs/validate.ex

defmodule OpenapiValidator.Plugs.Validate do
@moduledoc ~S"""
Validates input and output according to one or more OpenAPI specs.
Usage:
Add the plug at the bottom of one or more pipelines in `router.ex`:
pipeline "api" do
# ...
plug Api.Plugs.OASValidator, spec: Appcues.MobileApiSpecs.sdk()
end
"""
require Logger
import Plug.Conn
alias OpenapiValidator.Helpers.Errors
@behaviour Plug
@type path :: String.t()
@type path_regex :: {path, Regex.t()}
@type open_api_spec :: map()
@impl Plug
def init(%{specs: raw_specs} = opts) do
resolved_specs = OpenapiValidator.compile_specs(raw_specs)
# set empty defaults for specs and paths just in case
Map.merge(opts, %{
paths: %{},
specs: %{},
all_paths_required: Map.get(opts, :all_paths_required, false),
custom_error: Map.get(opts, :custom_error, nil),
explain_error: Map.get(opts, :explain_error, true),
validate_params: Map.get(opts, :validate_params, true),
validate_body: Map.get(opts, :validate_body, true),
validate_response: Map.get(opts, :validate_response, true),
output_stubs: Map.get(opts, :output_stubs, false),
allow_invalid_input: Map.get(opts, :allow_invalid_input, false),
allow_invalid_output: Map.get(opts, :allow_invalid_output, false),
})
|> Map.merge(resolved_specs)
end
def init(_opts) do
Logger.error(
"#{inspect(__MODULE__)}: specs are required but were not provided."
)
nil
end
@impl Plug
def call(conn, opts) do
# if validate output
# Plug.Conn.register_before_send(conn, &HTML.minify_body/1)
{method, schema_url} = get_schema_url_from_request(conn)
schema = OpenapiValidator.find_schema({method, schema_url}, opts)
found_schema_body(conn, opts, {schema, method, schema_url})
end
defp error_handler(conn, opts, error, info) do
case Map.get(opts, :custom_error) do
nil ->
standard_error_handler(conn, opts, error, info)
custom ->
custom.error_handler(conn, opts, error, info)
end
end
defp standard_error_handler(conn, opts, error, info) do
message =
case Map.get(opts, :explain_error) do
true ->
Jason.encode!(Errors.get_stock_message(error, info))
false ->
""
end
conn
|> put_resp_header("content-type", "application/json")
|> resp(
Errors.get_stock_code(error),
message
)
|> halt()
end
defp found_schema_body(conn, opts, {schema, method, schema_url} = open_api)
when schema == %{} do
case Map.get(opts, :all_paths_required) do
true ->
error_handler(conn, opts, :not_found, {method, schema_url, nil})
false ->
Logger.info("No spec found for #{method}:#{schema_url}")
conn
end
end
defp found_schema_body(conn, opts, {schema, method, schema_url} = open_api) do
case Map.get(opts, :validate_body) do
true ->
body_schema = get_body_fragment(open_api)
is_body_required = is_body_required?(open_api)
conn = conn |> Plug.Conn.fetch_query_params()
case attempt_validate_body(
body_schema,
is_body_required,
schema,
conn.body_params
) do
:ok ->
validated_body(conn, opts, open_api)
{false, errors} ->
invalid_body(conn, opts, errors, open_api)
end
false ->
validated_body(conn, opts, open_api)
end
end
defp validated_body(conn, opts, {schema, _method, _schema_url} = open_api) do
case Map.get(opts, :validate_params) do
true ->
params_schema = get_params_fragments(open_api)
conn = conn |> Plug.Conn.fetch_query_params()
case attempt_validate_params(params_schema, schema, conn) do
:ok ->
validated_params(conn, opts, open_api)
{false, errors} ->
invalid_params(conn, opts, errors, open_api)
end
false ->
validated_params(conn, opts, open_api)
end
end
defp validated_params(conn, opts, open_api) do
case Map.get(opts, :validate_response) do
true ->
Plug.Conn.register_before_send(conn, fn conn ->
validate_response(conn, Map.put(opts, :open_api, open_api))
end)
false ->
conn
end
end
def validate_response(conn, opts) do
open_api = Map.get(opts, :open_api, {nil, nil, nil})
{schema, method, schema_url} = open_api
fragment = get_response_fragment({schema, method, schema_url}, conn.status)
is_body_required = true
body =
case Jason.decode(conn.resp_body) do
{:ok, json} -> json
_ -> %{"error" => "invalid json"}
end
case attempt_validate_body(fragment, is_body_required, schema, body) do
:ok ->
conn
{false, errors} ->
case Map.get(opts, :allow_invalid_output) do
true ->
conn
false ->
error_handler(
conn,
opts,
:invalid_response,
{method, schema_url, %{body: body, errors: errors}}
)
end
end
end
defp invalid_body(conn, opts, errors, {schema, method, schema_url} = open_api) do
case Map.get(opts, :allow_invalid_input) do
true ->
Logger.info("Invalid request on #{method} #{schema_url}")
validated_body(conn, opts, open_api)
false ->
Logger.warn("Invalid request on #{method} #{schema_url}")
error_handler(conn, opts, :invalid_body, {method, schema_url, errors})
end
end
defp invalid_params(
conn,
opts,
errors,
{schema, method, schema_url} = open_api
) do
case Map.get(opts, :allow_invalid_input) do
true ->
Logger.info("Invalid request on #{method} #{schema_url}")
validated_params(conn, opts, open_api)
false ->
Logger.warn("Invalid request on #{method} #{schema_url}")
error_handler(conn, opts, :invalid_params, {method, schema_url, errors})
end
end
defp attempt_validate_body(false, _is_body_required, _schema, payload)
when payload == %{} do
:ok
end
defp attempt_validate_body(false, false, _schema, _payload) do
:ok
end
defp attempt_validate_body(false, _is_body_required, _schema, payload) do
{false, %{unmatched_payload: payload}}
end
defp attempt_validate_body(fragment, _is_body_required, schema, payload) do
case ExJsonSchema.Validator.validate_fragment(schema, fragment, payload) do
:ok ->
:ok
{:error, result} ->
name = String.split(fragment, "/") |> Enum.reverse() |> Enum.at(0)
{false, %{name: name, reason: result |> Map.new()}}
end
end
defp attempt_validate_params(false, schema, conn) do
:ok
end
defp attempt_validate_params([], schema, conn) do
:ok
end
defp attempt_validate_params(fragments, schema, conn) do
headers = conn.req_headers |> Map.new()
results =
Enum.map(
fragments,
fn fragment_ref ->
fragment = ExJsonSchema.Schema.get_fragment!(schema, fragment_ref)
name = Map.get(fragment, "name")
fragment_schema = Map.get(fragment, "schema")
required = Map.get(fragment, "required", true)
input_data =
case Map.get(fragment, "in") do
"path" ->
Map.get(conn.path_params, name)
"query" ->
Map.get(conn.query_params, name)
"header" ->
Map.get(headers, name)
end
case is_nil(input_data) and required == false do
true ->
:ok
false ->
case ExJsonSchema.Validator.validate_fragment(
schema,
fragment_schema,
input_data
) do
:ok ->
:ok
{:error, reason} ->
%{name: name, reason: reason |> Map.new()}
end
end
end
)
|> Enum.reject(fn elem -> elem == :ok end)
case results do
[] ->
:ok
items ->
{false, items}
end
end
defp get_params_fragments({schema, method, schema_url}) do
method = String.downcase(method)
case ExJsonSchema.Schema.get_fragment(schema, [
:root,
"paths",
schema_url,
method,
"parameters",
]) do
{:error, _} ->
false
{:ok, result} ->
Enum.map(result, fn elem ->
ref = Map.get(elem, "$ref")
Enum.reduce(ref, "", fn sub_elem, acc ->
case sub_elem do
:root -> "#"
_ -> acc <> "/" <> sub_elem
end
end)
end)
end
end
defp is_body_required?({schema, method, schema_url}) do
method = String.downcase(method)
case ExJsonSchema.Schema.get_fragment(schema, [
:root,
"paths",
schema_url,
method,
"requestBody",
"required",
]) do
{:error, _} ->
true
{:ok, result} ->
result
end
end
defp get_body_fragment({schema, method, schema_url}) do
method = String.downcase(method)
case ExJsonSchema.Schema.get_fragment(schema, [
:root,
"paths",
schema_url,
method,
"requestBody",
"content",
"application/json",
"schema",
"$ref",
]) do
{:error, _} ->
false
{:ok, result} ->
Enum.reduce(result, "", fn elem, acc ->
case elem do
:root -> "#"
_ -> acc <> "/" <> elem
end
end)
end
end
defp get_response_fragment({schema, method, schema_url}, response_code) do
method = String.downcase(method)
case ExJsonSchema.Schema.get_fragment(schema, [
:root,
"paths",
schema_url,
method,
"responses",
"#{response_code}",
"content",
"application/json",
"schema",
"$ref",
]) do
{:error, _} ->
false
{:ok, result} ->
Enum.reduce(result, "", fn elem, acc ->
case elem do
:root -> "#"
_ -> acc <> "/" <> elem
end
end)
end
end
defp get_schema_url_from_request(conn) do
path_info = Map.get(conn, :path_info)
path_params = Map.get(conn, :path_params)
method = Map.get(conn, :method)
{method, convert_to_schema(path_info, path_params)}
end
defp convert_to_schema(path_info, path_params) do
Enum.reduce(path_info, "", fn path, acc ->
path = URI.decode(path)
fixed_path =
path_params
|> Enum.find({path, nil}, fn {_key, val} -> val == path end)
|> elem(0)
case fixed_path == path do
true ->
acc <> "/" <> fixed_path
false ->
acc <> "/" <> "{" <> fixed_path <> "}"
end
end)
end
end