Page MenuHomePhorge

No OneTemporary

Size
16 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/open_api_spex/cast.ex b/lib/open_api_spex/cast.ex
index 35e198d..f9b155e 100644
--- a/lib/open_api_spex/cast.ex
+++ b/lib/open_api_spex/cast.ex
@@ -1,193 +1,189 @@
defmodule OpenApiSpex.Cast do
alias OpenApiSpex.{Reference, Schema}
alias OpenApiSpex.Reference
alias OpenApiSpex.Cast.{
AllOf,
AnyOf,
Array,
Discriminator,
Error,
Integer,
Object,
OneOf,
Primitive,
String
}
@type schema_or_reference :: Schema.t() | Reference.t()
@type t :: %__MODULE__{
value: term(),
schema: schema_or_reference | nil,
schemas: map(),
path: [atom() | String.t() | integer()],
key: atom() | nil,
index: integer,
errors: [Error.t()]
}
defstruct value: nil,
schema: nil,
schemas: %{},
path: [],
key: nil,
index: 0,
errors: []
@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
...> }
...> }
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
- if value in enum do
- {:ok, value}
- else
- error(ctx, {:invalid_enum})
- end
+ 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)
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/enum.ex b/lib/open_api_spex/cast/enum.ex
new file mode 100644
index 0000000..b78c146
--- /dev/null
+++ b/lib/open_api_spex/cast/enum.ex
@@ -0,0 +1,30 @@
+defmodule OpenApiSpex.Cast.Enum do
+ @moduledoc false
+ alias OpenApiSpex.Cast
+
+ def cast(ctx = %Cast{schema: %{enum: enum}, value: value}) do
+ case Enum.find(enum, {:error, :invalid_enum}, &equivalent?(&1, value)) do
+ {:error, :invalid_enum} -> Cast.error(ctx, {:invalid_enum})
+ found -> {:ok, found}
+ end
+ end
+
+ defp equivalent?(x, x), do: true
+
+ # Special case: atoms are equivalent to their stringified representation
+ defp equivalent?(left, right) when is_atom(left) and is_binary(right) do
+ to_string(left) == right
+ end
+
+ # an explicit schema should be used to cast to enum of structs
+ defp equivalent?(_x, %_struct{}), do: false
+
+ # Special case: Atom-keyed maps are equivalent to their string-keyed representation
+ defp equivalent?(left, right) when is_map(left) and is_map(right) do
+ Enum.all?(left, fn {k, v} ->
+ equivalent?(v, Map.get(right, to_string(k)))
+ end)
+ end
+
+ defp equivalent?(_left, _right), do: false
+end
diff --git a/test/cast/enum_test.exs b/test/cast/enum_test.exs
new file mode 100644
index 0000000..7f78df9
--- /dev/null
+++ b/test/cast/enum_test.exs
@@ -0,0 +1,90 @@
+defmodule OpenApiSpex.Cast.EnumTest do
+ use ExUnit.Case
+ alias OpenApiSpex.{Cast, Schema}
+ alias OpenApiSpex.Cast.Error
+
+ defp cast(ctx), do: Cast.cast(ctx)
+
+ defmodule User do
+ require OpenApiSpex
+ alias __MODULE__
+
+ defstruct [:age]
+
+ def schema() do
+ %OpenApiSpex.Schema{
+ type: :object,
+ required: [:age],
+ properties: %{
+ age: %Schema{type: :integer},
+ },
+ enum: [%User{age: 32}, %User{age: 45}],
+ "x-struct": __MODULE__
+ }
+ end
+ end
+
+ describe "Enum of strings" do
+ setup do
+ {:ok, %{schema: %Schema{type: :string, enum: ["one"]}}}
+ end
+
+ test "error on invalid string", %{schema: schema} do
+ assert {:error, [error]} = cast(schema: schema, value: "two")
+ assert %Error{} = error
+ assert error.reason == :invalid_enum
+ end
+
+ test "OK on valid string", %{schema: schema} do
+ assert {:ok, "one"} = cast(schema: schema, value: "one")
+ end
+ end
+
+ describe "Enum of atoms" do
+ setup do
+ {:ok, %{schema: %Schema{type: :string, enum: [:one, :two, :three]}}}
+ end
+
+ test "string will be converted to atom", %{schema: schema} do
+ assert {:ok, :three} = cast(schema: schema, value: "three")
+ end
+
+ test "error on invalid string", %{schema: schema} do
+ assert {:error, [error]} = cast(schema: schema, value: "four")
+ assert %Error{} = error
+ assert error.reason == :invalid_enum
+ end
+ end
+
+ describe "Enum with explicit schema" do
+ test "converts string keyed map to struct" do
+ assert {:ok, %User{age: 32}} = cast(schema: User.schema(), value: %{"age" => 32})
+ end
+
+ test "Must be a valid enum value" do
+ assert {:error, [error]} = cast(schema: User.schema(), value: %{"age" => 33})
+ assert %Error{} = error
+ assert error.reason == :invalid_enum
+ end
+ end
+
+ describe "Enum without explicit schema" do
+ setup do
+ schema = %Schema{
+ type: :object,
+ enum: [%{age: 55}, %{age: 66}, %{age: 77}]
+ }
+ {:ok, %{schema: schema}}
+ end
+
+ test "casts from string keyed map", %{schema: schema} do
+ assert {:ok, %{age: 55}} = cast(value: %{"age" => 55}, schema: schema)
+ end
+
+ test "value must be a valid enum value", %{schema: schema} do
+ assert {:error, [error]} = cast(value: %{"age" => 56}, schema: schema)
+ assert %Error{} = error
+ assert error.reason == :invalid_enum
+ end
+ end
+end
diff --git a/test/cast_test.exs b/test/cast_test.exs
index 2d7ecd6..5e41091 100644
--- a/test/cast_test.exs
+++ b/test/cast_test.exs
@@ -1,232 +1,219 @@
defmodule OpenApiSpec.CastTest do
use ExUnit.Case
alias OpenApiSpex.{Cast, Schema, Reference}
alias OpenApiSpex.Cast.Error
doctest OpenApiSpex.Cast
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

File Metadata

Mime Type
text/x-diff
Expires
Fri, Nov 29, 4:29 PM (1 d, 21 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
41256
Default Alt Text
(16 KB)

Event Timeline