Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F116192
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
41 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
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)
Attached To
Mode
R22 open_api_spex
Attached
Detach File
Event Timeline
Log In to Comment