Page MenuHomePhorge

No OneTemporary

Size
23 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/open_api_spex/cast.ex b/lib/open_api_spex/cast.ex
index 8d059c8..30787ac 100644
--- a/lib/open_api_spex/cast.ex
+++ b/lib/open_api_spex/cast.ex
@@ -1,190 +1,191 @@
defmodule OpenApiSpex.Cast do
alias OpenApiSpex.{Reference, Schema}
alias OpenApiSpex.Reference
alias OpenApiSpex.Cast.{
AllOf,
AnyOf,
Array,
Discriminator,
Error,
Integer,
+ Number,
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: []
@doc ~S"""
Cast and validate a value against the given schema.
Recognizes all the types defined in Open API (itself a superset of JSON Schema).
JSON Schema types:
[https://json-schema.org/latest/json-schema-core.html#rfc.section.4.2.1](https://json-schema.org/latest/json-schema-core.html#rfc.section.4.2.1)
Open API primitive types:
[https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#data-types](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#data-types)
For an `:object` schema type, the cast operation returns a map with atom keys.
## Examples
iex> alias OpenApiSpex.{Cast, Schema}
iex> schema = %Schema{type: :string}
iex> Cast.cast(schema, "a string")
{:ok, "a string"}
iex> Cast.cast(schema, :not_a_string)
{
:error,
[
%OpenApiSpex.Cast.Error{
reason: :invalid_type,
type: :string,
value: :not_a_string
}
]
}
iex> schema = %Schema{
...> type: :object,
...> properties: %{
...> name: nil
...> },
...> additionalProperties: false
...> }
iex> Cast.cast(schema, %{"name" => "spex"})
{:ok, %{name: "spex"}}
iex> Cast.cast(schema, %{"bad" => "spex"})
{
:error,
[
%OpenApiSpex.Cast.Error{
name: "bad",
path: ["bad"],
reason: :unexpected_field,
value: %{"bad" => "spex"}
}
]
}
"""
@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
OpenApiSpex.Cast.Enum.cast(%{ctx | value: value})
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)
+ do: Number.cast(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/error.ex b/lib/open_api_spex/cast/error.ex
index c480519..6b5cc58 100644
--- a/lib/open_api_spex/cast/error.ex
+++ b/lib/open_api_spex/cast/error.ex
@@ -1,408 +1,408 @@
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 maximum_error :: {:maximum, integer() | float(), integer() | float()}
@type min_items_error :: {:min_items, non_neg_integer(), non_neg_integer()}
@type min_length_error :: {:min_length, non_neg_integer()}
@type min_properties_error :: {:min_properties, non_neg_integer(), non_neg_integer()}
- @type minimum_error :: {:minimum, integer(), integer()}
+ @type minimum_error :: {:minimum, integer() | float(), integer() | float()}
@type missing_field_error :: {:missing_field, String.t() | atom()}
@type missing_header_error :: {:missing_header, String.t() | atom()}
@type invalid_header_error :: {:invalid_header, 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
| :missing_header
| :invalid_header
| :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()
| min_properties_error()
| minimum_error()
| missing_field_error()
| missing_header_error()
| invalid_header_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, {:missing_header, name}) do
%__MODULE__{reason: :missing_header, name: name}
|> add_context_fields(ctx)
end
def new(ctx, {:invalid_header, name}) do
%__MODULE__{reason: :invalid_header, 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
def new(ctx, {:min_properties, min_properties, property_count}) do
%__MODULE__{
reason: :min_properties,
meta: %{min_properties: min_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: array}) do
"Array length #{length(array)} is smaller than minItems: #{min}"
end
def message(%{reason: :max_items, length: max, value: array}) do
"Array length #{length(array)} 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: :exclusive_max, length: max, value: value})
when value >= max do
"#{value} is larger than exclusive maximum #{max}"
end
def message(%{reason: :maximum, length: max, value: value})
when value > max do
"#{value} is larger than inclusive maximum #{max}"
end
def message(%{reason: :exclusive_min, length: min, value: value})
when value <= min do
"#{value} is smaller than exclusive minimum #{min}"
end
def message(%{reason: :minimum, length: min, value: value})
when value < min do
"#{value} is smaller than inclusive 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: :missing_header, name: name}) do
"Missing header: #{name}"
end
def message(%{reason: :invalid_header, name: name}) do
"Invalid value for header: #{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(%{reason: :min_properties, meta: meta}) do
"Object property count #{meta.property_count} is less than minProperties: #{
meta.min_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/number.ex b/lib/open_api_spex/cast/number.ex
new file mode 100644
index 0000000..c7810a0
--- /dev/null
+++ b/lib/open_api_spex/cast/number.ex
@@ -0,0 +1,66 @@
+defmodule OpenApiSpex.Cast.Number do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ @spec cast(ctx :: Cast.t()) :: {:ok, Cast.t()} | Cast.Error.t()
+ def cast(%{value: value} = ctx) when is_number(value) do
+ case cast_number(ctx) do
+ {:cast, ctx} -> cast(ctx)
+ result -> result
+ end
+ end
+
+ # TODO We need a way to distinguish numbers in (JSON) body vs in request parameters
+ # so we can reject strings values for properties of `type: :number`
+ 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, :number})
+ end
+ end
+
+ def cast(ctx) do
+ Cast.error(ctx, {:invalid_type, :number})
+ end
+
+ ## Private functions
+
+ defp cast_number(%{value: value, schema: %{minimum: minimum, exclusiveMinimum: true}} = ctx)
+ when is_number(value) and is_number(minimum) do
+ if value > minimum do
+ Cast.success(ctx, [:minimum, :exclusiveMinimum])
+ else
+ Cast.error(ctx, {:exclusive_min, minimum, value})
+ end
+ end
+
+ defp cast_number(%{value: value, schema: %{minimum: minimum}} = ctx)
+ when is_number(value) and is_number(minimum) do
+ if value >= minimum do
+ Cast.success(ctx, :minimum)
+ else
+ Cast.error(ctx, {:minimum, minimum, value})
+ end
+ end
+
+ defp cast_number(%{value: value, schema: %{maximum: maximum, exclusiveMaximum: true}} = ctx)
+ when is_number(value) and is_number(maximum) do
+ if value < maximum do
+ Cast.success(ctx, [:maximum, :exclusiveMaximum])
+ else
+ Cast.error(ctx, {:exclusive_max, maximum, value})
+ end
+ end
+
+ defp cast_number(%{value: value, schema: %{maximum: maximum}} = ctx)
+ when is_number(value) and is_number(maximum) do
+ if value <= maximum do
+ Cast.success(ctx, :maximum)
+ else
+ Cast.error(ctx, {:maximum, maximum, value})
+ end
+ end
+
+ # For now, we don't do anything with `:multipleOf` for properties of `type: :number`
+ defp cast_number(ctx), do: Cast.ok(ctx)
+end
diff --git a/test/cast/number_test.exs b/test/cast/number_test.exs
new file mode 100644
index 0000000..a2c6a4c
--- /dev/null
+++ b/test/cast/number_test.exs
@@ -0,0 +1,66 @@
+defmodule OpenApiSpex.CastNumberTest do
+ use ExUnit.Case
+ alias OpenApiSpex.{Cast, Schema}
+ alias OpenApiSpex.Cast.{Error, Number}
+
+ defp cast(ctx), do: Number.cast(struct(Cast, ctx))
+
+ describe "cast/1" do
+ test "basics" do
+ schema = %Schema{type: :number}
+ assert cast(value: 1, schema: schema) == {:ok, 1}
+ assert cast(value: 1.5, schema: schema) == {:ok, 1.5}
+ assert cast(value: "1", schema: schema) == {:ok, 1}
+ assert cast(value: "1.5", schema: schema) == {:ok, 1.5}
+ assert {:error, [error]} = cast(value: "other", schema: schema)
+ assert %Error{reason: :invalid_type} = error
+ assert error.value == "other"
+ end
+
+ test "with minimum" do
+ schema = %Schema{type: :number, minimum: 2}
+ assert cast(value: 3, schema: schema) == {:ok, 3}
+ assert cast(value: 2, schema: schema) == {:ok, 2}
+ assert {:error, [error]} = cast(value: 1, schema: schema)
+ assert error.reason == :minimum
+ assert error.value == 1
+ # error.length is the minimum
+ assert error.length == 2
+ assert Error.message(error) =~ "smaller than inclusive minimum"
+ end
+
+ test "with maximum" do
+ schema = %Schema{type: :number, maximum: 2}
+ assert cast(value: 1, schema: schema) == {:ok, 1}
+ assert cast(value: 2, schema: schema) == {:ok, 2}
+ assert {:error, [error]} = cast(value: 3, schema: schema)
+ assert error.reason == :maximum
+ assert error.value == 3
+ # error.length is the maximum
+ assert error.length == 2
+ assert Error.message(error) =~ "larger than inclusive maximum"
+ end
+
+ test "with minimum w/ exclusiveMinimum" do
+ schema = %Schema{type: :number, minimum: 2, exclusiveMinimum: true}
+ assert cast(value: 3, schema: schema) == {:ok, 3}
+ assert {:error, [error]} = cast(value: 2, schema: schema)
+ assert error.reason == :exclusive_min
+ assert error.value == 2
+ # error.length is the minimum
+ assert error.length == 2
+ assert Error.message(error) =~ "smaller than exclusive minimum"
+ end
+
+ test "with maximum w/ exclusiveMaximum" do
+ schema = %Schema{type: :number, maximum: 2, exclusiveMaximum: true}
+ assert cast(value: 1, schema: schema) == {:ok, 1}
+ assert {:error, [error]} = cast(value: 2, schema: schema)
+ assert error.reason == :exclusive_max
+ assert error.value == 2
+ # error.length is the maximum
+ assert error.length == 2
+ assert Error.message(error) =~ "larger than exclusive maximum"
+ end
+ end
+end

File Metadata

Mime Type
text/x-diff
Expires
Tue, Nov 26, 10:19 PM (1 d, 14 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
40497
Default Alt Text
(23 KB)

Event Timeline