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.
@@ -26,15 +26,15 @@ module Twirp
26
26
  # List of all valid error codes in Twirp
27
27
  ERROR_CODES = ERROR_CODES_TO_HTTP_STATUS.keys
28
28
 
29
- def valid_error_code?(code)
30
- ERROR_CODES_TO_HTTP_STATUS.key? code # one of the valid symbols
31
- end
32
-
33
29
 
34
30
  # Twirp::Error represents an error response from a Twirp service.
35
31
  # Twirp::Error is not an Exception to be raised, but a value to be returned
36
32
  # by service handlers and received by clients.
37
- class Error
33
+ class Error
34
+
35
+ def self.valid_code?(code)
36
+ ERROR_CODES_TO_HTTP_STATUS.key? code # one of the valid symbols
37
+ end
38
38
 
39
39
  # Use this constructors to ensure the errors have valid error codes. Example:
40
40
  # Twirp::Error.internal("boom")
@@ -54,7 +54,7 @@ module Twirp
54
54
  end
55
55
 
56
56
  attr_reader :code, :msg, :meta
57
-
57
+
58
58
  attr_accessor :cause # used when wrapping another error, but this is not serialized
59
59
 
60
60
  # Initialize a Twirp::Error
@@ -77,7 +77,11 @@ module Twirp
77
77
  end
78
78
 
79
79
  def to_s
80
- "<Twirp::Error code:#{code.inspect} msg:#{msg.inspect} meta:#{meta.inspect}>"
80
+ "<Twirp::Error code:#{code} msg:#{msg.inspect} meta:#{meta.inspect}>"
81
+ end
82
+
83
+ def inspect
84
+ to_s
81
85
  end
82
86
 
83
87
 
@@ -89,7 +93,7 @@ module Twirp
89
93
  if !meta.is_a? Hash
90
94
  raise ArgumentError.new("Twirp::Error meta must be a Hash, but it is a #{meta.class.to_s}")
91
95
  end
92
- meta.each do |key, value|
96
+ meta.each do |key, value|
93
97
  if !value.is_a?(String)
94
98
  raise ArgumentError.new("Twirp::Error meta values must be Strings, but key #{key.inspect} has the value <#{value.class.to_s}> #{value.inspect}")
95
99
  end
@@ -1,68 +1,21 @@
1
1
  require "json"
2
2
 
3
+ require_relative "error"
4
+ require_relative "service_dsl"
5
+
3
6
  module Twirp
4
7
 
5
8
  class Service
6
9
 
7
- class << self
8
-
9
- # Configure service package name.
10
- def package(name)
11
- @package_name = name.to_s
12
- end
13
-
14
- # Configure service name.
15
- def service(name)
16
- @service_name = name.to_s
17
- end
18
-
19
- # Configure service routing to handle rpc calls.
20
- def rpc(rpc_method, input_class, output_class, opts)
21
- raise ArgumentError.new("input_class must be a Protobuf Message class") unless input_class.is_a?(Class)
22
- raise ArgumentError.new("output_class must be a Protobuf Message class") unless output_class.is_a?(Class)
23
- raise ArgumentError.new("opts[:handler_method] is mandatory") unless opts && opts[:handler_method]
24
-
25
- @base_envs ||= {}
26
- @base_envs[rpc_method.to_s] = {
27
- rpc_method: rpc_method.to_sym,
28
- input_class: input_class,
29
- output_class: output_class,
30
- handler_method: opts[:handler_method].to_sym,
31
- }
32
- end
33
-
34
- # Get configured package name as String.
35
- # And empty value means that there's no package.
36
- def package_name
37
- @package_name.to_s
38
- end
39
-
40
- # Service name as String.
41
- # Defaults to the current class name.
42
- def service_name
43
- (@service_name || self.name).to_s
44
- end
45
-
46
- # Base Twirp environments for each rpc method.
47
- def base_envs
48
- @base_envs || {}
49
- end
50
-
51
- # Package and servicce name, as a unique identifier for the service,
52
- # for example "example.v3.Haberdasher" (package "example.v3", service "Haberdasher").
53
- # This can be used as a path prefix to route requests to the service, because a Twirp URL is:
54
- # "#{BaseURL}/#{ServiceFullName}/#{Method]"
55
- def service_full_name
56
- package_name.empty? ? service_name : "#{package_name}.#{service_name}"
57
- end
10
+ # DSL to define a service with package, service and rpcs.
11
+ extend ServiceDSL
58
12
 
