Twirp on Rails (Twirp::Rails)
Motivation
Serving Twirp RPC Services should be as easy and familiar as Rails controllers. We add a few helpful abstractions, but don't hide Twirp, Protobufs, or make it seem too magical.
Out of the box, the twirp
gem lets you add Services, but it feels clunky coming from Rails REST-ful APIs. We make it simple to build full-featured APIs. Hook in authorization, use before_action
and more.
Extracted from a real, production application with many thousands of users.
Installation
Install the gem using gem install twirp-on-rails
or simply add it to your Gemfile
:
gem "twirp-on-rails"
Usage
Add to your routes.rb
:
mount Twirp::Rails::Engine, at: "/twirp"
Generate your _pb.rb
and _twirp.rb
files
Generate files how Twirp-Ruby recommends.
Example:
protoc --ruby_out=./lib --twirp_ruby_out=./lib haberdasher.proto
We (currently) don't run protoc
for you and have no opinions where you put the generated files.
Ok, one small opinion: we default to looking in lib/
, but you can change that.
Configuration
Twirp::Rails will automatically load any *_twirp.rb
files in your app's lib/
directory (and subdirectories). To modify the location, add this to an initializer:
Rails.application.config.load_paths = ["lib", "app/twirp"]
Features
Easy Routing
Add one line to your config/routes.rb
and routes are built automatically from your Twirp Services:
mount Twirp::Rails::Engine, at: "/twirp"
/twirp/twirp.example.haberdasher.HaberdasherService/MakeHat
These are routed to Handlers in app/handlers/
based on expected naming conventions.
For example if you have this service defined:
package twirp.example.haberdasher;
service HaberdasherService {
rpc MakeHat(Size) returns (Hat);
}
it will expect to find app/handlers/haberdasher_service_handler.rb
with a make_hat
method.
class HaberdasherServiceHandler < Twirp::Rails::Handler
def make_hat
end
end
Each handler method should return the appropriate Protobuf, or a Twirp::Error
.
Packages and Namespacing
Handlers can live in directories that reflect the service's package. For example, haberdasher.proto
defines:
package twirp.example.haberdasher;
You can use the full path, or because many projects have only one namespace, we also let you skip the namespace for simplicity:
We look for the handler in either location:
app/handlers/twirp/example/haberdasher/haberdasher_service_handler.rb
defines Twirp::Example::Haberdasher::HaberdasherServiceHandler
or
app/handlers/haberdasher_service_handler.rb
defines HaberdasherServiceHandler
TODO: Give more examples of handlers
Familiar Callbacks
Use before_action
, around_action
, and other callbacks you're used to, as we build on AbstractController::Callbacks.
rescue_from
Use rescue_from
just like you would in a controller:
class HaberdasherServiceHandler < Twirp::Rails::Handler
rescue_from "ArgumentError" do |error|
Twirp::Error.invalid_argument(error.message)
end
rescue_from "Pundit::NotAuthorizedError", :not_authorized
...
end
DRY Service Hooks
Apply Service Hooks one time across multiple services.
For example, we can add hooks in an initializer:
# Make IP address accessible to the handlers
Rails.application.config.twirp.service_hooks[:before] = lambda do |rack_env, env|
env[:ip] = rack_env["REMOTE_ADDR"]
end
# Send exceptions to Honeybadger
Rails.application.config.twirp.service_hooks[:exception_raised] = ->(exception, _env) { Honeybadger.notify(exception) }
Middleware
As an Engine, we avoid all the standard Rails middleware. That's nice for simplicity, but sometimes you want to add your own middleware. You can do that by specifying it in an initializer:
Rails.application.config.twirp.middleware = [Rack::Deflater]
Bonus Features
Outside the Twirp spec, we have some (optional) extra magic. They might be useful to you, but you can easily ignore them too.
Basic Caching with ETags/If-None-Match Headers
Like Rails GET actions, Twirp::Rails handlers add ETag
headers based on the response's content.
If you have RPCs that can be cached, you can have your Twirp clients send an If-None-Match
Header. Twirp::Rails will return a 304 Not Modified
HTTP status and not re-send the body if the ETag matches.
Enable by adding this to an initializer:
Rails.application.config.twirp.middleware = [
Twirp::Rails::Rack::ConditionalPost,
Rack::ETag
]
TODO
- More docs!
- More tests!
- installer generator to add
ApplicationHandler
- Maybe a generator for individual handlers that adds that if needed?
- Auto reload.
- Make service hooks more configurable? Apply to one service instead of all?
- Loosen Rails version requirement? Probably works, but haven't tested.
Prior Art
We evaluated all these projects and found them to be bad fits for us, for one reason or another. We're grateful to all for their work, and hope they continue and flourish. Some notes from our initial evaluation:
- Nice routing abstraction
- Minimal Handler abstraction
- Untouched for 4 years
- Too much setup.
- Nice controllers, but expects you to use their pbbuilder which I find unnecessary.
- Some nice things
- No Handler abstractions
- Archived and not touched for 3 years
- Allows routing to existing controllers
- I dislike the
respond_to
stuff. That shouldn't be something you think about. We have a better way to do that in other recent apps anyway.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/danielmorrison/twirp-rails.
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and the created tag, and push the .gem
file to rubygems.org.
License
The gem is available as open source under the terms of the MIT License.