Twirp service handlers receive the input message and also a Twirp environment that can be used to read headers from the request, environment data from before hooks and also set headers for the response

This commit is contained in:
Mario Izquierdo 2018-02-21 18:38:23 -08:00
Родитель 851adf93bc
Коммит 0286c3e057
6 изменённых файлов: 176 добавлений и 107 удалений

Просмотреть файл

@ -4,85 +4,86 @@ Twirp services and clients in Ruby.
### Installation
Install the `twirp` gem:
```sh
➜ gem install twirp
```
Use `go get` to install the ruby_twirp protoc plugin:
```sh
➜ go get github.com/cyrusaf/ruby-twirp/protoc-gen-twirp_ruby
```
You will also need:
- [protoc](https://github.com/golang/protobuf), the protobuf compiler. You need
version 3+.
### Haberdasher Example
### HelloWorld Example
See the `example/` folder for the final product.
First create a basic `.proto` file:
```protobuf
// haberdasher.proto
syntax = "proto3";
package example;
service Haberdasher {
rpc HelloWorld(HelloWorldRequest) returns (HelloWorldResponse);
service HelloWorld {
rpc Hello(HelloRequest) returns (HelloResponse);
}
message HelloWorldRequest {
message HelloRequest {
string name = 1;
}
message HelloWorldResponse {
message HelloResponse {
string message = 1;
}
```
Run the `protoc` binary to generate `gen/haberdasher_pb.rb` and `gen/haberdasher_twirp.rb`.
Run the `protoc` binary to auto-generate `helloworld_pb.rb` and `haberdasher_twirp.rb` files:
```sh
➜ protoc --proto_path=. ./haberdasher.proto --ruby_out=gen --twirp_ruby_out=gen
```
Write an implementation of our haberdasher service and attach to a rack server:
Write a handler for the auto-generated service, this is your implementation:
```ruby
class HellowWorldHandler
def hello(input, env)
{message: "Hello #{input.name}"}
end
end
```
Initialize the service with your handler and mount it as a Rack app:
```ruby
# main.rb
require 'rack'
require_relative 'gen/haberdasher_pb.rb'
require_relative 'gen/haberdasher_twirp.rb'
class HaberdasherHandler
def hello_world(req)
return {message: "Hello #{req.name}"}
end
end
handler = HaberdasherHandler.new()
service = Example::HaberdasherService.new(handler)
handler = HellowWorldHandler.new()
service = Example::HelloWorld.new(handler)
Rack::Handler::WEBrick.run service
```
You can also mount onto a rails service:
You can also mount onto a rails app:
```ruby
App::Application.routes.draw do
handler = HaberdasherHandler.new()
service = Example::HaberdasherService.new(handler)
mount service, at: HaberdasherService::PATH_PREFIX
mount service, at: service.path_prefix
end
```
Run `ruby main.rb` to start the server on port 8080:
```sh
➜ ruby main.rb
```
Twirp services accept both Protobuf and JSON messages. It is easy to `curl` your service to get a response:
`curl` your server to get a response:
```sh
curl --request POST \
--url http://localhost:8080/twirp/examples.Haberdasher/HelloWorld \
curl --request POST \
--url http://localhost:8080/twirp/example.HelloWorld/Hello \
--header 'content-type: application/json' \
--data '{
"name": "World"
}'
--data '{"name":"World"}'
```

Просмотреть файл

@ -1,4 +1,5 @@
require_relative 'twirp/version'
require_relative 'twirp/error'
require_relative 'twirp/exception'
require_relative 'twirp/environment'
require_relative 'twirp/service'

41
lib/twirp/environment.rb Normal file
Просмотреть файл

@ -0,0 +1,41 @@
module Twirp
class Environment
def initialize(rack_request)
@rack_request = rack_request
@response_http_headers = {}
@data = {}
end
def [](key)
@data[key]
end
def []=(key, value)
@data[key] = value
end
def get_http_request_header(header)
@rack_request.get_header(header)
end
def set_http_response_header(header, value)
@response_http_headers[header] = value
end
# Accessing the raw Rack::Request is convenient, but it is
# discouraged because it adds extra dependencies to your handler.
# Instead of directly accessing the rack_request, it is better to
# add a before hook in the service that reads data from the Rack environment
# and adds it to the Twirp environment, so all dependencies are clear. Example:
# svc.before do |rpc, input, env|
# env[:user] = env.rack_request.env['warden'].user
# end
#
def rack_request
@rack_request
end
end
end

Просмотреть файл

@ -26,7 +26,7 @@ module Twirp
rpc_method: rpc_method.to_s,
input_class: input_class,
output_class: output_class,
handler_method: opts[:handler_method].to_s,
handler_method: opts[:handler_method].to_sym,
}
end
@ -68,38 +68,43 @@ module Twirp
# Instantiate a new service with a handler.
# The handler must implemnt all rpc methods required by this service.
def initialize(handler)
self.class.rpcs.each do |method_name, rpc|
if !handler.respond_to? rpc[:handler_method]
raise ArgumentError.new("Handler must respond to .#{rpc[:handler_method]}(input) in order to handle the message #{method_name}.")
@handler = handler
self.class.rpcs.each do |rpc_method, rpc|
m = rpc[:handler_method]
if !handler.respond_to?(m)
raise ArgumentError.new("Handler must respond to .#{m}(input) in order to handle the rpc method #{rpc_method.inspect}.")
end
if handler.method(m).arity != 2
raise ArgumentError.new("Hanler method #{m} must accept exactly 2 arguments (input, env).")
end
end
@handler = handler
end
# Setup a before hook on this service.
# Before hooks are called after the request has been successfully routed to a method.
# If multiple hooks are added, they are run in the same order as declared.
# The hook is a block that accepts 3 parameters:
# * rpc_method: rpc method as defined in the proto file.
# The hook is a block that accepts 3 parameters: (rpc, input, request)
# * rpc: rpc data for the current method with info like rpc[:rpc_method] and rpc[:input_class].
# * input: Protobuf message object that will be passed to the handler method.
# * request: the raw Rack::Request
# * env: the Twirp environment object that will be passed to the handler method.
#
# If the before hook returns a Twirp::Error then the request is inmediatly
# canceled, the handler method is not called, and that error is returned instead.
# Any other return value from the hook is ignored (nil or otherwise).
# If an excetion is raised from the hook, it will be handled just like exceptions
# raised from handler methods: they will trigger the error hook and wrapped with a Twirp::Error.
# If an excetion is raised from the hook the request is also canceled,
# and the exception is handled with the error hook (just like exceptions raised from methods).
#
# Usage Example:
#
# handler = ExampleHandler.new
# svc = ExampleService.new(handler)
#
# svc.before do |rpc_method, input, request|
# if request.get_header "Force-Error"
# svc.before do |rpc, input, env|
# if env.get_http_request_header "Force-Error"
# return Twirp.canceled_error("failed as recuested sir")
# end
# request.env["example_service.before_succeed"] = true # can be later accessed on the handler method
# env[:before_hook_called] = true # can be later accessed on the handler method
# env[:easy_access] = env.rack_request.env["rack.data"] # before hooks can be used to read data from the request
# end
#
# svc.before handler.method(:before) # you can also delegate the hook to the handler (to reuse helpers, etc)
@ -115,20 +120,21 @@ module Twirp
end
# Rack app handler.
def call(env)
request = Rack::Request.new(env)
rpc, content_type, bad_route = route_request(request)
def call(rack_env)
rack_request = Rack::Request.new(rack_env)
rpc, content_type, bad_route = route_request(rack_request)
if bad_route
return error_response(bad_route)
end
input = decode_request(rpc[:input_class], content_type, request.body.read)
input = decode_request(rpc, content_type, rack_request.body.read)
env = Twirp::Environment.new(rack_request)
begin
if twerr = run_before_hooks(rpc[:rpc_method], input, request)
if twerr = run_before_hooks(rpc, input, env)
error_response(twerr)
end
handler_output = @handler.send(rpc[:handler_method], input)
handler_output = @handler.send(rpc[:handler_method], input, env)
if handler_output.is_a? Twirp::Error
return error_response(handler_output)
end
@ -176,43 +182,45 @@ module Twirp
return rpc, content_type, nil
end
def decode_request(input_class, content_type, body)
def decode_request(rpc, content_type, body)
case content_type
when "application/json"
input_class.decode_json(body)
rpc[:input_class].decode_json(body)
when "application/protobuf"
input_class.decode(body)
rpc[:input_class].decode(body)
end
end
# Before hooks are run in order after the request has been successfully routed to a Method.
def run_before_hooks(rpc_method, input, request)
def run_before_hooks(rpc, input, env)
return unless @before_hooks
@before_hooks.each do |hook|
twerr = hook.call(rpc_method, input, request)
twerr = hook.call(rpc, input, env)
return twerr if twerr && twerr.is_a?(Twirp::Error)
end
nil
end
def encode_response_from_handler(rpc, content_type, resp)
proto_class = rpc[:output_class]
def encode_response_from_handler(rpc, content_type, output)
output_class = rpc[:output_class]
# Handlers may return just the attributes
if resp.is_a? Hash
resp = proto_class.new(resp)
if output.is_a? Hash
output = output_class.new(output)
end
# Handlers may return nil, that should be converted to zero-values
if !resp
resp = proto_class.new
if output == nil
output = output_class.new # empty output with zero-values
end
if !output.is_a? output_class # validate return value
raise TypeError.new("Return value from .#{rpc[:handler_method]} expected to be an #{output_class.name} or Hash, but it is #{resp.class.name}")
end
case content_type
when "application/json"
proto_class.encode_json(resp)
output_class.encode_json(output)
when "application/protobuf"
proto_class.encode(resp)
output_class.encode(output)
end
end

