Skip to content

Commit 2aab2c4

Browse files
authored
fix: recursive input types (#371)
1 parent 85400f4 commit 2aab2c4

File tree

3 files changed

+292
-66
lines changed

3 files changed

+292
-66
lines changed

lib/ash_json_api/json_schema/open_api.ex

Lines changed: 136 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,16 @@ if Code.ensure_loaded?(OpenApiSpex) do
5050
Tag
5151
}
5252

53+
require Logger
54+
5355
@typep content_type_format() :: :json | :multipart
5456
@typep acc() :: map()
5557

5658
@doc """
5759
Creates an empty accumulator for schema generation.
5860
"""
5961
def empty_acc do
60-
%{schemas: %{}, seen_non_schema_types: []}
62+
%{schemas: %{}, seen_non_schema_types: [], seen_input_types: []}
6163
end
6264

6365
@dialyzer {:nowarn_function, {:action_description, 3}}
@@ -673,7 +675,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
673675
) do
674676
if instance_of = constraints[:instance_of] do
675677
if AshJsonApi.JsonSchema.embedded?(instance_of) && !constraints[:fields] do
676-
embedded_type_input(attr, action_type, acc, format)
678+
embedded_type_input(attr, resource, action_type, acc, format)
677679
else
678680
{schema, acc} =
679681
resource_write_attribute_type(
@@ -695,7 +697,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
695697
{schema, acc} =
696698
cond do
697699
AshJsonApi.JsonSchema.embedded?(type) ->
698-
embedded_type_input(attr, action_type, acc)
700+
embedded_type_input(attr, resource, action_type, acc)
699701

700702
:erlang.function_exported(type, :json_write_schema, 1) ->
701703
{type.json_write_schema(attr.constraints), acc}
@@ -947,7 +949,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
947949

948950
if type_key in acc.seen_non_schema_types do
949951
# We're in a recursive loop, return $ref and warn
950-
require Logger
952+
# Recursive type detected, using $ref instead of inline definition
951953

952954
Logger.warning(
953955
"Detected recursive embedded type with JSON API type: #{inspect(instance_of)}"
@@ -988,8 +990,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
988990

989991
if type_key in acc.seen_non_schema_types do
990992
# We're in a recursive loop, return empty schema
991-
require Logger
992-
Logger.warning("Detected recursive embedded type: #{inspect(instance_of)}")
993+
# Recursive type detected, returning empty schema to prevent infinite loop
993994
{%Schema{}, acc}
994995
else
995996
# Mark this type as seen and process it
@@ -1059,18 +1060,77 @@ if Code.ensure_loaded?(OpenApiSpex) do
10591060
end
10601061
end
10611062

1062-
defp embedded_type_input(%{type: resource} = attribute, action_type, acc, format \\ :json) do
1063+
defp embedded_type_input(
1064+
%{type: embedded_resource} = attribute,
1065+
parent_resource,
1066+
action_type,
1067+
acc,
1068+
format \\ :json
1069+
) do
10631070
attribute = %{
10641071
attribute
1065-
| constraints: Ash.Type.NewType.constraints(resource, attribute.constraints)
1072+
| constraints: Ash.Type.NewType.constraints(embedded_resource, attribute.constraints)
10661073
}
10671074

1068-
resource =
1075+
embedded_resource =
10691076
case attribute.constraints[:instance_of] do
1070-
nil -> Ash.Type.NewType.subtype_of(resource)
1077+
nil -> Ash.Type.NewType.subtype_of(embedded_resource)
10711078
type -> type
10721079
end
10731080

1081+
input_schema_name =
1082+
create_input_schema_name(attribute, parent_resource, action_type, embedded_resource)
1083+
1084+
type_key = {embedded_resource, action_type, attribute.constraints}
1085+
1086+
# Check for recursion
1087+
if type_key in acc.seen_input_types do
1088+
# We're in a recursive loop
1089+
if input_schema_name do
1090+
# Return $ref and unchanged accumulator (the schema will be created by the non-recursive path)
1091+
schema = %{"$ref" => "#/components/schemas/#{input_schema_name}"}
1092+
{schema, acc}
1093+
else
1094+
# No schema name, return empty schema to break recursion
1095+
{%Schema{}, acc}
1096+
end
1097+
else
1098+
# Not recursive, mark as seen and process normally
1099+
new_acc = %{acc | seen_input_types: [type_key | acc.seen_input_types]}
1100+
1101+
# Build the schema
1102+
embedded_type_input_impl(
1103+
attribute,
1104+
embedded_resource,
1105+
action_type,
1106+
new_acc,
1107+
format,
1108+
input_schema_name
1109+
)
1110+
end
1111+
end
1112+
1113+
defp create_input_schema_name(attribute, parent_resource, action_type, embedded_resource) do
1114+
# Check if this embedded resource has a JSON API type for input schema naming
1115+
json_api_type = AshJsonApi.Resource.Info.type(embedded_resource)
1116+
1117+
if json_api_type do
1118+
"#{json_api_type}-input-#{action_type}"
1119+
else
1120+
# Use parent resource type and attribute name for schema naming
1121+
# This matches the pattern used in the generated refs
1122+
parent_type = AshJsonApi.Resource.Info.type(parent_resource)
1123+
attribute_name = Map.get(attribute, :name)
1124+
1125+
if parent_type && attribute_name do
1126+
"#{parent_type}_#{attribute_name}-input-#{action_type}"
1127+
else
1128+
nil
1129+
end
1130+
end
1131+
end
1132+
1133+
defp embedded_type_input_impl(attribute, resource, action_type, acc, format, schema_name) do
10741134
create_action =
10751135
case attribute.constraints[:create_action] do
10761136
nil ->
@@ -1145,7 +1205,15 @@ if Code.ensure_loaded?(OpenApiSpex) do
11451205
}
11461206
|> add_null_for_non_required()
11471207

1148-
{schema, acc}
1208+
if schema_name do
1209+
# Store the schema in the accumulator
1210+
final_acc = %{acc | schemas: Map.put(acc.schemas, schema_name, schema)}
1211+
# Return a $ref to the schema
1212+
ref_schema = %{"$ref" => "#/components/schemas/#{schema_name}"}
1213+
{ref_schema, final_acc}
1214+
else
1215+
{schema, acc}
1216+
end
11491217
end
11501218

11511219
defp unwrap_any_of(%{"anyOf" => options} = schema) do
@@ -1478,6 +1546,10 @@ if Code.ensure_loaded?(OpenApiSpex) do
14781546
{parameters_list, acc} = parameters(route, resource, path_params, acc)
14791547
{response, acc} = response_body(route, resource, acc)
14801548

1549+
{request_body_result, request_schemas} = request_body(route, resource)
1550+
1551+
acc_with_request_schemas = %{acc | schemas: Map.merge(acc.schemas, request_schemas)}
1552+
14811553
operation = %Operation{
14821554
description: action_description(action, route, resource),
14831555
operationId: route.name,
@@ -1489,10 +1561,10 @@ if Code.ensure_loaded?(OpenApiSpex) do
14891561
},
14901562
response_code => response
14911563
},
1492-
requestBody: request_body(route, resource)
1564+
requestBody: request_body_result
14931565
}
14941566

