Skip to content

Commit b64bb2c

Browse files
committed
[OpenAPI]: Fix scope circular reference detection to class + view, not class alone
Before this, the Blueprinter parser ignored explicit views mentioned for any association and used a default view instead. For example: ```ruby class ProjectBlueprint < Blueprinter::Base fields :name view :normal do association :users, blueprint: UserBlueprint end end class UserBlueprint < Blueprinter::Base fields :email association :projects, blueprint: ProjectBlueprint view :extended do association :all_projects, blueprint: ProjectBlueprint, view: :normal end end ``` Parsing UserBlueprint(view: :extended) reaches ProjectBlueprint twice: once via "projects" (default view, no association back to UserBlueprint), and once via "all_projects" (view: :normal, which does associate back to UserBlueprint, closing the circular reference). Previously, both associations, i.e., `projects` and `all_projects` of class `UserBlueprint`, used the `default view` even if an explicit view was mentioned, for example, by `all_projects`, which doesn't want a default view and says it wants the view `normal` instead. Now the @parsing_stack includes the `view name` along with its `class name`, e.g., `['ClassName', :view_name]`, defaulting to `:default` when none is given. So now, in `Rage::OpenAPI::Parsers::Ext::Blueprinter#extract_association`, it checks both the `Classname` and `view name` in the parsing stack, and if the view doesn't match even when the classname is the same, it starts building the schema for its association. And when it matches both its view and classname with the parsing stack, it uses the ref.
1 parent 82439a7 commit b64bb2c

3 files changed

Lines changed: 186 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- [Router] Fall back to `SERVER_NAME` when deriving exact host constraints.
2020
- [Cookies] Use request host fallback when resolving cookie domains.
2121
- [Cookies] Match array-based cookie domains case-insensitively.
22+
- [OpenAPI] Fix schema registry key collisions for Blueprinter classes referenced under different views
2223

2324
## [1.25.1] - 2026-06-08
2425

lib/rage/openapi/parsers/ext/blueprinter.rb

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ def parse(klass_str)
2020
is_collection, raw_klass_str, serializer_options = Rage::OpenAPI.__parse_serializer_args(klass_str)
2121
klass = @namespace.const_get(raw_klass_str)
2222
schema = build_schema(klass, is_collection, serializer_options)
23+
registry_key = [raw_klass_str, { view: serializer_options[:view] || :default }]
2324

24-
if @root.schema_registry.key?(raw_klass_str)
25-
@root.schema_registry[raw_klass_str] = is_collection ? schema["items"] : schema
25+
if @root.schema_registry.key?(registry_key)
26+
@root.schema_registry[registry_key] = is_collection ? schema["items"] : schema
2627
end
2728

2829
schema
@@ -32,19 +33,19 @@ def parse(klass_str)
3233

3334
private
3435

35-
def build_schema(klass, is_collection, serializer_options = nil)
36-
@parsing_stack.add(klass.name)
37-
38-
view_name = serializer_options&.key?(:view) ? serializer_options[:view] : :default
36+
def build_schema(klass, is_collection, serializer_options)
37+
view_name = serializer_options.key?(:view) ? serializer_options[:view] : :default
3938
reflections = klass.reflections
4039
view = reflections[view_name]
4140
raise InvalidViewError, "invalid view #{view_name}" unless view
4241

42+
@parsing_stack.add([klass.name, view_name])
43+
4344
identifier_fields = extract_fields(reflections[:identifier])
4445
default_fields = extract_fields(view)
4546
association_fields = extract_associations(view)
4647

47-
@parsing_stack.delete(klass.name)
48+
@parsing_stack.delete([klass.name, view_name])
4849

4950
schema = identifier_fields.merge(default_fields.merge(association_fields).sort.to_h)
5051

@@ -65,14 +66,15 @@ def extract_associations(view)
6566
blueprint = association.blueprint
6667
name, display_name = association.name.to_s, association.display_name.to_s
6768
is_collection = collection_association?(name)
69+
serializer_options = { view: association.view }
6870

6971
item_schema = if blueprint.is_a?(Proc)
7072
{ "type" => "object" }
71-
elsif @parsing_stack.include?(blueprint.name)
72-
@root.schema_registry[blueprint.name] ||= nil
73-
{ "$ref" => "#/components/schemas/#{blueprint.name}" }
73+
elsif @parsing_stack.include?([blueprint.name, association.view])
74+
@root.schema_registry[[blueprint.name, serializer_options]] ||= nil
75+
{ "$ref" => "#/components/schemas/#{[blueprint.name, serializer_options]}" }
7476
else
75-
build_schema(blueprint, false)
77+
build_schema(blueprint, false, serializer_options)
7678
end
7779

7880
properties[display_name] = is_collection ? { "type" => "array", "items" => item_schema } : item_schema

spec/openapi/parsers/ext/blueprinter_spec.rb

Lines changed: 172 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@
455455
"name" => { "type" => "string" },
456456
"users" => {
457457
"type" => "array",
458-
"items" => { "$ref" => "#/components/schemas/UserBlueprint" }
458+
"items" => { "$ref" => "#/components/schemas/[\"UserBlueprint\", {view: :default}]" }
459459
}
460460
}
461461
}
@@ -600,7 +600,7 @@
600600
"name" => { "type" => "string" },
601601
"users" => {
602602
"type" => "array",
603-
"items" => { "$ref" => "#/components/schemas/UserBlueprint" }
603+
"items" => { "$ref" => "#/components/schemas/[\"UserBlueprint\", {view: :default}]" }
604604
}
605605
}
606606
}
@@ -1484,6 +1484,174 @@
14841484
})
14851485
end
14861486
end
1487+
1488+
context "with circular association through default views only (self-referencing pair)" do
1489+
let_blueprinter_class("DataMiningBase") do
1490+
<<~'RUBY'
1491+
field :first_name
1492+
association :data_mining, blueprint: DataMining
1493+
RUBY
1494+
end
1495+
1496+
let_blueprinter_class("DataMining") do
1497+
<<~'RUBY'
1498+
fields :first_name, :hello_world
1499+
association :project, blueprint: DataMiningBase
1500+
view :extended do
1501+
field :extended_author
1502+
end
1503+
RUBY
1504+
end
1505+
1506+
let(:resource) { "DataMiningBase" }
1507+
1508+
it "resolves one level then falls back to $ref for the repeated default-view class" do
1509+
expect { subject }.not_to raise_error
1510+
is_expected.to eq({
1511+
"type" => "object",
1512+
"properties" => {
1513+
"first_name" => { "type" => "string" },
1514+
"data_mining" => {
1515+
"type" => "object",
1516+
"properties" => {
1517+
"first_name" => { "type" => "string" },
1518+
"hello_world" => { "type" => "string" },
1519+
"project" => { "$ref" => "#/components/schemas/[\"DataMiningBase\", {view: :default}]" }
1520+
}
1521+
}
1522+
}
1523+
})
1524+
end
1525+
end
1526+
1527+
context "with circular association where only the outer association pins an explicit view" do
1528+
let_blueprinter_class("DataMiningBase") do
1529+
<<~'RUBY'
1530+
field :first_name
1531+
view :sample do
1532+
field :hello_world
1533+
association :data_mining, blueprint: DataMining
1534+
end
1535+
RUBY
1536+
end
1537+
1538+
let_blueprinter_class("DataMining") do
1539+
<<~'RUBY'
1540+
fields :first_name, :hello_world
1541+
association :project, blueprint: DataMiningBase, view: :sample
1542+
RUBY
1543+
end
1544+
1545+
let(:resource) { "DataMiningBase(view: :sample)" }
1546+
1547+
it "keys the circular $ref by the explicit view, not by :default" do
1548+
expect { subject }.not_to raise_error
1549+
is_expected.to eq({
1550+
"type" => "object",
1551+
"properties" => {
1552+
"first_name" => { "type" => "string" },
1553+
"hello_world" => { "type" => "string" },
1554+
"data_mining" => {
1555+
"type" => "object",
1556+
"properties" => {
1557+
"first_name" => { "type" => "string" },
1558+
"hello_world" => { "type" => "string" },
1559+
"project" => { "$ref" => "#/components/schemas/[\"DataMiningBase\", {view: :sample}]" }
1560+
}
1561+
}
1562+
}
1563+
})
1564+
end
1565+
end
1566+
1567+
context "with a genuine cycle where both sides pin distinct explicit views" do
1568+
let_blueprinter_class("ProjectBlueprint") do
1569+
<<~'RUBY'
1570+
fields :name
1571+
view :detailed do
1572+
association :owner, blueprint: UserBlueprint, view: :extended
1573+
end
1574+
RUBY
1575+
end
1576+
1577+
let_blueprinter_class("UserBlueprint") do
1578+
<<~'RUBY'
1579+
fields :email
1580+
view :extended do
1581+
fields :bio
1582+
association :projects, blueprint: ProjectBlueprint, view: :detailed
1583+
end
1584+
RUBY
1585+
end
1586+
1587+
let(:resource) { "ProjectBlueprint(view: :detailed)" }
1588+
1589+
it "detects the cycle via the (class, view) pair and does not confuse it with the default view" do
1590+
expect { subject }.not_to raise_error
1591+
is_expected.to eq({
1592+
"type" => "object",
1593+
"properties" => {
1594+
"name" => { "type" => "string" },
1595+
"owner" => {
1596+
"type" => "object",
1597+
"properties" => {
1598+
"bio" => { "type" => "string" },
1599+
"email" => { "type" => "string" },
1600+
"projects" => {
1601+
"type" => "array",
1602+
"items" => { "$ref" => "#/components/schemas/[\"ProjectBlueprint\", {view: :detailed}]" }
1603+
}
1604+
}
1605+
}
1606+
}
1607+
})
1608+
end
1609+
end
1610+
1611+
context "with an association chain that only cycles through one specific view (not a true cycle at the tuple level)" do
1612+
let_blueprinter_class("DataMiningBase") do
1613+
<<~'RUBY'
1614+
field :first_name
1615+
view :sample do
1616+
field :hello_world
1617+
association :data_mining, blueprint: DataMining
1618+
end
1619+
RUBY
1620+
end
1621+
1622+
let_blueprinter_class("DataMining") do
1623+
<<~'RUBY'
1624+
fields :first_name, :hello_world
1625+
association :project, blueprint: DataMiningBase
1626+
RUBY
1627+
end
1628+
1629+
let(:resource) { "DataMiningBase(view: :sample)" }
1630+
1631+
it "fully expands instead of using $ref, since the association loops back to the default view, not :sample" do
1632+
expect { subject }.not_to raise_error
1633+
is_expected.to eq({
1634+
"type" => "object",
1635+
"properties" => {
1636+
"first_name" => { "type" => "string" },
1637+
"hello_world" => { "type" => "string" },
1638+
"data_mining" => {
1639+
"type" => "object",
1640+
"properties" => {
1641+
"first_name" => { "type" => "string" },
1642+
"hello_world" => { "type" => "string" },
1643+
"project" => {
1644+
"type" => "object",
1645+
"properties" => {
1646+
"first_name" => { "type" => "string" }
1647+
}
1648+
}
1649+
}
1650+
}
1651+
}
1652+
})
1653+
end
1654+
end
14871655
end
14881656

14891657
describe "collection" do
@@ -1731,7 +1899,7 @@
17311899
"name" => { "type" => "string" },
17321900
"users" => {
17331901
"type" => "array",
1734-
"items" => { "$ref" => "#/components/schemas/UserBlueprint" }
1902+
"items" => { "$ref" => "#/components/schemas/[\"UserBlueprint\", {view: :default}]" }
17351903
}
17361904
}
17371905
}
@@ -1885,7 +2053,7 @@
18852053
"name" => { "type" => "string" },
18862054
"users" => {
18872055
"type" => "array",
1888-
"items" => { "$ref" => "#/components/schemas/UserBlueprint" }
2056+
"items" => { "$ref" => "#/components/schemas/[\"UserBlueprint\", {view: :default}]" }
18892057
}
18902058
}
18912059
}

0 commit comments

Comments
 (0)