13
+ class << self
59
14
  # Raise exceptions instead of handling them with exception_raised hooks.
60
15
  # Useful during tests to easily debug and catch unexpected exceptions.
61
16
  # Default false.
62
17
  attr_accessor :raise_exceptions
63
-
64
- end # class << self
65
-
18
+ end
66
19
 
67
20
  def initialize(handler)
68
21
  @handler = handler
@@ -73,20 +26,16 @@ module Twirp
73
26
  @exception_raised = []
74
27
  end
75
28
 
76
- def name
77
- self.class.service_name
78
- end
79
-
80
- def full_name
81
- self.class.service_full_name # use to route requests to this servie
82
- end
83
-
84
- # Setup hook blocks
29
+ # Setup hook blocks.
85
30
  def before(&block) @before << block; end
86
31
  def on_success(&block) @on_success << block; end
87
32
  def on_error(&block) @on_error << block; end
88
33
  def exception_raised(&block) @exception_raised << block; end
89
34
 
35
+ # Service full_name is needed to route http requests to this service.
36
+ def full_name; self.class.service_full_name; end
37
+ def name; self.class.service_name; end
38
+
90
39
  # Rack app handler.
91
40
  def call(rack_env)
92
41
  begin
@@ -116,6 +65,23 @@ module Twirp
116
65
  end
117
66
  end
118
67
 
68
+ # Call the handler method with input attributes or protobuf object.
69
+ # Returns a proto object (response) or a Twirp::Error.
70
+ # Hooks are not called and exceptions are raised instead of being wrapped.
71
+ # This is useful for unit testing the handler. The env should include
72
+ # fake data that is used by the handler, replicating middleware and before hooks.
73
+ def call_rpc(rpc_method, input={}, env={})
74
+ base_env = self.class.rpcs[rpc_method.to_s]
75
+ return Twirp::Error.bad_route("Invalid rpc method #{rpc_method.to_s.inspect}") unless base_env
76
+
77
+ env = env.merge(base_env)
78
+ input = env[:input_class].new(input) if input.is_a? Hash
79
+ env[:input] = input
80
+ env[:content_type] ||= "application/protobuf"
81
+ env[:http_response_headers] = {}
82
+ call_handler(env)
83
+ end
84
+
119
85
 
120
86
  private
121
87
 
@@ -140,11 +106,11 @@ module Twirp
140
106
  end
141
107
  method_name = path_parts[-1]
142
108
 
143
- base_env = self.class.base_envs[method_name]
109
+ base_env = self.class.rpcs[method_name]
144
110
  if !base_env
145
111
  return bad_route_error("Invalid rpc method #{method_name.inspect}", rack_request)
146
112
  end
147
- env.merge!(base_env) # :rpc_method, :input_class, :output_class, :handler_method
113
+ env.merge!(base_env) # :rpc_method, :input_class, :output_class
148
114
 
149
115
  input = nil
150
116
  begin
@@ -162,35 +128,35 @@ module Twirp
162
128
  Twirp::Error.bad_route msg, twirp_invalid_route: "#{req.request_method} #{req.fullpath}"
163
129
  end
164
130
 
165
- def decode_input(body, input_class, content_type)
131
+ def decode_input(bytes, input_class, content_type)
166
132
  case content_type
167
- when "application/protobuf" then input_class.decode(body)
168
- when "application/json" then input_class.decode_json(body)
133
+ when "application/protobuf" then input_class.decode(bytes)
134
+ when "application/json" then input_class.decode_json(bytes)
169
135
  end
170
136
  end
171
137
 
172
- def encode_output(output, output_class, content_type)
138
+ def encode_output(obj, output_class, content_type)
173
139
  case content_type
174
- when "application/protobuf" then output_class.encode(output)
175
- when "application/json" then output_class.encode_json(output)
140
+ when "application/protobuf" then output_class.encode(obj)
141
+ when "application/json" then output_class.encode_json(obj)
176
142
  end
