Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F115359
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
19 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lib/open_api_spex/cast/object.ex b/lib/open_api_spex/cast/object.ex
index 9c83a93..a87c067 100644
--- a/lib/open_api_spex/cast/object.ex
+++ b/lib/open_api_spex/cast/object.ex
@@ -1,128 +1,145 @@
defmodule OpenApiSpex.Cast.Object do
@moduledoc false
alias OpenApiSpex.Cast
alias OpenApiSpex.Cast.Error
def cast(%{value: value} = ctx) when not is_map(value) do
Cast.error(ctx, {:invalid_type, :object})
end
def cast(%{value: value, schema: %{properties: nil}}) do
{:ok, value}
end
def cast(%{value: value, schema: schema} = ctx) do
schema_properties = schema.properties || %{}
with :ok <- check_unrecognized_properties(ctx, schema_properties),
value = cast_atom_keys(value, schema_properties),
ctx = %{ctx | value: value},
:ok <- check_required_fields(ctx, schema),
:ok <- check_max_properties(ctx),
:ok <- check_min_properties(ctx),
{:ok, value} <- cast_properties(%{ctx | schema: schema_properties}) do
- ctx = to_struct(%{ctx | value: value})
+ value_with_defaults = apply_defaults(value, schema_properties)
+ ctx = to_struct(%{ctx | value: value_with_defaults})
{:ok, ctx}
end
end
# When additionalProperties is true, extra properties are allowed in input
defp check_unrecognized_properties(%{schema: %{additionalProperties: true}}, _expected_keys) do
:ok
end
defp check_unrecognized_properties(%{value: value} = ctx, expected_keys) do
input_keys = value |> Map.keys() |> Enum.map(&to_string/1)
schema_keys = expected_keys |> Map.keys() |> Enum.map(&to_string/1)
extra_keys = input_keys -- schema_keys
if extra_keys == [] do
:ok
else
[name | _] = extra_keys
ctx = %{ctx | path: [name | ctx.path]}
Cast.error(ctx, {:unexpected_field, name})
end
end
defp check_required_fields(%{value: input_map} = ctx, schema) do
required = schema.required || []
input_keys = Map.keys(input_map)
missing_keys = required -- input_keys
if missing_keys == [] do
:ok
else
errors =
Enum.map(missing_keys, fn key ->
ctx = %{ctx | path: [key | ctx.path]}
Error.new(ctx, {:missing_field, key})
end)
{:error, ctx.errors ++ errors}
end
end
defp check_max_properties(%{schema: %{maxProperties: max_properties}} = ctx)
when is_integer(max_properties) do
count = ctx.value |> Map.keys() |> length()
if count > max_properties do
Cast.error(ctx, {:max_properties, max_properties, count})
else
:ok
end
end
defp check_max_properties(_ctx), do: :ok
defp check_min_properties(%{schema: %{minProperties: min_properties}} = ctx)
when is_integer(min_properties) do
count = ctx.value |> Map.keys() |> length()
if count < min_properties do
Cast.error(ctx, {:min_properties, min_properties, count})
else
:ok
end
end
defp check_min_properties(_ctx), do: :ok
defp cast_atom_keys(input_map, properties) do
Enum.reduce(properties, %{}, fn {key, _}, output ->
string_key = to_string(key)
case input_map do
%{^key => value} -> Map.put(output, key, value)
%{^string_key => value} -> Map.put(output, key, value)
_ -> output
end
end)
end
defp cast_properties(%{value: object, schema: schema_properties} = ctx) do
Enum.reduce(object, {:ok, %{}}, fn
{key, value}, {:ok, output} ->
cast_property(%{ctx | key: key, value: value, schema: schema_properties}, output)
_, error ->
error
end)
end
defp cast_property(%{key: key, schema: schema_properties} = ctx, output) do
prop_schema = Map.get(schema_properties, key)
path = [key | ctx.path]
with {:ok, value} <- Cast.cast(%{ctx | path: path, schema: prop_schema}) do
{:ok, Map.put(output, key, value)}
end
end
+ defp apply_defaults(object_value, schema_properties) do
+ Enum.reduce(schema_properties, object_value, &apply_default/2)
+ end
+
+ defp apply_default({_key, %{default: nil}}, object_value), do: object_value
+
+ defp apply_default({key, %{default: default_value}}, object_value) do
+ if Map.has_key?(object_value, key) do
+ object_value
+ else
+ Map.put(object_value, key, default_value)
+ end
+ end
+
+ defp apply_default(_, object_value), do: object_value
+
defp to_struct(%{value: value = %_{}}), do: value
defp to_struct(%{value: value, schema: %{"x-struct": nil}}), do: value
defp to_struct(%{value: value, schema: %{"x-struct": module}}),
do: struct(module, value)
end
diff --git a/test/cast/object_test.exs b/test/cast/object_test.exs
index 22d698d..ab07637 100644
--- a/test/cast/object_test.exs
+++ b/test/cast/object_test.exs
@@ -1,157 +1,205 @@
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 "fields with default values" do
+ schema = %Schema{
+ type: :object,
+ properties: %{name: %Schema{type: :string, default: "Rubi"}}
+ }
+
+ assert cast(value: %{}, schema: schema) == {:ok, %{name: "Rubi"}}
+ assert cast(value: %{"name" => "Jane"}, schema: schema) == {:ok, %{name: "Jane"}}
+ assert cast(value: %{name: "Robin"}, schema: schema) == {:ok, %{name: "Robin"}}
+ end
+
+ test "explicitly passing nil for fields with default values (not nullable)" do
+ schema = %Schema{
+ type: :object,
+ properties: %{name: %Schema{type: :string, default: "Rubi"}}
+ }
+
+ assert {:error, [%{reason: :null_value}]} = cast(value: %{"name" => nil}, schema: schema)
+ assert {:error, [%{reason: :null_value}]} = cast(value: %{name: nil}, schema: schema)
+ end
+
+ test "explicitly passing nil for fields with default values (nullable)" do
+ schema = %Schema{
+ type: :object,
+ properties: %{name: %Schema{type: :string, default: "Rubi", nullable: true}}
+ }
+
+ assert cast(value: %{"name" => nil}, schema: schema) == {:ok, %{name: nil}}
+ assert cast(value: %{name: nil}, schema: schema) == {:ok, %{name: nil}}
+ end
+
+ test "default values in nested schemas" do
+ child_schema = %Schema{
+ type: :object,
+ properties: %{name: %Schema{type: :string, default: "Rubi"}}
+ }
+
+ parent_schema = %Schema{
+ type: :object,
+ properties: %{child: child_schema}
+ }
+
+ assert cast(value: %{child: %{}}, schema: parent_schema) == {:ok, %{child: %{name: "Rubi"}}}
+
+ assert cast(value: %{child: %{"name" => "Jane"}}, schema: parent_schema) ==
+ {:ok, %{child: %{name: "Jane"}}}
+ 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
test "validates minProperties" do
schema = %Schema{
type: :object,
properties: %{
one: nil,
two: nil
},
minProperties: 1
}
assert {:error, [error]} = cast(value: %{}, schema: schema)
assert %Error{} = error
assert error.reason == :min_properties
assert {:ok, _} = cast(value: %{one: "one"}, schema: schema)
end
end
end
diff --git a/test/operation2_test.exs b/test/operation2_test.exs
index cb45fc3..8c52c97 100644
--- a/test/operation2_test.exs
+++ b/test/operation2_test.exs
@@ -1,258 +1,276 @@
defmodule OpenApiSpex.Operation2Test do
use ExUnit.Case
alias OpenApiSpex.{Operation, Operation2, Schema, Components, Reference}
alias OpenApiSpex.Cast.Error
defmodule SchemaFixtures do
@user %Schema{
type: :object,
properties: %{
user: %Schema{
type: :object,
properties: %{
email: %Schema{type: :string}
}
}
}
}
@user_list %Schema{
type: :array,
items: @user
}
@schemas %{"User" => @user, "UserList" => @user_list}
def user, do: @user
def user_list, do: @user_list
def schemas, do: @schemas
end
defmodule ParameterFixtures do
- alias OpenApiSpex.Operation
+ alias OpenApiSpex.{Schema, Operation}
def parameters do
%{
"member" => Operation.parameter(:member, :query, :boolean, "Membership flag")
}
end
end
defmodule OperationFixtures do
@user_index %Operation{
operationId: "UserController.index",
parameters: [
Operation.parameter(:name, :query, :string, "Filter by user name"),
- Operation.parameter(:age, :query, :integer, "User's age"),
- %Reference{"$ref": "#/components/parameters/member"}
+ Operation.parameter(:age, :query, :integer, "Filter by user age"),
+ %Reference{"$ref": "#/components/parameters/member"},
+ Operation.parameter(
+ :include_archived,
+ :query,
+ %Schema{type: :boolean, default: false},
+ "Example of a default value"
+ )
],
responses: %{
200 => Operation.response("User", "application/json", SchemaFixtures.user())
}
}
def user_index, do: @user_index
@create_user %Operation{
operationId: "UserController.create",
parameters: [
Operation.parameter(:name, :query, :string, "Filter by user name")
],
requestBody:
Operation.request_body("request body", "application/json", SchemaFixtures.user(),
required: true
),
responses: %{
200 => Operation.response("User list", "application/json", SchemaFixtures.user_list())
}
}
def create_user, do: @create_user
end
defmodule SpecModule do
@behaviour OpenApiSpex.OpenApi
@impl OpenApiSpex.OpenApi
def spec do
paths = %{
"/users" => %{
"post" => OperationFixtures.create_user()
}
}
%OpenApiSpex.OpenApi{
info: nil,
paths: paths,
components: %Components{
schemas: SchemaFixtures.schemas(),
parameters: ParameterFixtures.parameters()
}
}
end
end
defmodule RenderError do
def init(_) do
nil
end
def call(_conn, _errors) do
raise "should not have errors"
end
end
describe "cast/4" do
test "cast request body" do
conn = create_conn(%{"user" => %{"email" => "foo@bar.com"}})
assert {:ok, conn} =
Operation2.cast(
OperationFixtures.create_user(),
conn,
"application/json",
SpecModule.spec().components
)
assert %Plug.Conn{} = conn
end
test "cast request body - invalid data type" do
conn = create_conn(%{"user" => %{"email" => 123}})
assert {:error, errors} =
Operation2.cast(
OperationFixtures.create_user(),
conn,
"application/json",
SpecModule.spec().components
)
assert [error] = errors
assert %Error{} = error
assert error.reason == :invalid_type
end
- test "casts valid query params" do
+ test "casts valid query params and respects defaults" do
valid_query_params = %{"name" => "Rubi", "age" => "31", "member" => "true"}
assert {:ok, conn} = do_index_cast(valid_query_params)
- assert conn.params == %{age: 31, member: true, name: "Rubi"}
+ assert conn.params == %{age: 31, member: true, name: "Rubi", include_archived: false}
+ end
+
+ test "casts valid query params and overrides defaults" do
+ valid_query_params = %{
+ "name" => "Rubi",
+ "age" => "31",
+ "member" => "true",
+ "include_archived" => "true"
+ }
+
+ assert {:ok, conn} = do_index_cast(valid_query_params)
+ assert conn.params == %{age: 31, member: true, name: "Rubi", include_archived: true}
end
test "validate undefined query param name" do
query_params = %{"unknown" => "asdf"}
assert {:error, [error]} = do_index_cast(query_params)
assert %Error{} = error
assert error.reason == :unexpected_field
assert error.name == "unknown"
assert error.path == ["unknown"]
end
test "validate invalid data type for query param" do
query_params = %{"age" => "asdf"}
assert {:error, [error]} = do_index_cast(query_params)
assert %Error{} = error
assert error.reason == :invalid_type
assert error.type == :integer
assert error.value == "asdf"
end
test "validate missing required query param" do
parameter =
Operation.parameter(:name, :query, :string, "Filter by user name", required: true)
operation = %{OperationFixtures.user_index() | parameters: [parameter]}
assert {:error, [error]} = do_index_cast(%{}, operation: operation)
assert %Error{} = error
assert error.reason == :missing_field
assert error.name == :name
end
test "validate missing content-type header for required requestBody" do
conn = :post |> Plug.Test.conn("/api/users/") |> Plug.Conn.fetch_query_params()
operation = OperationFixtures.create_user()
assert {:error, [%Error{reason: :missing_header, name: "content-type"}]} =
Operation2.cast(
operation,
conn,
nil,
SpecModule.spec().components
)
end
test "validate invalid content-type header for required requestBody" do
conn =
create_conn(%{})
|> Plug.Conn.put_req_header("content-type", "text/html")
operation = OperationFixtures.create_user()
assert {:error, [%Error{reason: :invalid_header, name: "content-type"}]} =
Operation2.cast(
operation,
conn,
"text/html",
SpecModule.spec().components
)
end
test "validate invalid value for integer range" do
parameter =
Operation.parameter(
:age,
:query,
%Schema{type: :integer, minimum: 1, maximum: 99},
"Filter by user age",
required: true
)
operation = %{OperationFixtures.user_index() | parameters: [parameter]}
assert {:error, [error]} = do_index_cast(%{"age" => 100}, operation: operation)
assert %Error{} = error
assert error.reason == :maximum
assert {:error, [error]} = do_index_cast(%{"age" => 0}, operation: operation)
assert %Error{} = error
assert error.reason == :minimum
end
defp do_index_cast(query_params, opts \\ []) do
conn =
:get
|> Plug.Test.conn("/api/users?" <> URI.encode_query(query_params))
|> Plug.Conn.put_req_header("content-type", "application/json")
|> Plug.Conn.fetch_query_params()
|> build_params()
operation = opts[:operation] || OperationFixtures.user_index()
Operation2.cast(
operation,
conn,
"application/json",
SpecModule.spec().components
)
end
defp create_conn(body_params) do
:post
|> Plug.Test.conn("/api/users")
|> Plug.Conn.put_req_header("content-type", "application/json")
|> Plug.Conn.fetch_query_params()
|> Map.put(:body_params, body_params)
|> build_params()
end
defp build_params(conn) do
params =
conn.path_params
|> Map.merge(conn.query_params)
|> Map.merge(conn.body_params)
%{conn | params: params}
end
end
end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Nov 28, 12:23 AM (1 d, 21 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
40774
Default Alt Text
(19 KB)
Attached To
Mode
R22 open_api_spex
Attached
Detach File
Event Timeline
Log In to Comment