1495-
{operation, acc}
1567+
{operation, acc_with_request_schemas}
14961568
end
14971569

14981570
defp action_description(action, route, resource) do
@@ -1921,7 +1993,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
19211993
|> then(fn {params, acc} -> {Enum.reverse(params), acc} end)
19221994
end
19231995

1924-
@spec request_body(Route.t(), resource :: module) :: nil | RequestBody.t()
1996+
@spec request_body(Route.t(), resource :: module) :: {nil | RequestBody.t(), map()}
19251997
defp request_body(%{type: type}, _resource)
19261998
when type not in [
19271999
:route,
@@ -1931,55 +2003,66 @@ if Code.ensure_loaded?(OpenApiSpex) do
19312003
:patch_relationship,
19322004
:delete_from_relationship
19332005
] do
1934-
nil
2006+
{nil, %{}}
19352007
end
19362008

19372009
defp request_body(route, resource) do
1938-
{json_body_schema, _acc} = request_body_schema(route, resource, :json, %{})
1939-
{multipart_body_schema, _acc} = request_body_schema(route, resource, :multipart, %{})
2010+
{json_body_schema, json_acc} = request_body_schema(route, resource, :json, empty_acc())
19402011

1941-
if route.type == :route &&
1942-
(route.method == :delete || Enum.empty?(json_body_schema.properties.data.properties)) do
1943-
nil
1944-
else
1945-
body_required =
1946-
cond do
1947-
route.type in [:post_to_relationship, :delete_from_relationship, :patch_relationship] ->
1948-
true
2012+
{multipart_body_schema, multipart_acc} =
2013+
request_body_schema(route, resource, :multipart, empty_acc())
19492014

