Skip to content

Commit

Permalink
sets read_write_scope from opts, this will permit to comply to readOn…
Browse files Browse the repository at this point in the history
…ly (#572)
  • Loading branch information
albertored authored May 24, 2024
1 parent 10042c2 commit e8a3538
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 59 deletions.
11 changes: 8 additions & 3 deletions lib/open_api_spex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
13 changes: 11 additions & 2 deletions lib/open_api_spex/cast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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

Expand Down
121 changes: 68 additions & 53 deletions lib/open_api_spex/cast/object.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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} ->
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion lib/open_api_spex/cast/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
20 changes: 20 additions & 0 deletions test/cast_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down

0 comments on commit e8a3538

Please sign in to comment.