diff --git a/CHANGELOG.md b/CHANGELOG.md index f297f00a..50dc7981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Added - [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 ### Fixed diff --git a/Gemfile b/Gemfile index 8ce5b9e6..e79c965e 100644 --- a/Gemfile +++ b/Gemfile @@ -26,4 +26,5 @@ group :test do gem "prism" gem "redis-client" gem "appraisal", "~> 2.5" + gem "blueprinter" end diff --git a/lib/rage/openapi/parsers/ext/blueprinter.rb b/lib/rage/openapi/parsers/ext/blueprinter.rb index 20ffa48c..499d16ad 100644 --- a/lib/rage/openapi/parsers/ext/blueprinter.rb +++ b/lib/rage/openapi/parsers/ext/blueprinter.rb @@ -14,107 +14,31 @@ def known_definition?(str) end def parse(klass_str) - visitor = __parse(klass_str) - visitor.build_schema + is_collection, raw_klass_str, _ = Rage::OpenAPI.__parse_serializer_args(klass_str) + klass = @namespace.const_get(raw_klass_str) + build_schema(klass, is_collection) end - def __parse(klass_str) - is_collection, klass_str, _ = Rage::OpenAPI.__parse_serializer_args(klass_str) + private - klass = @namespace.const_get(klass_str) - source_path, _ = Object.const_source_location(klass.name) - ast = Prism.parse_file(source_path) + def build_schema(klass, is_collection) + reflections = klass.reflections + identifier_fields = extract_fields(reflections, :identifier) + default_fields = extract_fields(reflections, :default) - visitor = Visitor.new(self, is_collection) - ast.value.accept(visitor) + schema = identifier_fields.merge(default_fields.sort.to_h) - visitor + result = { "type" => "object" } + result["properties"] = schema if schema.any? + result = { "type" => "array", "items" => result } if is_collection + result end - class VisitorContext - attr_accessor :symbols, :keywords, :strings + def extract_fields(reflections, view_name) + return {} unless (view = reflections[view_name]) - def initialize - @symbols = [] - @strings = [] - @keywords = {} - end - end - - class Visitor < Prism::Visitor - attr_accessor :schema, :identifier - - def initialize(parser, is_collection) - @parser = parser - @is_collection = is_collection - - @context = nil - @schema = {} - @segment = @schema - @identifier = {} - end - - def build_schema - result = { "type" => "object" } - - properties = {} - properties.merge!(@identifier) - properties.merge!(@schema.sort.to_h) - - result["properties"] = properties if properties.any? - result = { "type" => "array", "items" => result } if @is_collection - result - end - - def visit_class_node(node) - if node.superclass && node.superclass.full_name != "Blueprinter::Base" - visitor = @parser.__parse(node.superclass.name.to_s) - @identifier.merge!(visitor.identifier) - @schema.merge!(visitor.schema) - end - - super - end - - def visit_call_node(node) - case node.name - when :identifier - context = with_context { visit(node.arguments) } - @identifier[context.symbols.first] = { "type" => "string" } - - when :fields, :field - context = with_context { visit(node.arguments) } - - if context.keywords["name"] - @segment[context.keywords["name"]] = { "type" => "string" } - elsif node.block - @segment[context.symbols.first] = { "type" => "string" } if context.symbols.first - @segment[context.strings.first] = { "type" => "string" } if context.strings.first - else - context.symbols.each { |symbol| @segment[symbol] = { "type" => "string" } } - context.strings.each { |string| @segment[string] = { "type" => "string" } } - end - end - end - - def visit_assoc_node(node) - @context.keywords[node.key.value] = node.value.unescaped - end - - def visit_symbol_node(node) - @context.symbols << node.value - end - - def visit_string_node(node) - @context.strings << node.unescaped - end - - private - - def with_context - @context = VisitorContext.new - yield - @context + view.fields.each_with_object({}) do |(_, field), hash| + hash[field.display_name.to_s] = { "type" => "string" } end end end diff --git a/spec/openapi/parsers/ext/blueprinter_spec.rb b/spec/openapi/parsers/ext/blueprinter_spec.rb index 286923c3..9f7427d0 100644 --- a/spec/openapi/parsers/ext/blueprinter_spec.rb +++ b/spec/openapi/parsers/ext/blueprinter_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "prism" +require "blueprinter" RSpec.describe Rage::OpenAPI::Parsers::Ext::Blueprinter do include_context "mocked_classes" @@ -13,10 +13,8 @@ let(:resource) { "UserBlueprint" } context "with an empty blueprint" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - end RUBY end @@ -26,33 +24,28 @@ class UserBlueprint < Blueprinter::Base end context "with basic fields" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - fields :id, :name, :email, :age - end + fields :id, :name, :email, :age RUBY end - it do is_expected.to eq({ "type" => "object", "properties" => { - "id" => { "type" => "string" }, - "name" => { "type" => "string" }, + "age" => { "type" => "string" }, "email" => { "type" => "string" }, - "age" => { "type" => "string" } + "id" => { "type" => "string" }, + "name" => { "type" => "string" } } }) end end context "when fields are declared with strings" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - fields "id", "name", "email" - end + fields "id", "name", "email" RUBY end @@ -69,11 +62,9 @@ class UserBlueprint < Blueprinter::Base end context "with identifier" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - identifier :uuid - end + identifier :uuid RUBY end @@ -88,11 +79,9 @@ class UserBlueprint < Blueprinter::Base end context "with a single field" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - field :email - end + field :email RUBY end @@ -107,11 +96,9 @@ class UserBlueprint < Blueprinter::Base end context "with field name alias" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - field :email, name: :login - end + field :email, name: :login RUBY end @@ -126,11 +113,9 @@ class UserBlueprint < Blueprinter::Base end context "when field alias is declared with string values" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - field "email", name: "login" - end + field "email", name: "login" RUBY end @@ -145,11 +130,9 @@ class UserBlueprint < Blueprinter::Base end context "with a block field" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - field(:full_name) { |u| "#{u.first_name} #{u.last_name}" } - end + field(:full_name) { |u| "#{u.first_name} #{u.last_name}" } RUBY end @@ -164,11 +147,9 @@ class UserBlueprint < Blueprinter::Base end context "with a block field declared with string values" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - field("full_name") { |u| "#{u.first_name} #{u.last_name}" } - end + field("full_name") { |u| "#{u.first_name} #{u.last_name}" } RUBY end @@ -183,15 +164,13 @@ class UserBlueprint < Blueprinter::Base end context "with all declaration types combined" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - identifier :uuid - fields :id, :name, :age - field :email, name: :login - fields :first_name, :last_name - field(:full_name) { |u| "#{u.first_name} #{u.last_name}" } - end + identifier :uuid + fields :id, :name, :age + field :email, name: :login + fields :first_name, :last_name + field(:full_name) { |u| "#{u.first_name} #{u.last_name}" } RUBY end @@ -213,15 +192,11 @@ class UserBlueprint < Blueprinter::Base end context "with all declaration types combined with string values" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - identifier :uuid - fields "id", "name", "age" - field "email", name: "login" - fields "first_name", "last_name" - field("full_name") { |u| "#{u.first_name} #{u.last_name}" } - end + identifier :uuid + fields "id", "name", "age" + field "email", name: :login RUBY end @@ -233,52 +208,17 @@ class UserBlueprint < Blueprinter::Base "id" => { "type" => "string" }, "name" => { "type" => "string" }, "age" => { "type" => "string" }, - "login" => { "type" => "string" }, - "first_name" => { "type" => "string" }, - "last_name" => { "type" => "string" }, - "full_name" => { "type" => "string" } - } - }) - end - end - - context "with all declaration types combined with string and symbol vales" do - let_class("UserBlueprint") do - <<~'RUBY' - class UserBlueprint < Blueprinter::Base - identifier :uuid - fields :id, "name", :age - field :email, name: "login" - fields "first_name", :last_name - field("full_name") { |u| "#{u.first_name} #{u.last_name}" } - end - RUBY - end - - it do - is_expected.to eq({ - "type" => "object", - "properties" => { - "uuid" => { "type" => "string" }, - "id" => { "type" => "string" }, - "name" => { "type" => "string" }, - "age" => { "type" => "string" }, - "login" => { "type" => "string" }, - "first_name" => { "type" => "string" }, - "last_name" => { "type" => "string" }, - "full_name" => { "type" => "string" } + "login" => { "type" => "string" } } }) end end context "ensures identifier appears first in properties regardless of definition order" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - fields :name, :email - identifier :uuid - end + fields :name, :email + identifier :uuid RUBY end it do @@ -287,19 +227,15 @@ class UserBlueprint < Blueprinter::Base end context "with inheritance from another blueprint" do - let_class("BaseUserBlueprint") do + let_class("BaseUserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class BaseUserBlueprint < Blueprinter::Base - fields :id, :name - end + fields :id, :name RUBY end - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: mocked_classes["BaseUserBlueprint"]) do <<~'RUBY' - class UserBlueprint < BaseUserBlueprint - fields :email, :age - end + fields :email, :age RUBY end @@ -317,11 +253,9 @@ class UserBlueprint < BaseUserBlueprint end context "when superclass is Base (should not merge)" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - fields :id, :name - end + fields :id, :name RUBY end @@ -337,19 +271,15 @@ class UserBlueprint < Blueprinter::Base end context "when child blueprint overrides a parent field" do - let_class("BaseUserBlueprint") do + let_class("BaseUserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class BaseUserBlueprint < Blueprinter::Base - fields :id, :name - end + fields :id, :name RUBY end - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: mocked_classes["BaseUserBlueprint"]) do <<~'RUBY' - class UserBlueprint < BaseUserBlueprint - fields :name, :email - end + fields :name, :email RUBY end @@ -366,27 +296,21 @@ class UserBlueprint < BaseUserBlueprint end context "with multiple levels of inheritance" do - let_class("GrandparentBlueprint") do + let_class("GrandparentBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class GrandparentBlueprint < Blueprinter::Base - fields :id, :name - end + fields :id, :name RUBY end - let_class("ParentBlueprint") do + let_class("ParentBlueprint", parent: mocked_classes["GrandparentBlueprint"]) do <<~'RUBY' - class ParentBlueprint < GrandparentBlueprint - fields :email - end + fields :email RUBY end - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: mocked_classes["ParentBlueprint"]) do <<~'RUBY' - class UserBlueprint < ParentBlueprint - fields :age - end + fields :age RUBY end @@ -404,21 +328,17 @@ class UserBlueprint < ParentBlueprint end context "with identifier in parent blueprint" do - let_class("BaseUserBlueprint") do + let_class("BaseUserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class BaseUserBlueprint < Blueprinter::Base - identifier :uuid - fields :name - end + identifier :uuid + fields :name RUBY end - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: mocked_classes["BaseUserBlueprint"]) do <<~'RUBY' - class UserBlueprint < BaseUserBlueprint - identifier :id - fields :email - end + identifier :id + fields :email RUBY end @@ -442,11 +362,9 @@ class UserBlueprint < BaseUserBlueprint let(:resource) { "Array" } context "with basic fields" do - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - fields :id, :name, :email - end + fields :id, :name, :email RUBY end @@ -467,12 +385,10 @@ class UserBlueprint < Blueprinter::Base context "with identifier" do let(:resource) { "[UserBlueprint]" } - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class UserBlueprint < Blueprinter::Base - identifier :uuid - fields :name, :email - end + identifier :uuid + fields :name, :email RUBY end @@ -492,19 +408,15 @@ class UserBlueprint < Blueprinter::Base end context "with inherited fields" do - let_class("BaseUserBlueprint") do + let_class("BaseUserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class BaseUserBlueprint < Blueprinter::Base - fields :id, :name - end + fields :id, :name RUBY end - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: mocked_classes["BaseUserBlueprint"]) do <<~'RUBY' - class UserBlueprint < BaseUserBlueprint - fields :email - end + fields :email RUBY end @@ -524,25 +436,19 @@ class UserBlueprint < BaseUserBlueprint end context "with multiple levels of inheritance" do - let_class("GrandparentBlueprint") do + let_class("GrandparentBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class GrandparentBlueprint < Blueprinter::Base - fields :id, :name - end + fields :id, :name RUBY end - let_class("ParentBlueprint") do + let_class("ParentBlueprint", parent: mocked_classes["GrandparentBlueprint"]) do <<~'RUBY' - class ParentBlueprint < GrandparentBlueprint - fields :email - end + fields :email RUBY end - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: mocked_classes["ParentBlueprint"]) do <<~'RUBY' - class UserBlueprint < ParentBlueprint - fields :age - end + fields :age RUBY end it do @@ -562,20 +468,16 @@ class UserBlueprint < ParentBlueprint end context "with identifier in parent blueprint" do - let_class("BaseUserBlueprint") do + let_class("BaseUserBlueprint", parent: Blueprinter::Base) do <<~'RUBY' - class BaseUserBlueprint < Blueprinter::Base - identifier :uuid - fields :name - end + identifier :uuid + fields :name RUBY end - let_class("UserBlueprint") do + let_class("UserBlueprint", parent: mocked_classes["BaseUserBlueprint"]) do <<~'RUBY' - class UserBlueprint < BaseUserBlueprint - identifier :id - fields :email - end + identifier :id + fields :email RUBY end it "inherits identifier from parent" do diff --git a/spec/support/contexts/mocked_classes.rb b/spec/support/contexts/mocked_classes.rb index cb80288e..85671b8c 100644 --- a/spec/support/contexts/mocked_classes.rb +++ b/spec/support/contexts/mocked_classes.rb @@ -25,6 +25,15 @@ class #{class_name} #{"< #{parent.name}" if parent != Object} end klass = Class.new(parent, &block) + + if block + if defined?(Blueprinter::Base) && parent.ancestors.include?(Blueprinter::Base) + klass.class_eval(block.call) + else + klass.class_eval(&block) + end + end + klass.define_singleton_method(:name) { class_name } mocked_classes[class_name] = klass