twirp 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +141 -62
- data/lib/twirp.rb +1 -0
- data/lib/twirp/client.rb +147 -0
- data/lib/twirp/error.rb +12 -8
- data/lib/twirp/service.rb +42 -76
- data/lib/twirp/service_dsl.rb +61 -0
- data/lib/twirp/version.rb +1 -1
- data/test/client_test.rb +203 -0
- data/test/fake_services.rb +21 -1
- data/test/service_test.rb +27 -28
- data/twirp.gemspec +6 -6
- metadata +30 -15
- data/.gitignore +0 -1
- data/Gemfile.lock +0 -20
- data/example/Gemfile +0 -7
- data/example/Gemfile.lock +0 -17
- data/example/gen/haberdasher_pb.rb +0 -18
- data/example/gen/haberdasher_twirp.rb +0 -10
- data/example/haberdasher.proto +0 -14
- data/example/main.rb +0 -14
- data/protoc-gen-twirp_ruby/main.go +0 -259
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: de95a607dde7addc708877a41f8b0f99453f1653
|
4
|
+
data.tar.gz: 7e84e7a36ad28d2d79c8d1ed793194fb3b549552
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7e553cb895062292854559ece9f472213cd66a4fd61bd81b671f9645d51d65057078692c4c6ef2ebf96697265efd0cd3d212b1d069cb08a588937e4a64770760
|
7
|
+
data.tar.gz: 2ad9fcca77b96fa7e5740be188c18d5f08e514acfb08c91bba1b785a5061f3cd2b325b074466076273ac94ae1b627ca0dd5f9dc247138fb7019205692a6304ed
|
data/README.md
CHANGED
@@ -1,32 +1,41 @@
|
|
1
|
-
#
|
1
|
+
# Twirp
|
2
2
|
|
3
|
-
|
3
|
+
Ruby implementation for for [Twirp](https://github.com/twitchtv/twirp). It includes:
|
4
4
|
|
5
|
-
|
6
|
-
|
5
|
+
* [twirp gem](https://rubygems.org/gems/twirp) with classes for:
|
6
|
+
* Twirp::Error
|
7
|
+
* Twirp::Service
|
8
|
+
* Twirp::Client
|
9
|
+
* `protoc-gen-twirp_ruby` protoc plugin for code generation.
|
10
|
+
|
11
|
+
|
12
|
+
## Installation
|
13
|
+
|
14
|
+
Add `gem "twirp"` to your Gemfile, or install with:
|
7
15
|
|
8
16
|
```sh
|
9
17
|
➜ gem install twirp
|
10
18
|
```
|
11
19
|
|
12
|
-
|
20
|
+
For code generation, Make sure that you have the [protobuf compiler](https://github.com/golang/protobuf) (install version 3+).
|
21
|
+
And then use `go get` to install the ruby_twirp protoc plugin:
|
13
22
|
|
14
23
|
```sh
|
15
|
-
➜ go get github.com/cyrusaf/ruby-twirp/protoc-gen-twirp_ruby
|
24
|
+
➜ go get -u github.com/cyrusaf/ruby-twirp/protoc-gen-twirp_ruby
|
16
25
|
```
|
17
26
|
|
18
|
-
You will also need:
|
19
27
|
|
20
|
-
|
21
|
-
version 3+.
|
28
|
+
## Usage
|
22
29
|
|
23
|
-
|
30
|
+
Let's make a `HelloWorld` service in Twirp.
|
24
31
|
|
25
|
-
|
32
|
+
### Code Generation
|
26
33
|
|
27
|
-
|
34
|
+
Starting with a [Protobuf](https://developers.google.com/protocol-buffers/docs/proto3) file:
|
28
35
|
|
29
36
|
```protobuf
|
37
|
+
// hello_world/service.proto
|
38
|
+
|
30
39
|
syntax = "proto3";
|
31
40
|
package example;
|
32
41
|
|
@@ -43,58 +52,142 @@ message HelloResponse {
|
|
43
52
|
}
|
44
53
|
```
|
45
54
|
|
46
|
-
Run the `protoc` binary
|
55
|
+
Run the `protoc` binary with the `twirp_ruby` plugin to auto-generate code:
|
47
56
|
|
48
57
|
```sh
|
49
|
-
➜ protoc --proto_path=. ./
|
58
|
+
➜ protoc --proto_path=. ./hello_world/service.proto --ruby_out=. --twirp_ruby_out=.
|
50
59
|
```
|
51
60
|
|
52
|
-
|
61
|
+
This will generate `/hello_world/service_pb.rb` and `hello_world/service_twirp.rb`. The generated code looks something like this:
|
62
|
+
|
63
|
+
```ruby
|
64
|
+
# Code generated by protoc-gen-twirp_ruby, DO NOT EDIT.
|
65
|
+
require 'twirp'
|
66
|
+
|
67
|
+
module Example
|
68
|
+
class HelloWorldService < Twirp::Service
|
69
|
+
package "example"
|
70
|
+
service "HelloWorld"
|
71
|
+
rpc :Hello, HelloRequest, HelloResponse, :ruby_method => :hello
|
72
|
+
end
|
73
|
+
|
74
|
+
class HelloWorldClient < Twirp::Client
|
75
|
+
client_for HelloWorldService
|
76
|
+
end
|
77
|
+
end
|
78
|
+
```
|
79
|
+
|
80
|
+
If you don't have Proto files, or don't like the code-generation step, you can always define your service and/or client using the DSL.
|
81
|
+
|
82
|
+
|
83
|
+
#### Implement the Service Handler
|
84
|
+
|
85
|
+
The Service Handler is a simple class that has one method to handle each rpc call.
|
86
|
+
For each method, the `intput` is an instance of the protobuf request message. The Twirp `env`
|
87
|
+
contains metadata related to the request, and other fields that could have been set from before
|
88
|
+
hooks (e.g. `env[:user_id]` from authentication).
|
53
89
|
|
54
90
|
```ruby
|
55
|
-
class
|
91
|
+
class HelloWorldHandler
|
56
92
|
def hello(input, env)
|
57
93
|
{message: "Hello #{input.name}"}
|
58
94
|
end
|
59
95
|
end
|
60
96
|
```
|
61
97
|
|
62
|
-
|
98
|
+
### Mount the service to receive HTTP requests
|
99
|
+
|
100
|
+
The service is a Rack app instantiated with your handler impementation.
|
63
101
|
|
64
102
|
```ruby
|
65
103
|
require 'rack'
|
66
|
-
require_relative 'gen/haberdasher_pb.rb'
|
67
|
-
require_relative 'gen/haberdasher_twirp.rb'
|
68
104
|
|
69
|
-
handler =
|
70
|
-
service = Example::
|
105
|
+
handler = HelloWorldHandler.new() # your implementation
|
106
|
+
service = Example::HelloWorldService.new(handler) # twirp-generated
|
107
|
+
|
71
108
|
Rack::Handler::WEBrick.run service
|
72
109
|
```
|
73
110
|
|
74
|
-
|
111
|
+
Since it is a Rack app, it can easily be mounted onto a Rails route with `mount service, at: service.full_name`.
|
112
|
+
|
113
|
+
Now you can start the server and `curl` with JSON to test if everything works:
|
114
|
+
|
115
|
+
```sh
|
116
|
+
➜ curl --request POST \
|
117
|
+
--url http://localhost:8080/example.HelloWorld/Hello \
|
118
|
+
--header 'Content-Type: application/json' \
|
119
|
+
--data '{"name": "World"}'
|
120
|
+
```
|
121
|
+
|
122
|
+
### Unit testing the Service Handler
|
123
|
+
|
124
|
+
Twirp already takes care of HTTP routing and serialization, you don't really need to build fake HTTP requests in your tests.
|
125
|
+
Instead, you should focus on testing your Service Handler. For convenience, the Twirp Service has the method
|
126
|
+
`.call_rpc(rpc_method, attrs={}, env={})` to call the handler with a fake Twirp env and making sure that the handler output is valid.
|
75
127
|
|
76
128
|
```ruby
|
77
|
-
|
78
|
-
|
129
|
+
require 'minitest/autorun'
|
130
|
+
|
131
|
+
class HelloWorldHandlerTest < Minitest::Test
|
132
|
+
def test_hello_responds_with_name
|
133
|
+
service = Example::HelloWorld.new(HelloWorldHandler.new())
|
134
|
+
out = service.call_rpc :Hello, name: "World"
|
135
|
+
assert_equal "Hello World", out.message
|
136
|
+
end
|
79
137
|
end
|
80
138
|
```
|
81
139
|
|
82
|
-
Twirp services accept both Protobuf and JSON messages. It is easy to `curl` your service to get a response:
|
83
140
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
141
|
+
## Clients
|
142
|
+
|
143
|
+
Generated clients implement the methods defined in the proto file. The response object contains `data` with an instance of the response class if successfull,
|
144
|
+
or an `error` with an instance of `Twirp::Error` if there was a problem. For example, with the HelloWorld generated client:
|
145
|
+
|
146
|
+
```ruby
|
147
|
+
c = Example::HelloWorldClient.new("http://localhost:3000")
|
148
|
+
resp = c.hello(name: "World")
|
149
|
+
if resp.error
|
150
|
+
puts resp.error #=> <Twirp::Error code:... msg:"..." meta:{...}>
|
151
|
+
else
|
152
|
+
puts resp.data #=> <Example::HelloResponse: message:"Hello World">
|
153
|
+
end
|
89
154
|
```
|
90
155
|
|
156
|
+
You can also use the DSL to define your own client if you don't have easy access to the proto file or generated code:
|
157
|
+
|
158
|
+
```ruby
|
159
|
+
class MyClient < Twirp::Client
|
160
|
+
package "example"
|
161
|
+
service "MyService"
|
162
|
+
rpc :MyMethod, ReqClass, RespClass, :ruby_method => :my_method
|
163
|
+
end
|
164
|
+
|
165
|
+
c = MyClient.new("http://localhost:3000")
|
166
|
+
resp = c.my_method(ReqClass.new())
|
167
|
+
```
|
168
|
+
|
169
|
+
|
170
|
+
### Configure client with Faraday
|
91
171
|
|
92
|
-
|
172
|
+
A Twirp client takes care of routing, serialization and error handling.
|
173
|
+
|
174
|
+
Other advanced HTTP options can be configured with [Faraday](https://github.com/lostisland/faraday) middleware. For example:
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
c = MyClient.new(Faraday.new(:url => 'http://localhost:3000') do |c|
|
178
|
+
c.use Faraday::Request::Retry # configure retries
|
179
|
+
c.use Faraday::Request::BasicAuthentication, 'login', 'pass'
|
180
|
+
c.use Faraday::Response::Logger # log to STDOUT
|
181
|
+
c.use Faraday::Adapter::NetHttp # multiple adapters for different HTTP libraries
|
182
|
+
end)
|
183
|
+
```
|
184
|
+
|
185
|
+
## Server Hooks
|
93
186
|
|
94
187
|
In the lifecycle of a request, the Twirp service starts by routing the request to a valid
|
95
|
-
RPC method. If routing fails, the `on_error` hook is called with a bad_route error.
|
96
|
-
If routing succeeds, the `before` hook is called before calling the RPC method handler,
|
97
|
-
and then either `on_success` or `on_error` depending if the response is a Twirp error or not.
|
188
|
+
RPC method. If routing fails, the `on_error` hook is called with a bad_route error.
|
189
|
+
If routing succeeds, the `before` hook is called before calling the RPC method handler,
|
190
|
+
and then either `on_success` or `on_error` depending if the response is a Twirp error or not.
|
98
191
|
|
99
192
|
```
|
100
193
|
routing -> before -> handler -> on_success
|
@@ -114,54 +207,40 @@ routing -> before -> handler
|
|
114
207
|
! exception_raised -> on_error
|
115
208
|
```
|
116
209
|
|
117
|
-
|
118
|
-
|
210
|
+
Hooks are setup in the service instance:
|
119
211
|
|
120
212
|
```ruby
|
121
|
-
|
122
|
-
def make_hat(size, env)
|
123
|
-
return {}
|
124
|
-
end
|
125
|
-
end
|
126
|
-
|
127
|
-
handler = HaberdasherHandler.new
|
128
|
-
svc = Example::Haberdasher.new(handler)
|
129
|
-
|
213
|
+
svc = Example::HelloWorld.new(handler)
|
130
214
|
|
131
215
|
svc.before do |rack_env, env|
|
132
216
|
# Runs if properly routed to an rpc method, but before calling the method handler.
|
133
|
-
# This is the only place to read the Rack
|
217
|
+
# This is the only place to read the Rack env to access http request and middleware data.
|
134
218
|
# The Twirp env has the same routing info as in the handler method, e.g. :rpc_method, :input and :input_class.
|
135
|
-
#
|
136
|
-
# If an exception is raised, the exception_raised hook will be called
|
219
|
+
# Returning a Twirp::Error here cancels the request, and the error is returned instead.
|
220
|
+
# If an exception is raised, the exception_raised hook will be called followed by on_error.
|
221
|
+
env[:user_id] = authenticate(rack_env)
|
137
222
|
end
|
138
223
|
|
139
224
|
svc.on_success do |env|
|
140
225
|
# Runs after the rpc method is handled, if it didn't return Twirp errors or raised exceptions.
|
141
|
-
# The env[:output] contains the serialized message of class env[:ouput_class]
|
142
|
-
#
|
143
|
-
|
144
|
-
# called, however on_error will not (either on_success or on_error are called once per request).
|
226
|
+
# The env[:output] contains the serialized message of class env[:ouput_class].
|
227
|
+
# If an exception is raised, the exception_raised hook will be called.
|
228
|
+
success_count += 1
|
145
229
|
end
|
146
230
|
|
147
231
|
svc.on_error do |twerr, env|
|
148
232
|
# Runs on error responses, that is:
|
149
|
-
# *
|
233
|
+
# * bad_route errors
|
150
234
|
# * before filters returning Twirp errors or raising exceptions.
|
151
235
|
# * hander methods returning Twirp errors or raising exceptions.
|
152
236
|
# Raised exceptions are wrapped with Twirp::Error.internal_with(e).
|
153
|
-
#
|
154
|
-
|
155
|
-
# called without calling on_error again later.
|
237
|
+
# If an exception is raised here, the exception_raised hook will be called.
|
238
|
+
error_count += 1
|
156
239
|
end
|
157
240
|
|
158
241
|
svc.exception_raised do |e, env|
|
159
242
|
# Runs if an exception was raised from the handler or any of the hooks.
|
160
|
-
|
161
|
-
case environment
|
162
|
-
when :development raise e
|
163
|
-
when :test
|
164
|
-
puts "[Error] #{e}\n#{e.backtrace.join("\n")}"
|
165
|
-
end
|
243
|
+
puts "[Error] #{e}\n#{e.backtrace.join("\n")}"
|
166
244
|
end
|
167
245
|
```
|
246
|
+
|
data/lib/twirp.rb
CHANGED
data/lib/twirp/client.rb
ADDED
@@ -0,0 +1,147 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
require_relative "error"
|
5
|
+
require_relative "service_dsl"
|
6
|
+
|
7
|
+
module Twirp
|
8
|
+
|
9
|
+
class Client
|
10
|
+
|
11
|
+
# DSL to define a client with package, service and rpcs.
|
12
|
+
extend ServiceDSL
|
13
|
+
|
14
|
+
# DSL (alternative) to define a client from a Service class.
|
15
|
+
def self.client_for(svclass)
|
16
|
+
package svclass.package_name
|
17
|
+
service svclass.service_name
|
18
|
+
svclass.rpcs.each do |rpc_method, rpcdef|
|
19
|
+
rpc rpc_method, rpcdef[:input_class], rpcdef[:output_class], ruby_method: rpcdef[:ruby_method]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Hook for ServiceDSL#rpc to define a new method client.<ruby_method>(input, opts).
|
24
|
+
def self.rpc_define_method(rpcdef)
|
25
|
+
define_method rpcdef[:ruby_method] do |input|
|
26
|
+
call_rpc(rpcdef[:rpc_method], input)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Init with a Faraday connection.
|
31
|
+
def initialize(conn)
|
32
|
+
@conn = case conn
|
33
|
+
when String then Faraday.new(url: conn) # init with hostname
|
34
|
+
when Faraday::Connection then conn # inith with connection
|
35
|
+
else raise ArgumentError.new("Expected hostname String or Faraday::Connection")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def service_full_name; self.class.service_full_name; end
|
40
|
+
|
41
|
+
def rpc_path(rpc_method)
|
42
|
+
"/#{service_full_name}/#{rpc_method}"
|
43
|
+
end
|
44
|
+
|
45
|
+
def call_rpc(rpc_method, input)
|
46
|
+
rpcdef = self.class.rpcs[rpc_method.to_s]
|
47
|
+
if !rpcdef
|
48
|
+
return ClientResp.new(nil, Twirp::Error.bad_route("rpc not defined on this client"))
|
49
|
+
end
|
50
|
+
|
51
|
+
input = rpcdef[:input_class].new(input) if input.is_a? Hash
|
52
|
+
body = rpcdef[:input_class].encode(input)
|
53
|
+
|
54
|
+
resp = @conn.post do |r|
|
55
|
+
r.url rpc_path(rpc_method)
|
56
|
+
r.headers['Content-Type'] = 'application/protobuf'
|
57
|
+
r.body = body
|
58
|
+
end
|
59
|
+
|
60
|
+
if resp.status != 200
|
61
|
+
return ClientResp.new(nil, error_from_response(resp))
|
62
|
+
end
|
63
|
+
|
64
|
+
if resp.headers['Content-Type'] != 'application/protobuf'
|
65
|
+
return ClientResp.new(nil, Twirp::Error.internal("Expected response Content-Type \"application/protobuf\" but found #{resp.headers['Content-Type'].inspect}"))
|
66
|
+
end
|
67
|
+
|
68
|
+
data = rpcdef[:output_class].decode(resp.body)
|
69
|
+
return ClientResp.new(data, nil)
|
70
|
+
end
|
71
|
+
|
72
|
+
def error_from_response(resp)
|
73
|
+
status = resp.status
|
74
|
+
|
75
|
+
if is_http_redirect? status
|
76
|
+
return twirp_redirect_error(status, resp.headers['Location'])
|
77
|
+
end
|
78
|
+
|
79
|
+
err_attrs = nil
|
80
|
+
begin
|
81
|
+
err_attrs = JSON.parse(resp.body)
|
82
|
+
rescue JSON::ParserError => e
|
83
|
+
return twirp_error_from_intermediary(status, "Response is not JSON", resp.body)
|
84
|
+
end
|
85
|
+
|
86
|
+
code = err_attrs["code"]
|
87
|
+
if code.to_s.empty?
|
88
|
+
return twirp_error_from_intermediary(status, "Response is JSON but it has no \"code\" attribute", resp.body)
|
89
|
+
end
|
90
|
+
code = code.to_s.to_sym
|
91
|
+
if !Twirp::Error.valid_code?(code)
|
92
|
+
return Twirp::Error.internal("Invalid Twirp error code: #{code}", invalid_code: code.to_s, body: resp.body)
|
93
|
+
end
|
94
|
+
|
95
|
+
Twirp::Error.new(code, err_attrs["msg"], err_attrs["meta"])
|
96
|
+
end
|
97
|
+
|
98
|
+
# Error that was caused by an intermediary proxy like a load balancer.
|
99
|
+
# The HTTP errors code from non-twirp sources is mapped to equivalent twirp errors.
|
100
|
+
# The mapping is similar to gRPC: https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md.
|
101
|
+
# Returned twirp Errors have some additional metadata for inspection.
|
102
|
+
def twirp_error_from_intermediary(status, reason, body)
|
103
|
+
code = case status
|
104
|
+
when 400 then :internal
|
105
|
+
when 401 then :unauthenticated
|
106
|
+
when 403 then :permission_denied
|
107
|
+
when 404 then :bad_route
|
108
|
+
when 429, 502, 503, 504 then :unavailable
|
109
|
+
else :unknown
|
110
|
+
end
|
111
|
+
|
112
|
+
twerr = Twirp::Error.new(code, code.to_s, {
|
113
|
+
http_error_from_intermediary: "true",
|
114
|
+
not_a_twirp_error_because: reason,
|
115
|
+
status_code: status.to_s,
|
116
|
+
body: body.to_s,
|
117
|
+
})
|
118
|
+
end
|
119
|
+
|
120
|
+
# Twirp clients should not follow redirects automatically, Twirp only handles
|
121
|
+
# POST requests, redirects should only happen on GET and HEAD requests.
|
122
|
+
def twirp_redirect_error(status, location)
|
123
|
+
msg = "Unexpected HTTP Redirect from location=#{location}"
|
124
|
+
Twirp::Error.new(:internal, msg, {
|
125
|
+
http_error_from_intermediary: "true",
|
126
|
+
not_a_twirp_error_because: "Redirects not allowed on Twirp requests",
|
127
|
+
status_code: status.to_s,
|
128
|
+
location: location.to_s,
|
129
|
+
})
|
130
|
+
end
|
131
|
+
|
132
|
+
def is_http_redirect?(status)
|
133
|
+
status >= 300 && status <= 399
|
134
|
+
end
|
135
|
+
|
136
|
+
end
|
137
|
+
|
138
|
+
class ClientResp
|
139
|
+
attr_accessor :data
|
140
|
+
attr_accessor :error
|
141
|
+
|
142
|
+
def initialize(data, error)
|
143
|
+
@data = data
|
144
|
+
@error = error
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|