1950-
route.type == :route ->
1951-
json_body_schema.properties.data.required != []
2015+
all_schemas = Map.merge(json_acc.schemas, multipart_acc.schemas)
19522016

1953-
true ->
1954-
json_body_schema.properties.data.properties.attributes.required != [] ||
1955-
json_body_schema.properties.data.properties.relationships.required != []
1956-
end
2017+
body =
2018+
if route.type == :route &&
2019+
(route.method == :delete || Enum.empty?(json_body_schema.properties.data.properties)) do
2020+
nil
2021+
else
2022+
body_required =
2023+
cond do
2024+
route.type in [
2025+
:post_to_relationship,
2026+
:delete_from_relationship,
2027+
:patch_relationship
2028+
] ->
2029+
true
19572030

1958-
content =
1959-
if json_body_schema == multipart_body_schema do
1960-
# No file inputs declared, multipart is not necessary
1961-
%{
1962-
"application/vnd.api+json" => %MediaType{schema: json_body_schema}
1963-
}
1964-
else
1965-
%{
1966-
"application/vnd.api+json" => %MediaType{schema: json_body_schema},
1967-
"multipart/x.ash+form-data" => %MediaType{
1968-
schema: %Schema{
1969-
multipart_body_schema
1970-
| additionalProperties: %{type: :string, format: :binary}
2031+
route.type == :route ->
2032+
json_body_schema.properties.data.required != []
2033+
2034+
true ->
2035+
json_body_schema.properties.data.properties.attributes.required != [] ||
2036+
json_body_schema.properties.data.properties.relationships.required != []
2037+
end
2038+
2039+
content =
2040+
if json_body_schema == multipart_body_schema do
2041+
# No file inputs declared, multipart is not necessary
2042+
%{
2043+
"application/vnd.api+json" => %MediaType{schema: json_body_schema}
2044+
}
2045+
else
2046+
%{
2047+
"application/vnd.api+json" => %MediaType{schema: json_body_schema},
2048+
"multipart/x.ash+form-data" => %MediaType{
2049+
schema: %Schema{
2050+
multipart_body_schema
2051+
| additionalProperties: %{type: :string, format: :binary}
2052+
}
19712053
}
19722054
}
1973-
}
1974-
end
2055+
end
19752056

1976-
%RequestBody{
1977-
description:
1978-
"Request body for the #{route.name || route.route} operation on #{AshJsonApi.Resource.Info.type(resource)} resource",
1979-
required: body_required,
1980-
content: content
1981-
}
1982-
end
2057+
%RequestBody{
2058+
description:
2059+
"Request body for the #{route.name || route.route} operation on #{AshJsonApi.Resource.Info.type(resource)} resource",
2060+
required: body_required,
2061+
content: content
2062+
}
2063+
end
2064+
2065+
{body, all_schemas}
19832066
end
19842067

19852068
@spec request_body_schema(

0 commit comments

Comments
 (0)