diff --git a/lib/protobuf.rb b/lib/protobuf.rb index c6040fd4..3e269dc0 100644 --- a/lib/protobuf.rb +++ b/lib/protobuf.rb @@ -44,6 +44,8 @@ require 'protobuf/message' require 'protobuf/descriptors' +require 'protobuf/railtie' if defined?(::Rails::Railtie) + module Protobuf class << self @@ -114,6 +116,7 @@ def self.ignore_unknown_fields=(value) unless ENV.key?('PB_NO_NETWORKING') require 'protobuf/rpc/client' require 'protobuf/rpc/service' + require 'protobuf/rpc/application_service' env_connector_type = ENV.fetch('PB_CLIENT_TYPE') do :socket diff --git a/lib/protobuf/railtie.rb b/lib/protobuf/railtie.rb new file mode 100644 index 00000000..b3659989 --- /dev/null +++ b/lib/protobuf/railtie.rb @@ -0,0 +1,29 @@ +require 'rails/railtie' +require 'protobuf/service_loader' + +module Protobuf + class Railtie < ::Rails::Railtie + config.protobuf = ::ActiveSupport::OrderedOptions.new + config.protobuf.services_path = nil + + initializer 'protobuf.configure_autoload', :before => :set_autoload_paths do |app| + path = ::Protobuf::Railtie.services_path(app) + if path && ::File.directory?(path) + app.config.autoload_paths.delete(path) + app.config.autoload_once_paths.delete(path) + app.config.eager_load_paths.delete(path) + end + end + + config.to_prepare do + ::Protobuf::ServiceLoader.load_services( + ::Protobuf::Railtie.services_path(::Rails.application), + ) + end + + def self.services_path(app) + configured = app.config.protobuf.services_path + configured ? configured.to_s : ::File.join(app.root.to_s, 'app', 'services') + end + end +end diff --git a/lib/protobuf/rpc/application_service.rb b/lib/protobuf/rpc/application_service.rb new file mode 100644 index 00000000..500d91c1 --- /dev/null +++ b/lib/protobuf/rpc/application_service.rb @@ -0,0 +1,11 @@ +require 'protobuf/rpc/service' + +module Protobuf + module Rpc + # Base class application services should inherit from. Mirrors the + # `ApplicationController` pattern: a stable seam for shared filters, + # callbacks, and helpers that belong to the host app rather than the gem. + class ApplicationService < Service + end + end +end diff --git a/lib/protobuf/rpc/service.rb b/lib/protobuf/rpc/service.rb index cc1974ad..7027532d 100644 --- a/lib/protobuf/rpc/service.rb +++ b/lib/protobuf/rpc/service.rb @@ -1,3 +1,4 @@ +require 'active_support/core_ext/class/subclasses' require 'protobuf/logging' require 'protobuf/message' require 'protobuf/rpc/client' @@ -65,9 +66,11 @@ class << self end # An array of defined service classes that contain implementation - # code + # code. Walks the full descendant tree so intermediate parents + # (e.g. ApplicationService) don't hide the real services from + # discovery. def self.implemented_services - classes = (subclasses || []).select do |subclass| + classes = descendants.select do |subclass| subclass.rpcs.any? do |(name, _)| subclass.method_defined? name end diff --git a/lib/protobuf/service_loader.rb b/lib/protobuf/service_loader.rb new file mode 100644 index 00000000..818ef797 --- /dev/null +++ b/lib/protobuf/service_loader.rb @@ -0,0 +1,19 @@ +module Protobuf + # Loads service classes from a directory tree so that + # `Protobuf::Rpc::Service.subclasses` is populated before the RPC server + # asks for `implemented_services`. Needed because Zeitwerk autoloading is + # lazy and `subclasses` only returns classes that have been loaded. + module ServiceLoader + DEFAULT_GLOB = '**/*_service.rb'.freeze + + def self.load_services(path, glob = DEFAULT_GLOB) + return [] if path.nil? + dir = path.to_s + return [] unless ::File.directory?(dir) + + ::Dir.glob(::File.join(dir, glob)).sort.each do |file| + load file + end + end + end +end diff --git a/spec/lib/protobuf/rpc/application_service_spec.rb b/spec/lib/protobuf/rpc/application_service_spec.rb new file mode 100644 index 00000000..60187913 --- /dev/null +++ b/spec/lib/protobuf/rpc/application_service_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' +require SUPPORT_PATH.join('resource_service') + +RSpec.describe Protobuf::Rpc::ApplicationService do + it 'is a subclass of Protobuf::Rpc::Service' do + expect(described_class.ancestors).to include(Protobuf::Rpc::Service) + end + + it 'inherits Service class methods (host, port, configure)' do + expect(described_class).to respond_to(:host, :port, :configure, :rpc, :rpcs) + end + + describe 'integration with implemented_services' do + let!(:app_subclass) do + Class.new(described_class) do + def self.name; 'ImplementedAppSubclassService'; end + rpc :find, Test::ResourceFindRequest, Test::Resource + def find; end + end + end + + let!(:abstract_subclass) do + Class.new(described_class) do + def self.name; 'AbstractAppSubclassService'; end + rpc :find, Test::ResourceFindRequest, Test::Resource + # no #find method defined -> not "implemented" + end + end + + after do + # `descendants` walks the live class tree; remove anonymous classes + # so they don't leak into other specs. + ObjectSpace.garbage_collect + end + + it 'is discovered by Service.implemented_services (descendants traversal)' do + names = Protobuf::Rpc::Service.implemented_services + expect(names).to include('ImplementedAppSubclassService') + end + + it 'omits subclasses with no implemented rpc methods' do + names = Protobuf::Rpc::Service.implemented_services + expect(names).not_to include('AbstractAppSubclassService') + end + + it 'does not list the ApplicationService base itself' do + names = Protobuf::Rpc::Service.implemented_services + expect(names).not_to include(described_class.name) + end + end +end diff --git a/spec/lib/protobuf/service_loader_spec.rb b/spec/lib/protobuf/service_loader_spec.rb new file mode 100644 index 00000000..52524cae --- /dev/null +++ b/spec/lib/protobuf/service_loader_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'protobuf/service_loader' + +RSpec.describe Protobuf::ServiceLoader do + describe '.load_services' do + around do |example| + Dir.mktmpdir do |dir| + @services_dir = dir + example.run + end + end + + let(:tracker) { [] } + + before do + # Expose the tracker globally so files loaded via `load` can reach it. + $service_loader_spec_tracker = tracker + end + + after do + $service_loader_spec_tracker = nil + end + + def write_service(relative_path, marker) + full = File.join(@services_dir, relative_path) + FileUtils.mkdir_p(File.dirname(full)) + File.write(full, "$service_loader_spec_tracker << #{marker.inspect}\n") + full + end + + it 'returns an empty array when path is nil' do + expect(described_class.load_services(nil)).to eq([]) + end + + it 'returns an empty array when directory does not exist' do + expect(described_class.load_services('/no/such/dir/here')).to eq([]) + end + + it 'loads every *_service.rb file under the path' do + write_service('foo_service.rb', 'foo') + write_service('nested/bar_service.rb', 'bar') + write_service('ignore_me.rb', 'nope') + + described_class.load_services(@services_dir) + + expect(tracker).to contain_exactly('foo', 'bar') + end + + it 'loads files in deterministic (sorted) order' do + write_service('b_service.rb', 'b') + write_service('a_service.rb', 'a') + write_service('c_service.rb', 'c') + + described_class.load_services(@services_dir) + + expect(tracker).to eq(%w(a b c)) + end + + it 're-executes files on subsequent calls (Rails to_prepare reload semantics)' do + write_service('reload_service.rb', 'one') + + described_class.load_services(@services_dir) + described_class.load_services(@services_dir) + + expect(tracker).to eq(%w(one one)) + end + + it 'accepts a Pathname for the path' do + write_service('path_service.rb', 'path') + + described_class.load_services(Pathname.new(@services_dir)) + + expect(tracker).to eq(['path']) + end + + it 'honors a custom glob' do + write_service('foo_handler.rb', 'handler') + write_service('bar_service.rb', 'service') + + described_class.load_services(@services_dir, '**/*_handler.rb') + + expect(tracker).to eq(['handler']) + end + end +end