From be227d873400a2920dddcd40aa63f55a2eeb7ab2 Mon Sep 17 00:00:00 2001 From: Majd Sehwail <56893463+MajdSehwail@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:30:14 +0300 Subject: [PATCH] Enforce required on object schemas without properties Cast.Object short-circuits object schemas that declare neither properties nor additionalProperties, so their required list never runs. Either/or arms written as bare required-only object schemas therefore enforce nothing under anyOf and reject every value under oneOf. The short-circuit now checks required first; a required name counts as present when the input carries the atom key or its string form, since no property declarations exist to atomize keys. Co-Authored-By: Claude Fable 5 --- lib/open_api_spex/cast/object.ex | 33 +++++++++++++++++++++++++++++--- test/cast/object_test.exs | 11 +++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/open_api_spex/cast/object.ex b/lib/open_api_spex/cast/object.ex index c4451e3b..f7831a87 100644 --- a/lib/open_api_spex/cast/object.ex +++ b/lib/open_api_spex/cast/object.ex @@ -1,15 +1,17 @@ defmodule OpenApiSpex.Cast.Object do @moduledoc false alias OpenApiSpex.Cast - alias OpenApiSpex.Cast.Utils + alias OpenApiSpex.Cast.{Error, Utils} alias OpenApiSpex.Reference 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, additionalProperties: nil}}) do - {:ok, value} + def cast(%{value: value, schema: %{properties: nil, additionalProperties: nil}} = ctx) do + with :ok <- check_required_by_name(ctx, value) do + {:ok, value} + end end def cast(ctx) do @@ -50,6 +52,31 @@ 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 + # `required` on a schema without `properties` cannot rely on key + # atomization, so a required name counts as present when the input carries + # the atom key or its string form. + defp check_required_by_name(ctx, input_map) do + required = Map.get(ctx.schema, :required) || [] + + missing = + Enum.reject(required, fn key -> + Map.has_key?(input_map, key) or Map.has_key?(input_map, to_string(key)) + end) + + case missing do + [] -> + :ok + + _ -> + errors = + Enum.map(missing, fn key -> + Error.new(%{ctx | path: [key | ctx.path]}, {:missing_field, key}) + end) + + {:error, ctx.errors ++ errors} + end + end + defp check_unrecognized_properties(%{schema: %{additionalProperties: ap}}) when ap != false do :ok end diff --git a/test/cast/object_test.exs b/test/cast/object_test.exs index 84261c18..b45c37ff 100644 --- a/test/cast/object_test.exs +++ b/test/cast/object_test.exs @@ -7,6 +7,17 @@ defmodule OpenApiSpex.ObjectTest do defp cast(ctx), do: Object.cast(struct(Cast, ctx)) describe "cast/3" do + test "required is enforced on a schema without properties" do + schema = %Schema{type: :object, required: [:one]} + + assert {:error, [error]} = cast(value: %{}, schema: schema) + assert error.reason == :missing_field + assert error.name == :one + + assert {:ok, %{"one" => 1}} = cast(value: %{"one" => 1}, schema: schema) + assert {:ok, %{one: 1}} = cast(value: %{one: 1}, schema: schema) + end + test "when input is not an object" do schema = %Schema{type: :object} assert {:error, [error]} = cast(value: ["hello"], schema: schema)