Просмотреть файл

@ -41,8 +41,8 @@ class HaberdasherHandler
@block = block if block_given?
end
def make_hat(size)
@block && @block.call(size)
def make_hat(input, env)
@block && @block.call(input, env)
end
end

Просмотреть файл

@ -15,7 +15,7 @@ class ServiceTest < Minitest::Test
rpc_method: "MakeHat",
input_class: Example::Size,
output_class: Example::Hat,
handler_method: "make_hat",
handler_method: :make_hat,
}, Example::Haberdasher.rpcs["MakeHat"])
end
@ -54,12 +54,12 @@ class ServiceTest < Minitest::Test
err = assert_raises ArgumentError do
Example::Haberdasher.new("fake handler")
end
assert_equal "Handler must respond to .make_hat(input) in order to handle the message MakeHat.", err.message
assert_equal 'Handler must respond to .make_hat(input) in order to handle the rpc method "MakeHat".', err.message
end
def test_successful_json_request
env = json_req "/twirp/example.Haberdasher/MakeHat", inches: 10
status, headers, body = haberdasher_service.call(env)
rack_env = json_req "/twirp/example.Haberdasher/MakeHat", inches: 10
status, headers, body = haberdasher_service.call(rack_env)
assert_equal 200, status
assert_equal 'application/json', headers['Content-Type']
@ -67,8 +67,8 @@ class ServiceTest < Minitest::Test
end
def test_successful_proto_request
env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new(inches: 10)
status, headers, body = haberdasher_service.call(env)
rack_env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new(inches: 10)
status, headers, body = haberdasher_service.call(rack_env)
assert_equal 200, status
assert_equal 'application/protobuf', headers['Content-Type']
@ -76,8 +76,8 @@ class ServiceTest < Minitest::Test
end
def test_bad_route_with_wrong_rpc_method
env = json_req "/twirp/example.Haberdasher/MakeUnicorns", and_rainbows: true
status, headers, body = haberdasher_service.call(env)
rack_env = json_req "/twirp/example.Haberdasher/MakeUnicorns", and_rainbows: true
status, headers, body = haberdasher_service.call(rack_env)
assert_equal 404, status
assert_equal 'application/json', headers['Content-Type']
@ -89,9 +89,9 @@ class ServiceTest < Minitest::Test
end
def test_bad_route_with_wrong_http_method
env = Rack::MockRequest.env_for "/twirp/example.Haberdasher/MakeHat",
rack_env = Rack::MockRequest.env_for "/twirp/example.Haberdasher/MakeHat",
method: "GET", input: '{"inches": 10}', "CONTENT_TYPE" => "application/json"
status, headers, body = haberdasher_service.call(env)
status, headers, body = haberdasher_service.call(rack_env)
assert_equal 404, status
assert_equal 'application/json', headers['Content-Type']
@ -103,9 +103,9 @@ class ServiceTest < Minitest::Test
end
def test_bad_route_with_wrong_content_type
env = Rack::MockRequest.env_for "/twirp/example.Haberdasher/MakeHat",
rack_env = Rack::MockRequest.env_for "/twirp/example.Haberdasher/MakeHat",
method: "POST", input: 'free text', "CONTENT_TYPE" => "text/plain"
status, headers, body = haberdasher_service.call(env)
status, headers, body = haberdasher_service.call(rack_env)
assert_equal 404, status
assert_equal 'application/json', headers['Content-Type']
@ -117,8 +117,8 @@ class ServiceTest < Minitest::Test
end
def test_bad_route_with_wrong_path_json
env = json_req "/wrongpath", {}
status, headers, body = haberdasher_service.call(env)
rack_env = json_req "/wrongpath", {}
status, headers, body = haberdasher_service.call(rack_env)
assert_equal 404, status
assert_equal 'application/json', headers['Content-Type']
@ -130,8 +130,8 @@ class ServiceTest < Minitest::Test
end
def test_bad_route_with_wrong_path_protobuf
env = proto_req "/another/wrong.Path/MakeHat", Example::Empty.new()
status, headers, body = haberdasher_service.call(env)
rack_env = proto_req "/another/wrong.Path/MakeHat", Example::Empty.new()
status, headers, body = haberdasher_service.call(rack_env)
assert_equal 404, status
assert_equal 'application/json', headers['Content-Type'] # error responses are always JSON, even for Protobuf requests
@ -148,8 +148,8 @@ class ServiceTest < Minitest::Test
Example::Hat.new(inches: 11)
end)
env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new
status, headers, body = svc.call(env)
rack_env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new
status, headers, body = svc.call(rack_env)
assert_equal 200, status
assert_equal Example::Hat.new(inches: 11, color: ""), Example::Hat.decode(body[0])
@ -161,8 +161,8 @@ class ServiceTest < Minitest::Test
{inches: 11}
end)
env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new
status, headers, body = svc.call(env)
rack_env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new
status, headers, body = svc.call(rack_env)
assert_equal 200, status
assert_equal Example::Hat.new(inches: 11, color: ""), Example::Hat.decode(body[0])
@ -174,8 +174,8 @@ class ServiceTest < Minitest::Test
nil
end)
env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new
status, headers, body = svc.call(env)
rack_env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new
status, headers, body = svc.call(rack_env)
assert_equal 200, status
assert_equal Example::Hat.new(inches: 0, color: ""), Example::Hat.decode(body[0])
@ -187,8 +187,8 @@ class ServiceTest < Minitest::Test
return Twirp.invalid_argument_error "I don't like that size"
end)
env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new(inches: 666)
status, headers, body = svc.call(env)
rack_env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new(inches: 666)
status, headers, body = svc.call(rack_env)
assert_equal 400, status
assert_equal 'application/json', headers['Content-Type'] # error responses are always JSON, even for Protobuf requests
assert_equal({
@ -203,8 +203,8 @@ class ServiceTest < Minitest::Test
raise Twirp::Exception.new(:invalid_argument, "I don't like that size")
end)
env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new(inches: 666)
status, headers, body = svc.call(env)
rack_env = proto_req "/twirp/example.Haberdasher/MakeHat", Example::Size.new(inches: 666)
status, headers, body = svc.call(rack_env)
assert_equal 400, status
assert_equal 'application/json', headers['Content-Type'] # error responses are always JSON, even for Protobuf requests
assert_equal({
@ -213,12 +213,25 @@ class ServiceTest < Minitest::Test
}, JSON.parse(body[0]))
end
# TODO: Error handler
# def test_handler_raises_standard_error
# svc = Example::Haberdasher.new(HaberdasherHandler.new do |size|
# raise "random error"
# end)
# end
def test_handler_method_can_access_request_headers_through_the_env
svc = Example::Haberdasher.new(HaberdasherHandler.new do |size, env|
inches = env.get_http_request_header("INCHES_FROM_HEADER")
{inches: inches.to_i}
end)
rack_env = Rack::MockRequest.env_for "/twirp/example.Haberdasher/MakeHat", method: "POST",
input: '{"inches": 666}', # value should be ignored
"CONTENT_TYPE" => "application/json",
"INCHES_FROM_HEADER" => "7" # this value should be used
status, headers, body = svc.call(rack_env)
assert_equal 200, status
assert_equal({"inches" => 7}, JSON.parse(body[0]))
end
# TODO: test_handler_method_can_set_response_headers_through_the_env
# TODO: test_handler_raises_standard_error
def test_before_hook_simple
handler_method_called = false
@ -229,22 +242,27 @@ class ServiceTest < Minitest::Test
called_with = nil
svc = Example::Haberdasher.new(handler)
svc.before do |rpc_method, input, request|
called_with = {rpc_method: rpc_method, input: input, request: request}
svc.before do |rpc, input, env|
env[:contet_type] = env.rack_request.get_header("CONTENT_TYPE")
called_with = {rpc: rpc, input: input, env: env}
end
env = json_req "/twirp/example.Haberdasher/MakeHat", inches: 10
status, _, _ = svc.call(env)
rack_env = json_req "/twirp/example.Haberdasher/MakeHat", inches: 10
status, _, _ = svc.call(rack_env)
refute_nil called_with, "the before hook was called"
assert_equal "MakeHat", called_with[:rpc_method]
assert_equal "MakeHat", called_with[:rpc][:rpc_method]
assert_equal Example::Size.new(inches: 10), called_with[:input]
assert_equal "application/json", called_with[:request].get_header('CONTENT_TYPE') # the request is accessible
assert_equal "application/json", called_with[:env][:contet_type]
assert handler_method_called, "the handler method was called"
assert_equal 200, status, "response is successful"
end
# TODO: test_before_hook_add_data_in_env_for_the_handler_method
# TODO: test_before_hook_returning_twirp_error_cancels_request
# TODO: test_before_hook_raising_exception_cancels_request
# Test Helpers
@ -263,7 +281,7 @@ class ServiceTest < Minitest::Test
end
def haberdasher_service
Example::Haberdasher.new(HaberdasherHandler.new do |size|
Example::Haberdasher.new(HaberdasherHandler.new do |size, _|
{inches: size.inches, color: "white"}
end)
end