Page MenuHomePhorge

No OneTemporary

Size
86 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/open_api_spex.ex b/lib/open_api_spex.ex
index 88d583a..89162fb 100644
--- a/lib/open_api_spex.ex
+++ b/lib/open_api_spex.ex
@@ -1,140 +1,211 @@
defmodule OpenApiSpex do
@moduledoc """
Provides the entry-points for defining schemas, validating and casting.
"""
- alias OpenApiSpex.{OpenApi, Operation, Operation2, 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 [Poison.Encoder]
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.
+ """
+ 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
index 8c143d1..6714acb 100644
--- a/lib/open_api_spex/cast.ex
+++ b/lib/open_api_spex/cast.ex
@@ -1,98 +1,140 @@
defmodule OpenApiSpex.Cast do
alias OpenApiSpex.{Reference, Schema}
- alias OpenApiSpex.Cast.{Array, Error, Object, Primitive, String}
+ 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: Primitive.cast_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: :object}} = ctx),
- do: Object.cast(ctx)
-
def cast(%__MODULE__{schema: %{type: _other}} = ctx),
do: error(ctx, {:invalid_schema_type})
def cast(%{} = ctx), do: cast(struct(__MODULE__, ctx))
def cast(ctx) when is_list(ctx), do: cast(struct(__MODULE__, ctx))
# Add an error
def error(ctx, error_args) do
error = Error.new(ctx, error_args)
{:error, [error | ctx.errors]}
end
+
+ 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
index 9d7becb..0404586 100644
--- a/lib/open_api_spex/cast/array.ex
+++ b/lib/open_api_spex/cast/array.ex
@@ -1,36 +1,71 @@
defmodule OpenApiSpex.Cast.Array do
@moduledoc false
alias OpenApiSpex.Cast
def cast(%{value: []}), do: {:ok, []}
def cast(%{value: items} = ctx) when is_list(items) do
- case cast_items(ctx) do
- {items, []} -> {:ok, items}
+ case cast_array(ctx) do
+ {:cast, ctx} -> cast(ctx)
+ {:ok, items} -> {:ok, items}
+ {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_items(%{value: items} = ctx) do
+ 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
index 58c5980..c523f92 100644
--- a/lib/open_api_spex/cast/error.ex
+++ b/lib/open_api_spex/cast/error.ex
@@ -1,154 +1,357 @@
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_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_required_for_discriminator, name: field}) do
- "No value for required disciminator property: #{field}"
+ 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/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
index 9a02040..519d8ac 100644
--- a/lib/open_api_spex/cast/primitive.ex
+++ b/lib/open_api_spex/cast/primitive.ex
@@ -1,58 +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
- ## integer
-
- def cast_integer(%{value: value}) when is_integer(value) do
- {:ok, value}
- end
-
- def cast_integer(%{value: value}) when is_number(value) do
- {:ok, round(value)}
- end
-
- def cast_integer(%{value: value} = ctx) when is_binary(value) do
- case Float.parse(value) do
- {value, ""} -> cast_integer(%{ctx | value: value})
- _ -> Cast.error(ctx, {:invalid_type, :integer})
- end
- end
-
- def cast_integer(ctx) do
- Cast.error(ctx, {:invalid_type, :integer})
- end
-
## number
def cast_number(%{value: value}) when is_number(value) do
{:ok, value}
end
def cast_number(%{value: value}) when is_integer(value) do
{:ok, value / 1}
end
def cast_number(%{value: value} = ctx) when is_binary(value) do
case Float.parse(value) do
{value, ""} -> {:ok, value}
_ -> Cast.error(ctx, {:invalid_type, :number})
end
end
def cast_number(ctx) do
Cast.error(ctx, {:invalid_type, :number})
end
end
diff --git a/lib/open_api_spex/cast/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
index 2250025..a181973 100644
--- a/lib/open_api_spex/cast/string.ex
+++ b/lib/open_api_spex/cast/string.ex
@@ -1,40 +1,68 @@
defmodule OpenApiSpex.Cast.String do
@moduledoc false
alias OpenApiSpex.Cast
def cast(%{value: value} = ctx) when is_binary(value) do
- cast_binary(ctx)
+ 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
- {:ok, value}
+ 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
- trimmed = String.trim(value)
- length = String.length(trimmed)
+ length = String.trim(value) |> String.length()
if length < min_length do
- Cast.error(ctx, {:min_length, length})
+ Cast.error(ctx, {:min_length, min_length})
else
- {:ok, value}
+ Cast.success(ctx, :minLength)
end
end
- defp cast_binary(%{value: value}) do
- {:ok, value}
+ 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/mix.exs b/mix.exs
index 7aee4f1..3af3178 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,58 +1,59 @@
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],
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"},
{: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
index 056f31c..ac0721f 100644
--- a/test/cast/array_test.exs
+++ b/test/cast/array_test.exs
@@ -1,35 +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/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
index d6d01cf..51c1f99 100644
--- a/test/cast/object_test.exs
+++ b/test/cast/object_test.exs
@@ -1,137 +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
index 95c6605..c77f8a7 100644
--- a/test/cast/primitive_test.exs
+++ b/test/cast/primitive_test.exs
@@ -1,38 +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 "integer" do
- assert cast_integer(%Cast{value: 1}) == {:ok, 1}
- assert cast_integer(%Cast{value: 1.5}) == {:ok, 2}
- assert cast_integer(%Cast{value: "1"}) == {:ok, 1}
- assert cast_integer(%Cast{value: "1.5"}) == {:ok, 2}
- assert {:error, [error]} = cast_integer(%Cast{value: "other"})
- assert %Error{reason: :invalid_type} = error
- assert error.value == "other"
- end
-
test "number" do
assert cast_number(%Cast{value: 1}) == {:ok, 1.0}
assert cast_number(%Cast{value: 1.5}) == {:ok, 1.5}
assert cast_number(%Cast{value: "1"}) == {:ok, 1.0}
assert cast_number(%Cast{value: "1.5"}) == {:ok, 1.5}
assert {:error, [error]} = cast_number(%Cast{value: "other"})
assert %Error{reason: :invalid_type} = error
assert error.value == "other"
end
end
end
diff --git a/test/cast/string_test.exs b/test/cast/string_test.exs
index 790e36b..b17195b 100644
--- a/test/cast/string_test.exs
+++ b/test/cast/string_test.exs
@@ -1,35 +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
index 65038a7..afca598 100644
--- a/test/cast_test.exs
+++ b/test/cast_test.exs
@@ -1,207 +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/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

Mime Type
text/x-diff
Expires
Sat, Nov 30, 3:52 PM (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
41491
Default Alt Text
(86 KB)

Event Timeline