Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Accept read_write_scope from opts when calling cast functions directly #572

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading