Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/protobuf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
require 'protobuf/message'
require 'protobuf/descriptors'

require 'protobuf/railtie' if defined?(::Rails::Railtie)

module Protobuf

class << self
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions lib/protobuf/railtie.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions lib/protobuf/rpc/application_service.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions lib/protobuf/rpc/service.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require 'active_support/core_ext/class/subclasses'
require 'protobuf/logging'
require 'protobuf/message'
require 'protobuf/rpc/client'
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions lib/protobuf/service_loader.rb
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions spec/lib/protobuf/rpc/application_service_spec.rb
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions spec/lib/protobuf/service_loader_spec.rb
Original file line number Diff line number Diff line change
@@ -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