Page MenuHomePhorge

No OneTemporary

Size
41 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/open_api_spex.ex b/lib/open_api_spex.ex
index 3fa9c2e..88d583a 100644
--- a/lib/open_api_spex.ex
+++ b/lib/open_api_spex.ex
@@ -1,126 +1,140 @@
defmodule OpenApiSpex do
@moduledoc """
Provides the entry-points for defining schemas, validating and casting.
"""
- alias OpenApiSpex.{OpenApi, Operation, Reference, Schema, SchemaResolver}
+ alias OpenApiSpex.{OpenApi, Operation, Operation2, Reference, Schema, SchemaResolver}
+ alias OpenApiSpex.Cast.Error
@doc """
Adds schemas to the api spec from the modules specified in the Operations.
Eg, if the response schema for an operation is defined with:
responses: %{
200 => Operation.response("User", "application/json", UserResponse)
}
Then the `UserResponse.schema()` function will be called to load the schema, and
a `Reference` to the loaded schema will be used in the operation response.
See `OpenApiSpex.schema` macro for a convenient syntax for defining schema modules.
"""
@spec resolve_schema_modules(OpenApi.t) :: OpenApi.t
def resolve_schema_modules(spec = %OpenApi{}) do
SchemaResolver.resolve_schema_modules(spec)
end
+ def cast_and_validate(
+ spec = %OpenApi{},
+ operation = %Operation{},
+ conn = %Plug.Conn{},
+ content_type \\ nil
+ ) do
+ Operation2.cast(operation, conn, content_type, spec.components.schemas)
+ end
+
@doc """
Cast params to conform to a `OpenApiSpex.Schema`.
See `OpenApiSpex.Schema.cast/3` for additional examples and details.
"""
@spec cast(OpenApi.t, Schema.t | Reference.t, any) :: {:ok, any} | {:error, String.t}
def cast(spec = %OpenApi{}, schema = %Schema{}, params) do
Schema.cast(schema, params, spec.components.schemas)
end
def cast(spec = %OpenApi{}, schema = %Reference{}, params) do
Schema.cast(schema, params, spec.components.schemas)
end
@doc """
Cast all params in `Plug.Conn` to conform to the schemas for `OpenApiSpex.Operation`.
Returns `{:ok, Plug.Conn.t}` with `params` and `body_params` fields updated if successful,
or `{:error, reason}` if casting fails.
`content_type` may optionally be supplied to select the `requestBody` schema.
"""
@spec cast(OpenApi.t, Operation.t, Plug.Conn.t, content_type | nil) :: {:ok, Plug.Conn.t} | {:error, String.t}
when content_type: String.t
def cast(spec = %OpenApi{}, operation = %Operation{}, conn = %Plug.Conn{}, content_type \\ nil) do
Operation.cast(operation, conn, content_type, spec.components.schemas)
end
@doc """
Validate params against `OpenApiSpex.Schema`.
See `OpenApiSpex.Schema.validate/3` for examples of error messages.
"""
@spec validate(OpenApi.t, Schema.t | Reference.t, any) :: :ok | {:error, String.t}
def validate(spec = %OpenApi{}, schema = %Schema{}, params) do
Schema.validate(schema, params, spec.components.schemas)
end
def validate(spec = %OpenApi{}, schema = %Reference{}, params) do
Schema.validate(schema, params, spec.components.schemas)
end
@doc """
Validate all params in `Plug.Conn` against `OpenApiSpex.Operation` `parameter` and `requestBody` schemas.
`content_type` may be optionally supplied to select the `requestBody` schema.
"""
@spec validate(OpenApi.t, Operation.t, Plug.Conn.t, content_type | nil) :: :ok | {:error, String.t}
when content_type: String.t
def validate(spec = %OpenApi{}, operation = %Operation{}, conn = %Plug.Conn{}, content_type \\ nil) do
Operation.validate(operation, conn, content_type, spec.components.schemas)
end
+ def path_to_string(%Error{} = error) do
+ Error.path_to_string(error)
+ end
+
@doc """
Declares a struct based `OpenApiSpex.Schema`
- defines the schema/0 callback
- ensures the schema is linked to the module by "x-struct" extension property
- defines a struct with keys matching the schema properties
- defines a @type `t` for the struct
- derives a `Poison.Encoder` for the struct
See `OpenApiSpex.Schema` for additional examples and details.
## Example
require OpenApiSpex
defmodule User do
OpenApiSpex.schema %{
title: "User",
description: "A user of the app",
type: :object,
properties: %{
id: %Schema{type: :integer, description: "User ID"},
name: %Schema{type: :string, description: "User name", pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/},
email: %Schema{type: :string, description: "Email address", format: :email},
inserted_at: %Schema{type: :string, description: "Creation timestamp", format: :'date-time'},
updated_at: %Schema{type: :string, description: "Update timestamp", format: :'date-time'}
},
required: [:name, :email],
example: %{
"id" => 123,
"name" => "Joe User",
"email" => "joe@gmail.com",
"inserted_at" => "2017-09-12T12:34:55Z",
"updated_at" => "2017-09-13T10:11:12Z"
}
}
end
"""
defmacro schema(body) do
quote do
@behaviour OpenApiSpex.Schema
@schema struct(OpenApiSpex.Schema, Map.put(unquote(body), :"x-struct", __MODULE__))
def schema, do: @schema
@derive [Poison.Encoder]
defstruct Schema.properties(@schema)
@type t :: %__MODULE__{}
end
end
end
diff --git a/lib/open_api_spex/cast.ex b/lib/open_api_spex/cast.ex
new file mode 100644
index 0000000..f84d807
--- /dev/null
+++ b/lib/open_api_spex/cast.ex
@@ -0,0 +1,84 @@
+defmodule OpenApiSpex.Cast do
+ alias OpenApiSpex.Reference
+ alias OpenApiSpex.Cast.{Array, Error, Object, Primitive, String}
+
+ defstruct value: nil,
+ schema: nil,
+ schemas: %{},
+ path: [],
+ key: nil,
+ index: 0,
+ errors: []
+
+ def cast(schema, value, schemas) do
+ ctx = %__MODULE__{schema: schema, value: value, schemas: schemas}
+ cast(ctx)
+ end
+
+ # nil schema
+ def cast(%__MODULE__{value: value, schema: nil}),
+ do: {:ok, value}
+
+ def cast(%__MODULE__{schema: %Reference{}} = ctx) do
+ schema = Reference.resolve_schema(ctx.schema, ctx.schemas)
+ cast(%{ctx | schema: schema})
+ end
+
+ # nullable: true
+ def cast(%__MODULE__{value: nil, schema: %{nullable: true}}) do
+ {:ok, nil}
+ end
+
+ # nullable: false
+ def cast(%__MODULE__{value: nil} = ctx) do
+ error(ctx, {:null_value})
+ end
+
+ # Enum
+ def cast(%__MODULE__{schema: %{enum: []}} = ctx) do
+ cast(%{ctx | enum: nil})
+ end
+
+ # Enum
+ def cast(%__MODULE__{schema: %{enum: enum}} = ctx) when is_list(enum) do
+ with {:ok, value} <- cast(%{ctx | schema: %{ctx.schema | enum: nil}}) do
+ if value in enum do
+ {:ok, value}
+ else
+ error(ctx, {:invalid_enum})
+ end
+ end
+ end
+
+ ## Specific types
+
+ def cast(%__MODULE__{schema: %{type: :boolean}} = ctx),
+ do: Primitive.cast_boolean(ctx)
+
+ def cast(%__MODULE__{schema: %{type: :integer}} = ctx),
+ do: Primitive.cast_integer(ctx)
+
+ def cast(%__MODULE__{schema: %{type: :number}} = ctx),
+ do: Primitive.cast_number(ctx)
+
+ def cast(%__MODULE__{schema: %{type: :string}} = ctx),
+ do: String.cast(ctx)
+
+ def cast(%__MODULE__{schema: %{type: :array}} = ctx),
+ do: Array.cast(ctx)
+
+ def cast(%__MODULE__{schema: %{type: :object}} = ctx),
+ do: Object.cast(ctx)
+
+ def cast(%__MODULE__{schema: %{type: _other}} = ctx),
+ do: error(ctx, {:invalid_schema_type})
+
+ def cast(%{} = ctx), do: cast(struct(__MODULE__, ctx))
+ def cast(ctx) when is_list(ctx), do: cast(struct(__MODULE__, ctx))
+
+ # Add an error
+ def error(ctx, error_args) do
+ error = Error.new(ctx, error_args)
+ {:error, [error | ctx.errors]}
+ end
+end
diff --git a/lib/open_api_spex/cast/array.ex b/lib/open_api_spex/cast/array.ex
new file mode 100644
index 0000000..9d7becb
--- /dev/null
+++ b/lib/open_api_spex/cast/array.ex
@@ -0,0 +1,36 @@
+defmodule OpenApiSpex.Cast.Array do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ def cast(%{value: []}), do: {:ok, []}
+
+ def cast(%{value: items} = ctx) when is_list(items) do
+ case cast_items(ctx) do
+ {items, []} -> {:ok, items}
+ {_, errors} -> {:error, errors}
+ end
+ end
+
+ def cast(ctx),
+ do: Cast.error(ctx, {:invalid_type, :array})
+
+ ## Private functions
+
+ defp cast_items(%{value: items} = ctx) do
+ cast_results =
+ items
+ |> Enum.with_index()
+ |> Enum.map(fn {item, index} ->
+ path = [index | ctx.path]
+ Cast.cast(%{ctx | value: item, schema: ctx.schema.items, path: path})
+ end)
+
+ errors =
+ for({:error, errors} <- cast_results, do: errors)
+ |> Enum.concat()
+
+ items = for {:ok, item} <- cast_results, do: item
+
+ {items, errors}
+ end
+end
diff --git a/lib/open_api_spex/cast/error.ex b/lib/open_api_spex/cast/error.ex
new file mode 100644
index 0000000..4c00261
--- /dev/null
+++ b/lib/open_api_spex/cast/error.ex
@@ -0,0 +1,142 @@
+defmodule OpenApiSpex.Cast.Error do
+ alias OpenApiSpex.TermType
+
+ defstruct reason: nil,
+ value: nil,
+ format: nil,
+ type: nil,
+ name: nil,
+ path: [],
+ length: 0,
+ meta: %{}
+
+ def new(ctx, {:invalid_schema_type}) do
+ %__MODULE__{reason: :invalid_schema_type, type: ctx.schema.type}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:null_value}) do
+ type = ctx.schema && ctx.schema.type
+
+ %__MODULE__{reason: :null_value, type: type}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:min_length, length}) do
+ %__MODULE__{reason: :min_length, length: length}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:invalid_type, type}) do
+ %__MODULE__{reason: :invalid_type, type: type}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:invalid_format, format}) do
+ %__MODULE__{reason: :invalid_format, format: format}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:invalid_enum}) do
+ %__MODULE__{reason: :invalid_enum}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:unexpected_field, name}) do
+ %__MODULE__{reason: :unexpected_field, name: name}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:missing_field, name}) do
+ %__MODULE__{reason: :missing_field, name: name}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:max_properties, max_properties}) do
+ %__MODULE__{reason: :max_properties, meta: %{max_properties: max_properties}}
+ |> add_context_fields(ctx)
+ end
+
+ def message(%{reason: :invalid_schema_type, type: type}) do
+ "Invalid schema.type. Got: #{inspect(type)}"
+ end
+
+ def message(%{reason: :null_value} = error) do
+ case error.type do
+ nil -> "null value"
+ type -> "null value where #{type} expected"
+ end
+ end
+
+ def message(%{reason: :min_length, length: length}) do
+ "String length is smaller than minLength: #{length}"
+ end
+
+ def message(%{reason: :invalid_type, type: type, value: value}) do
+ "Invalid #{type}. Got: #{TermType.type(value)}"
+ end
+
+ def message(%{reason: :invalid_format, format: format}) do
+ "Invalid format. Expected #{inspect(format)}"
+ end
+
+ def message(%{reason: :invalid_enum}) do
+ "Invalid value for enum"
+ end
+
+ def message(%{reason: :polymorphic_failed, type: polymorphic_type}) do
+ "Failed to cast to any schema in #{polymorphic_type}"
+ end
+
+ def message(%{reason: :unexpected_field, name: name}) do
+ "Unexpected field: #{safe_string(name)}"
+ end
+
+ def message(%{reason: :no_value_required_for_discriminator, name: field}) do
+ "No value for required disciminator property: #{field}"
+ end
+
+ def message(%{reason: :unknown_schema, name: name}) do
+ "Unknown schema: #{name}"
+ end
+
+ def message(%{reason: :missing_field, name: name}) do
+ "Missing field: #{name}"
+ end
+
+ def message(%{reason: :max_properties, meta: meta}) do
+ "More object properties than allowed by maxProperties, #{meta.max_properties}"
+ end
+
+ def message_with_path(error) do
+ prepend_path(error, message(error))
+ end
+
+ def path_to_string(%{path: path} = _error) do
+ "/" <> (path |> Enum.map(&to_string/1) |> Path.join())
+ end
+
+ defp add_context_fields(error, ctx) do
+ %{error | path: Enum.reverse(ctx.path), value: ctx.value}
+ end
+
+ defp prepend_path(error, message) do
+ path =
+ case error.path do
+ [] -> "#"
+ _ -> "#" <> path_to_string(error)
+ end
+
+ path <> ": " <> message
+ end
+
+ defp safe_string(string) do
+ to_string(string) |> String.slice(0..39)
+ end
+end
+
+defimpl String.Chars, for: OpenApiSpex.Cast.Error do
+ def to_string(error) do
+ OpenApiSpex.Cast.Error.message(error)
+ end
+end
diff --git a/lib/open_api_spex/cast/object.ex b/lib/open_api_spex/cast/object.ex
new file mode 100644
index 0000000..372c55e
--- /dev/null
+++ b/lib/open_api_spex/cast/object.ex
@@ -0,0 +1,109 @@
+defmodule OpenApiSpex.Cast.Object do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+ alias OpenApiSpex.Cast.Error
+
+ def cast(%{value: value} = ctx) when not is_map(value) do
+ Cast.error(ctx, {:invalid_type, :object})
+ end
+
+ def cast(%{value: value, schema: %{properties: nil}}) do
+ {:ok, value}
+ end
+
+ def cast(%{value: value, schema: schema} = ctx) do
+ schema_properties = schema.properties || %{}
+
+ with :ok <- check_unrecognized_properties(ctx, schema_properties),
+ value = cast_atom_keys(value, schema_properties),
+ ctx = %{ctx | value: value},
+ :ok <- check_required_fields(ctx, schema),
+ :ok <- check_max_properties(ctx),
+ {:ok, value} <- cast_properties(%{ctx | schema: schema_properties}) do
+ ctx = to_struct(%{ctx | value: value})
+ {:ok, ctx}
+ end
+ end
+
+ defp check_unrecognized_properties(%{value: value} = ctx, expected_keys) do
+ input_keys = value |> Map.keys() |> Enum.map(&to_string/1)
+ schema_keys = expected_keys |> Map.keys() |> Enum.map(&to_string/1)
+ extra_keys = input_keys -- schema_keys
+
+ if extra_keys == [] do
+ :ok
+ else
+ [name | _] = extra_keys
+ Cast.error(ctx, {:unexpected_field, name})
+ end
+ end
+
+ defp check_required_fields(%{value: input_map} = ctx, schema) do
+ required = schema.required || []
+ input_keys = Map.keys(input_map)
+ missing_keys = required -- input_keys
+
+ if missing_keys == [] do
+ :ok
+ else
+ errors =
+ Enum.map(missing_keys, fn key ->
+ ctx = %{ctx | path: [key | ctx.path]}
+ Error.new(ctx, {:missing_field, key})
+ end)
+
+ {:error, ctx.errors ++ errors}
+ end
+ end
+
+ defp check_max_properties(%{schema: %{maxProperties: max_properties}} = ctx)
+ when is_integer(max_properties) do
+ count = ctx.value |> Map.keys() |> length()
+
+ if count > max_properties do
+ error = Error.new(ctx, {:max_properties, max_properties})
+ {:error, error}
+ else
+ :ok
+ end
+ end
+
+ defp check_max_properties(_ctx), do: :ok
+
+ defp cast_atom_keys(input_map, properties) do
+ Enum.reduce(properties, %{}, fn {key, _}, output ->
+ string_key = to_string(key)
+
+ case input_map do
+ %{^key => value} -> Map.put(output, key, value)
+ %{^string_key => value} -> Map.put(output, key, value)
+ _ -> output
+ end
+ end)
+ end
+
+ defp cast_properties(%{value: object, schema: schema_properties} = ctx) do
+ Enum.reduce(object, {:ok, %{}}, fn
+ {key, value}, {:ok, output} ->
+ cast_property(%{ctx | key: key, value: value, schema: schema_properties}, output)
+
+ _, error ->
+ error
+ end)
+ end
+
+ defp cast_property(%{key: key, schema: schema_properties} = ctx, output) do
+ prop_schema = Map.get(schema_properties, key)
+ path = [key | ctx.path]
+
+ with {:ok, value} <- Cast.cast(%{ctx | path: path, schema: prop_schema}) do
+ {:ok, Map.put(output, key, value)}
+ end
+ end
+
+ defp to_struct(%{value: value = %_{}}), do: value
+ defp to_struct(%{value: value, schema: %{"x-struct": nil}}), do: value
+
+ defp to_struct(%{value: value, schema: %{"x-struct": module}}),
+ do: struct(module, value)
+end
diff --git a/lib/open_api_spex/cast/primitive.ex b/lib/open_api_spex/cast/primitive.ex
new file mode 100644
index 0000000..9a02040
--- /dev/null
+++ b/lib/open_api_spex/cast/primitive.ex
@@ -0,0 +1,58 @@
+defmodule OpenApiSpex.Cast.Primitive do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ ## boolean
+
+ def cast_boolean(%{value: value}) when is_boolean(value) do
+ {:ok, value}
+ end
+
+ def cast_boolean(%{value: "true"}), do: {:ok, true}
+ def cast_boolean(%{value: "false"}), do: {:ok, false}
+
+ def cast_boolean(ctx) do
+ Cast.error(ctx, {:invalid_type, :boolean})
+ end
+
+ ## integer
+
+ def cast_integer(%{value: value}) when is_integer(value) do
+ {:ok, value}
+ end
+
+ def cast_integer(%{value: value}) when is_number(value) do
+ {:ok, round(value)}
+ end
+
+ def cast_integer(%{value: value} = ctx) when is_binary(value) do
+ case Float.parse(value) do
+ {value, ""} -> cast_integer(%{ctx | value: value})
+ _ -> Cast.error(ctx, {:invalid_type, :integer})
+ end
+ end
+
+ def cast_integer(ctx) do
+ Cast.error(ctx, {:invalid_type, :integer})
+ end
+
+ ## number
+ def cast_number(%{value: value}) when is_number(value) do
+ {:ok, value}
+ end
+
+ def cast_number(%{value: value}) when is_integer(value) do
+ {:ok, value / 1}
+ end
+
+ def cast_number(%{value: value} = ctx) when is_binary(value) do
+ case Float.parse(value) do
+ {value, ""} -> {:ok, value}
+ _ -> Cast.error(ctx, {:invalid_type, :number})
+ end
+ end
+
+ def cast_number(ctx) do
+ Cast.error(ctx, {:invalid_type, :number})
+ end
+end
diff --git a/lib/open_api_spex/cast/string.ex b/lib/open_api_spex/cast/string.ex
new file mode 100644
index 0000000..2250025
--- /dev/null
+++ b/lib/open_api_spex/cast/string.ex
@@ -0,0 +1,40 @@
+defmodule OpenApiSpex.Cast.String do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ def cast(%{value: value} = ctx) when is_binary(value) do
+ cast_binary(ctx)
+ end
+
+ def cast(ctx) do
+ Cast.error(ctx, {:invalid_type, :string})
+ end
+
+ ## Private functions
+
+ defp cast_binary(%{value: value, schema: %{pattern: pattern}} = ctx) when not is_nil(pattern) do
+ if Regex.match?(pattern, value) do
+ {:ok, value}
+ else
+ Cast.error(ctx, {:invalid_format, pattern})
+ end
+ end
+
+ defp cast_binary(%{value: value, schema: %{minLength: min_length}} = ctx)
+ when is_integer(min_length) do
+ # Note: This is not part of the JSON Shema spec: trim string before measuring length
+ # It's just too important to miss
+ trimmed = String.trim(value)
+ length = String.length(trimmed)
+
+ if length < min_length do
+ Cast.error(ctx, {:min_length, length})
+ else
+ {:ok, value}
+ end
+ end
+
+ defp cast_binary(%{value: value}) do
+ {:ok, value}
+ end
+end
diff --git a/lib/open_api_spex/operation2.ex b/lib/open_api_spex/operation2.ex
new file mode 100644
index 0000000..5c542fa
--- /dev/null
+++ b/lib/open_api_spex/operation2.ex
@@ -0,0 +1,93 @@
+defmodule OpenApiSpex.Operation2 do
+ @moduledoc """
+ Defines the `OpenApiSpex.Operation.t` type.
+ """
+ alias OpenApiSpex.{
+ Cast,
+ Operation,
+ Parameter,
+ RequestBody,
+ Schema
+ }
+
+ def cast(operation = %Operation{}, conn = %Plug.Conn{}, content_type, schemas) do
+ parameters =
+ Enum.filter(operation.parameters || [], fn p ->
+ Map.has_key?(conn.params, Atom.to_string(p.name))
+ end)
+
+ with :ok <- check_query_params_defined(conn, operation.parameters),
+ {:ok, parameter_values} <- cast_parameters(parameters, conn.params, schemas),
+ conn = %{conn | params: parameter_values},
+ parameters =
+ Enum.filter(operation.parameters || [], &Map.has_key?(conn.params, &1.name)),
+ :ok <- validate_parameter_schemas(parameters, conn.params, schemas),
+ {:ok, body} <-
+ cast_request_body(operation.requestBody, conn.body_params, content_type, schemas) do
+ {:ok, %{conn | params: parameter_values, body_params: body}}
+ end
+ end
+
+ @spec check_query_params_defined(Conn.t(), list | nil) :: :ok | {:error, String.t()}
+ defp check_query_params_defined(%Plug.Conn{}, nil = _defined_params) do
+ :ok
+ end
+
+ defp check_query_params_defined(%Plug.Conn{} = conn, defined_params)
+ when is_list(defined_params) do
+ defined_query_params =
+ for param <- defined_params,
+ param.in == :query,
+ into: MapSet.new(),
+ do: to_string(param.name)
+
+ case validate_parameter_keys(Map.keys(conn.query_params), defined_query_params) do
+ {:error, param} -> {:error, "Undefined query parameter: #{inspect(param)}"}
+ :ok -> :ok
+ end
+ end
+
+ @spec validate_parameter_keys([String.t()], MapSet.t()) :: {:error, String.t()} | :ok
+ defp validate_parameter_keys([], _defined_params), do: :ok
+
+ defp validate_parameter_keys([param | params], defined_params) do
+ case MapSet.member?(defined_params, param) do
+ false -> {:error, param}
+ _ -> validate_parameter_keys(params, defined_params)
+ end
+ end
+
+ @spec cast_parameters([Parameter.t()], map, %{String.t() => Schema.t()}) ::
+ {:ok, map} | {:error, String.t()}
+ defp cast_parameters([], _params, _schemas), do: {:ok, %{}}
+
+ defp cast_parameters([p | rest], params = %{}, schemas) do
+ with {:ok, cast_val} <-
+ Cast.cast(Parameter.schema(p), params[Atom.to_string(p.name)], schemas),
+ {:ok, cast_tail} <- cast_parameters(rest, params, schemas) do
+ {:ok, Map.put_new(cast_tail, p.name, cast_val)}
+ end
+ end
+
+ @spec cast_request_body(RequestBody.t() | nil, map, String.t() | nil, %{
+ String.t() => Schema.t()
+ }) :: {:ok, map} | {:error, String.t()}
+ defp cast_request_body(nil, _, _, _), do: {:ok, %{}}
+
+ defp cast_request_body(%RequestBody{content: content}, params, content_type, schemas) do
+ schema = content[content_type].schema
+ Cast.cast(schema, params, schemas)
+ end
+
+ @spec validate_parameter_schemas([Parameter.t()], map, %{String.t() => Schema.t()}) ::
+ :ok | {:error, String.t()}
+ defp validate_parameter_schemas([], %{} = _params, _schemas), do: :ok
+
+ defp validate_parameter_schemas([p | rest], %{} = params, schemas) do
+ {:ok, parameter_value} = Map.fetch(params, p.name)
+
+ with {:ok, _value} <- Cast.cast(Parameter.schema(p), parameter_value, schemas) do
+ validate_parameter_schemas(rest, params, schemas)
+ end
+ end
+end
diff --git a/lib/open_api_spex/plug/cast2.ex b/lib/open_api_spex/plug/cast2.ex
new file mode 100644
index 0000000..c849044
--- /dev/null
+++ b/lib/open_api_spex/plug/cast2.ex
@@ -0,0 +1,107 @@
+defmodule OpenApiSpex.Plug.Cast2 do
+ @moduledoc """
+ Module plug that will cast the `Conn.params` and `Conn.body_params` according to the schemas defined for the operation.
+ Note that when using this plug, the body params are no longer merged into `Conn.params` and must be read from `Conn.body_params`
+ separately.
+
+ The operation_id can be given at compile time as an argument to `init`:
+
+ plug OpenApiSpex.Plug.Cast, operation_id: "MyApp.ShowUser"
+
+ For phoenix applications, the operation_id can be obtained at runtime automatically.
+
+ defmodule MyAppWeb.UserController do
+ use Phoenix.Controller
+ plug OpenApiSpex.Plug.Cast
+ ...
+ end
+
+ If you want customize the error response, you can provide the `:render_error` option to register a plug which creates
+ a custom response in the case of a validation error.
+
+ ## Example
+
+ defmodule MyAppWeb.UserController do
+ use Phoenix.Controller
+ plug OpenApiSpex.Plug.Cast,
+ render_error: MyApp.RenderError
+
+ ...
+ end
+
+ defmodule MyApp.RenderError do
+ def init(opts), do: opts
+
+ def call(conn, reason) do
+ msg = %{error: reason} |> Posion.encode!()
+
+ conn
+ |> Conn.put_resp_content_type("application/json")
+ |> Conn.send_resp(400, msg)
+ end
+ end
+ """
+
+ @behaviour Plug
+
+ alias Plug.Conn
+
+ @impl Plug
+ def init(opts) do
+ opts
+ |> Map.new()
+ |> Map.put_new(:render_error, OpenApiSpex.Plug.DefaultRenderError)
+ end
+
+ @impl Plug
+ def call(conn = %{private: %{open_api_spex: private_data}}, %{
+ operation_id: operation_id,
+ render_error: render_error
+ }) do
+ spec = private_data.spec
+ operation = private_data.operation_lookup[operation_id]
+
+ content_type =
+ Conn.get_req_header(conn, "content-type")
+ |> Enum.at(0, "")
+ |> String.split(";")
+ |> Enum.at(0)
+
+ private_data = Map.put(private_data, :operation_id, operation_id)
+ conn = Conn.put_private(conn, :open_api_spex, private_data)
+
+ with {:ok, conn} <- OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do
+ conn
+ else
+ {:error, reason} ->
+ opts = render_error.init(reason)
+
+ conn
+ |> render_error.call(opts)
+ |> Plug.Conn.halt()
+ end
+ end
+
+ def call(
+ conn = %{
+ private: %{phoenix_controller: controller, phoenix_action: action, open_api_spex: _pd}
+ },
+ opts
+ ) do
+ operation_id = controller.open_api_operation(action).operationId
+
+ if operation_id do
+ call(conn, Map.put(opts, :operation_id, operation_id))
+ else
+ raise "operationId was not found in action API spec"
+ end
+ end
+
+ def call(_conn = %{private: %{open_api_spex: _pd}}, _opts) do
+ raise ":operation_id was neither provided nor inferred from conn. Consider putting plug OpenApiSpex.Plug.Cast rather into your phoenix controller."
+ end
+
+ def call(_conn, _opts) do
+ raise ":open_api_spex was not found under :private. Maybe OpenApiSpex.Plug.PutApiSpec was not called before?"
+ end
+end
diff --git a/lib/open_api_spex/term_type.ex b/lib/open_api_spex/term_type.ex
new file mode 100644
index 0000000..0b40914
--- /dev/null
+++ b/lib/open_api_spex/term_type.ex
@@ -0,0 +1,13 @@
+defmodule OpenApiSpex.TermType do
+ alias OpenApiSpex.Schema
+
+ @spec type(term) :: Schema.data_type() | nil | String.t()
+ def type(v) when is_list(v), do: :array
+ def type(v) when is_map(v), do: :object
+ def type(v) when is_binary(v), do: :string
+ def type(v) when is_boolean(v), do: :boolean
+ def type(v) when is_integer(v), do: :integer
+ def type(v) when is_number(v), do: :number
+ def type(v) when is_nil(v), do: nil
+ def type(_), do: :unknown
+end
diff --git a/test/cast/array_test.exs b/test/cast/array_test.exs
new file mode 100644
index 0000000..056f31c
--- /dev/null
+++ b/test/cast/array_test.exs
@@ -0,0 +1,35 @@
+defmodule OpenApiSpec.Cast.ArrayTest do
+ use ExUnit.Case
+ alias OpenApiSpex.Cast.{Array, Error}
+ alias OpenApiSpex.{Cast, Schema}
+
+ defp cast(map), do: Array.cast(struct(Cast, map))
+
+ describe "cast/4" do
+ test "array" do
+ schema = %Schema{type: :array}
+ assert cast(value: [], schema: schema) == {:ok, []}
+ assert cast(value: [1, 2, 3], schema: schema) == {:ok, [1, 2, 3]}
+ assert cast(value: ["1", "2", "3"], schema: schema) == {:ok, ["1", "2", "3"]}
+
+ assert {:error, [error]} = cast(value: %{}, schema: schema)
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ assert error.value == %{}
+ end
+
+ test "array with items schema" do
+ items_schema = %Schema{type: :integer}
+ schema = %Schema{type: :array, items: items_schema}
+ assert cast(value: [], schema: schema) == {:ok, []}
+ assert cast(value: [1, 2, 3], schema: schema) == {:ok, [1, 2, 3]}
+ assert cast(value: ["1", "2", "3"], schema: schema) == {:ok, [1, 2, 3]}
+
+ assert {:error, [error]} = cast(value: [1, "two"], schema: schema)
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ assert error.value == "two"
+ assert error.path == [1]
+ end
+ end
+end
diff --git a/test/cast/object_test.exs b/test/cast/object_test.exs
new file mode 100644
index 0000000..007903e
--- /dev/null
+++ b/test/cast/object_test.exs
@@ -0,0 +1,125 @@
+defmodule OpenApiSpex.ObjectTest do
+ use ExUnit.Case
+ alias OpenApiSpex.{Cast, Schema}
+ alias OpenApiSpex.Cast.{Object, Error}
+
+ defp cast(ctx), do: Object.cast(struct(Cast, ctx))
+
+ describe "cast/3" do
+ test "when input is not an object" do
+ schema = %Schema{type: :object}
+ assert {:error, [error]} = cast(value: ["hello"], schema: schema)
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ assert error.value == ["hello"]
+ end
+
+ test "input map can have atom keys" do
+ schema = %Schema{type: :object}
+ assert {:ok, map} = cast(value: %{one: "one"}, schema: schema)
+ assert map == %{one: "one"}
+ end
+
+ test "converting string keys to atom keys when properties are defined" do
+ schema = %Schema{
+ type: :object,
+ properties: %{
+ one: nil
+ }
+ }
+
+ assert {:ok, map} = cast(value: %{"one" => "one"}, schema: schema)
+ assert map == %{one: "one"}
+ end
+
+ test "properties:nil, given unknown input property" do
+ schema = %Schema{type: :object}
+ assert cast(value: %{}, schema: schema) == {:ok, %{}}
+
+ assert cast(value: %{"unknown" => "hello"}, schema: schema) ==
+ {:ok, %{"unknown" => "hello"}}
+ end
+
+ test "with empty schema properties, given unknown input property" do
+ schema = %Schema{type: :object, properties: %{}}
+ assert cast(value: %{}, schema: schema) == {:ok, %{}}
+ assert {:error, [error]} = cast(value: %{"unknown" => "hello"}, schema: schema)
+ assert %Error{} = error
+ end
+
+ test "with schema properties set, given known input property" do
+ schema = %Schema{
+ type: :object,
+ properties: %{age: nil}
+ }
+
+ assert cast(value: %{}, schema: schema) == {:ok, %{}}
+ assert cast(value: %{"age" => "hello"}, schema: schema) == {:ok, %{age: "hello"}}
+ end
+
+ test "required fields" do
+ schema = %Schema{
+ type: :object,
+ properties: %{age: nil, name: nil},
+ required: [:age, :name]
+ }
+
+ assert {:error, [error, error2]} = cast(value: %{}, schema: schema)
+ assert %Error{} = error
+ assert error.reason == :missing_field
+ assert error.name == :age
+ assert error.path == [:age]
+
+ assert error2.reason == :missing_field
+ assert error2.name == :name
+ assert error2.path == [:name]
+ end
+
+ test "cast property against schema" do
+ schema = %Schema{
+ type: :object,
+ properties: %{age: %Schema{type: :integer}}
+ }
+
+ assert cast(value: %{}, schema: schema) == {:ok, %{}}
+ assert {:error, [error]} = cast(value: %{"age" => "hello"}, schema: schema)
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ assert error.path == [:age]
+ end
+
+ defmodule User do
+ defstruct [:name]
+ end
+
+ test "optionally casts to struct" do
+ schema = %Schema{
+ type: :object,
+ "x-struct": User,
+ properties: %{
+ name: %Schema{type: :string}
+ }
+ }
+
+ assert {:ok, user} = cast(value: %{"name" => "Name"}, schema: schema)
+ assert user == %User{name: "Name"}
+ end
+
+ test "validates maxProperties" do
+ schema = %Schema{
+ type: :object,
+ properties: %{
+ one: nil,
+ two: nil
+ },
+ maxProperties: 1
+ }
+
+ assert {:error, error} = cast(value: %{one: "one", two: "two"}, schema: schema)
+ assert %Error{} = error
+ assert error.reason == :max_properties
+
+ assert {:ok, _} = cast(value: %{one: "one"}, schema: schema)
+ end
+ end
+end
diff --git a/test/cast/primitive_test.exs b/test/cast/primitive_test.exs
new file mode 100644
index 0000000..95c6605
--- /dev/null
+++ b/test/cast/primitive_test.exs
@@ -0,0 +1,38 @@
+defmodule OpenApiSpex.PrimitiveTest do
+ use ExUnit.Case
+ alias OpenApiSpex.Cast
+ alias OpenApiSpex.Cast.{Primitive, Error}
+ import Primitive
+
+ describe "cast/3" do
+ test "boolean" do
+ assert cast_boolean(%Cast{value: true}) == {:ok, true}
+ assert cast_boolean(%Cast{value: false}) == {:ok, false}
+ assert cast_boolean(%Cast{value: "true"}) == {:ok, true}
+ assert cast_boolean(%Cast{value: "false"}) == {:ok, false}
+ assert {:error, [error]} = cast_boolean(%Cast{value: "other"})
+ assert %Error{reason: :invalid_type} = error
+ assert error.value == "other"
+ end
+
+ test "integer" do
+ assert cast_integer(%Cast{value: 1}) == {:ok, 1}
+ assert cast_integer(%Cast{value: 1.5}) == {:ok, 2}
+ assert cast_integer(%Cast{value: "1"}) == {:ok, 1}
+ assert cast_integer(%Cast{value: "1.5"}) == {:ok, 2}
+ assert {:error, [error]} = cast_integer(%Cast{value: "other"})
+ assert %Error{reason: :invalid_type} = error
+ assert error.value == "other"
+ end
+
+ test "number" do
+ assert cast_number(%Cast{value: 1}) == {:ok, 1.0}
+ assert cast_number(%Cast{value: 1.5}) == {:ok, 1.5}
+ assert cast_number(%Cast{value: "1"}) == {:ok, 1.0}
+ assert cast_number(%Cast{value: "1.5"}) == {:ok, 1.5}
+ assert {:error, [error]} = cast_number(%Cast{value: "other"})
+ assert %Error{reason: :invalid_type} = error
+ assert error.value == "other"
+ end
+ end
+end
diff --git a/test/cast/string_test.exs b/test/cast/string_test.exs
new file mode 100644
index 0000000..790e36b
--- /dev/null
+++ b/test/cast/string_test.exs
@@ -0,0 +1,35 @@
+defmodule OpenApiSpex.CastStringTest do
+ use ExUnit.Case
+ alias OpenApiSpex.{Cast, Schema}
+ alias OpenApiSpex.Cast.{Error, String}
+
+ defp cast(ctx), do: String.cast(struct(Cast, ctx))
+
+ describe "cast/1" do
+ test "basics" do
+ schema = %Schema{type: :string}
+ assert cast(value: "hello", schema: schema) == {:ok, "hello"}
+ assert cast(value: "", schema: schema) == {:ok, ""}
+ assert {:error, [error]} = cast(value: %{}, schema: schema)
+ assert %Error{reason: :invalid_type} = error
+ assert error.value == %{}
+ end
+
+ test "string with pattern" do
+ schema = %Schema{type: :string, pattern: ~r/\d-\d/}
+ assert cast(value: "1-2", schema: schema) == {:ok, "1-2"}
+ assert {:error, [error]} = cast(value: "hello", schema: schema)
+ assert error.reason == :invalid_format
+ assert error.value == "hello"
+ assert error.format == ~r/\d-\d/
+ end
+
+ # Note: we measure length of string after trimming leading and trailing whitespace
+ test "minLength" do
+ schema = %Schema{type: :string, minLength: 1}
+ assert {:error, [error]} = cast(value: " ", schema: schema)
+ assert %Error{} = error
+ assert error.reason == :min_length
+ end
+ end
+end
diff --git a/test/cast_test.exs b/test/cast_test.exs
new file mode 100644
index 0000000..65038a7
--- /dev/null
+++ b/test/cast_test.exs
@@ -0,0 +1,207 @@
+defmodule OpenApiSpec.CastTest do
+ use ExUnit.Case
+ alias OpenApiSpex.{Cast, Schema, Reference}
+ alias OpenApiSpex.Cast.Error
+
+ def cast(ctx), do: Cast.cast(ctx)
+
+ describe "cast/1" do
+ test "unknown schema type" do
+ assert {:error, [error]} = cast(value: "string", schema: %Schema{type: :nope})
+ assert error.reason == :invalid_schema_type
+ assert error.type == :nope
+
+ assert {:error, [error]} = cast(value: "string", schema: %Schema{type: nil})
+ assert error.reason == :invalid_schema_type
+ assert error.type == nil
+ end
+
+ # Note: full tests for primitives are covered in Cast.PrimitiveTest
+ test "primitives" do
+ tests = [
+ {:string, "1", :ok},
+ {:string, "", :ok},
+ {:string, true, :invalid},
+ {:string, nil, :invalid},
+ {:integer, 1, :ok},
+ {:integer, "1", :ok},
+ {:integer, %{}, :invalid},
+ {:integer, nil, :invalid},
+ {:array, nil, :invalid},
+ {:object, nil, :invalid}
+ ]
+
+ for {type, input, expected} <- tests do
+ case expected do
+ :ok -> assert {:ok, _} = cast(value: input, schema: %Schema{type: type})
+ :invalid -> assert {:error, _} = cast(value: input, schema: %Schema{type: type})
+ end
+ end
+ end
+
+ test "array type, nullable, given nil" do
+ schema = %Schema{type: :array, nullable: true}
+ assert {:ok, nil} = cast(value: nil, schema: schema)
+ end
+
+ test "array type, given nil" do
+ schema = %Schema{type: :array}
+ assert {:error, [error]} = cast(value: nil, schema: schema)
+ assert error.reason == :null_value
+ assert Error.message_with_path(error) == "#: null value where array expected"
+ end
+
+ test "array" do
+ schema = %Schema{type: :array}
+ assert cast(value: [], schema: schema) == {:ok, []}
+ assert cast(value: [1, 2, 3], schema: schema) == {:ok, [1, 2, 3]}
+ assert cast(value: ["1", "2", "3"], schema: schema) == {:ok, ["1", "2", "3"]}
+
+ assert {:error, [error]} = cast(value: %{}, schema: schema)
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ assert error.value == %{}
+ end
+
+ test "array with items schema" do
+ items_schema = %Schema{type: :integer}
+ schema = %Schema{type: :array, items: items_schema}
+ assert cast(value: [], schema: schema) == {:ok, []}
+ assert cast(value: [1, 2, 3], schema: schema) == {:ok, [1, 2, 3]}
+ assert cast(value: ["1", "2", "3"], schema: schema) == {:ok, [1, 2, 3]}
+
+ assert {:error, errors} = cast(value: [1, "two"], schema: schema)
+ assert [%Error{} = error] = errors
+ assert error.reason == :invalid_type
+ assert error.value == "two"
+ assert error.path == [1]
+ end
+
+ # Additional object tests found in Cast.ObjectTest
+ test "object with schema properties set, given known input property" do
+ schema = %Schema{
+ type: :object,
+ properties: %{age: nil}
+ }
+
+ assert cast(value: %{}, schema: schema) == {:ok, %{}}
+ assert cast(value: %{"age" => "hello"}, schema: schema) == {:ok, %{age: "hello"}}
+ end
+
+ test "reference" do
+ age_schema = %Schema{type: :integer}
+
+ assert cast(
+ value: "20",
+ schema: %Reference{"$ref": "#/components/schemas/Age"},
+ schemas: %{"Age" => age_schema}
+ ) == {:ok, 20}
+ end
+
+ test "reference nested in object" do
+ age_schema = %Schema{type: :integer}
+
+ schema = %Schema{
+ type: :object,
+ properties: %{
+ age: %Reference{"$ref": "#/components/schemas/Age"}
+ }
+ }
+
+ assert cast(
+ value: %{"age" => "20"},
+ schema: schema,
+ schemas: %{"Age" => age_schema}
+ ) == {:ok, %{age: 20}}
+ end
+
+ test "paths" do
+ schema = %Schema{
+ type: :object,
+ properties: %{
+ age: %Schema{type: :integer}
+ }
+ }
+
+ assert {:error, errors} = cast(value: %{"age" => "twenty"}, schema: schema)
+ assert [error] = errors
+ assert %Error{} = error
+ assert error.path == [:age]
+ end
+
+ test "nested paths" do
+ schema = %Schema{
+ type: :object,
+ properties: %{
+ data: %Schema{
+ type: :object,
+ properties: %{
+ age: %Schema{type: :integer}
+ }
+ }
+ }
+ }
+
+ assert {:error, errors} = cast(value: %{"data" => %{"age" => "twenty"}}, schema: schema)
+ assert [error] = errors
+ assert %Error{} = error
+ assert error.path == [:data, :age]
+ assert Error.message_with_path(error) == "#/data/age: Invalid integer. Got: string"
+ end
+
+ test "paths involving arrays" do
+ schema = %Schema{
+ type: :object,
+ properties: %{
+ data: %Schema{
+ type: :array,
+ items: %Schema{
+ type: :object,
+ properties: %{
+ age: %Schema{type: :integer}
+ }
+ }
+ }
+ }
+ }
+
+ assert {:error, errors} =
+ cast(value: %{"data" => [%{"age" => "20"}, %{"age" => "twenty"}]}, schema: schema)
+
+ assert [error] = errors
+ assert %Error{} = error
+ assert error.path == [:data, 1, :age]
+ assert Error.message_with_path(error) == "#/data/1/age: Invalid integer. Got: string"
+ end
+
+ test "multiple errors" do
+ schema = %Schema{
+ type: :array,
+ items: %Schema{type: :integer}
+ }
+
+ value = [1, "two", 3, "four"]
+ assert {:error, errors} = cast(value: value, schema: schema)
+ assert [error, error2] = errors
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ assert error.path == [1]
+ assert Error.message_with_path(error) == "#/1: Invalid integer. Got: string"
+
+ assert Error.message_with_path(error2) == "#/3: Invalid integer. Got: string"
+ end
+
+ test "enum - invalid" do
+ schema = %Schema{type: :string, enum: ["one"]}
+ assert {:error, [error]} = cast(value: "two", schema: schema)
+
+ assert %Error{} = error
+ assert error.reason == :invalid_enum
+ end
+
+ test "enum - valid" do
+ schema = %Schema{type: :string, enum: ["one"]}
+ assert {:ok, "one"} = cast(value: "one", schema: schema)
+ end
+ end
+end

File Metadata

Mime Type
text/x-diff
Expires
Sat, Nov 30, 3:55 PM (1 d, 20 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
41492
Default Alt Text
(41 KB)

Event Timeline