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 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