177
143
  end
178
144
 
179
145
  # Call handler method and return a Protobuf Message or a Twirp::Error.
180
146
  def call_handler(env)
181
- handler_method = env[:handler_method]
182
- if !@handler.respond_to?(handler_method)
183
- return Twirp::Error.unimplemented("Handler method #{handler_method} is not implemented.")
147
+ m = env[:ruby_method]
148
+ if !@handler.respond_to?(m)
149
+ return Twirp::Error.unimplemented("Handler method #{m} is not implemented.")
184
150
  end
185
151
 
186
- out = @handler.send(handler_method, env[:input], env)
152
+ out = @handler.send(m, env[:input], env)
187
153
  case out
188
154
  when env[:output_class], Twirp::Error
189
155
  out
190
156
  when Hash
191
157
  env[:output_class].new(out)
192
158
  else
193
- Twirp::Error.internal("Handler method #{handler_method} expected to return one of #{env[:output_class].name}, Hash or Twirp::Error, but returned #{out.class.name}.")
159
+ Twirp::Error.internal("Handler method #{m} expected to return one of #{env[:output_class].name}, Hash or Twirp::Error, but returned #{out.class.name}.")
194
160
  end
195
161
  end
196
162
 
@@ -0,0 +1,61 @@
1
+ module Twirp
2
+
3
+ module ServiceDSL
4
+
5
+ # Configure service package name.
6
+ def package(name)
7
+ @package = name
8
+ end
9
+
10
+ # Configure service name.
11
+ def service(name)
12
+ @service = name
13
+ end
14
+
15
+ # Configure service rpc methods.
16
+ def rpc(rpc_method, input_class, output_class, opts)
17
+ raise ArgumentError.new("rpc_method can not be empty") if rpc_method.to_s.empty?
18
+ raise ArgumentError.new("input_class must be a Protobuf Message class") unless input_class.is_a?(Class)
19
+ raise ArgumentError.new("output_class must be a Protobuf Message class") unless output_class.is_a?(Class)
20
+ raise ArgumentError.new("opts[:ruby_method] is mandatory") unless opts && opts[:ruby_method]
21
+
22
+ rpcdef = {
23
+ rpc_method: rpc_method.to_sym, # as defined in the Proto file.
24
+ input_class: input_class, # google/protobuf Message class to serialize the input (proto request).
25
+ output_class: output_class, # google/protobuf Message class to serialize the output (proto response).
26
+ ruby_method: opts[:ruby_method].to_sym, # method on the handler or client to handle this rpc requests.
27
+ }
28
+
29
+ @rpcs ||= {}
30
+ @rpcs[rpc_method.to_s] = rpcdef
31
+
32
+ rpc_define_method(rpcdef) if respond_to? :rpc_define_method # hook for the client to implement the methods on the class
33
+ end
34
+
35
+ # Get configured package name as String.
36
+ # An empty value means that there's no package.
37
+ def package_name
38
+ @package_name ||= @package.to_s
39
+ end
40
+
41
+ # Service name as String. Defaults to the class name.
42
+ def service_name
43
+ @service_name ||= (@service || self.name.split("::").last).to_s
44
+ end
45
+
46
+ # Service name with package prefix, which should uniquelly identifiy the service,
47
+ # for example "example.v3.Haberdasher" for package "example.v3" and service "Haberdasher".
48
+ # This can be used as a path prefix to route requests to the service, because a Twirp URL is:
49
+ # "#{base_url}/#{service_full_name}/#{method]"
50
+ def service_full_name
51
+ @service_full_name ||= package_name.empty? ? service_name : "#{package_name}.#{service_name}"
52
+ end
53
+
54
+ # Get raw definitions for rpc methods.
55
+ # This values are used as base env for handler methods.
56
+ def rpcs
57
+ @rpcs || {}
58
+ end
59
+
60
+ end
61
+ end
@@ -1,3 +1,3 @@
1
1
  module Twirp
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -0,0 +1,203 @@
1
+ require 'minitest/autorun'
2
+ require 'rack/mock'
3
+ require 'google/protobuf'
4
+ require 'json'
5
+
6
+ require_relative '../lib/twirp'
7
+ require_relative './fake_services'
8
+
9
+ class ClientTest < Minitest::Test
10
+
11
+ def test_new_empty_client
12
+ c = EmptyClient.new("http://localhost:3000")
13
+ refute_nil c
14
+ refute_nil c.instance_variable_get(:@conn) # make sure that connection was assigned
15
+ assert_equal "EmptyClient", c.service_full_name
16
+ end
17
+
18
+ def test_new_with_invalid_url
19
+ assert_raises URI::InvalidURIError do
20
+ EmptyClient.new("lulz")
21
+ end
22
+ end
23
+
24
+ def test_new_with_invalid_faraday_connection
25
+ assert_raises ArgumentError do
26
+ EmptyClient.new(something: "else")
27
+ end
28
+ end
29
+
30
+ def test_call_rpc_success
31
+ c = FooClient.new(conn_stub("/Foo/Foo") {|req|
32
+ [200, protoheader, proto(Foo, foo: "out")]
33
+ })
34
+ resp = c.call_rpc(:Foo, foo: "in")
35
+ assert_nil resp.error
36
+ refute_nil resp.data
37
+ assert_equal "out", resp.data.foo
38
+ end
39
+
40
+ def test_call_rpc_error
41
+ c = FooClient.new(conn_stub("/Foo/Foo") {|req|
42
+ [400, {}, json(code: "invalid_argument", msg: "dont like empty")]
43
+ })
44
+ resp = c.call_rpc(:Foo, foo: "")
45
+ assert_nil resp.data
46
+ refute_nil resp.error
47
+ assert_equal :invalid_argument, resp.error.code
48
+ assert_equal "dont like empty", resp.error.msg
49
+ end
50
+
51
+ def test_call_rpc_serialization_exception
52
+ c = FooClient.new(conn_stub("/Foo/Foo") {|req|
53
+ [200, protoheader, "badstuff"]
54
+ })
55
+ assert_raises Google::Protobuf::ParseError do
56
+ resp = c.call_rpc(:Foo, foo: "in")
57
+ end
58
+ end
59
+
60
+ def test_call_rpc_invalid_method
61
+ c = FooClient.new("http://localhost")
62
+ resp = c.call_rpc(:OtherStuff, foo: "noo")
63
+ assert_nil resp.data
64
+ refute_nil resp.error
65
+ assert_equal :bad_route, resp.error.code
66
+ end
67
+
68
+ def test_success
69
+ c = Example::HaberdasherClient.new(conn_stub("/example.Haberdasher/MakeHat") {|req|
70
+ [200, protoheader, proto(Example::Hat, inches: 99, color: "red")]
71
+ })
72
+ resp = c.make_hat({})
73
+ assert_nil resp.error
74
+ assert_equal 99, resp.data.inches
75
+ assert_equal "red", resp.data.color
76
+ end
77
+
78
+ def test_serialized_request_body_attrs
79
+ c = Example::HaberdasherClient.new(conn_stub("/example.Haberdasher/MakeHat") {|req|
80
+ size = Example::Size.decode(req.body) # body is valid protobuf
81
+ assert_equal 666, size.inches
82
+
83
+ [200, protoheader, proto(Example::Hat)]
84
+ })
85
+ resp = c.make_hat(inches: 666)
86
+ assert_nil resp.error
87
+ refute_nil resp.data
88
+ end
89
+
90
+ def test_serialized_request_body_proto
91
+ c = Example::HaberdasherClient.new(conn_stub("/example.Haberdasher/MakeHat") {|req|
92
+ assert_equal "application/protobuf", req.request_headers['Content-Type']
93
+
94
+ size = Example::Size.decode(req.body) # body is valid protobuf
95
+ assert_equal 666, size.inches
96
+
97
+ [200, protoheader, proto(Example::Hat)]
98
+ })
99
+ resp = c.make_hat(Example::Size.new(inches: 666))
100
+ assert_nil resp.error
101
+ refute_nil resp.data
102
+ end
103
+
104
+ def test_twirp_error
105
+ c = Example::HaberdasherClient.new(conn_stub("/example.Haberdasher/MakeHat") {|req|
106
+ [500, {}, json(code: "internal", msg: "something went wrong")]
107
+ })
108
+ resp = c.make_hat(inches: 1)
109
+ assert_nil resp.data
110
+ refute_nil resp.error
111
+ assert_equal :internal, resp.error.code
112
+ assert_equal "something went wrong", resp.error.msg
113
+ end
114
+
115
+ def test_intermediary_plain_error
116
+ c = Example::HaberdasherClient.new(conn_stub("/example.Haberdasher/MakeHat") {|req|
117
+ [503, {}, 'plain text error from proxy']
118
+ })
119
+ resp = c.make_hat(inches: 1)
120
+ assert_nil resp.data
121
+ refute_nil resp.error
122
+ assert_equal :unavailable, resp.error.code # 503 maps to :unavailable
123
+ assert_equal "unavailable", resp.error.msg
124
+ assert_equal "true", resp.error.meta[:http_error_from_intermediary]
125
+ assert_equal "Response is not JSON", resp.error.meta[:not_a_twirp_error_because]
126
+ assert_equal "plain text error from proxy", resp.error.meta[:body]
127
+ end
128
+
129
+ def test_redirect_error
130
+ c = Example::HaberdasherClient.new(conn_stub("/example.Haberdasher/MakeHat") {|req|
131
+ [300, {'location' => "http://rainbow.com"}, '']
132
+ })
133
+ resp = c.make_hat(inches: 1)
134
+ assert_nil resp.data
135
+ refute_nil resp.error
136
+ assert_equal :internal, resp.error.code
137
+ assert_equal "Unexpected HTTP Redirect from location=http://rainbow.com", resp.error.msg
138
+ assert_equal "true", resp.error.meta[:http_error_from_intermediary]
139
+ assert_equal "Redirects not allowed on Twirp requests", resp.error.meta[:not_a_twirp_error_because]
140
+ end
141
+
142
+ def test_missing_proto_response_header
143
+ c = Example::HaberdasherClient.new(conn_stub("/example.Haberdasher/MakeHat") {|req|
144
+ [200, {}, proto(Example::Hat, inches: 99, color: "red")]
145
+ })
146
+ resp = c.make_hat({})
147
+ refute_nil resp.error
148
+ assert_equal :internal, resp.error.code
149
+ assert_equal 'Expected response Content-Type "application/protobuf" but found nil', resp.error.msg
150
+ end
151
+
152
+ def test_error_with_invalid_code
153
+ c = Example::HaberdasherClient.new(conn_stub("/example.Haberdasher/MakeHat") {|req|
154
+ [500, {}, json(code: "unicorn", msg: "the unicorn is here")]
155
+ })
156
+ resp = c.make_hat({})
157
+ assert_nil resp.data
158
+ refute_nil resp.error
159
+ assert_equal :internal, resp.error.code
160
+ assert_equal "Invalid Twirp error code: unicorn", resp.error.msg
161
+ end
162
+
163
+ def test_error_with_no_code
164
+ c = Example::HaberdasherClient.new(conn_stub("/example.Haberdasher/MakeHat") {|req|
165
+ [500, {}, json(msg: "I have no code of honor")]
166
+ })
167
+ resp = c.make_hat({})
168
+ assert_nil resp.data
169
+ refute_nil resp.error
170
+ assert_equal :unknown, resp.error.code # 500 maps to :unknown
171
+ assert_equal "unknown", resp.error.msg
172
+ assert_equal "true", resp.error.meta[:http_error_from_intermediary]
173
+ assert_equal 'Response is JSON but it has no "code" attribute', resp.error.meta[:not_a_twirp_error_because]
174
+ assert_equal '{"msg":"I have no code of honor"}', resp.error.meta[:body]
175
+ end
176
+
177
+
178
+ # Test Helpers
179
+ # ------------
180
+
181
+ def protoheader
182
+ {'Content-Type' => 'application/protobuf'}
183
+ end
184
+
185
+ def proto(clss, attrs={})
186
+ clss.encode(clss.new(attrs))
187
+ end
188
+
189
+ def json(attrs)
190
+ JSON.generate(attrs)
191
+ end
192
+
193
+ def conn_stub(path)
194
+ Faraday.new do |conn|
195
+ conn.adapter :test do |stub|
196
+ stub.post(path) do |env|
197
+ yield(env)
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ end