diff --git a/CHANGELOG.md b/CHANGELOG.md index 53eb6d25..d1cc94a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - [OpenAPI] Add support for blueprint inheritance. Child blueprints now inherit fields from parent blueprints. - [OpenAPI] Refactor `Rage::OpenAPI::Parsers::Ext::Blueprinter` using live reflection instead of Prism AST traversal to simplify schema extraction +- [OpenAPI] Add Blueprinter association parsing with cycle detection (#291) ### Fixed diff --git a/lib/rage/openapi/parsers/ext/blueprinter.rb b/lib/rage/openapi/parsers/ext/blueprinter.rb index 499d16ad..4b500a57 100644 --- a/lib/rage/openapi/parsers/ext/blueprinter.rb +++ b/lib/rage/openapi/parsers/ext/blueprinter.rb @@ -4,6 +4,7 @@ class Rage::OpenAPI::Parsers::Ext::Blueprinter def initialize(namespace: Object, root: Rage::OpenAPI::Nodes::Root.new, **) @namespace = namespace @root = root + @parsing_stack = Set.new end def known_definition?(str) @@ -16,17 +17,28 @@ def known_definition?(str) def parse(klass_str) is_collection, raw_klass_str, _ = Rage::OpenAPI.__parse_serializer_args(klass_str) klass = @namespace.const_get(raw_klass_str) - build_schema(klass, is_collection) + schema = build_schema(klass, is_collection) + + if @root.schema_registry.key?(raw_klass_str) + @root.schema_registry[raw_klass_str] = is_collection ? schema["items"] : schema + end + + schema end private def build_schema(klass, is_collection) + @parsing_stack.add(klass.name) + reflections = klass.reflections identifier_fields = extract_fields(reflections, :identifier) default_fields = extract_fields(reflections, :default) + association_fields = extract_associations(reflections, :default) + + @parsing_stack.delete(klass.name) - schema = identifier_fields.merge(default_fields.sort.to_h) + schema = identifier_fields.merge(default_fields.merge(association_fields).sort.to_h) result = { "type" => "object" } result["properties"] = schema if schema.any? @@ -41,4 +53,25 @@ def extract_fields(reflections, view_name) hash[field.display_name.to_s] = { "type" => "string" } end end + + def extract_associations(reflections, view_name) + return {} unless (view = reflections[view_name]) + + view.associations.each_with_object({}) do |(_, assoc), hash| + blueprint = assoc.blueprint + + if @parsing_stack.include?(blueprint&.name) + @root.schema_registry[blueprint&.name] ||= nil + hash[assoc.display_name.to_s] = { + "type" => "array", + "items" => { "$ref" => "#/components/schemas/#{blueprint&.name}" } + } + else + hash[assoc.display_name.to_s] = { + "type" => "array", + "items" => build_schema(blueprint, false) + } + end + end + end end diff --git a/spec/openapi/parsers/ext/blueprinter_spec.rb b/spec/openapi/parsers/ext/blueprinter_spec.rb index 9f7427d0..d5770c92 100644 --- a/spec/openapi/parsers/ext/blueprinter_spec.rb +++ b/spec/openapi/parsers/ext/blueprinter_spec.rb @@ -356,6 +356,347 @@ expect(subject["properties"].keys[1]).to eq("id") end end + + context "with a basic association" do + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :name + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + before { ProjectBlueprint } + + it "defaults to array type with nested blueprint schema" do + is_expected.to eq({ + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + }) + end + end + + context "with association name alias" do + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :name + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint, name: :work_projects + RUBY + end + + it "uses the name alias as the association key" do + is_expected.to eq({ + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "work_projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + }) + end + end + + context "with circular association" do + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :name + association :user, blueprint: UserBlueprint + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + it "does not loop infinitely and falls back to $ref for circular reference" do + expect { subject }.not_to raise_error + is_expected.to eq({ + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "user" => { + "type" => "array", + "items" => { "$ref" => "#/components/schemas/UserBlueprint" } + } + } + } + } + } + }) + end + end + + context "with association across multiple levels of inheritance" do + let_class("TagBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :label + RUBY + end + + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :name + association :tags, blueprint: TagBlueprint + RUBY + end + + let_class("BaseUserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + let_class("UserBlueprint", parent: mocked_classes["BaseUserBlueprint"]) do + <<~'RUBY' + fields :first_name + RUBY + end + + it "includes identifier in collection items" do + is_expected.to eq({ + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "first_name" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "tags" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "label" => { "type" => "string" } + } + } + } + } + } + } + } + }) + end + end + + context "with identifier in associated blueprint" do + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + identifier :uuid + fields :name + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + identifier :id + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + it "includes identifier in nested blueprint schema" do + is_expected.to eq({ + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "uuid" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + }) + end + + it "ensures identifier appears first in properties" do + expect(subject["properties"].keys.first).to eq("id") + expect(subject.dig("properties", "projects", "items", "properties").keys.first).to eq("uuid") + end + end + + context "with circular association through inheritance" do + let_class("BaseProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :name + RUBY + end + + let_class("ProjectBlueprint", parent: mocked_classes["BaseProjectBlueprint"]) do + <<~'RUBY' + fields :description + association :user, blueprint: UserBlueprint + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + it "does not loop infinitely and falls back to $ref for circular reference" do + expect { subject }.not_to raise_error + is_expected.to eq({ + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "description" => { "type" => "string" }, + "name" => { "type" => "string" }, + "user" => { + "type" => "array", + "items" => { "$ref" => "#/components/schemas/UserBlueprint" } + } + } + } + } + } + }) + end + end + + context "with multiple associations" do + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :name + RUBY + end + + let_class("TeamBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :name + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + association :teams, blueprint: TeamBlueprint + RUBY + end + + it "includes schemas for all associations" do + is_expected.to eq({ + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + }, + "teams" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + }) + end + end + + context "with namespaced association" do + unless Object.const_defined?(:V1) + Object.const_set(:V1, Module.new) + end + V1::ProjectBlueprint = Class.new(Blueprinter::Base) + V1::ProjectBlueprint.class_eval do + fields :id, :name + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: V1::ProjectBlueprint + RUBY + end + + it "resolves namespaced blueprint" do + is_expected.to eq({ + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + }) + end + end end describe "collection" do @@ -497,5 +838,372 @@ expect(subject["items"]["properties"].keys[1]).to eq("id") end end + + context "with a basic association" do + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :name + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + it "defaults to array type with nested blueprint schema" do + is_expected.to eq({ + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + } + }) + end + end + + context "with association name alias" do + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :name + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint, name: :work_projects + RUBY + end + + it "uses the name alias as the association key" do + is_expected.to eq({ + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "work_projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + } + }) + end + end + + context "with circular association" do + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :name + association :user, blueprint: UserBlueprint + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + it "does not loop infinitely and falls back to $ref for circular reference" do + expect { subject }.not_to raise_error + is_expected.to eq({ + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "user" => { + "type" => "array", + "items" => { "$ref" => "#/components/schemas/UserBlueprint" } + } + } + } + } + } + } + }) + end + end + + context "with association across multiple levels of inheritance" do + let_class("TagBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :label + RUBY + end + + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :name + association :tags, blueprint: TagBlueprint + RUBY + end + + let_class("BaseUserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + let_class("UserBlueprint", parent: mocked_classes["BaseUserBlueprint"]) do + <<~'RUBY' + fields :first_name + RUBY + end + + it "inherits and resolves nested associations across multiple blueprint levels" do + is_expected.to eq({ + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "first_name" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "tags" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "label" => { "type" => "string" } + } + } + } + } + } + } + } + } + }) + end + end + + context "with identifier in associated blueprint" do + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + identifier :uuid + fields :name + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + identifier :id + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + it "includes identifier in nested blueprint schema" do + is_expected.to eq({ + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "uuid" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + } + }) + end + + it "ensures identifier appears first in properties" do + expect(subject["items"]["properties"].keys.first).to eq("id") + expect(subject.dig("items", "properties", "projects", "items", "properties").keys.first).to eq("uuid") + end + end + + context "with circular association through inheritance" do + let_class("BaseProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :name + RUBY + end + + let_class("ProjectBlueprint", parent: mocked_classes["BaseProjectBlueprint"]) do + <<~'RUBY' + fields :description + association :user, blueprint: UserBlueprint + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + RUBY + end + + it "does not loop infinitely and falls back to $ref for circular reference" do + expect { subject }.not_to raise_error + is_expected.to eq({ + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "description" => { "type" => "string" }, + "name" => { "type" => "string" }, + "user" => { + "type" => "array", + "items" => { "$ref" => "#/components/schemas/UserBlueprint" } + } + } + } + } + } + } + }) + end + end + + context "with multiple associations" do + let(:resource) { "Array" } + + let_class("ProjectBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :name + RUBY + end + + let_class("TeamBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :id, :name + RUBY + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: ProjectBlueprint + association :teams, blueprint: TeamBlueprint + RUBY + end + + it "includes schemas for all associations" do + is_expected.to eq({ + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + }, + "teams" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + } + }) + end + end + + context "with namespaced association" do + let(:resource) { "Array" } + + unless Object.const_defined?(:V1) + Object.const_set(:V1, Module.new) + end + V1::ProjectBlueprint = Class.new(Blueprinter::Base) unless V1.const_defined?(:ProjectBlueprint) + V1::ProjectBlueprint.class_eval do + fields :id, :name + end + + let_class("UserBlueprint", parent: Blueprinter::Base) do + <<~'RUBY' + fields :email + association :projects, blueprint: V1::ProjectBlueprint + RUBY + end + + it "resolves namespaced blueprint" do + is_expected.to eq({ + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "email" => { "type" => "string" }, + "projects" => { + "type" => "array", + "items" => { + "type" => "object", + "properties" => { + "id" => { "type" => "string" }, + "name" => { "type" => "string" } + } + } + } + } + } + }) + end + end end end diff --git a/spec/support/contexts/mocked_classes.rb b/spec/support/contexts/mocked_classes.rb index 85671b8c..b9d4ab98 100644 --- a/spec/support/contexts/mocked_classes.rb +++ b/spec/support/contexts/mocked_classes.rb @@ -29,8 +29,8 @@ class #{class_name} #{"< #{parent.name}" if parent != Object} if block if defined?(Blueprinter::Base) && parent.ancestors.include?(Blueprinter::Base) klass.class_eval(block.call) - else - klass.class_eval(&block) + Object.send(:remove_const, class_name) if Object.const_defined?(class_name, false) + Object.const_set(class_name, klass) end end