diff --git a/lib/ash_oaskit/core/type_mapper.ex b/lib/ash_oaskit/core/type_mapper.ex index d92283d..223f2b2 100644 --- a/lib/ash_oaskit/core/type_mapper.ex +++ b/lib/ash_oaskit/core/type_mapper.ex @@ -55,6 +55,7 @@ defmodule AshOaskit.TypeMapper do | `Ash.Type.File` | `string` (`byte`) | Base64 encoded content | | `Ash.Type.DurationName` | `string` | Enum from the type's `values/0` | | `Ash.Type.Enum` implementors | `string` | Enum from the type's `values/0` | + | `Ash.TypedStruct` modules | `object` | Typed properties and required list from the field definitions | | `Ash.Type.NewType` wrappers | (subtype schema) | Resolved via `subtype_of/0` | | Custom types | Calls `json_schema/1` | If defined on type | @@ -241,6 +242,7 @@ defmodule AshOaskit.TypeMapper do defp complex_type_schema({:union, types}), do: build_union_schema(types) defp complex_type_schema({:struct, module}), do: build_struct_schema(module) + defp complex_type_schema({:struct_fields, module}), do: build_typed_struct_schema(module) defp complex_type_schema({:custom, custom_schema}), do: custom_schema defp complex_type_schema(_), do: %{"type" => "string"} @@ -285,6 +287,37 @@ defmodule AshOaskit.TypeMapper do defp build_struct_schema(_), do: %{"type" => "object"} + # Build an object schema for a NewType of Ash.Type.Struct (e.g. a + # module defined with `use Ash.TypedStruct`). Unlike build_struct_schema/1, + # the field definitions carry declared types and allow_nil? flags in the + # NewType's subtype constraints, so properties keep their real types and + # non-nil fields become required. + defp build_typed_struct_schema(module) do + fields = module.subtype_constraints()[:fields] || [] + + properties = + Map.new(fields, fn {name, config} -> + {to_string(name), ash_type_to_base_schema(Keyword.get(config, :type, :string))} + end) + + required = + for {name, config} <- fields, + Keyword.get(config, :allow_nil?, true) == false, + do: to_string(name) + + schema = %{ + "type" => "object", + "properties" => properties, + "description" => "Struct of type #{inspect(module)}" + } + + if required == [] do + schema + else + Map.put(schema, "required", Enum.sort(required)) + end + end + # Known basic atom types @basic_types ~w(string ci_string integer float decimal boolean date time time_usec datetime utc_datetime utc_datetime_usec naive_datetime duration uuid @@ -356,12 +389,13 @@ defmodule AshOaskit.TypeMapper do # Ash.Type.Enum implementors, and NewType wrappers defp normalize_complex_type(type) do cond do - embedded_resource?(type) -> - {:embedded, type} - + # make sure callback always take priority has_json_schema_callback?(type) -> {:custom, get_custom_json_schema(type)} + embedded_resource?(type) -> + {:embedded, type} + union_result = get_union_types(type) -> union_result @@ -369,13 +403,24 @@ defmodule AshOaskit.TypeMapper do {:custom, enum_schema(type)} newtype?(type) -> - type |> NewType.subtype_of() |> normalize_type() + normalize_newtype(type) true -> :string end end + # NewType wrappers resolve to their subtype — except typed structs + # (subtype_of: :struct, e.g. `use Ash.TypedStruct`), whose field + # definitions live in the NewType's constraints and would be lost by + # plain recursion on the subtype + defp normalize_newtype(type) do + case NewType.subtype_of(type) do + Ash.Type.Struct -> {:struct_fields, type} + subtype -> normalize_type(subtype) + end + end + # Check if a type uses Ash.Type.Enum (e.g. `use Ash.Type.Enum, values: [...]`) defp enum_type?(type) do Code.ensure_loaded?(type) and Spark.implements_behaviour?(type, Ash.Type.Enum) diff --git a/test/ash_oaskit/kitchen_sink_test.exs b/test/ash_oaskit/kitchen_sink_test.exs index ab43c8c..09dfe4c 100644 --- a/test/ash_oaskit/kitchen_sink_test.exs +++ b/test/ash_oaskit/kitchen_sink_test.exs @@ -6,6 +6,7 @@ defmodule AshOaskit.KitchenSinkTest do covering: - Union types (Ash.Type.NewType subtype of Ash.Type.Union) + - Typed structs (Ash.TypedStruct), direct and as discriminated-union variants - Custom types with json_schema/1 callback - Deeply nested embedded resources (3 levels: Venue → Location → GeoPoint) - Array of embedded resources @@ -87,6 +88,71 @@ defmodule AshOaskit.KitchenSinkTest do end end + describe "discriminated union of typed structs (actor)" do + test "3.1 documents typed variants with declared field types", %{spec_31: spec} do + actor = get_attr(spec, "actor") + + assert %{ + "anyOf" => [ + %{"type" => "null"}, + %{ + "title" => "person", + "description" => "Struct of type AshOaskit.Test.Person", + "properties" => %{ + "age" => %{"type" => "integer"}, + "email" => %{"type" => "string"}, + "name" => %{"type" => "string"}, + "type" => %{"type" => "string"} + }, + "required" => ["email", "name", "type"], + "type" => "object" + }, + %{ + "title" => "company", + "description" => "Struct of type AshOaskit.Test.Company", + "properties" => %{ + "company_name" => %{"type" => "string"}, + "employee_count" => %{"type" => "integer"}, + "tax_id" => %{"type" => "string"}, + "type" => %{"type" => "string"} + }, + "required" => ["company_name", "tax_id", "type"], + "type" => "object" + } + ], + "description" => "The actor (person or company) associated with this record" + } === actor + end + + test "3.0 uses anyOf with nullable flag", %{spec_30: spec} do + actor = get_attr(spec, "actor") + + assert actor["nullable"] == true + assert [person, _] = actor["anyOf"] + assert person["properties"]["age"]["type"] == "integer" + end + end + + describe "typed struct as a direct attribute (owner)" do + test "3.1 documents a typed nullable object", %{spec_31: spec} do + owner = get_attr(spec, "owner") + + assert owner["type"] == ["object", "null"] + assert owner["properties"]["age"]["type"] == "integer" + assert owner["properties"]["name"]["type"] == "string" + assert owner["required"] == ["email", "name", "type"] + assert owner["description"] == "The owner of this record" + end + + test "3.0 uses the nullable flag", %{spec_30: spec} do + owner = get_attr(spec, "owner") + + assert owner["type"] == "object" + assert owner["nullable"] == true + assert owner["properties"]["age"]["type"] == "integer" + end + end + describe "deeply nested embedded resources" do test "venue references Venue via $ref (3.1)", %{spec_31: spec} do venue_attr = get_attr(spec, "venue") @@ -242,6 +308,16 @@ defmodule AshOaskit.KitchenSinkTest do end end + describe "Oaskit validation" do + test "3.1 spec with typed structs passes Oaskit validation", %{spec_31: spec} do + assert {:ok, %Oaskit.Spec.OpenAPI{}} = AshOaskit.validate(spec) + end + + test "3.0 spec with typed structs passes Oaskit validation", %{spec_30: spec} do + assert {:ok, %Oaskit.Spec.OpenAPI{}} = AshOaskit.validate(spec) + end + end + # --- Helpers --- defp get_attr(spec, name) do diff --git a/test/support/kitchen_sink_resources.ex b/test/support/kitchen_sink_resources.ex index aeb2a3b..1af0131 100644 --- a/test/support/kitchen_sink_resources.ex +++ b/test/support/kitchen_sink_resources.ex @@ -6,6 +6,7 @@ # - Deeply nested embedded resources (3+ levels) # - Array of embedded resources # - Union types as attributes (via Ash.Type.NewType) +# - Typed structs (Ash.TypedStruct), direct and as discriminated-union variants # - Read-only attributes (writable?: false) — excluded from input schemas # - DurationName type in a real resource # - Custom type with json_schema/1 callback in a real resource @@ -44,6 +45,64 @@ defmodule AshOaskit.Test.ContentBlock do ] end +# =========================================================================== +# Typed Structs and a Discriminated Union of them (regression: these +# previously degraded to "string" schemas) +# =========================================================================== + +defmodule AshOaskit.Test.Person do + @moduledoc """ + TypedStruct for Person variant. + """ + + use Ash.TypedStruct + + typed_struct do + field :type, :string, allow_nil?: false + field :name, :string, allow_nil?: false + field :email, :string, allow_nil?: false + field :age, :integer + end +end + +defmodule AshOaskit.Test.Company do + @moduledoc """ + TypedStruct for Company variant. + """ + + use Ash.TypedStruct + + typed_struct do + field :type, :string, allow_nil?: false + field :company_name, :string, allow_nil?: false + field :tax_id, :string, allow_nil?: false + field :employee_count, :integer + end +end + +defmodule AshOaskit.Test.Actor do + @moduledoc """ + Union type for Actor (person or company) with discriminator. + """ + + use Ash.Type.NewType, + subtype_of: :union, + constraints: [ + types: [ + person: [ + type: AshOaskit.Test.Person, + tag: :type, + tag_value: "person" + ], + company: [ + type: AshOaskit.Test.Company, + tag: :type, + tag_value: "company" + ] + ] + ] +end + # =========================================================================== # Custom type with json_schema/1 callback # =========================================================================== @@ -158,6 +217,8 @@ defmodule AshOaskit.Test.KitchenSink do Covers gaps identified in test resource analysis: - Union type attribute (ContentBlock) + - Typed struct attribute (Person) and a discriminated union of typed + structs (Actor: Person or Company) - Custom type with json_schema/1 (Coordinate) - Deeply nested embedded (Venue → Location → GeoPoint, 3 levels) - Array of embedded resources @@ -189,6 +250,18 @@ defmodule AshOaskit.Test.KitchenSink do description "Polymorphic content block (text, image, or code)" end + # --- Union of typed structs with discriminator (Actor) --- + attribute :actor, AshOaskit.Test.Actor do + public? true + description "The actor (person or company) associated with this record" + end + + # --- Typed struct as a direct attribute type --- + attribute :owner, AshOaskit.Test.Person do + public? true + description "The owner of this record" + end + # --- Custom type with json_schema/1 --- attribute :coordinates, AshOaskit.Test.Coordinate do public? true