Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F116127
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
140 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lib/open_api_spex.ex b/lib/open_api_spex.ex
index 941acc5..d0e19fe 100644
--- a/lib/open_api_spex.ex
+++ b/lib/open_api_spex.ex
@@ -1,127 +1,214 @@
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,
+ SchemaException,
+ 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
+ @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}
+ @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
+ @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}
+ @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
+ @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
+ @compile {:report_warnings, false}
@behaviour OpenApiSpex.Schema
- @schema struct(OpenApiSpex.Schema, Map.put(unquote(body), :"x-struct", __MODULE__))
+ @schema struct(OpenApiSpex.Schema, Map.put(unquote(body), :"x-struct", __MODULE__))
def schema, do: @schema
@derive Enum.filter([Poison.Encoder, Jason.Encoder], &Code.ensure_loaded?/1)
defstruct Schema.properties(@schema)
@type t :: %__MODULE__{}
+
+ Map.from_struct(@schema) |> OpenApiSpex.validate_compiled_schema()
end
end
+
+ @doc """
+ Validate the compiled schema's properties to ensure the schema is not improperly
+ defined. Only errors which would cause a given schema to _always_ fail should be
+ raised here.
+ """
+ def validate_compiled_schema(schema) do
+ Enum.each(schema, fn prop_and_val ->
+ :ok = validate_compiled_schema(prop_and_val, schema)
+ end)
+ end
+
+ def validate_compiled_schema({_, %Schema{} = schema}, _parent) do
+ validate_compiled_schema(schema)
+ end
+
+ @doc """
+ Used for validating the schema at compile time, otherwise we're forced
+ to raise errors for improperly defined schemas at runtime.
+ """
+ def validate_compiled_schema({:discriminator, %{propertyName: property, mapping: _}}, %{
+ anyOf: schemas
+ })
+ when is_list(schemas) do
+ Enum.each(schemas, fn schema ->
+ case schema do
+ %Schema{title: title} when is_binary(title) -> :ok
+ _ -> error!(:discriminator_schema_missing_title, schema, property_name: property)
+ end
+ end)
+ end
+
+ def validate_compiled_schema({:discriminator, %{propertyName: _, mapping: _}}, schema) do
+ case {schema.anyOf, schema.allOf, schema.oneOf} do
+ {nil, nil, nil} ->
+ error!(:discriminator_missing_composite_key, schema)
+
+ _ ->
+ :ok
+ end
+ end
+
+ def validate_compiled_schema({_property, _value}, _schema), do: :ok
+
+ @doc """
+ Raises compile time errors for improperly defined schemas.
+ """
+ @spec error!(atom(), Schema.t(), keyword()) :: no_return()
+ @spec error!(atom(), Schema.t()) :: no_return()
+ def error!(error, schema, details \\ []) do
+ raise SchemaException, %{error: error, schema: schema, details: details}
+ end
end
diff --git a/lib/open_api_spex/cast.ex b/lib/open_api_spex/cast.ex
new file mode 100644
index 0000000..6714acb
--- /dev/null
+++ b/lib/open_api_spex/cast.ex
@@ -0,0 +1,140 @@
+defmodule OpenApiSpex.Cast do
+ alias OpenApiSpex.{Reference, Schema}
+ alias OpenApiSpex.Reference
+
+ alias OpenApiSpex.Cast.{
+ AllOf,
+ AnyOf,
+ Array,
+ Discriminator,
+ Error,
+ Integer,
+ Object,
+ OneOf,
+ Primitive,
+ String
+ }
+
+ @type schema_or_reference :: Schema.t() | Reference.t()
+ @type t :: %__MODULE__{
+ value: term(),
+ schema: schema_or_reference | nil,
+ schemas: map(),
+ path: [atom() | String.t() | integer()],
+ key: atom() | nil,
+ index: integer,
+ errors: [Error.t()]
+ }
+
+ defstruct value: nil,
+ schema: nil,
+ schemas: %{},
+ path: [],
+ key: nil,
+ index: 0,
+ errors: []
+
+ @spec cast(schema_or_reference | nil, term(), map()) :: {:ok, term()} | {:error, [Error.t()]}
+ def cast(schema, value, schemas) do
+ ctx = %__MODULE__{schema: schema, value: value, schemas: schemas}
+ cast(ctx)
+ end
+
+ @spec cast(t()) :: {:ok, term()} | {:error, [Error.t()]}
+
+ # 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 | schema: %{ctx.schema | 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: :object, discriminator: discriminator}} = ctx)
+ when is_map(discriminator),
+ do: Discriminator.cast(ctx)
+
+ def cast(%__MODULE__{schema: %{type: _, anyOf: schemas}} = ctx) when is_list(schemas),
+ do: AnyOf.cast(ctx)
+
+ def cast(%__MODULE__{schema: %{type: _, allOf: schemas}} = ctx) when is_list(schemas),
+ do: AllOf.cast(ctx)
+
+ def cast(%__MODULE__{schema: %{type: _, oneOf: schemas}} = ctx) when is_list(schemas),
+ do: OneOf.cast(ctx)
+
+ def cast(%__MODULE__{schema: %{type: :object}} = ctx),
+ do: Object.cast(ctx)
+
+ def cast(%__MODULE__{schema: %{type: :boolean}} = ctx),
+ do: Primitive.cast_boolean(ctx)
+
+ def cast(%__MODULE__{schema: %{type: :integer}} = ctx),
+ do: Integer.cast(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: _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
+
+ def ok(%__MODULE__{value: value}), do: {:ok, value}
+
+ def success(%__MODULE__{schema: schema} = ctx, schema_properties)
+ when is_list(schema_properties) do
+ schema_without_successful_validation_property =
+ Enum.reduce(schema_properties, schema, fn property, schema ->
+ %{schema | property => nil}
+ end)
+
+ {:cast, %{ctx | schema: schema_without_successful_validation_property}}
+ end
+
+ def success(%__MODULE__{schema: _schema} = ctx, schema_property) do
+ success(ctx, [schema_property])
+ end
+end
diff --git a/lib/open_api_spex/cast/all_of.ex b/lib/open_api_spex/cast/all_of.ex
new file mode 100644
index 0000000..2a38690
--- /dev/null
+++ b/lib/open_api_spex/cast/all_of.ex
@@ -0,0 +1,20 @@
+defmodule OpenApiSpex.Cast.AllOf do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ def cast(ctx),
+ do: cast_all_of(ctx, nil)
+
+ defp cast_all_of(%{schema: %{type: _, allOf: [schema | remaining]}} = ctx, result) do
+ with {:ok, value} <- Cast.cast(%{ctx | schema: schema}) do
+ new_schema = %{ctx.schema | allOf: remaining}
+ cast_all_of(%{ctx | schema: new_schema}, result || {:ok, value})
+ else
+ _ -> Cast.error(ctx, {:all_of, to_string(schema.title || schema.type)})
+ end
+ end
+
+ defp cast_all_of(_, {:ok, result}) do
+ {:ok, result}
+ end
+end
diff --git a/lib/open_api_spex/cast/any_of.ex b/lib/open_api_spex/cast/any_of.ex
new file mode 100644
index 0000000..fd19883
--- /dev/null
+++ b/lib/open_api_spex/cast/any_of.ex
@@ -0,0 +1,42 @@
+defmodule OpenApiSpex.Cast.AnyOf do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ def cast(ctx, failed_schemas \\ [])
+
+ def cast(%_{schema: %{type: _, anyOf: []}} = ctx, failed_schemas) do
+ Cast.error(ctx, {:any_of, error_message(failed_schemas)})
+ end
+
+ def cast(
+ %{schema: %{type: _, anyOf: [schema | schemas]}} = ctx,
+ failed_schemas
+ ) do
+ with {:ok, value} <- Cast.cast(%{ctx | schema: schema}) do
+ {:ok, value}
+ else
+ _ ->
+ new_schema = %{ctx.schema | anyOf: schemas}
+ cast(%{ctx | schema: new_schema}, [schema | failed_schemas])
+ end
+ end
+
+ ## Private functions
+
+ defp error_message([]) do
+ "[] (no schemas provided)"
+ end
+
+ defp error_message(failed_schemas) do
+ for schema <- failed_schemas do
+ case schema do
+ %{title: title, type: type} when not is_nil(title) ->
+ "Schema(title: #{inspect(title)}, type: #{inspect(type)})"
+
+ %{type: type} ->
+ "Schema(type: #{inspect(type)})"
+ end
+ end
+ |> Enum.join(", ")
+ 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..83dc113
--- /dev/null
+++ b/lib/open_api_spex/cast/array.ex
@@ -0,0 +1,70 @@
+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_array(ctx) do
+ {:cast, ctx} -> cast(ctx)
+ {items, []} -> Cast.ok(%{ctx | value: items})
+ {_, errors} -> {:error, errors}
+ end
+ end
+
+ def cast(ctx),
+ do: Cast.error(ctx, {:invalid_type, :array})
+
+ ## Private functions
+
+ defp cast_array(%{value: value, schema: %{minItems: minimum}} = ctx) when is_integer(minimum) do
+ item_count = Enum.count(value)
+
+ if item_count < minimum do
+ Cast.error(ctx, {:min_items, minimum, item_count})
+ else
+ Cast.success(ctx, :minItems)
+ end
+ end
+
+ defp cast_array(%{value: value, schema: %{maxItems: maximum}} = ctx) when is_integer(maximum) do
+ item_count = Enum.count(value)
+
+ if item_count > maximum do
+ Cast.error(ctx, {:max_items, maximum, item_count})
+ else
+ Cast.success(ctx, :maxItems)
+ end
+ end
+
+ defp cast_array(%{value: value, schema: %{uniqueItems: true}} = ctx) do
+ unique_size =
+ value
+ |> MapSet.new()
+ |> MapSet.size()
+
+ if unique_size != Enum.count(value) do
+ Cast.error(ctx, {:unique_items})
+ else
+ Cast.success(ctx, :uniqueItems)
+ end
+ end
+
+ defp cast_array(%{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/discriminator.ex b/lib/open_api_spex/cast/discriminator.ex
new file mode 100644
index 0000000..dc4d42d
--- /dev/null
+++ b/lib/open_api_spex/cast/discriminator.ex
@@ -0,0 +1,102 @@
+defmodule OpenApiSpex.Cast.Discriminator do
+ @moduledoc """
+ Defines the `OpenApiSpex.Discriminator.t` type.
+ """
+
+ alias OpenApiSpex.Cast
+
+ @enforce_keys :propertyName
+ defstruct [
+ :propertyName,
+ :mapping
+ ]
+
+ @typedoc """
+ [Discriminator Object](https://swagger.io/specification/#discriminatorObject)
+
+ When request bodies or response payloads may be one of a number of different schemas,
+ a discriminator object can be used to aid in serialization, deserialization, and validation.
+ The discriminator is a specific object in a schema which is used to inform the consumer of the
+ specification of an alternative schema based on the value associated with it.
+
+ A discriminator requires a composite key be set on the schema:
+
+ * `allOf`
+ * `oneOf`
+ * `anyOf`
+ """
+ @type t :: %__MODULE__{
+ propertyName: String.t(),
+ mapping: %{String.t() => String.t()} | nil
+ }
+
+ def cast(ctx) do
+ case cast_discriminator(ctx) do
+ {:ok, result} -> {:ok, result}
+ error -> error
+ end
+ end
+
+ defp cast_discriminator(%_{value: value, schema: schema} = ctx) do
+ {discriminator_property, mappings} = discriminator_details(schema)
+
+ case Map.pop(value, "#{discriminator_property}") do
+ {"", _} ->
+ error(:no_value_for_discriminator, ctx)
+
+ {discriminator_value, castable_value} ->
+ # The cast specified by the composite key (allOf, anyOf, oneOf) MUST succeed
+ # or return an error according to the Open API Spec.
+ composite_ctx = %{
+ ctx
+ | value: castable_value,
+ schema: %{schema | discriminator: nil},
+ path: ["#{discriminator_property}" | ctx.path]
+ }
+
+ cast_composition(composite_ctx, ctx, discriminator_value, mappings)
+ end
+ end
+
+ defp cast_composition(composite_ctx, ctx, discriminator_value, mappings) do
+ with {composite_schemas, {:ok, _}} <- cast_composition(composite_ctx),
+ %{} = schema <-
+ find_discriminator_schema(discriminator_value, mappings, composite_schemas) do
+ Cast.cast(%{composite_ctx | schema: schema})
+ else
+ nil -> error(:invalid_discriminator_value, ctx)
+ other -> other
+ end
+ end
+
+ defp cast_composition(%_{schema: %{anyOf: schemas, discriminator: nil}} = ctx)
+ when is_list(schemas),
+ do: {schemas, Cast.cast(ctx)}
+
+ defp cast_composition(%_{schema: %{allOf: schemas, discriminator: nil}} = ctx)
+ when is_list(schemas),
+ do: {schemas, Cast.cast(ctx)}
+
+ defp cast_composition(%_{schema: %{oneOf: schemas, discriminator: nil}} = ctx)
+ when is_list(schemas),
+ do: {schemas, Cast.cast(ctx)}
+
+ defp find_discriminator_schema(discriminator, mappings = %{}, schemas) do
+ with {:ok, "#/components/schemas/" <> name} <- Map.fetch(mappings, discriminator) do
+ find_discriminator_schema(name, nil, schemas)
+ else
+ :error -> find_discriminator_schema(discriminator, nil, schemas)
+ end
+ end
+
+ defp find_discriminator_schema(discriminator, _, schemas) do
+ Enum.find(schemas, &Kernel.==(&1.title, discriminator))
+ end
+
+ defp discriminator_details(%{discriminator: %{propertyName: property_name, mapping: mappings}}),
+ do: {String.to_existing_atom(property_name), mappings}
+
+ defp error(message, %{schema: %{discriminator: %{propertyName: property}}} = ctx) do
+ Cast.error(ctx, {message, property})
+ 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..358dc5a
--- /dev/null
+++ b/lib/open_api_spex/cast/error.ex
@@ -0,0 +1,358 @@
+defmodule OpenApiSpex.Cast.Error do
+ alias OpenApiSpex.TermType
+
+ @type all_of_error :: {:all_of, [String.t()]}
+ @type any_of_error :: {:any_of, [String.t()]}
+ @type exclusive_max_error :: {:exclusive_max, non_neg_integer(), non_neg_integer()}
+ @type exclusive_min_error :: {:exclusive_min, non_neg_integer(), non_neg_integer()}
+ @type invalid_enum_error :: {:invalid_enum}
+ @type invalid_format_error :: {:invalid_format, any()}
+ @type invalid_schema_error :: {:invalid_schema_type}
+ @type invalid_type_error :: {:invalid_type, String.t() | atom()}
+ @type max_items_error :: {:max_items, non_neg_integer(), non_neg_integer()}
+ @type max_length_error :: {:max_length, non_neg_integer()}
+ @type max_properties_error :: {:max_properties, non_neg_integer(), non_neg_integer()}
+ @type maximum_error :: {:maximum, integer(), integer()}
+ @type min_items_error :: {:min_items, non_neg_integer(), non_neg_integer()}
+ @type min_length_error :: {:min_length, non_neg_integer()}
+ @type minimum_error :: {:minimum, integer(), integer()}
+ @type missing_field_error :: {:missing_field, String.t() | atom()}
+ @type multiple_of_error :: {:multiple_of, non_neg_integer(), non_neg_integer()}
+ @type no_value_for_discriminator_error :: {:no_value_for_discriminator, String.t() | atom()}
+ @type invalid_discriminator_value_error :: {:invalid_discriminator_value, String.t() | atom()}
+ @type null_value_error :: {:null_value}
+ @type one_of_error :: {:one_of, [String.t()]}
+ @type unexpected_field_error :: {:unexpected_field, String.t() | atom()}
+ @type unique_items_error :: {:unique_items}
+
+ @type reason ::
+ :all_of
+ | :any_of
+ | :invalid_schema_type
+ | :exclusive_max
+ | :exclusive_min
+ | :invalid_discriminator_value
+ | :invalid_enum
+ | :invalid_format
+ | :invalid_type
+ | :max_items
+ | :max_length
+ | :max_properties
+ | :maximum
+ | :min_items
+ | :min_length
+ | :minimum
+ | :missing_field
+ | :multiple_of
+ | :no_value_for_discriminator
+ | :null_value
+ | :one_of
+ | :unexpected_field
+ | :unique_items
+
+ @type args ::
+ all_of_error()
+ | any_of_error()
+ | invalid_schema_error()
+ | exclusive_max_error()
+ | exclusive_min_error()
+ | invalid_discriminator_value_error()
+ | invalid_enum_error()
+ | invalid_format_error()
+ | invalid_type_error()
+ | max_items_error()
+ | max_length_error()
+ | max_properties_error()
+ | maximum_error()
+ | min_items_error()
+ | min_length_error()
+ | minimum_error()
+ | missing_field_error()
+ | multiple_of_error()
+ | no_value_for_discriminator_error()
+ | null_value_error()
+ | one_of_error()
+ | unexpected_field_error()
+ | unique_items_error()
+
+ @type t :: %__MODULE__{
+ reason: reason(),
+ value: any(),
+ format: String.t(),
+ name: String.t(),
+ path: list(String.t()),
+ length: non_neg_integer(),
+ meta: map()
+ }
+
+ defstruct reason: nil,
+ value: nil,
+ format: nil,
+ type: nil,
+ name: nil,
+ path: [],
+ length: 0,
+ meta: %{}
+
+ @spec new(map(), args()) :: %__MODULE__{}
+
+ 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, {:all_of, schema_detail}) do
+ %__MODULE__{reason: :all_of, meta: %{invalid_schema: schema_detail}}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:any_of, schema_names}) do
+ %__MODULE__{reason: :any_of, meta: %{failed_schemas: schema_names}}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:one_of, schema_names}) do
+ %__MODULE__{reason: :one_of, meta: %{failed_schemas: schema_names}}
+ |> 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, {:max_length, length}) do
+ %__MODULE__{reason: :max_length, length: length}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:multiple_of, multiple, item_count}) do
+ %__MODULE__{reason: :multiple_of, length: multiple, value: item_count}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:unique_items}) do
+ %__MODULE__{reason: :unique_items}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:min_items, min_items, item_count}) do
+ %__MODULE__{reason: :min_items, length: min_items, value: item_count}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:max_items, max_items, value}) do
+ %__MODULE__{reason: :max_items, length: max_items, value: value}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:minimum, minimum, value}) do
+ %__MODULE__{reason: :minimum, length: minimum, value: value}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:maximum, maximum, value}) do
+ %__MODULE__{reason: :maximum, length: maximum, value: value}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:exclusive_min, exclusive_min, value}) do
+ %__MODULE__{reason: :exclusive_min, length: exclusive_min, value: value}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:exclusive_max, exclusive_max, value}) do
+ %__MODULE__{reason: :exclusive_max, length: exclusive_max, value: value}
+ |> 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, {:no_value_for_discriminator, field}) do
+ %__MODULE__{reason: :no_value_for_discriminator, name: field}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:invalid_discriminator_value, field}) do
+ %__MODULE__{reason: :invalid_discriminator_value, name: field}
+ |> add_context_fields(ctx)
+ end
+
+ def new(ctx, {:max_properties, max_properties, property_count}) do
+ %__MODULE__{
+ reason: :max_properties,
+ meta: %{max_properties: max_properties, property_count: property_count}
+ }
+ |> add_context_fields(ctx)
+ end
+
+ @spec message(t()) :: String.t()
+
+ 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: :all_of, meta: %{invalid_schema: invalid_schema}}) do
+ "Failed to cast value as #{invalid_schema}. Value must be castable using `allOf` schemas listed."
+ end
+
+ def message(%{reason: :any_of, meta: %{failed_schemas: failed_schemas}}) do
+ "Failed to cast value using any of: #{failed_schemas}"
+ end
+
+ def message(%{reason: :one_of, meta: %{failed_schemas: failed_schemas}}) do
+ "Failed to cast value to one of: #{failed_schemas}"
+ end
+
+ def message(%{reason: :min_length, length: length}) do
+ "String length is smaller than minLength: #{length}"
+ end
+
+ def message(%{reason: :max_length, length: length}) do
+ "String length is larger than maxLength: #{length}"
+ end
+
+ def message(%{reason: :unique_items}) do
+ "Array items must be unique"
+ end
+
+ def message(%{reason: :min_items, length: min, value: count}) do
+ "Array length #{count} is smaller than minItems: #{min}"
+ end
+
+ def message(%{reason: :max_items, length: max, value: count}) do
+ "Array length #{count} is larger than maxItems: #{max}"
+ end
+
+ def message(%{reason: :multiple_of, length: multiple, value: count}) do
+ "#{count} is not a multiple of #{multiple}"
+ end
+
+ def message(%{reason: max, length: max, value: size})
+ when max in [:exclusive_max, :maximum] do
+ "#{size} is larger than maximum #{max}"
+ end
+
+ def message(%{reason: min, length: min, value: size})
+ when min in [:exclusive_min, :minimum] do
+ "#{size} is smaller than (exclusive) minimum #{min}"
+ 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_for_discriminator, name: field}) do
+ "Value used as discriminator for `#{field}` matches no schemas"
+ end
+
+ def message(%{reason: :invalid_discriminator_value, name: field}) do
+ "No value provided for required discriminator `#{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
+ "Object property count #{meta.property_count} is greater than 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 =
+ if path == [] do
+ ""
+ else
+ path |> Enum.map(&to_string/1) |> Path.join()
+ end
+
+ "/" <> path
+ 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/integer.ex b/lib/open_api_spex/cast/integer.ex
new file mode 100644
index 0000000..63ae9f5
--- /dev/null
+++ b/lib/open_api_spex/cast/integer.ex
@@ -0,0 +1,75 @@
+defmodule OpenApiSpex.Cast.Integer do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ def cast(%{value: value} = ctx) when is_integer(value) do
+ case cast_integer(ctx) do
+ {:cast, ctx} -> cast(ctx)
+ result -> result
+ end
+ end
+
+ def cast(%{value: value}) when is_number(value) do
+ {:ok, round(value)}
+ end
+
+ def cast(%{value: value} = ctx) when is_binary(value) do
+ case Float.parse(value) do
+ {value, ""} -> cast(%{ctx | value: value})
+ _ -> Cast.error(ctx, {:invalid_type, :integer})
+ end
+ end
+
+ def cast(ctx) do
+ Cast.error(ctx, {:invalid_type, :integer})
+ end
+
+ ## Private functions
+
+ defp cast_integer(%{value: value, schema: %{minimum: minimum, exclusiveMinimum: true}} = ctx)
+ when is_integer(value) and is_integer(minimum) do
+ if value < minimum do
+ Cast.error(ctx, {:exclusive_min, minimum, value})
+ else
+ Cast.success(ctx, [:minimum, :exclusiveMinimum])
+ end
+ end
+
+ defp cast_integer(%{value: value, schema: %{minimum: minimum}} = ctx)
+ when is_integer(value) and is_integer(minimum) do
+ if value <= minimum do
+ Cast.error(ctx, {:minimum, minimum, value})
+ else
+ Cast.success(ctx, :minimum)
+ end
+ end
+
+ defp cast_integer(%{value: value, schema: %{maximum: maximum, exclusiveMaximum: true}} = ctx)
+ when is_integer(value) and is_integer(maximum) do
+ if value > maximum do
+ Cast.error(ctx, {:exclusive_max, maximum, value})
+ else
+ Cast.success(ctx, [:maximum, :exclusiveMaximum])
+ end
+ end
+
+ defp cast_integer(%{value: value, schema: %{maximum: maximum}} = ctx)
+ when is_integer(value) and is_integer(maximum) do
+ if value >= maximum do
+ Cast.error(ctx, {:maximum, maximum, value})
+ else
+ Cast.success(ctx, :maximum)
+ end
+ end
+
+ defp cast_integer(%{value: value, schema: %{multipleOf: multiple}} = ctx)
+ when is_integer(value) and is_integer(multiple) do
+ if Integer.mod(value, multiple) > 0 do
+ Cast.error(ctx, {:multiple_of, multiple, value})
+ else
+ Cast.success(ctx, :multipleOf)
+ end
+ end
+
+ defp cast_integer(ctx), do: Cast.ok(ctx)
+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..820ef16
--- /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
+ ctx = %{ctx | path: [name | ctx.path]}
+ 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
+ Cast.error(ctx, {:max_properties, max_properties, count})
+ 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/one_of.ex b/lib/open_api_spex/cast/one_of.ex
new file mode 100644
index 0000000..5306f73
--- /dev/null
+++ b/lib/open_api_spex/cast/one_of.ex
@@ -0,0 +1,46 @@
+defmodule OpenApiSpex.Cast.OneOf do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ def cast(%_{schema: %{type: _, oneOf: []}} = ctx) do
+ error(ctx, [])
+ end
+
+ def cast(%{schema: %{type: _, oneOf: schemas}} = ctx) do
+ castable_schemas =
+ Enum.reduce(schemas, {[], 0}, fn schema, {results, count} ->
+ case Cast.cast(%{ctx | schema: %{schema | anyOf: nil}}) do
+ {:ok, value} -> {[{:ok, value, schema} | results], count + 1}
+ _ -> {results, count}
+ end
+ end)
+
+ case castable_schemas do
+ {[{:ok, value, _schema}], 1} -> {:ok, value}
+ {failed_schemas, _count} -> error(ctx, failed_schemas)
+ end
+ end
+
+ ## Private functions
+
+ defp error(ctx, failed_schemas) do
+ Cast.error(ctx, {:one_of, error_message(failed_schemas)})
+ end
+
+ defp error_message([]) do
+ "[] (no schemas provided)"
+ end
+
+ defp error_message(failed_schemas) do
+ for {:ok, _value, schema} <- failed_schemas do
+ case schema do
+ %{title: title, type: type} when not is_nil(title) ->
+ "Schema(title: #{inspect(title)}, type: #{inspect(type)})"
+
+ %{type: type} ->
+ "Schema(type: #{inspect(type)})"
+ end
+ end
+ |> Enum.join(", ")
+ end
+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..519d8ac
--- /dev/null
+++ b/lib/open_api_spex/cast/primitive.ex
@@ -0,0 +1,37 @@
+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
+
+ ## 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/schema_exception.ex b/lib/open_api_spex/cast/schema_exception.ex
new file mode 100644
index 0000000..9df54ba
--- /dev/null
+++ b/lib/open_api_spex/cast/schema_exception.ex
@@ -0,0 +1,31 @@
+defmodule OpenApiSpex.SchemaException do
+ defexception [:message]
+ @impl true
+ def exception(%{error: :discriminator_schema_missing_title, schema: schema, details: details}) do
+ identifier = schema.title || schema.type
+ discriminator = details[:property_name]
+
+ details =
+ "Invalid Schema for discriminator, schema must have a title. " <>
+ "Discriminator propertyName: " <> discriminator <> "schema: " <> inspect(schema)
+
+ exception(%{identifier: identifier, details: details})
+ end
+
+ def exception(%{error: :discriminator_missing_composite_key, schema: schema}) do
+ identifier = schema.title || schema.type
+ details = "Discriminators require a composite key (`allOf`, `anyOf`, `oneOf`) be set."
+
+ exception(%{identifier: identifier, details: details})
+ end
+
+ def exception(%{identifier: identifier, details: details}) do
+ message = "Fatal! Improperly defined schema `#{identifier}`.\n\tDetails: #{details}\n"
+
+ %__MODULE__{message: message}
+ end
+
+ def exception(value) do
+ "Error Resolving Schema, details: #{inspect(value)}"
+ 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..a181973
--- /dev/null
+++ b/lib/open_api_spex/cast/string.ex
@@ -0,0 +1,68 @@
+defmodule OpenApiSpex.Cast.String do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ def cast(%{value: value} = ctx) when is_binary(value) do
+ case cast_binary(ctx) do
+ {:cast, ctx} -> cast(ctx)
+ result -> result
+ end
+ end
+
+ def cast(ctx) do
+ Cast.error(ctx, {:invalid_type, :string})
+ end
+
+ ## Private functions
+
+ defp cast_binary(%{value: value, schema: %{format: :"date-time"}} = ctx)
+ when is_binary(value) do
+ case DateTime.from_iso8601(value) do
+ {:ok, %DateTime{}, _offset} -> Cast.success(ctx, :format)
+ _ -> Cast.error(ctx, {:invalid_format, :"date-time"})
+ end
+ end
+
+ defp cast_binary(%{value: value, schema: %{format: :date}} = ctx) do
+ case Date.from_iso8601(value) do
+ {:ok, %Date{}} -> Cast.success(ctx, :format)
+ _ -> Cast.error(ctx, {:invalid_format, :date})
+ end
+ end
+
+ defp cast_binary(%{value: value, schema: %{pattern: pattern}} = ctx) when not is_nil(pattern) do
+ if Regex.match?(pattern, value) do
+ Cast.success(ctx, :pattern)
+ 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
+ length = String.trim(value) |> String.length()
+
+ if length < min_length do
+ Cast.error(ctx, {:min_length, min_length})
+ else
+ Cast.success(ctx, :minLength)
+ end
+ end
+
+ defp cast_binary(%{value: value, schema: %{maxLength: max_length}} = ctx)
+ when is_integer(max_length) do
+ # Note: This is not part of the JSON Shema spec: trim string before measuring length
+ # It's just too important to miss
+ length = String.trim(value) |> String.length()
+
+ if length > max_length do
+ Cast.error(ctx, {:max_length, max_length})
+ else
+ Cast.success(ctx, :maxLength)
+ end
+ end
+
+ defp cast_binary(ctx), do: Cast.ok(ctx)
+end
diff --git a/lib/open_api_spex/cast_parameters.ex b/lib/open_api_spex/cast_parameters.ex
new file mode 100644
index 0000000..b6587dd
--- /dev/null
+++ b/lib/open_api_spex/cast_parameters.ex
@@ -0,0 +1,37 @@
+defmodule OpenApiSpex.CastParameters do
+ @moduledoc false
+ alias OpenApiSpex.{Cast, Operation, Parameter, Schema}
+ alias OpenApiSpex.Cast.{Error, Object}
+ alias Plug.Conn
+
+ @spec cast(Plug.Conn.t(), Operation.t(), Schema.schemas()) ::
+ {:error, [Error.t()]} | {:ok, Conn.t()}
+ def cast(conn, operation, schemas) do
+ # Taken together as a set, operation parameters are similar to an object schema type.
+ # Convert parameters to an object schema, then delegate to `Cast.Object.cast/1`
+
+ properties =
+ operation.parameters
+ |> Enum.map(fn parameter -> {parameter.name, Parameter.schema(parameter)} end)
+ |> Map.new()
+
+ required =
+ operation.parameters
+ |> Enum.filter(& &1.required)
+ |> Enum.map(& &1.name)
+
+ object_schema = %Schema{
+ type: :object,
+ properties: properties,
+ required: required
+ }
+
+ params = Map.merge(conn.path_params, conn.query_params)
+
+ ctx = %Cast{value: params, schema: object_schema, schemas: schemas}
+
+ with {:ok, params} <- Object.cast(ctx) do
+ {:ok, %{conn | params: params}}
+ end
+ end
+end
diff --git a/lib/open_api_spex/operation.ex b/lib/open_api_spex/operation.ex
index 4a0a7cf..e9a1627 100644
--- a/lib/open_api_spex/operation.ex
+++ b/lib/open_api_spex/operation.ex
@@ -1,223 +1,221 @@
defmodule OpenApiSpex.Operation do
@moduledoc """
Defines the `OpenApiSpex.Operation.t` type.
"""
alias OpenApiSpex.{
Callback,
ExternalDocumentation,
MediaType,
Operation,
Parameter,
Reference,
RequestBody,
Response,
Responses,
Schema,
SecurityRequirement,
Server,
}
@enforce_keys :responses
- defstruct [
- :tags,
- :summary,
- :description,
- :externalDocs,
- :operationId,
- :parameters,
- :requestBody,
- :responses,
- :callbacks,
- :deprecated,
- :security,
- :servers
- ]
+ defstruct tags: [],
+ summary: nil,
+ description: nil,
+ externalDocs: nil,
+ operationId: nil,
+ parameters: [],
+ requestBody: nil,
+ responses: nil,
+ callbacks: %{},
+ deprecated: false,
+ security: nil,
+ servers: nil
@typedoc """
[Operation Object](https://swagger.io/specification/#operationObject)
Describes a single API operation on a path.
"""
@type t :: %__MODULE__{
- tags: [String.t] | nil,
- summary: String.t | nil,
- description: String.t | nil,
- externalDocs: ExternalDocumentation.t | nil,
- operationId: String.t | nil,
- parameters: [Parameter.t | Reference.t] | nil,
- requestBody: RequestBody.t | Reference.t | nil,
- responses: Responses.t,
- callbacks: %{String.t => Callback.t | Reference.t} | nil,
- deprecated: boolean | nil,
- security: [SecurityRequirement.t] | nil,
- servers: [Server.t] | nil
- }
+ tags: [String.t()],
+ summary: String.t() | nil,
+ description: String.t() | nil,
+ externalDocs: ExternalDocumentation.t() | nil,
+ operationId: String.t() | nil,
+ parameters: [Parameter.t() | Reference.t()],
+ requestBody: RequestBody.t() | Reference.t() | nil,
+ responses: Responses.t(),
+ callbacks: %{String.t() => Callback.t() | Reference.t()},
+ deprecated: boolean,
+ security: [SecurityRequirement.t()] | nil,
+ servers: [Server.t()] | nil
+ }
@doc """
Constructs an Operation struct from the plug and opts specified in the given route
"""
@spec from_route(PathItem.route) :: t
def from_route(route) do
from_plug(route.plug, route.opts)
end
@doc """
Constructs an Operation struct from plug module and opts
"""
@spec from_plug(module, any) :: t
def from_plug(plug, opts) do
plug.open_api_operation(opts)
end
@doc """
Shorthand for constructing a Parameter name, location, type, description and optional examples
"""
@spec parameter(atom, Parameter.location, Reference.t | Schema.t | atom, String.t, keyword) :: Parameter.t
def parameter(name, location, type, description, opts \\ []) do
params =
[name: name, in: location, description: description, required: location == :path]
|> Keyword.merge(opts)
Parameter
|> struct(params)
|> Parameter.put_schema(type)
end
@doc """
Shorthand for constructing a RequestBody with description, media_type, schema and optional examples
"""
@spec request_body(String.t, String.t, (Schema.t | Reference.t | module), keyword) :: RequestBody.t
def request_body(description, media_type, schema_ref, opts \\ []) do
%RequestBody{
description: description,
content: %{
media_type => %MediaType{
schema: schema_ref,
example: opts[:example],
examples: opts[:examples]
}
},
required: opts[:required] || false
}
end
@doc """
Shorthand for constructing a Response with description, media_type, schema and optional examples
"""
@spec response(String.t, String.t, (Schema.t | Reference.t | module), keyword) :: Response.t
def response(description, media_type, schema_ref, opts \\ []) do
%Response{
description: description,
content: %{
media_type => %MediaType {
schema: schema_ref,
example: opts[:example],
examples: opts[:examples]
}
}
}
end
@doc """
Cast params to the types defined by the schemas of the operation parameters and requestBody
"""
@spec cast(Operation.t, Conn.t, String.t | nil, %{String.t => Schema.t}) :: {:ok, Plug.Conn.t} | {:error, String.t}
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),
{: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{} = conn, defined_params) when is_nil(defined_params) do
case conn.query_params do
%{} -> :ok
_ -> {:error, "No query parameters defined for this operation"}
end
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} <- Schema.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
Schema.cast(schema, params, schemas)
end
@doc """
Validate params against the schemas of the operation parameters and requestBody
"""
@spec validate(Operation.t, Conn.t, String.t | nil, %{String.t => Schema.t}) :: :ok | {:error, String.t}
def validate(operation = %Operation{}, conn = %Plug.Conn{}, content_type, schemas) do
with :ok <- validate_required_parameters(operation.parameters || [], conn.params),
parameters <- Enum.filter(operation.parameters || [], &Map.has_key?(conn.params, &1.name)),
:ok <- validate_parameter_schemas(parameters, conn.params, schemas),
:ok <- validate_body_schema(operation.requestBody, conn.body_params, content_type, schemas) do
:ok
end
end
@spec validate_required_parameters([Parameter.t], map) :: :ok | {:error, String.t}
defp validate_required_parameters(parameter_list, params = %{}) do
required =
parameter_list
|> Stream.filter(fn parameter -> parameter.required end)
|> Enum.map(fn parameter -> parameter.name end)
missing = required -- Map.keys(params)
case missing do
[] -> :ok
_ -> {:error, "Missing required parameters: #{inspect(missing)}"}
end
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 <- Schema.validate(Parameter.schema(p), parameter_value, schemas) do
validate_parameter_schemas(rest, params, schemas)
end
end
@spec validate_body_schema(RequestBody.t | nil, map, String.t | nil, %{String.t => Schema.t}) :: :ok | {:error, String.t}
defp validate_body_schema(nil, _, _, _), do: :ok
defp validate_body_schema(%RequestBody{required: false}, params, _content_type, _schemas) when map_size(params) == 0 do
:ok
end
defp validate_body_schema(%RequestBody{content: content}, params, content_type, schemas) do
content
|> Map.get(content_type)
|> Map.get(:schema)
|> Schema.validate(params, schemas)
end
end
diff --git a/lib/open_api_spex/operation2.ex b/lib/open_api_spex/operation2.ex
new file mode 100644
index 0000000..f8cdef6
--- /dev/null
+++ b/lib/open_api_spex/operation2.ex
@@ -0,0 +1,38 @@
+defmodule OpenApiSpex.Operation2 do
+ @moduledoc """
+ Defines the `OpenApiSpex.Operation.t` type.
+ """
+ alias OpenApiSpex.{
+ Cast,
+ CastParameters,
+ Operation,
+ RequestBody,
+ Schema
+ }
+
+ alias OpenApiSpex.Cast.Error
+ alias Plug.Conn
+
+ @spec cast(Operation.t(), Conn.t(), String.t() | nil, Schema.schemas()) ::
+ {:error, [Error.t()]} | {:ok, Conn.t()}
+ def cast(operation = %Operation{}, conn = %Conn{}, content_type, schemas) do
+ with {:ok, conn} <- cast_parameters(conn, operation, schemas),
+ {:ok, body} <-
+ cast_request_body(operation.requestBody, conn.body_params, content_type, schemas) do
+ {:ok, %{conn | body_params: body}}
+ end
+ end
+
+ ## Private functions
+
+ defp cast_parameters(conn, operation, schemas) do
+ CastParameters.cast(conn, operation, schemas)
+ end
+
+ 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
+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/schema.ex b/lib/open_api_spex/schema.ex
index 67a4264..2cbdd66 100644
--- a/lib/open_api_spex/schema.ex
+++ b/lib/open_api_spex/schema.ex
@@ -1,849 +1,854 @@
defmodule OpenApiSpex.Schema do
@moduledoc """
Defines the `OpenApiSpex.Schema.t` type and operations for casting and validating against a schema.
The `OpenApiSpex.schema` macro can be used to declare schemas with an associated struct and `Poison.Encoder`.
## Examples
defmodule MyApp.Schemas do
defmodule EmailString do
@behaviour OpenApiSpex.Schema
def schema do
%OpenApiSpex.Schema {
title: "EmailString",
type: :string,
format: :email
}
end
end
defmodule Person do
require OpenApiSpex
alias OpenApiSpex.{Reference, Schema}
OpenApiSpex.schema(%{
type: :object,
required: [:name],
properties: %{
name: %Schema{type: :string},
address: %Reference{"$ref": "#components/schemas/Address"},
age: %Schema{type: :integer, format: :int32, minimum: 0}
}
})
end
defmodule StringDictionary do
@behaviour OpenApiSpex.Schema
def schema() do
%OpenApiSpex.Schema{
type: :object,
additionalProperties: %{
type: :string
}
}
end
end
defmodule Pet do
require OpenApiSpex
alias OpenApiSpex.{Schema, Discriminator}
OpenApiSpex.schema(%{
title: "Pet",
type: :object,
discriminator: %Discriminator{
propertyName: "petType"
},
properties: %{
name: %Schema{type: :string},
petType: %Schema{type: :string}
},
required: [:name, :petType]
})
end
defmodule Cat do
require OpenApiSpex
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
title: "Cat",
type: :object,
description: "A representation of a cat. Note that `Cat` will be used as the discriminator value.",
allOf: [
Pet,
%Schema{
type: :object,
properties: %{
huntingSkill: %Schema{
type: :string,
description: "The measured skill for hunting",
default: "lazy",
enum: ["clueless", "lazy", "adventurous", "aggresive"]
}
},
required: [:huntingSkill]
}
]
})
end
defmodule Dog do
require OpenApiSpex
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
type: :object,
title: "Dog",
description: "A representation of a dog. Note that `Dog` will be used as the discriminator value.",
allOf: [
Pet,
%Schema {
type: :object,
properties: %{
packSize: %Schema{
type: :integer,
format: :int32,
description: "the size of the pack the dog is from",
default: 0,
minimum: 0
}
},
required: [
:packSize
]
}
]
})
end
end
"""
alias OpenApiSpex.{
Schema,
Reference,
Discriminator,
Xml,
ExternalDocumentation
}
@doc """
A module implementing the `OpenApiSpex.Schema` behaviour should export a `schema/0` function
that produces an `OpenApiSpex.Schema` struct.
"""
@callback schema() :: t
defstruct [
:title,
:multipleOf,
:maximum,
:exclusiveMaximum,
:minimum,
:exclusiveMinimum,
:maxLength,
:minLength,
:pattern,
:maxItems,
:minItems,
:uniqueItems,
:maxProperties,
:minProperties,
:required,
:enum,
:type,
:allOf,
:oneOf,
:anyOf,
:not,
:items,
:properties,
:additionalProperties,
:description,
:format,
:default,
:nullable,
:discriminator,
:readOnly,
:writeOnly,
:xml,
:externalDocs,
:example,
:deprecated,
:"x-struct"
]
@typedoc """
[Schema Object](https://swagger.io/specification/#schemaObject)
The Schema Object allows the definition of input and output data types.
These types can be objects, but also primitives and arrays.
This object is an extended subset of the JSON Schema Specification Wright Draft 00.
## Example
alias OpenApiSpex.Schema
%Schema{
title: "User",
type: :object,
properties: %{
id: %Schema{type: :integer, minimum: 1},
name: %Schema{type: :string, pattern: "[a-zA-Z][a-zA-Z0-9_]+"},
email: %Scheam{type: :string, format: :email},
last_login: %Schema{type: :string, format: :"date-time"}
},
required: [:name, :email],
example: %{
"name" => "joe",
"email" => "joe@gmail.com"
}
}
"""
@type t :: %__MODULE__{
title: String.t() | nil,
multipleOf: number | nil,
maximum: number | nil,
exclusiveMaximum: boolean | nil,
minimum: number | nil,
exclusiveMinimum: boolean | nil,
maxLength: integer | nil,
minLength: integer | nil,
pattern: String.t() | Regex.t() | nil,
maxItems: integer | nil,
minItems: integer | nil,
uniqueItems: boolean | nil,
maxProperties: integer | nil,
minProperties: integer | nil,
required: [atom] | nil,
enum: [String.t()] | nil,
type: data_type | nil,
allOf: [Schema.t() | Reference.t() | module] | nil,
oneOf: [Schema.t() | Reference.t() | module] | nil,
anyOf: [Schema.t() | Reference.t() | module] | nil,
not: Schema.t() | Reference.t() | module | nil,
items: Schema.t() | Reference.t() | module | nil,
properties: %{atom => Schema.t() | Reference.t() | module} | nil,
additionalProperties: boolean | Schema.t() | Reference.t() | module | nil,
description: String.t() | nil,
format: String.t() | atom | nil,
default: any,
nullable: boolean | nil,
discriminator: Discriminator.t() | nil,
readOnly: boolean | nil,
writeOnly: boolean | nil,
xml: Xml.t() | nil,
externalDocs: ExternalDocumentation.t() | nil,
example: any,
deprecated: boolean | nil,
"x-struct": module | nil
}
@typedoc """
The basic data types supported by openapi.
[Reference](https://swagger.io/docs/specification/data-models/data-types/)
"""
@type data_type :: :string | :number | :integer | :boolean | :array | :object
+ @typedoc """
+ Global schemas lookup by name.
+ """
+ @type schemas :: %{String.t() => t()}
+
@doc """
Cast a simple value to the elixir type defined by a schema.
By default, object types are cast to maps, however if the "x-struct" attribute is set in the schema,
the result will be constructed as an instance of the given struct type.
## Examples
iex> OpenApiSpex.Schema.cast(%Schema{type: :integer}, "123", %{})
{:ok, 123}
iex> {:ok, dt = %DateTime{}} = OpenApiSpex.Schema.cast(%Schema{type: :string, format: :"date-time"}, "2018-04-02T13:44:55Z", %{})
...> dt |> DateTime.to_iso8601()
"2018-04-02T13:44:55Z"
## Casting Polymorphic Schemas
Schemas using `discriminator`, `allOf`, `oneOf`, `anyOf` are cast using the following rules:
- If a `discriminator` is present, cast the properties defined in the base schema, then
cast the result using the schema identified by the discriminator. To avoid infinite recursion,
the discriminator is only dereferenced if the discriminator property has not already been cast.
- Cast the properties using each schema listing in `allOf`. When a property is defined in
multiple `allOf` schemas, it will be cast using the first schema listed containing the property.
- Cast the value using each schema listed in `oneOf`, stopping as soon as a sucessful cast is made.
- Cast the value using each schema listed in `anyOf`, stopping as soon as a succesful cast is made.
"""
def cast(%Schema{type: :boolean}, value, _schemas) when is_boolean(value), do: {:ok, value}
def cast(%Schema{type: :boolean}, value, _schemas) when is_binary(value) do
case value do
"true" -> {:ok, true}
"false" -> {:ok, false}
_ -> {:error, "Invalid boolean: #{inspect(value)}"}
end
end
def cast(%Schema{type: :integer}, value, _schemas) when is_integer(value), do: {:ok, value}
def cast(%Schema{type: :integer}, value, _schemas) when is_binary(value) do
case Integer.parse(value) do
{i, ""} -> {:ok, i}
_ -> {:error, :bad_integer}
end
end
def cast(%Schema{type: :number}, value, _schemas) when is_number(value), do: {:ok, value}
def cast(%Schema{type: :number}, value, _schemas) when is_binary(value) do
case Float.parse(value) do
{x, ""} -> {:ok, x}
_ -> {:error, :bad_float}
end
end
def cast(%Schema{type: :string, format: :"date-time"}, value, _schemas) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, datetime = %DateTime{}, _offset} -> {:ok, datetime}
error = {:error, _reason} -> error
end
end
def cast(%Schema{type: :string, format: :date}, value, _schemas) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date = %Date{}} -> {:ok, date}
error = {:error, _reason} -> error
end
end
def cast(%Schema{type: :string}, value, _schemas) when is_binary(value), do: {:ok, value}
def cast(%Schema{type: :array, items: nil}, value, _schemas) when is_list(value),
do: {:ok, value}
def cast(%Schema{type: :array}, [], _schemas), do: {:ok, []}
def cast(schema = %Schema{type: :array, items: items_schema}, [x | rest], schemas) do
with {:ok, x_cast} <- cast(items_schema, x, schemas),
{:ok, rest_cast} <- cast(schema, rest, schemas) do
{:ok, [x_cast | rest_cast]}
end
end
def cast(%Schema{type: :array}, value, _schemas) when not is_list(value) do
{:error, "Invalid array: #{inspect(value)}"}
end
def cast(
schema = %Schema{type: :object, discriminator: discriminator = %{}},
value = %{},
schemas
) do
discriminator_property = String.to_existing_atom(discriminator.propertyName)
already_cast? =
if Map.has_key?(value, discriminator_property) do
{:error, :already_cast}
else
:ok
end
with :ok <- already_cast?,
{:ok, partial_cast} <-
cast(%Schema{type: :object, properties: schema.properties}, value, schemas),
{:ok, derived_schema} <- Discriminator.resolve(discriminator, value, schemas),
{:ok, result} <- cast(derived_schema, partial_cast, schemas) do
{:ok, make_struct(result, schema)}
else
{:error, :already_cast} -> {:ok, value}
{:error, reason} -> {:error, reason}
end
end
def cast(schema = %Schema{type: :object, allOf: [first | rest]}, value = %{}, schemas) do
with {:ok, cast_first} <- cast(first, value, schemas),
{:ok, result} <- cast(%{schema | allOf: rest}, cast_first, schemas) do
{:ok, result}
else
{:error, reason} -> {:error, reason}
end
end
def cast(schema = %Schema{type: :object, allOf: []}, value = %{}, schemas) do
cast(%{schema | allOf: nil}, value, schemas)
end
def cast(schema = %Schema{oneOf: [first | rest]}, value, schemas) do
case cast(first, value, schemas) do
{:ok, result} ->
cast(%{schema | oneOf: nil}, result, schemas)
{:error, _reason} ->
cast(%{schema | oneOf: rest}, value, schemas)
end
end
def cast(%Schema{oneOf: []}, _value, _schemas) do
{:error, "Failed to cast to any schema in oneOf"}
end
def cast(schema = %Schema{anyOf: [first | rest]}, value, schemas) do
case cast(first, value, schemas) do
{:ok, result} ->
cast(%{schema | anyOf: nil}, result, schemas)
{:error, _reason} ->
cast(%{schema | anyOf: rest}, value, schemas)
end
end
def cast(%Schema{anyOf: []}, _value, _schemas) do
{:error, "Failed to cast to any schema in anyOf"}
end
def cast(schema = %Schema{type: :object}, value, schemas) when is_map(value) do
schema = %{schema | properties: schema.properties || %{}}
{regular_properties, others} =
value
|> no_struct()
|> Enum.split_with(fn {k, _v} -> is_binary(k) end)
with {:ok, props} <- cast_properties(schema, regular_properties, schemas) do
result = Map.new(others ++ props) |> make_struct(schema)
{:ok, result}
end
end
def cast(ref = %Reference{}, val, schemas),
do: cast(Reference.resolve_schema(ref, schemas), val, schemas)
def cast(_additionalProperties = false, val, _schemas) do
{:error, "Unexpected field with value #{inspect(val)}"}
end
def cast(_additionalProperties, val, _schemas), do: {:ok, val}
defp make_struct(val = %_{}, _), do: val
defp make_struct(val, %{"x-struct": nil}), do: val
defp make_struct(val, %{"x-struct": mod}) do
Enum.reduce(val, struct(mod), fn {k, v}, acc ->
Map.put(acc, k, v)
end)
end
defp no_struct(val), do: Map.delete(val, :__struct__)
@spec cast_properties(Schema.t(), list, %{String.t() => Schema.t()}) ::
{:ok, list} | {:error, String.t()}
defp cast_properties(%Schema{}, [], _schemas), do: {:ok, []}
defp cast_properties(object_schema = %Schema{}, [{key, value} | rest], schemas) do
{name, schema} =
Enum.find(
object_schema.properties,
{key, object_schema.additionalProperties},
fn {name, _schema} -> to_string(name) == to_string(key) end
)
with {:ok, new_value} <- cast(schema, value, schemas),
{:ok, cast_tail} <- cast_properties(object_schema, rest, schemas) do
{:ok, [{name, new_value} | cast_tail]}
end
end
@doc ~S"""
Validate a value against a Schema.
This expects that the value has already been `cast` to the appropriate data type.
## Examples
iex> OpenApiSpex.Schema.validate(%OpenApiSpex.Schema{type: :integer, minimum: 5}, 3, %{})
{:error, "#: 3 is smaller than minimum 5"}
iex> OpenApiSpex.Schema.validate(%OpenApiSpex.Schema{type: :string, pattern: "(.*)@(.*)"}, "joe@gmail.com", %{})
:ok
iex> OpenApiSpex.Schema.validate(%OpenApiSpex.Schema{type: :string, pattern: "(.*)@(.*)"}, "joegmail.com", %{})
{:error, "#: Value \"joegmail.com\" does not match pattern: (.*)@(.*)"}
"""
@spec validate(Schema.t() | Reference.t(), any, %{String.t() => Schema.t() | Reference.t()}) ::
:ok | {:error, String.t()}
def validate(schema, val, schemas), do: validate(schema, val, "#", schemas)
@spec validate(Schema.t() | Reference.t(), any, String.t(), %{
String.t() => Schema.t() | Reference.t()
}) :: :ok | {:error, String.t()}
def validate(ref = %Reference{}, val, path, schemas),
do: validate(Reference.resolve_schema(ref, schemas), val, path, schemas)
def validate(%Schema{nullable: true}, nil, _path, _schemas), do: :ok
def validate(%Schema{type: type}, nil, path, _schemas) when not is_nil(type) do
{:error, "#{path}: null value where #{type} expected"}
end
def validate(schema = %Schema{anyOf: valid_schemas}, value, path, schemas)
when is_list(valid_schemas) do
if Enum.any?(valid_schemas, &(validate(&1, value, path, schemas) == :ok)) do
validate(%{schema | anyOf: nil}, value, path, schemas)
else
{:error, "#{path}: Failed to validate against any schema"}
end
end
def validate(schema = %Schema{oneOf: valid_schemas}, value, path, schemas)
when is_list(valid_schemas) do
case Enum.count(valid_schemas, &(validate(&1, value, path, schemas) == :ok)) do
1 -> validate(%{schema | oneOf: nil}, value, path, schemas)
0 -> {:error, "#{path}: Failed to validate against any schema"}
other -> {:error, "#{path}: Validated against #{other} schemas when only one expected"}
end
end
def validate(schema = %Schema{allOf: required_schemas}, value, path, schemas)
when is_list(required_schemas) do
required_schemas
|> Enum.map(&validate(&1, value, path, schemas))
|> Enum.reject(&(&1 == :ok))
|> Enum.map(fn {:error, msg} -> msg end)
|> case do
[] -> validate(%{schema | allOf: nil}, value, path, schemas)
errors -> {:error, Enum.join(errors, "\n")}
end
end
def validate(schema = %Schema{not: not_schema}, value, path, schemas)
when not is_nil(not_schema) do
case validate(not_schema, value, path, schemas) do
{:error, _} -> validate(%{schema | not: nil}, value, path, schemas)
:ok -> {:error, "#{path}: Value is valid for schema given in `not`"}
end
end
def validate(%Schema{enum: options = [_ | _]}, value, path, _schemas) do
case Enum.member?(options, value) do
true ->
:ok
_ ->
{:error, "#{path}: Value not in enum: #{inspect(value)}"}
end
end
def validate(schema = %Schema{type: :integer}, value, path, _schemas) when is_integer(value) do
validate_number_types(schema, value, path)
end
def validate(schema = %Schema{type: :number}, value, path, _schemas) when is_number(value) do
validate_number_types(schema, value, path)
end
def validate(schema = %Schema{type: :string}, value, path, _schemas) when is_binary(value) do
validate_string_types(schema, value, path)
end
def validate(%Schema{type: :string, format: :"date-time"}, %DateTime{}, _path, _schemas) do
:ok
end
def validate(%Schema{type: expected_type}, %DateTime{}, path, _schemas) do
{:error, "#{path}: invalid type DateTime where #{expected_type} expected"}
end
def validate(%Schema{type: :string, format: :date}, %Date{}, _path, _schemas) do
:ok
end
def validate(%Schema{type: expected_type}, %Date{}, path, _schemas) do
{:error, "#{path}: invalid type Date where #{expected_type} expected"}
end
def validate(%Schema{type: :boolean}, value, _path, _schemas) when is_boolean(value), do: :ok
def validate(schema = %Schema{type: :array}, value, path, schemas) when is_list(value) do
with :ok <- validate_max_items(schema, value, path),
:ok <- validate_min_items(schema, value, path),
:ok <- validate_unique_items(schema, value, path),
:ok <- validate_array_items(schema, value, {path, 0}, schemas) do
:ok
end
end
def validate(schema = %Schema{type: :object}, value = %{}, path, schemas) do
schema = %{schema | properties: schema.properties || %{}, required: schema.required || []}
with :ok <- validate_required_properties(schema, value, path),
:ok <- validate_max_properties(schema, value, path),
:ok <- validate_min_properties(schema, value, path),
:ok <-
validate_object_properties(
schema.properties,
MapSet.new(schema.required),
value,
path,
schemas
) do
:ok
end
end
def validate(%Schema{type: nil}, _value, _path, _schemas) do
# polymorphic schemas will terminate here after validating against anyOf/oneOf/allOf/not
:ok
end
def validate(%Schema{type: expected_type}, value, path, _schemas)
when not is_nil(expected_type) do
{:error, "#{path}: invalid type #{term_type(value)} where #{expected_type} expected"}
end
@spec term_type(term) :: data_type | nil | String.t()
defp term_type(v) when is_list(v), do: :array
defp term_type(v) when is_map(v), do: :object
defp term_type(v) when is_binary(v), do: :string
defp term_type(v) when is_boolean(v), do: :boolean
defp term_type(v) when is_integer(v), do: :integer
defp term_type(v) when is_number(v), do: :number
defp term_type(v) when is_nil(v), do: nil
defp term_type(v), do: inspect(v)
@spec validate_number_types(Schema.t(), number, String.t()) :: :ok | {:error, String.t()}
defp validate_number_types(schema, value, path) do
with :ok <- validate_multiple(schema, value, path),
:ok <- validate_maximum(schema, value, path),
:ok <- validate_minimum(schema, value, path) do
:ok
end
end
@spec validate_string_types(Schema.t(), String.t(), String.t()) :: :ok | {:error, String.t()}
defp validate_string_types(schema, value, path) do
with :ok <- validate_max_length(schema, value, path),
:ok <- validate_min_length(schema, value, path),
:ok <- validate_pattern(schema, value, path) do
:ok
end
end
@spec validate_multiple(Schema.t(), number, String.t()) :: :ok | {:error, String.t()}
defp validate_multiple(%{multipleOf: nil}, _, _), do: :ok
defp validate_multiple(%{multipleOf: n}, value, _) when round(value / n) * n == value, do: :ok
defp validate_multiple(%{multipleOf: n}, value, path),
do: {:error, "#{path}: #{value} is not a multiple of #{n}"}
@spec validate_maximum(Schema.t(), number, String.t()) :: :ok | {:error, String.t()}
defp validate_maximum(%{maximum: nil}, _val, _path), do: :ok
defp validate_maximum(%{maximum: n, exclusiveMaximum: true}, value, _path) when value < n,
do: :ok
defp validate_maximum(%{maximum: n, exclusiveMaximum: true}, value, path),
do: {:error, "#{path}: #{value} is larger than the exclusive maximum #{n}"}
defp validate_maximum(%{maximum: n}, value, _path) when value <= n, do: :ok
defp validate_maximum(%{maximum: n}, value, path),
do: {:error, "#{path}: #{value} is larger than maximum #{n}"}
@spec validate_minimum(Schema.t(), number, String.t()) :: :ok | {:error, String.t()}
defp validate_minimum(%{minimum: nil}, _val, _path), do: :ok
defp validate_minimum(%{minimum: n, exclusiveMinimum: true}, value, _path) when value > n,
do: :ok
defp validate_minimum(%{minimum: n, exclusiveMinimum: true}, value, path),
do: {:error, "#{path}: #{value} is smaller than the exclusive minimum #{n}"}
defp validate_minimum(%{minimum: n}, value, _path) when value >= n, do: :ok
defp validate_minimum(%{minimum: n}, value, path),
do: {:error, "#{path}: #{value} is smaller than minimum #{n}"}
@spec validate_max_length(Schema.t(), String.t(), String.t()) :: :ok | {:error, String.t()}
defp validate_max_length(%{maxLength: nil}, _val, _path), do: :ok
defp validate_max_length(%{maxLength: n}, value, path) do
case String.length(value) <= n do
true -> :ok
_ -> {:error, "#{path}: String length is larger than maxLength: #{n}"}
end
end
@spec validate_min_length(Schema.t(), String.t(), String.t()) :: :ok | {:error, String.t()}
defp validate_min_length(%{minLength: nil}, _val, _path), do: :ok
defp validate_min_length(%{minLength: n}, value, path) do
case String.length(value) >= n do
true -> :ok
_ -> {:error, "#{path}: String length is smaller than minLength: #{n}"}
end
end
@spec validate_pattern(Schema.t(), String.t(), String.t()) :: :ok | {:error, String.t()}
defp validate_pattern(%{pattern: nil}, _val, _path), do: :ok
defp validate_pattern(schema = %{pattern: regex}, val, path) when is_binary(regex) do
with {:ok, regex} <- Regex.compile(regex) do
validate_pattern(%{schema | pattern: regex}, val, path)
end
end
defp validate_pattern(%{pattern: regex = %Regex{}}, val, path) do
case Regex.match?(regex, val) do
true -> :ok
_ -> {:error, "#{path}: Value #{inspect(val)} does not match pattern: #{regex.source}"}
end
end
@spec validate_max_items(Schema.t(), list, String.t()) :: :ok | {:error, String.t()}
defp validate_max_items(%Schema{maxItems: nil}, _val, _path), do: :ok
defp validate_max_items(%Schema{maxItems: n}, value, _path) when length(value) <= n, do: :ok
defp validate_max_items(%Schema{maxItems: n}, value, path) do
{:error, "#{path}: Array length #{length(value)} is larger than maxItems: #{n}"}
end
@spec validate_min_items(Schema.t(), list, String.t()) :: :ok | {:error, String.t()}
defp validate_min_items(%Schema{minItems: nil}, _val, _path), do: :ok
defp validate_min_items(%Schema{minItems: n}, value, _path) when length(value) >= n, do: :ok
defp validate_min_items(%Schema{minItems: n}, value, path) do
{:error, "#{path}: Array length #{length(value)} is smaller than minItems: #{n}"}
end
@spec validate_unique_items(Schema.t(), list, String.t()) :: :ok | {:error, String.t()}
defp validate_unique_items(%Schema{uniqueItems: true}, value, path) do
unique_size =
value
|> MapSet.new()
|> MapSet.size()
case unique_size == length(value) do
true -> :ok
_ -> {:error, "#{path}: Array items must be unique"}
end
end
defp validate_unique_items(_schema, _value, _path), do: :ok
@spec validate_array_items(Schema.t(), list, {String.t(), integer}, %{String.t() => Schema.t()}) ::
:ok | {:error, String.t()}
defp validate_array_items(%Schema{type: :array, items: nil}, value, _path, _schemas)
when is_list(value),
do: :ok
defp validate_array_items(%Schema{type: :array}, [], _path, _schemas), do: :ok
defp validate_array_items(
schema = %Schema{type: :array, items: item_schema},
[x | rest],
{path, index},
schemas
) do
with :ok <- validate(item_schema, x, "#{path}/#{index}", schemas) do
validate_array_items(schema, rest, {path, index + 1}, schemas)
end
end
@spec validate_required_properties(Schema.t(), %{}, String.t()) :: :ok | {:error, String.t()}
defp validate_required_properties(%Schema{type: :object, required: nil}, _val, _path), do: :ok
defp validate_required_properties(%Schema{type: :object, required: required}, value = %{}, path) do
missing = required -- Map.keys(value)
case missing do
[] -> :ok
_ -> {:error, "#{path}: Missing required properties: #{inspect(missing)}"}
end
end
@spec validate_max_properties(Schema.t(), %{}, String.t()) :: :ok | {:error, String.t()}
defp validate_max_properties(%Schema{type: :object, maxProperties: nil}, _val, _path), do: :ok
defp validate_max_properties(%Schema{type: :object, maxProperties: n}, val, _path)
when map_size(val) <= n,
do: :ok
defp validate_max_properties(%Schema{type: :object, maxProperties: n}, val, path) do
{:error,
"#{path}: Object property count #{map_size(val)} is greater than maxProperties: #{n}"}
end
@spec validate_min_properties(Schema.t(), %{}, String.t()) :: :ok | {:error, String.t()}
defp validate_min_properties(%Schema{type: :object, minProperties: nil}, _val, _path), do: :ok
defp validate_min_properties(%Schema{type: :object, minProperties: n}, val, _path)
when map_size(val) >= n,
do: :ok
defp validate_min_properties(%Schema{type: :object, minProperties: n}, val, path) do
{:error, "#{path}: Object property count #{map_size(val)} is less than minProperties: #{n}"}
end
@spec validate_object_properties(Enumerable.t(), MapSet.t(), %{}, String.t(), %{
String.t() => Schema.t() | Reference.t()
}) :: :ok | {:error, String.t()}
defp validate_object_properties(properties = %{}, required, value = %{}, path, schemas = %{}) do
properties
|> Enum.filter(fn {name, _schema} -> Map.has_key?(value, name) end)
|> validate_object_properties(required, value, path, schemas)
end
defp validate_object_properties([], _required, _val, _path, _schemas), do: :ok
defp validate_object_properties(
[{name, schema} | rest],
required,
value = %{},
path,
schemas = %{}
) do
property_required = MapSet.member?(required, name)
property_value = Map.get(value, name)
property_path = "#{path}/#{name}"
with :ok <-
validate_object_property(
schema,
property_required,
property_value,
property_path,
schemas
),
:ok <- validate_object_properties(rest, required, value, path, schemas) do
:ok
end
end
defp validate_object_property(_schema, false, nil, _path, _schemas), do: :ok
defp validate_object_property(schema, _required, value, path, schemas) do
validate(schema, value, path, schemas)
end
@doc """
Get the names of all properties definied for a schema.
Includes all properties directly defined in the schema, and all schemas
included in the `allOf` list.
"""
def properties(schema = %Schema{type: :object, properties: properties = %{}}) do
Map.keys(properties) ++ properties(%{schema | properties: nil})
end
def properties(%Schema{allOf: schemas}) when is_list(schemas) do
Enum.flat_map(schemas, &properties/1) |> Enum.uniq()
end
def properties(schema_module) when is_atom(schema_module) do
properties(schema_module.schema())
end
def properties(_), do: []
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/lib/open_api_spex/test/assertions2.ex b/lib/open_api_spex/test/assertions2.ex
new file mode 100644
index 0000000..225afcf
--- /dev/null
+++ b/lib/open_api_spex/test/assertions2.ex
@@ -0,0 +1,37 @@
+defmodule OpenApiSpex.Test.Assertions2 do
+ @moduledoc """
+ Defines helpers for testing API responses and examples against API spec schemas.
+ """
+ alias OpenApiSpex.OpenApi
+ alias OpenApiSpex.Cast
+ import ExUnit.Assertions
+
+ @dialyzer {:no_match, assert_schema: 3}
+
+ @doc """
+ Asserts that `value` conforms to the schema with title `schema_title` in `api_spec`.
+ """
+ @spec assert_schema(map, String.t(), OpenApi.t()) :: map | no_return
+ def assert_schema(value = %{}, schema_title, api_spec = %OpenApi{}) do
+ schemas = api_spec.components.schemas
+ schema = schemas[schema_title]
+
+ if !schema do
+ flunk("Schema: #{schema_title} not found in #{inspect(Map.keys(schemas))}")
+ end
+
+ case Cast.cast(schema, value, api_spec.components.schemas) do
+ {:ok, data} ->
+ data
+
+ {:error, errors} ->
+ errors = Enum.map(errors, &to_string/1)
+
+ flunk(
+ "Value does not conform to schema #{schema_title}: #{Enum.join(errors, "\n")}\n#{
+ inspect(value)
+ }"
+ )
+ end
+ end
+end
diff --git a/mix.exs b/mix.exs
index dbdecb5..7eba9a1 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,59 +1,60 @@
defmodule OpenApiSpex.Mixfile do
use Mix.Project
@version "3.1.0"
def project do
[
app: :open_api_spex,
version: @version,
elixir: "~> 1.6",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
description: description(),
package: package(),
deps: deps(),
+ consolidate_protocols: Mix.env() != :test,
source_url: "https://github.com/open-api-spex/open_api_spex",
homepage_url: "https://github.com/open-api-spex/open_api_spex",
docs: [extras: ["README.md"], main: "readme", source_ref: "v#{@version}"],
dialyzer: [
plt_add_apps: [:mix, :jason, :poison],
plt_add_deps: :apps_direct,
flags: ["-Werror_handling", "-Wno_unused", "-Wunmatched_returns", "-Wunderspecs"],
remove_defaults: [:unknown]
]
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Run "mix help compile.app" to learn about applications.
def application, do: [extra_applications: []]
defp description() do
"Leverage Open Api Specification 3 (swagger) to document, test, validate and explore your Plug and Phoenix APIs."
end
defp package() do
[
name: "open_api_spex",
files: ["lib", "mix.exs", "README.md", "LICENSE", "CHANGELOG.md"],
maintainers: ["Mike Buhot (m.buhot@gmail.com)"],
licenses: ["Mozilla Public License, version 2.0"],
links: %{"GitHub" => "https://github.com/open-api-spex/open_api_spex"}
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:poison, "~> 3.1", optional: true},
{:jason, "~> 1.0", optional: true},
{:plug, "~> 1.7"},
{:phoenix, "~> 1.3", only: :test},
{:ex_doc, "~> 0.19", only: :dev, runtime: false},
{:dialyxir, "~> 0.5", only: [:dev, :test], runtime: false}
]
end
end
diff --git a/test/cast/all_of_test.exs b/test/cast/all_of_test.exs
new file mode 100644
index 0000000..8a00b35
--- /dev/null
+++ b/test/cast/all_of_test.exs
@@ -0,0 +1,30 @@
+defmodule OpenApiSpex.CastAllOfTest do
+ use ExUnit.Case
+ alias OpenApiSpex.{Cast, Schema}
+ alias OpenApiSpex.Cast.{Error, AllOf}
+
+ defp cast(ctx), do: AllOf.cast(struct(Cast, ctx))
+
+ describe "cast/1" do
+ test "allOf" do
+ schema = %Schema{allOf: [%Schema{type: :integer}, %Schema{type: :string}]}
+ assert {:ok, 1} = cast(value: "1", schema: schema)
+ end
+
+ test "allOf, uncastable schema" do
+ schema = %Schema{allOf: [%Schema{type: :integer}, %Schema{type: :string}]}
+ assert {:error, [error]} = cast(value: [:whoops], schema: schema)
+
+ assert Error.message(error) ==
+ "Failed to cast value as integer. Value must be castable using `allOf` schemas listed."
+
+ schema_with_title = %Schema{allOf: [%Schema{title: "Age", type: :integer}]}
+
+ assert {:error, [error_with_schema_title]} =
+ cast(value: [:nopes], schema: schema_with_title)
+
+ assert Error.message(error_with_schema_title) ==
+ "Failed to cast value as Age. Value must be castable using `allOf` schemas listed."
+ end
+ end
+end
diff --git a/test/cast/any_of_test.exs b/test/cast/any_of_test.exs
new file mode 100644
index 0000000..338d6c6
--- /dev/null
+++ b/test/cast/any_of_test.exs
@@ -0,0 +1,60 @@
+defmodule OpenApiSpex.CastAnyOfTest do
+ use ExUnit.Case
+ alias OpenApiSpex.{Cast, Schema}
+ alias OpenApiSpex.Cast.{Error, AnyOf}
+
+ defp cast(ctx), do: AnyOf.cast(struct(Cast, ctx))
+
+ describe "cast/1" do
+ test "basics" do
+ schema = %Schema{anyOf: [%Schema{type: :array}, %Schema{type: :string}]}
+ assert {:ok, "jello"} = cast(value: "jello", schema: schema)
+ end
+
+ test "object" do
+ dog_schema = %Schema{
+ title: "Dog",
+ type: :object,
+ properties: %{
+ breed: %Schema{type: :string},
+ age: %Schema{type: :integer}
+ }
+ }
+
+ cat_schema = %Schema{
+ title: "Cat",
+ type: :object,
+ properties: %{
+ breed: %Schema{type: :string},
+ lives: %Schema{type: :integer}
+ }
+ }
+
+ schema = %Schema{anyOf: [dog_schema, cat_schema], title: "MyCoolSchema"}
+ value = %{"breed" => "Corgi", "age" => 3}
+ assert {:ok, %{breed: "Corgi", age: 3}} = cast(value: value, schema: schema)
+ end
+
+ test "no castable schema" do
+ schema = %Schema{anyOf: [%Schema{type: :integer}, %Schema{type: :string}]}
+ assert {:error, [error]} = cast(value: [:whoops], schema: schema)
+
+ assert Error.message(error) ==
+ "Failed to cast value using any of: Schema(type: :string), Schema(type: :integer)"
+
+ schema_with_title = %Schema{anyOf: [%Schema{title: "Age", type: :integer}]}
+ assert {:error, [error_with_schema_title]} = cast(value: [], schema: schema_with_title)
+
+ assert Error.message(error_with_schema_title) ==
+ "Failed to cast value using any of: Schema(title: \"Age\", type: :integer)"
+ end
+
+ test "empty list" do
+ schema = %Schema{anyOf: [], title: "MyCoolSchema"}
+ assert {:error, [error]} = cast(value: "jello", schema: schema)
+ assert error.reason == :any_of
+
+ assert Error.message(error) == "Failed to cast value using any of: [] (no schemas provided)"
+ end
+ end
+end
diff --git a/test/cast/array_test.exs b/test/cast/array_test.exs
new file mode 100644
index 0000000..ac0721f
--- /dev/null
+++ b/test/cast/array_test.exs
@@ -0,0 +1,65 @@
+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 "with maxItems" do
+ schema = %Schema{type: :array, maxItems: 2}
+ assert cast(value: [1, 2], schema: schema) == {:ok, [1, 2]}
+ assert {:error, [error]} = cast(value: [1, 2, 3], schema: schema)
+
+ assert %Error{} = error
+ assert error.reason == :max_items
+ assert error.value == [1, 2, 3]
+ end
+
+ test "with minItems" do
+ schema = %Schema{type: :array, minItems: 2}
+ assert cast(value: [1, 2], schema: schema) == {:ok, [1, 2]}
+ assert {:error, [error]} = cast(value: [1], schema: schema)
+
+ assert %Error{} = error
+ assert error.reason == :min_items
+ assert error.value == [1]
+ end
+
+ test "with uniqueItems" do
+ schema = %Schema{type: :array, uniqueItems: true}
+ assert cast(value: [1, 2], schema: schema) == {:ok, [1, 2]}
+ assert {:error, [error]} = cast(value: [1, 1], schema: schema)
+
+ assert %Error{} = error
+ assert error.reason == :unique_items
+ assert error.value == [1, 1]
+ 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/discriminator_test.exs b/test/cast/discriminator_test.exs
new file mode 100644
index 0000000..0ee7945
--- /dev/null
+++ b/test/cast/discriminator_test.exs
@@ -0,0 +1,277 @@
+defmodule OpenApiSpex.CastDiscriminatorTest do
+ use ExUnit.Case
+
+ alias OpenApiSpex.{Cast, Schema}
+ alias OpenApiSpex.Cast.{Discriminator, Error}
+
+ # The discriminator we'll be using across the tests. Animal type was
+ # specifically chosen to help the examples be more clear since abstract
+ # objects can be difficult to grok in this context.
+ @discriminator "animal_type"
+
+ defp cast(ctx) do
+ Discriminator.cast(struct(Cast, ctx))
+ end
+
+ # This function is used for testing descriminators within nested
+ # schemas.
+ defp cast_cast(ctx) do
+ Cast.cast(struct(Cast, ctx))
+ end
+
+ # Maps an arbitrary string value to a schema
+ def build_discriminator_mapping(name, schema) do
+ %{name => schema_ref(schema)}
+ end
+
+ def schema_ref(%{title: title}), do: "#/components/schemas/#{title}"
+
+ def build_schema(title, properties) do
+ %Schema{
+ type: :object,
+ title: title,
+ properties: properties
+ }
+ end
+
+ def build_discriminator_schema(schemas, composite_keyword, property_name, mapppings \\ nil) do
+ %Schema{
+ type: :object,
+ discriminator: %{
+ propertyName: "#{property_name}",
+ mapping: mapppings
+ }
+ }
+ |> Map.put(composite_keyword, schemas)
+ end
+
+ setup do
+ dog_schema =
+ build_schema("Dog", %{
+ breed: %Schema{type: :string},
+ age: %Schema{type: :integer}
+ })
+
+ wolf_schema =
+ build_schema("Wolf", %{
+ breed: %Schema{type: :string, minLength: 5},
+ age: %Schema{type: :integer}
+ })
+
+ cat_schema =
+ build_schema("Cat", %{
+ breed: %Schema{type: :string},
+ lives: %Schema{type: :integer}
+ })
+
+ {:ok, schemas: %{dog: dog_schema, cat: cat_schema, wolf: wolf_schema}}
+ end
+
+ describe "cast/1" do
+ test "basics, anyOf", %{schemas: %{dog: dog, wolf: wolf}} do
+ # "animal_type" is the discriminator and the keys need to be strings.
+ input_value = %{@discriminator => "Dog", "breed" => "Pug", "age" => 1}
+ # Create the discriminator schema. Discriminators require a composite
+ # key be provided (`allOf`, `anyOf`, `oneOf`).
+ discriminator_schema =
+ build_discriminator_schema([dog, wolf], :anyOf, String.to_atom(@discriminator), nil)
+
+ # Note: We're expecting to getting atoms back, not strings
+ expected = {:ok, %{age: 1, breed: "Pug"}}
+
+ assert cast(value: input_value, schema: discriminator_schema) == expected
+ end
+
+ test "basics, allOf", %{schemas: %{dog: dog, wolf: wolf}} do
+ # Wolf has a constraint on its "breed attribute" requiring the breed to have
+ # a minimum length of 5.
+ input_value = %{@discriminator => "Dog", "breed" => "Corgi", "age" => 1}
+
+ discriminator_schema =
+ build_discriminator_schema([dog, wolf], :allOf, String.to_atom(@discriminator), nil)
+
+ # Note: We're expecting to getting atoms back, not strings
+ expected = {:ok, %{age: 1, breed: "Corgi"}}
+
+ assert cast(value: input_value, schema: discriminator_schema) == expected
+ end
+
+ test "basics, oneOf", %{schemas: %{dog: dog, wolf: wolf}} do
+ # Wolf has a constraint on its "breed attribute" requiring the breed to have
+ # a minimum length of 5.
+ input_value = %{@discriminator => "Dog", "breed" => "Pug", "age" => 1}
+
+ discriminator_schema =
+ build_discriminator_schema([dog, wolf], :oneOf, String.to_atom(@discriminator), nil)
+
+ # Note: We're expecting to getting atoms back, not strings
+ expected = {:ok, %{age: 1, breed: "Pug"}}
+
+ assert cast(value: input_value, schema: discriminator_schema) == expected
+ end
+
+ test "with mapping", %{schemas: %{dog: dog, cat: cat}} do
+ dog_schema_alias = "dag"
+ # "animal_type" is the discriminator and the keys need to be strings.
+ input_value = %{@discriminator => dog_schema_alias, "breed" => "Corgi", "age" => 1}
+ # Map the value 'dag' to the schema "Dog"
+ mapping = build_discriminator_mapping(dog_schema_alias, dog)
+ # Create the discriminator schema. Discriminators require a composite
+ # key be provided (`allOf`, `anyOf`, `oneOf`).
+ discriminator_schema =
+ build_discriminator_schema([dog, cat], :anyOf, String.to_atom(@discriminator), mapping)
+
+ # Note: We're expecting to getting atoms back, not strings
+ expected = {:ok, %{age: 1, breed: "Corgi"}}
+
+ assert cast(value: input_value, schema: discriminator_schema) == expected
+ end
+
+ test "invalid property on discriminator schema", %{
+ schemas: %{dog: dog, wolf: wolf}
+ } do
+ # "Pug" will fail because the `breed` property for a Wolf must have a minimum
+ # length of 5.
+ input_value = %{@discriminator => "Wolf", "breed" => "Pug", "age" => 1}
+
+ # Create the discriminator schema. Discriminators require a composite
+ # key be provided (`allOf`, `anyOf`, `oneOf`).
+ discriminator_schema =
+ build_discriminator_schema([dog, wolf], :anyOf, String.to_atom(@discriminator), nil)
+
+ assert {:error, [error]} = cast(value: input_value, schema: discriminator_schema)
+ assert error.reason == :min_length
+ end
+
+ test "invalid discriminator value", %{schemas: %{dog: dog}} do
+ # Goats is not registered to any schema provided
+ empty_discriminator_value = %{@discriminator => "Goats", "breed" => "Corgi", "age" => 1}
+
+ discriminator_schema =
+ build_discriminator_schema([dog], :anyOf, String.to_atom(@discriminator), nil)
+
+ assert {:error, [error]} =
+ cast(
+ value: empty_discriminator_value,
+ schema: discriminator_schema
+ )
+
+ assert error.reason == :invalid_discriminator_value
+ end
+
+ test "empty discriminator value", %{schemas: %{dog: dog}} do
+ empty_discriminator_value = %{@discriminator => "", "breed" => "Corgi", "age" => 1}
+
+ discriminator_schema =
+ build_discriminator_schema([dog], :anyOf, String.to_atom(@discriminator), nil)
+
+ assert {:error, [error]} =
+ cast(
+ value: empty_discriminator_value,
+ schema: discriminator_schema
+ )
+
+ assert error.reason == :no_value_for_discriminator
+ end
+
+ test "nested, success", %{schemas: %{dog: dog, cat: cat}} do
+ # "animal_type" is the discriminator and the keys need to be strings.
+ input_value = %{"data" => %{@discriminator => "Dog", "breed" => "Corgi", "age" => 1}}
+ # Nested schema to better simulate use with JSON API (real world)
+ discriminator_schema = %Schema{
+ title: "Nested Skemuh",
+ type: :object,
+ properties: %{
+ data:
+ build_discriminator_schema([dog, cat], :anyOf, String.to_atom(@discriminator), nil)
+ }
+ }
+
+ # Note: We're expecting to getting atoms back, not strings
+ expected = {:ok, %{data: %{age: 1, breed: "Corgi"}}}
+
+ assert expected == cast_cast(value: input_value, schema: discriminator_schema)
+ end
+
+ test "nested, with invalid property on discriminator schema", %{
+ schemas: %{dog: dog, wolf: wolf}
+ } do
+ # "animal_type" is the discriminator and the keys need to be strings.
+ input_value = %{"data" => %{@discriminator => "Wolf", "breed" => "Pug", "age" => 1}}
+ # Nested schema to better simulate use with JSON API (real world)
+ discriminator_schema = %Schema{
+ title: "Nested Skemuh",
+ type: :object,
+ properties: %{
+ data:
+ build_discriminator_schema([dog, wolf], :anyOf, String.to_atom(@discriminator), nil)
+ }
+ }
+
+ assert {:error, [error]} = cast_cast(value: input_value, schema: discriminator_schema)
+
+ # The format of the error path should be confirmed. This is just a guess.
+ assert Error.message_with_path(error) ==
+ "#/data/#{@discriminator}/breed: String length is smaller than minLength: 5"
+ end
+
+ test "without setting a composite key, raises compile time error" do
+ # While we're still specifying the composite key here, it'll be set to
+ # nil. E.g. %Schema{anyOf: nil, discriminator: %{...}}
+ discriminator_schema =
+ build_discriminator_schema(nil, :anyOf, String.to_atom(@discriminator), nil)
+
+ # We have to escape the map to unquote it later.
+ schema_as_map = Map.from_struct(discriminator_schema) |> Macro.escape()
+
+ # A module which will define our broken schema, and throw an error.
+ code =
+ quote do
+ defmodule DiscriminatorWihoutCompositeKey do
+ require OpenApiSpex
+
+ OpenApiSpex.schema(unquote(schema_as_map))
+ end
+ end
+
+ # Confirm we raise the error when we compile the code
+ assert_raise(OpenApiSpex.SchemaException, fn ->
+ Code.eval_quoted(code)
+ end)
+ end
+
+ # From the OAS discriminator docs:
+ #
+ # "[..] inline schema definitions, which do not have a given id,
+ # cannot be used in polymorphism."
+ test "discriminator schemas without titles, raise compile time error", %{
+ schemas: %{dog: dog, cat: cat}
+ } do
+ discriminator_schema =
+ build_discriminator_schema(
+ [cat, %{dog | title: nil}],
+ :anyOf,
+ String.to_atom(@discriminator),
+ nil
+ )
+
+ # We have to escape the map to unquote it later.
+ schema_as_map = Map.from_struct(discriminator_schema) |> Macro.escape()
+
+ # A module which will define our broken schema, and throw an error.
+ code =
+ quote do
+ defmodule DiscriminatorSchemaWithoutId do
+ require OpenApiSpex
+
+ OpenApiSpex.schema(unquote(schema_as_map))
+ end
+ end
+
+ # Confirm we raise the error when we compile the code
+ assert_raise(OpenApiSpex.SchemaException, fn ->
+ Code.eval_quoted(code)
+ end)
+ end
+ end
+end
diff --git a/test/cast/error_test.exs b/test/cast/error_test.exs
new file mode 100644
index 0000000..dfe35e8
--- /dev/null
+++ b/test/cast/error_test.exs
@@ -0,0 +1,11 @@
+defmodule OpenApiSpex.Cast.ErrorTest do
+ use ExUnit.Case
+ alias OpenApiSpex.Cast.Error
+
+ describe "path_to_string/1" do
+ test "with empty path" do
+ error = %Error{path: []}
+ assert Error.path_to_string(error) == "/"
+ end
+ end
+end
diff --git a/test/cast/integer_test.exs b/test/cast/integer_test.exs
new file mode 100644
index 0000000..1921b95
--- /dev/null
+++ b/test/cast/integer_test.exs
@@ -0,0 +1,70 @@
+defmodule OpenApiSpex.CastIntegerTest do
+ use ExUnit.Case
+ alias OpenApiSpex.{Cast, Schema}
+ alias OpenApiSpex.Cast.{Error, Integer}
+
+ defp cast(ctx), do: Integer.cast(struct(Cast, ctx))
+
+ describe "cast/1" do
+ test "basics" do
+ schema = %Schema{type: :integer}
+ assert cast(value: 1, schema: schema) == {:ok, 1}
+ assert cast(value: 1.5, schema: schema) == {:ok, 2}
+ assert cast(value: "1", schema: schema) == {:ok, 1}
+ assert cast(value: "1.5", schema: schema) == {:ok, 2}
+ assert {:error, [error]} = cast(value: "other", schema: schema)
+ assert %Error{reason: :invalid_type} = error
+ assert error.value == "other"
+ end
+
+ test "with multiple of" do
+ schema = %Schema{type: :integer, multipleOf: 2}
+ assert cast(value: 2, schema: schema) == {:ok, 2}
+ assert {:error, [error]} = cast(value: 3, schema: schema)
+ assert error.reason == :multiple_of
+ assert error.value == 3
+ # error.length is the multiple
+ assert error.length == 2
+ end
+
+ test "with minimum" do
+ schema = %Schema{type: :integer, minimum: 2}
+ assert cast(value: 3, schema: schema) == {:ok, 3}
+ assert {:error, [error]} = cast(value: 2, schema: schema)
+ assert error.reason == :minimum
+ assert error.value == 2
+ # error.length is the minimum
+ assert error.length == 2
+ end
+
+ test "with maximum" do
+ schema = %Schema{type: :integer, maximum: 2}
+ assert cast(value: 1, schema: schema) == {:ok, 1}
+ assert {:error, [error]} = cast(value: 2, schema: schema)
+ assert error.reason == :maximum
+ assert error.value == 2
+ # error.length is the maximum
+ assert error.length == 2
+ end
+
+ test "with minimum w/ exclusiveMinimum" do
+ schema = %Schema{type: :integer, minimum: 2, exclusiveMinimum: true}
+ assert cast(value: 2, schema: schema) == {:ok, 2}
+ assert {:error, [error]} = cast(value: 1, schema: schema)
+ assert error.reason == :exclusive_min
+ assert error.value == 1
+ # error.length is the minimum
+ assert error.length == 2
+ end
+
+ test "with maximum w/ exclusiveMaximum" do
+ schema = %Schema{type: :integer, maximum: 2, exclusiveMaximum: true}
+ assert cast(value: 2, schema: schema) == {:ok, 2}
+ assert {:error, [error]} = cast(value: 3, schema: schema)
+ assert error.reason == :exclusive_max
+ assert error.value == 3
+ # error.length is the maximum
+ assert error.length == 2
+ end
+ end
+end
diff --git a/test/cast/object_test.exs b/test/cast/object_test.exs
new file mode 100644
index 0000000..51c1f99
--- /dev/null
+++ b/test/cast/object_test.exs
@@ -0,0 +1,140 @@
+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
+ assert error.reason == :unexpected_field
+ assert error.name == "unknown"
+ assert error.path == ["unknown"]
+ 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 "unexpected field" do
+ schema = %Schema{
+ type: :object,
+ properties: %{}
+ }
+
+ assert {:error, [error]} = cast(value: %{foo: "foo"}, schema: schema)
+ assert %Error{} = error
+ assert error.reason == :unexpected_field
+ assert error.path == ["foo"]
+ 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/one_of_test.exs b/test/cast/one_of_test.exs
new file mode 100644
index 0000000..284d72c
--- /dev/null
+++ b/test/cast/one_of_test.exs
@@ -0,0 +1,31 @@
+defmodule OpenApiSpex.CastOneOfTest do
+ use ExUnit.Case
+ alias OpenApiSpex.{Cast, Schema}
+ alias OpenApiSpex.Cast.{Error, OneOf}
+
+ defp cast(ctx), do: OneOf.cast(struct(Cast, ctx))
+
+ describe "cast/1" do
+ test "oneOf" do
+ schema = %Schema{oneOf: [%Schema{type: :integer}, %Schema{type: :string}]}
+ assert {:ok, "hello"} = cast(value: "hello", schema: schema)
+ end
+
+ test "oneOf, more than one matching schema" do
+ schema = %Schema{oneOf: [%Schema{type: :integer}, %Schema{type: :string}]}
+ assert {:error, [error]} = cast(value: "1", schema: schema)
+ assert error.reason == :one_of
+
+ assert Error.message(error) ==
+ "Failed to cast value to one of: Schema(type: :string), Schema(type: :integer)"
+ end
+
+ test "oneOf, no castable schema" do
+ schema = %Schema{oneOf: [%Schema{type: :string}]}
+ assert {:error, [error]} = cast(value: 1, schema: schema)
+ assert error.reason == :one_of
+
+ assert Error.message(error) == "Failed to cast value to one of: [] (no schemas provided)"
+ end
+ end
+end
diff --git a/test/cast/primitive_test.exs b/test/cast/primitive_test.exs
new file mode 100644
index 0000000..c77f8a7
--- /dev/null
+++ b/test/cast/primitive_test.exs
@@ -0,0 +1,28 @@
+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 "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..b17195b
--- /dev/null
+++ b/test/cast/string_test.exs
@@ -0,0 +1,75 @@
+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
+
+ test "string with format (date time)" do
+ schema = %Schema{type: :string, format: :"date-time"}
+ time_string = DateTime.utc_now() |> DateTime.to_string()
+ assert cast(value: time_string, schema: schema) == {:ok, time_string}
+ assert {:error, [error]} = cast(value: "hello", schema: schema)
+ assert error.reason == :invalid_format
+ assert error.value == "hello"
+ assert error.format == :"date-time"
+ end
+
+ test "string with format (date)" do
+ schema = %Schema{type: :string, format: :date}
+ date_string = DateTime.utc_now() |> DateTime.to_date() |> Date.to_string()
+ assert cast(value: date_string, schema: schema) == {:ok, date_string}
+ assert {:error, [error]} = cast(value: "hello", schema: schema)
+ assert error.reason == :invalid_format
+ assert error.value == "hello"
+ assert error.format == :date
+ end
+
+ # Note: we measure length of string after trimming leading and trailing whitespace
+ test "minLength" do
+ schema = %Schema{type: :string, minLength: 1}
+ assert {:ok, "a"} = cast(value: "a", schema: schema)
+ assert {:error, [error]} = cast(value: " ", schema: schema)
+ assert %Error{} = error
+ assert error.reason == :min_length
+ end
+
+ # Note: we measure length of string after trimming leading and trailing whitespace
+ test "maxLength" do
+ schema = %Schema{type: :string, maxLength: 1}
+ assert {:ok, "a"} = cast(value: "a", schema: schema)
+ assert {:error, [error]} = cast(value: "aa", schema: schema)
+ assert %Error{} = error
+ assert error.reason == :max_length
+ end
+
+ test "maxLength and minLength" do
+ schema = %Schema{type: :string, minLength: 1, maxLength: 2}
+ assert {:error, [error]} = cast(value: "", schema: schema)
+ assert %Error{} = error
+ assert error.reason == :min_length
+ assert {:error, [error]} = cast(value: "aaa", schema: schema)
+ assert %Error{} = error
+ assert error.reason == :max_length
+ end
+ end
+end
diff --git a/test/cast_test.exs b/test/cast_test.exs
new file mode 100644
index 0000000..afca598
--- /dev/null
+++ b/test/cast_test.exs
@@ -0,0 +1,231 @@
+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
+
+ describe "ok/1" do
+ test "basics" do
+ assert {:ok, 1} = Cast.ok(%Cast{value: 1})
+ end
+ end
+
+ describe "success/2" do
+ test "nils out property" do
+ schema = %Schema{minimum: 1}
+ ctx = %Cast{schema: schema}
+ expected = {:cast, %Cast{schema: %Schema{minimum: nil}}}
+
+ assert expected == Cast.success(ctx, :minimum)
+ end
+
+ test "nils out properties" do
+ schema = %Schema{minimum: 1, exclusiveMinimum: true}
+ ctx = %Cast{schema: schema}
+ expected = {:cast, %Cast{schema: %Schema{minimum: nil, exclusiveMinimum: nil}}}
+
+ assert expected == Cast.success(ctx, [:minimum, :exclusiveMinimum])
+ end
+ end
+end
diff --git a/test/operation2_test.exs b/test/operation2_test.exs
new file mode 100644
index 0000000..b8c8ace
--- /dev/null
+++ b/test/operation2_test.exs
@@ -0,0 +1,187 @@
+defmodule OpenApiSpex.Operation2Test do
+ use ExUnit.Case
+ alias OpenApiSpex.{Operation, Operation2, Schema}
+ alias OpenApiSpex.Cast.Error
+
+ defmodule SchemaFixtures do
+ @user %Schema{
+ type: :object,
+ properties: %{
+ user: %Schema{
+ type: :object,
+ properties: %{
+ email: %Schema{type: :string}
+ }
+ }
+ }
+ }
+ @user_list %Schema{
+ type: :array,
+ items: @user
+ }
+ @schemas %{"User" => @user, "UserList" => @user_list}
+
+ def user, do: @user
+ def user_list, do: @user_list
+ def schemas, do: @schemas
+ end
+
+ defmodule OperationFixtures do
+ @user_index %Operation{
+ operationId: "UserController.index",
+ parameters: [
+ Operation.parameter(:name, :query, :string, "Filter by user name"),
+ Operation.parameter(:age, :query, :integer, "User's age")
+ ],
+ responses: %{
+ 200 => Operation.response("User", "application/json", SchemaFixtures.user())
+ }
+ }
+
+ def user_index, do: @user_index
+
+ @create_user %Operation{
+ operationId: "UserController.create",
+ parameters: [
+ Operation.parameter(:name, :query, :string, "Filter by user name")
+ ],
+ requestBody:
+ Operation.request_body("request body", "application/json", SchemaFixtures.user(),
+ required: true
+ ),
+ responses: %{
+ 200 => Operation.response("User list", "application/json", SchemaFixtures.user_list())
+ }
+ }
+
+ def create_user, do: @create_user
+ end
+
+ defmodule SpecModule do
+ def spec do
+ paths = %{
+ "/users" => %{
+ "post" => OperationFixtures.create_user()
+ }
+ }
+
+ %OpenApiSpex.OpenApi{
+ info: nil,
+ paths: paths,
+ components: %{
+ schemas: SchemaFixtures.schemas()
+ }
+ }
+ end
+ end
+
+ defmodule RenderError do
+ def init(_) do
+ nil
+ end
+
+ def call(_conn, _errors) do
+ raise "should not have errors"
+ end
+ end
+
+ describe "cast/4" do
+ test "cast request body" do
+ conn = create_conn(%{"user" => %{"email" => "foo@bar.com"}})
+
+ assert {:ok, conn} =
+ Operation2.cast(
+ OperationFixtures.create_user(),
+ conn,
+ "application/json",
+ SchemaFixtures.schemas()
+ )
+
+ assert %Plug.Conn{} = conn
+ end
+
+ test "cast request body - invalid data type" do
+ conn = create_conn(%{"user" => %{"email" => 123}})
+
+ assert {:error, errors} =
+ Operation2.cast(
+ OperationFixtures.create_user(),
+ conn,
+ "application/json",
+ SchemaFixtures.schemas()
+ )
+
+ assert [error] = errors
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ end
+
+ test "validate undefined query param name" do
+ query_params = %{"unknown" => "asdf"}
+
+ assert {:error, [error]} = do_index_cast(query_params)
+
+ assert %Error{} = error
+ assert error.reason == :unexpected_field
+ assert error.name == "unknown"
+ assert error.path == ["unknown"]
+ end
+
+ test "validate invalid data type for query param" do
+ query_params = %{"age" => "asdf"}
+ assert {:error, [error]} = do_index_cast(query_params)
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ assert error.type == :integer
+ assert error.value == "asdf"
+ end
+
+ test "validate missing required query param" do
+ parameter =
+ Operation.parameter(:name, :query, :string, "Filter by user name", required: true)
+
+ operation = %{OperationFixtures.user_index() | parameters: [parameter]}
+
+ assert {:error, [error]} = do_index_cast(%{}, operation: operation)
+ assert %Error{} = error
+ assert error.reason == :missing_field
+ assert error.name == :name
+ end
+
+ defp do_index_cast(query_params, opts \\ []) do
+ conn =
+ :get
+ |> Plug.Test.conn("/api/users?" <> URI.encode_query(query_params))
+ |> Plug.Conn.put_req_header("content-type", "application/json")
+ |> Plug.Conn.fetch_query_params()
+ |> build_params()
+
+ operation = opts[:operation] || OperationFixtures.user_index()
+
+ Operation2.cast(
+ operation,
+ conn,
+ "application/json",
+ SchemaFixtures.schemas()
+ )
+ end
+
+ defp create_conn(body_params) do
+ :post
+ |> Plug.Test.conn("/api/users")
+ |> Plug.Conn.put_req_header("content-type", "application/json")
+ |> Plug.Conn.fetch_query_params()
+ |> Map.put(:body_params, body_params)
+ |> build_params()
+ end
+
+ defp build_params(conn) do
+ params =
+ conn.path_params
+ |> Map.merge(conn.query_params)
+ |> Map.merge(conn.body_params)
+
+ %{conn | params: params}
+ end
+ end
+end
diff --git a/test/support/schemas.ex b/test/support/schemas.ex
index 1f0436a..234c9dc 100644
--- a/test/support/schemas.ex
+++ b/test/support/schemas.ex
@@ -1,245 +1,274 @@
defmodule OpenApiSpexTest.Schemas do
require OpenApiSpex
alias OpenApiSpex.Schema
defmodule User do
- OpenApiSpex.schema %{
+ 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_]+/},
+ 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'}
+ 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],
additionalProperties: false,
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
defmodule ContactInfo do
- OpenApiSpex.schema %{
+ OpenApiSpex.schema(%{
title: "ContactInfo",
description: "A users contact information",
type: :object,
properties: %{
phone_number: %Schema{type: :string, description: "Phone number"},
postal_address: %Schema{type: :string, description: "Postal address"}
},
required: [:phone_number],
additionalProperties: false,
example: %{
"phone_number" => "555-123-456",
"postal_address" => "123 Evergreen Tce"
}
- }
+ })
end
defmodule CreditCardPaymentDetails do
- OpenApiSpex.schema %{
+ OpenApiSpex.schema(%{
title: "CreditCardPaymentDetails",
description: "Payment details when using credit-card method",
type: :object,
properties: %{
credit_card_number: %Schema{type: :string, description: "Credit card number"},
name_on_card: %Schema{type: :string, description: "Name as appears on card"},
expiry: %Schema{type: :string, description: "4 digit expiry MMYY"}
},
required: [:credit_card_number, :name_on_card, :expiry],
example: %{
"credit_card_number" => "1234-5678-1234-6789",
"name_on_card" => "Joe User",
"expiry" => "1234"
}
- }
+ })
end
defmodule DirectDebitPaymentDetails do
- OpenApiSpex.schema %{
+ OpenApiSpex.schema(%{
title: "DirectDebitPaymentDetails",
description: "Payment details when using direct-debit method",
type: :object,
properties: %{
account_number: %Schema{type: :string, description: "Bank account number"},
account_name: %Schema{type: :string, description: "Name of account"},
bsb: %Schema{type: :string, description: "Branch identifier"}
},
required: [:account_number, :account_name, :bsb],
example: %{
"account_number" => "12349876",
"account_name" => "Joes Savings Account",
"bsb" => "123-4567"
}
- }
+ })
end
defmodule PaymentDetails do
- OpenApiSpex.schema %{
+ OpenApiSpex.schema(%{
title: "PaymentDetails",
description: "Abstract Payment details type",
type: :object,
oneOf: [
CreditCardPaymentDetails,
DirectDebitPaymentDetails
]
- }
+ })
end
defmodule UserRequest do
- OpenApiSpex.schema %{
+ OpenApiSpex.schema(%{
title: "UserRequest",
description: "POST body for creating a user",
type: :object,
properties: %{
user: User
},
example: %{
"user" => %{
"name" => "Joe User",
"email" => "joe@gmail.com"
}
}
- }
+ })
end
defmodule UserResponse do
- OpenApiSpex.schema %{
+ OpenApiSpex.schema(%{
title: "UserResponse",
description: "Response schema for single user",
type: :object,
properties: %{
data: User
},
example: %{
"data" => %{
"id" => 123,
"name" => "Joe User",
"email" => "joe@gmail.com",
"inserted_at" => "2017-09-12T12:34:55Z",
"updated_at" => "2017-09-13T10:11:12Z"
}
}
- }
+ })
end
defmodule UsersResponse do
- OpenApiSpex.schema %{
+ OpenApiSpex.schema(%{
title: "UsersResponse",
description: "Response schema for multiple users",
type: :object,
properties: %{
data: %Schema{description: "The users details", type: :array, items: User}
},
example: %{
"data" => [
%{
"id" => 123,
"name" => "Joe User",
"email" => "joe@gmail.com"
},
%{
"id" => 456,
"name" => "Jay Consumer",
"email" => "jay@yahoo.com"
}
]
}
- }
+ })
end
defmodule EntityWithDict do
- OpenApiSpex.schema %{
+ OpenApiSpex.schema(%{
title: "EntityWithDict",
description: "Entity with a dictionary defined via additionalProperties",
type: :object,
properties: %{
id: %Schema{type: :integer, description: "Entity ID"},
- stringDict: %Schema{type: :object, description: "String valued dict", additionalProperties: %Schema{type: :string}},
- anyTypeDict: %Schema{type: :object, description: "Untyped valued dict", additionalProperties: true},
+ stringDict: %Schema{
+ type: :object,
+ description: "String valued dict",
+ additionalProperties: %Schema{type: :string}
+ },
+ anyTypeDict: %Schema{
+ type: :object,
+ description: "Untyped valued dict",
+ additionalProperties: true
+ }
},
example: %{
"id" => 123,
"stringDict" => %{"key1" => "value1", "key2" => "value2"},
"anyTypeDict" => %{"key1" => 42, "key2" => %{"foo" => "bar"}}
}
- }
+ })
end
defmodule Pet do
require OpenApiSpex
alias OpenApiSpex.{Schema, Discriminator}
+ pet_schemas = [
+ %Schema{
+ title: "Dog",
+ type: :object,
+ properties: %{
+ bark: %Schema{type: :string}
+ }
+ },
+ %Schema{
+ title: "Cat",
+ properties: %{
+ meow: %Schema{type: :string}
+ }
+ }
+ ]
+
OpenApiSpex.schema(%{
title: "Pet",
type: :object,
properties: %{
pet_type: %Schema{type: :string}
},
required: [:pet_type],
+ anyOf: pet_schemas,
discriminator: %Discriminator{
propertyName: "pet_type"
}
})
end
defmodule Cat do
require OpenApiSpex
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
title: "Cat",
type: :object,
allOf: [
Pet,
%Schema{
type: :object,
properties: %{
meow: %Schema{type: :string}
},
required: [:meow]
}
]
})
end
defmodule Dog do
require OpenApiSpex
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
title: "Dog",
type: :object,
allOf: [
Pet,
%Schema{
type: :object,
properties: %{
bark: %Schema{type: :string}
},
required: [:bark]
}
]
})
end
defmodule CatOrDog do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CatOrDog",
oneOf: [Cat, Dog]
})
end
end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Nov 30, 12:53 PM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
41430
Default Alt Text
(140 KB)
Attached To
Mode
R22 open_api_spex
Attached
Detach File
Event Timeline
Log In to Comment