From e8a35383db984cca99ccccf587f301af114b36af Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Fri, 24 May 2024 05:48:31 +0200 Subject: [PATCH] sets read_write_scope from opts, this will permit to comply to readOnly (#572) --- lib/open_api_spex.ex | 11 ++- lib/open_api_spex/cast.ex | 13 +++- lib/open_api_spex/cast/object.ex | 121 +++++++++++++++++-------------- lib/open_api_spex/cast/utils.ex | 2 +- test/cast_test.exs | 20 +++++ 5 files changed, 108 insertions(+), 59 deletions(-) diff --git a/lib/open_api_spex.ex b/lib/open_api_spex.ex index 165de826..a7d18c74 100644 --- a/lib/open_api_spex.ex +++ b/lib/open_api_spex.ex @@ -72,12 +72,17 @@ defmodule OpenApiSpex do @doc """ Cast and validate a value against a given Schema belonging to a given OpenApi spec. """ - def cast_value(value, schema = %schema_mod{}, spec = %OpenApi{}) + def cast_value(value, schema = %schema_mod{}, spec = %OpenApi{}, opts \\ []) when schema_mod in [Schema, Reference] do - OpenApiSpex.Cast.cast(schema, value, spec.components.schemas) + OpenApiSpex.Cast.cast(schema, value, spec.components.schemas, opts) end - @type cast_opt :: {:replace_params, boolean()} | {:apply_defaults, boolean()} + @type read_write_scope :: nil | :read | :write + + @type cast_opt :: + {:replace_params, boolean()} + | {:apply_defaults, boolean()} + | {:read_write_scope, read_write_scope()} @spec cast_and_validate( OpenApi.t(), diff --git a/lib/open_api_spex/cast.ex b/lib/open_api_spex/cast.ex index 4d245e91..269cf3f4 100644 --- a/lib/open_api_spex/cast.ex +++ b/lib/open_api_spex/cast.ex @@ -22,7 +22,7 @@ defmodule OpenApiSpex.Cast do @type schema_or_reference :: Schema.t() | Reference.t() - @type cast_opt :: {:apply_defaults, boolean()} + @type cast_opt :: {:apply_defaults, boolean()} | {:read_write_scope, read_write_scope()} @type t :: %__MODULE__{ value: term(), @@ -103,7 +103,16 @@ defmodule OpenApiSpex.Cast do @spec cast(schema_or_reference | nil, term(), map(), [cast_opt()]) :: {:ok, term()} | {:error, [Error.t()]} def cast(schema, value, schemas \\ %{}, opts \\ []) do - ctx = %__MODULE__{schema: schema, value: value, schemas: schemas, opts: opts} + read_write_scope = Keyword.get(opts, :read_write_scope) + + ctx = %__MODULE__{ + schema: schema, + value: value, + schemas: schemas, + read_write_scope: read_write_scope, + opts: opts + } + cast(ctx) end diff --git a/lib/open_api_spex/cast/object.ex b/lib/open_api_spex/cast/object.ex index 0b291dc3..c4451e3b 100644 --- a/lib/open_api_spex/cast/object.ex +++ b/lib/open_api_spex/cast/object.ex @@ -12,36 +12,35 @@ defmodule OpenApiSpex.Cast.Object do {:ok, value} end - def cast(%{value: value, schema: schema, schemas: schemas} = ctx) do - original_value = value - schema_properties = schema.properties || %{} + def cast(ctx) do + ctx = handle_struct_value(ctx) - with :ok <- check_unrecognized_properties(ctx, schema_properties), - resolved_schema_properties <- - resolve_schema_properties_references(schema_properties, schemas), - value = cast_atom_keys(value, resolved_schema_properties), - ctx = %{ctx | value: value}, - {:ok, ctx} <- cast_additional_properties(ctx, original_value), + with :ok <- check_unrecognized_properties(ctx), + ctx = resolve_schema_properties_references(ctx), + ctx = cast_atom_keys(ctx), + {:ok, ctx} <- cast_additional_properties(ctx), :ok <- Utils.check_required_fields(ctx), :ok <- check_max_properties(ctx), :ok <- check_min_properties(ctx), - {:ok, value} <- cast_properties(%{ctx | schema: resolved_schema_properties}) do - value_with_defaults = - if Keyword.get(ctx.opts, :apply_defaults, true) do - apply_defaults(value, resolved_schema_properties) - else - value - end + {:ok, ctx} <- cast_properties(ctx) do + ctx = + ctx + |> apply_defaults() + |> to_struct() - ctx = to_struct(%{ctx | value: value_with_defaults}, original_value) {:ok, ctx} end end - defp resolve_schema_properties_references(schema_properties, schemas) do - Enum.reduce(schema_properties, schema_properties, fn property, properties -> - resolve_property_if_reference(property, properties, schemas) - end) + defp resolve_schema_properties_references(%{schema: schema, schemas: schemas} = ctx) do + schema_properties = schema.properties || %{} + + resolved_schema_properties = + Enum.reduce(schema_properties, schema_properties, fn property, properties -> + resolve_property_if_reference(property, properties, schemas) + end) + + %{ctx | schema: %{schema | properties: resolved_schema_properties}} end defp resolve_property_if_reference({key, %Reference{} = reference}, properties, schemas) do @@ -51,14 +50,14 @@ defmodule OpenApiSpex.Cast.Object do defp resolve_property_if_reference(_not_a_reference, properties, _schemas), do: properties # When additionalProperties is not false, extra properties are allowed in input - defp check_unrecognized_properties(%{schema: %{additionalProperties: ap}}, _expected_keys) - when ap != false do + defp check_unrecognized_properties(%{schema: %{additionalProperties: ap}}) when ap != false do :ok end - defp check_unrecognized_properties(%{value: value} = ctx, expected_keys) do + defp check_unrecognized_properties(%{value: value, schema: schema} = ctx) do + schema_properties = schema.properties || %{} input_keys = value |> Map.keys() |> Enum.map(&to_string/1) - schema_keys = expected_keys |> Map.keys() |> Enum.map(&to_string/1) + schema_keys = schema_properties |> Map.keys() |> Enum.map(&to_string/1) extra_keys = input_keys -- schema_keys if extra_keys == [] do @@ -96,20 +95,25 @@ defmodule OpenApiSpex.Cast.Object do 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) + defp cast_atom_keys(%{value: input_map, schema: %{properties: properties}} = ctx) do + value = + Enum.reduce(properties, input_map, 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) + if Map.has_key?(output, string_key) do + {value, output} = Map.pop!(output, string_key) + Map.put(output, key, value) + else + output + end + end) + + %{ctx | value: value} end - defp cast_properties(%{value: object, schema: schema_properties} = ctx) do - Enum.reduce(object, {%{}, []}, fn {key, value}, {output, object_errors} -> + defp cast_properties(%{value: object, schema: %{properties: schema_properties}} = ctx) do + object + |> Enum.reduce({%{}, []}, fn {key, value}, {output, object_errors} -> case cast_property(%{ctx | key: key, value: value, schema: schema_properties}, output) do {:ok, output} -> {output, object_errors} @@ -119,16 +123,16 @@ defmodule OpenApiSpex.Cast.Object do end end) |> case do - {output, []} -> - {:ok, output} + {value, []} -> + {:ok, %{ctx | value: value}} {_, errors} -> {:error, errors} end end - defp cast_additional_properties(%{schema: %{additionalProperties: ap}} = ctx, original_value) do - original_value + defp cast_additional_properties(%{value: value, schema: %{additionalProperties: ap}} = ctx) do + value |> get_additional_properties(ctx) |> Enum.reduce({:ok, ctx}, fn {key, value}, {:ok, ctx} -> @@ -140,15 +144,14 @@ defmodule OpenApiSpex.Cast.Object do end) end - defp get_additional_properties(original_value, ctx) do + defp get_additional_properties(value, ctx) do recognized_keys = (ctx.schema.properties || %{}) |> Map.keys() |> Enum.flat_map(&[&1, to_string(&1)]) |> MapSet.new() - for {key, _value} = prop <- ensure_not_struct(original_value), - not MapSet.member?(recognized_keys, key) do + for {key, _value} = prop <- value, not MapSet.member?(recognized_keys, key) do prop end end @@ -172,8 +175,15 @@ defmodule OpenApiSpex.Cast.Object do end end - defp apply_defaults(object_value, schema_properties) do - Enum.reduce(schema_properties, object_value, &apply_default/2) + defp apply_defaults(%{opts: opts, value: value, schema: %{properties: properties}} = ctx) do + value = + if Keyword.get(opts, :apply_defaults, true) do + Enum.reduce(properties, value, &apply_default/2) + else + value + end + + %{ctx | value: value} end defp apply_default({_key, %{default: nil}}, object_value), do: object_value @@ -188,16 +198,21 @@ defmodule OpenApiSpex.Cast.Object do defp apply_default(_, object_value), do: object_value - defp to_struct(%{value: value = %_{}}, _original_value), do: value + defp to_struct(%{value: value = %_{}}), do: value + + defp to_struct(%{value: value, schema: %{"x-struct": module}}) when not is_nil(module), + do: struct(module, value) - defp to_struct(%{value: value, schema: %{"x-struct": module}}, _) - when not is_nil(module), - do: struct(module, value) + defp to_struct(%{value: value}), do: value - defp to_struct(%{value: value}, %original_module{}), do: struct(original_module, value) + defp handle_struct_value(%{value: %_{} = value, schema: %{"x-struct": module}} = ctx) + when not is_nil(module) do + %{ctx | value: Map.from_struct(value)} + end - defp to_struct(%{value: value}, _original_value), do: value + defp handle_struct_value(%{value: %struct{} = value, schema: schema} = ctx) do + %{ctx | value: Map.from_struct(value), schema: %{schema | "x-struct": struct}} + end - defp ensure_not_struct(val) when is_struct(val), do: Map.from_struct(val) - defp ensure_not_struct(val), do: val + defp handle_struct_value(ctx), do: ctx end diff --git a/lib/open_api_spex/cast/utils.ex b/lib/open_api_spex/cast/utils.ex index c2ba810b..6c3e3923 100644 --- a/lib/open_api_spex/cast/utils.ex +++ b/lib/open_api_spex/cast/utils.ex @@ -17,7 +17,7 @@ defmodule OpenApiSpex.Cast.Utils do def check_required_fields(%{value: input_map} = ctx), do: check_required_fields(ctx, input_map) def check_required_fields(ctx, %{} = input_map) do - required = ctx.schema.required || [] + required = Map.get(ctx.schema, :required) || [] # Adjust required fields list, based on read_write_scope required = diff --git a/test/cast_test.exs b/test/cast_test.exs index f5d4ef17..1cb2baf5 100644 --- a/test/cast_test.exs +++ b/test/cast_test.exs @@ -253,6 +253,26 @@ defmodule OpenApiSpec.CastTest do end end + describe "opts" do + test "read_write_scope" do + schema = %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string, readOnly: true}, + name: %Reference{"$ref": "#/components/schemas/Name"}, + age: %Schema{type: :integer} + }, + required: [:id, :name, :age] + } + + schemas = %{"Name" => %Schema{type: :string, readOnly: true}} + + value = %{"age" => 30} + assert {:error, _} = Cast.cast(schema, value, schemas, []) + assert {:ok, %{age: 30}} == Cast.cast(schema, value, schemas, read_write_scope: :write) + end + end + describe "ok/1" do test "basics" do assert {:ok, 1} = Cast.ok(%Cast{value: 1})