From f256bc52865d8b42663cb1cacf6c2c6e97d5c0ee Mon Sep 17 00:00:00 2001 From: Michael Buhot Date: Sat, 20 Apr 2019 19:34:14 +1000 Subject: [PATCH 1/4] Convert string to atom when casting enum containing atoms --- lib/open_api_spex/cast.ex | 6 +----- lib/open_api_spex/cast/enum.ex | 25 +++++++++++++++++++++++++ test/cast_test.exs | 5 +++++ 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 lib/open_api_spex/cast/enum.ex diff --git a/lib/open_api_spex/cast.ex b/lib/open_api_spex/cast.ex index 35e198d2..f9b155e9 100644 --- a/lib/open_api_spex/cast.ex +++ b/lib/open_api_spex/cast.ex @@ -122,11 +122,7 @@ defmodule OpenApiSpex.Cast do # 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 diff --git a/lib/open_api_spex/cast/enum.ex b/lib/open_api_spex/cast/enum.ex new file mode 100644 index 00000000..5bd35fb6 --- /dev/null +++ b/lib/open_api_spex/cast/enum.ex @@ -0,0 +1,25 @@ +defmodule OpenApiSpex.Cast.Enum do + @moduledoc false + alias OpenApiSpex.Cast + + def cast(%Cast{schema: %{enum: []}} = ctx) do + Cast.error(ctx, {:invalid_enum}) + end + + def cast(%Cast{schema: %{enum: [value | _]}, value: value}) do + {:ok, value} + end + + def cast(ctx = %Cast{schema: schema = %{enum: [atom_value | tail]}, value: value}) + when is_binary(value) and is_atom(atom_value) do + if value == to_string(atom_value) do + {:ok, atom_value} + else + cast(%{ctx | schema: %{schema | enum: tail}}) + end + end + + def cast(ctx = %Cast{schema: schema = %{enum: [_ | tail]}}) do + cast(%{ctx | schema: %{schema | enum: tail}}) + end +end diff --git a/test/cast_test.exs b/test/cast_test.exs index 2d7ecd6c..f575fa59 100644 --- a/test/cast_test.exs +++ b/test/cast_test.exs @@ -204,6 +204,11 @@ defmodule OpenApiSpec.CastTest do schema = %Schema{type: :string, enum: ["one"]} assert {:ok, "one"} = cast(value: "one", schema: schema) end + + test "enum - atoms" do + schema = %Schema{type: :string, enum: [:one, :two, :three]} + assert {:ok, :three} = cast(value: "three", schema: schema) + end end describe "ok/1" do From 67de070472f9ddb7c8bd96d440380314ec0eb477 Mon Sep 17 00:00:00 2001 From: Michael Buhot Date: Sat, 20 Apr 2019 19:58:39 +1000 Subject: [PATCH 2/4] Add test for enum of object type with schema --- test/cast_test.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/cast_test.exs b/test/cast_test.exs index f575fa59..9cc76896 100644 --- a/test/cast_test.exs +++ b/test/cast_test.exs @@ -209,6 +209,16 @@ defmodule OpenApiSpec.CastTest do schema = %Schema{type: :string, enum: [:one, :two, :three]} assert {:ok, :three} = cast(value: "three", schema: schema) end + + test "enum - atom keyed map" do + schema = %Schema{ + type: :object, + properties: %{age: %Schema{type: :integer}}, + enum: [%{age: 10}, %{age: 12}, %{age: 18}] + } + + assert {:ok, %{age: 12}} = cast(value: %{"age" => 12}, schema: schema) + end end describe "ok/1" do From 72bb633b64ea087b96eb66e636d0fb1ebd09e483 Mon Sep 17 00:00:00 2001 From: Michael Buhot Date: Sat, 20 Apr 2019 21:22:54 +1000 Subject: [PATCH 3/4] Cast from string keyed map to atom keyed enum map This is for convenience to save defining an explicit schema for the enum type. --- lib/open_api_spex/cast/enum.ex | 23 +++++++++ test/cast/enum_test.exs | 90 ++++++++++++++++++++++++++++++++++ test/cast_test.exs | 28 ----------- 3 files changed, 113 insertions(+), 28 deletions(-) create mode 100644 test/cast/enum_test.exs diff --git a/lib/open_api_spex/cast/enum.ex b/lib/open_api_spex/cast/enum.ex index 5bd35fb6..336ee6c9 100644 --- a/lib/open_api_spex/cast/enum.ex +++ b/lib/open_api_spex/cast/enum.ex @@ -10,6 +10,7 @@ defmodule OpenApiSpex.Cast.Enum do {:ok, value} end + # Special case: convert binary to atom enum def cast(ctx = %Cast{schema: schema = %{enum: [atom_value | tail]}, value: value}) when is_binary(value) and is_atom(atom_value) do if value == to_string(atom_value) do @@ -19,7 +20,29 @@ defmodule OpenApiSpex.Cast.Enum do end end + # Special case: convert string-keyed map to atom-keyed map enum + def cast(ctx = %Cast{schema: schema = %{enum: [enum_map = %{} | tail]}, value: value = %{}}) do + if maps_equivalent?(value, enum_map) do + {:ok, enum_map} + else + cast(%{ctx | schema: %{schema | enum: tail}}) + end + end + def cast(ctx = %Cast{schema: schema = %{enum: [_ | tail]}}) do cast(%{ctx | schema: %{schema | enum: tail}}) end + + defp maps_equivalent?(x, x), do: true + + # an explicit schema should be used to cast to enum of structs + defp maps_equivalent?(_left, %_struct{}), do: false + + defp maps_equivalent?(left = %{}, right = %{}) when map_size(left) == map_size(right) do + Enum.all?(right, fn {k, v} -> + maps_equivalent?(Map.get(left, to_string(k)), v) + end) + end + + defp maps_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 00000000..7f78df91 --- /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 9cc76896..5e41091a 100644 --- a/test/cast_test.exs +++ b/test/cast_test.exs @@ -191,34 +191,6 @@ defmodule OpenApiSpec.CastTest do 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 - - test "enum - atoms" do - schema = %Schema{type: :string, enum: [:one, :two, :three]} - assert {:ok, :three} = cast(value: "three", schema: schema) - end - - test "enum - atom keyed map" do - schema = %Schema{ - type: :object, - properties: %{age: %Schema{type: :integer}}, - enum: [%{age: 10}, %{age: 12}, %{age: 18}] - } - - assert {:ok, %{age: 12}} = cast(value: %{"age" => 12}, schema: schema) - end end describe "ok/1" do From 004abcc185d9846c632ca185a8703dbaa1112db2 Mon Sep 17 00:00:00 2001 From: Mike Buhot Date: Sun, 28 Apr 2019 16:05:58 +1000 Subject: [PATCH 4/4] Simplify Cast.Enum by generalizing equivalent?/2 function Enum.find a value in the Schema.enum that satisfies equivalent? --- lib/open_api_spex/cast/enum.ex | 46 +++++++++++----------------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/lib/open_api_spex/cast/enum.ex b/lib/open_api_spex/cast/enum.ex index 336ee6c9..b78c146c 100644 --- a/lib/open_api_spex/cast/enum.ex +++ b/lib/open_api_spex/cast/enum.ex @@ -2,47 +2,29 @@ defmodule OpenApiSpex.Cast.Enum do @moduledoc false alias OpenApiSpex.Cast - def cast(%Cast{schema: %{enum: []}} = ctx) do - Cast.error(ctx, {:invalid_enum}) - end - - def cast(%Cast{schema: %{enum: [value | _]}, value: value}) do - {:ok, value} - end - - # Special case: convert binary to atom enum - def cast(ctx = %Cast{schema: schema = %{enum: [atom_value | tail]}, value: value}) - when is_binary(value) and is_atom(atom_value) do - if value == to_string(atom_value) do - {:ok, atom_value} - else - cast(%{ctx | schema: %{schema | enum: tail}}) + 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 - # Special case: convert string-keyed map to atom-keyed map enum - def cast(ctx = %Cast{schema: schema = %{enum: [enum_map = %{} | tail]}, value: value = %{}}) do - if maps_equivalent?(value, enum_map) do - {:ok, enum_map} - else - cast(%{ctx | schema: %{schema | enum: tail}}) - end - end + defp equivalent?(x, x), do: true - def cast(ctx = %Cast{schema: schema = %{enum: [_ | tail]}}) do - cast(%{ctx | schema: %{schema | enum: tail}}) + # 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 - defp maps_equivalent?(x, x), do: true - # an explicit schema should be used to cast to enum of structs - defp maps_equivalent?(_left, %_struct{}), do: false + defp equivalent?(_x, %_struct{}), do: false - defp maps_equivalent?(left = %{}, right = %{}) when map_size(left) == map_size(right) do - Enum.all?(right, fn {k, v} -> - maps_equivalent?(Map.get(left, to_string(k)), v) + # 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 maps_equivalent?(_left, _right), do: false + defp equivalent?(_left, _right), do: false end