twirp 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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