Skip to content
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
53 changes: 49 additions & 4 deletions lib/ash_oaskit/core/type_mapper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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"}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -356,26 +389,38 @@ 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

enum_type?(type) ->
{: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)
Expand Down
76 changes: 76 additions & 0 deletions test/ash_oaskit/kitchen_sink_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions test/support/kitchen_sink_resources.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
# ===========================================================================
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading