twirp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|