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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f3e14392e7046ad1e01f88323f3966e612dacc23
4
- data.tar.gz: a12d8cc09c357bfd1501cb12594b83eacf2b0779
3
+ metadata.gz: de95a607dde7addc708877a41f8b0f99453f1653
4
+ data.tar.gz: 7e84e7a36ad28d2d79c8d1ed793194fb3b549552
5
5
  SHA512:
6
- metadata.gz: cdf630358e9d36782f10f5069aa389fb97c588092cb6b2af89a72f5763b8d63f95d70d49d576bcf294de8ec495a71eb3f1ccbdd9cf53032d84108dccfdd5a7f1
7
- data.tar.gz: c33a3b5aee715484475bffef8f325753d001a7c20ea69fbb180ffd4eb80db1123a6dd4c8f815905bf45c2a60fc3394b2965fdaa16a3d28696d1408b9d57a4104
6
+ metadata.gz: 7e553cb895062292854559ece9f472213cd66a4fd61bd81b671f9645d51d65057078692c4c6ef2ebf96697265efd0cd3d212b1d069cb08a588937e4a64770760
7
+ data.tar.gz: 2ad9fcca77b96fa7e5740be188c18d5f08e514acfb08c91bba1b785a5061f3cd2b325b074466076273ac94ae1b627ca0dd5f9dc247138fb7019205692a6304ed
data/README.md CHANGED
@@ -1,32 +1,41 @@
1
- # Ruby Twirp
1
+ # Twirp
2
2
 
3
- Twirp services and clients in Ruby.
3
+ Ruby implementation for for [Twirp](https://github.com/twitchtv/twirp). It includes:
4
4
 
5
- ### Installation
6
- Install the `twirp` gem:
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
- Use `go get` to install the ruby_twirp protoc plugin:
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
- - [protoc](https://github.com/golang/protobuf), the protobuf compiler. You need
21
- version 3+.
28
+ ## Usage
22
29
 
23
- ### HelloWorld Example
30
+ Let's make a `HelloWorld` service in Twirp.
24
31
 
25
- See the `example/` folder for the final product.
32
+ ### Code Generation
26
33
 
27
- First create a basic `.proto` file:
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 to auto-generate `helloworld_pb.rb` and `haberdasher_twirp.rb` files:
55
+ Run the `protoc` binary with the `twirp_ruby` plugin to auto-generate code:
47
56
 
48
57
  ```sh
49
- ➜ protoc --proto_path=. ./haberdasher.proto --ruby_out=gen --twirp_ruby_out=gen
58
+ ➜ protoc --proto_path=. ./hello_world/service.proto --ruby_out=. --twirp_ruby_out=.
50
59
  ```
51
60
 
52
- Write a handler for the auto-generated service, this is your implementation:
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 HellowWorldHandler
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
- Initialize the service with your handler and mount it as a Rack app:
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 = HellowWorldHandler.new()
70
- service = Example::HelloWorld.new(handler)
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
- You can also mount onto a rails app:
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
- App::Application.routes.draw do
78
- mount service, at: service.full_name
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
- ```sh
85
- curl --request POST \
86
- --url http://localhost:8080/example.HelloWorld/Hello \
87
- --header 'content-type: application/json' \
88
- --data '{"name":"World"}'
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
- ## Hooks
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
- Example code with hooks:
118
-
210
+ Hooks are setup in the service instance:
119
211
 
120
212
  ```ruby
121
- class HaberdasherHandler
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 Env to access http request and middleware data.
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
- # If it returns a Twirp::Error, the handler is not called and this error is returned instead.
136
- # If an exception is raised, the exception_raised hook will be called and then on_error with the internal error.
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
- # Returned values are ignored (even if it returns Twirp::Error).
143
- # Exceptions should not happen here, but if an exception is raised the exception_raised hook will be
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
- # * routing errors (env does not have routing info here)
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
- # Returned values are ignored (even if it returns Twirp::Error).
154
- # Exceptions should not happen here, but if an exception is raised the exception_raised hook will be
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
- environment = (ENV['APP_ENV'] || ENV['RACK_ENV'] || :development).to_sym
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
+
@@ -1,3 +1,4 @@
1
1
  require_relative 'twirp/version'
2
2
  require_relative 'twirp/error'
3
3
  require_relative 'twirp/service'
4
+ require_relative 'twirp/client'
@@ -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