songkick-transport 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +313 -0
- data/examples/example.rb +38 -0
- data/examples/loop.rb +17 -0
- data/examples/server.rb +15 -0
- data/examples/tcp_server.rb +42 -0
- data/examples/thread_safety.rb +16 -0
- data/lib/songkick/transport.rb +83 -0
- data/lib/songkick/transport/base.rb +53 -0
- data/lib/songkick/transport/curb.rb +76 -0
- data/lib/songkick/transport/header_decorator.rb +27 -0
- data/lib/songkick/transport/headers.rb +36 -0
- data/lib/songkick/transport/http_error.rb +24 -0
- data/lib/songkick/transport/httparty.rb +66 -0
- data/lib/songkick/transport/rack_test.rb +52 -0
- data/lib/songkick/transport/reporting.rb +60 -0
- data/lib/songkick/transport/request.rb +83 -0
- data/lib/songkick/transport/response.rb +45 -0
- data/lib/songkick/transport/serialization.rb +79 -0
- data/lib/songkick/transport/timeout_decorator.rb +27 -0
- data/lib/songkick/transport/upstream_error.rb +28 -0
- data/spec/songkick/transport/curb_spec.rb +60 -0
- data/spec/songkick/transport/httparty_spec.rb +55 -0
- data/spec/songkick/transport/request_spec.rb +60 -0
- data/spec/songkick/transport/response_spec.rb +76 -0
- data/spec/songkick/transport_spec.rb +189 -0
- data/spec/spec_helper.rb +72 -0
- metadata +217 -0
data/README.rdoc
ADDED
@@ -0,0 +1,313 @@
|
|
1
|
+
= Songkick::Transport {<img src="https://secure.travis-ci.org/songkick/transport.png?branch=master" />}[http://travis-ci.org/songkick/transport]
|
2
|
+
|
3
|
+
http://songkickontour.appspot.com/lego_tourbus.png
|
4
|
+
|
5
|
+
(Image from {Songkick on Tour}[http://songkickontour.appspot.com])
|
6
|
+
|
7
|
+
This is a transport layer abstraction for talking to our service APIs. It
|
8
|
+
provides an abstract HTTP-like interface while hiding the underlying transport
|
9
|
+
and serialization details. It transparently deals with parameter serialization,
|
10
|
+
including the following:
|
11
|
+
|
12
|
+
* Correctly CGI-escaping any data you pass in
|
13
|
+
* Nested parameters, e.g. <tt>'foo' => {'bar' => 'qux'}</tt>
|
14
|
+
* File uploads and multipart requests
|
15
|
+
* Entity body for POST/PUT, query string for everything else
|
16
|
+
|
17
|
+
We currently support three backends:
|
18
|
+
|
19
|
+
* Talking HTTP with {Curb}[http://curb.rubyforge.org/]
|
20
|
+
* Talking HTTP with {HTTParty}[http://httparty.rubyforge.org/]
|
21
|
+
* Talking directly to a {Rack}[http://rack.rubyforge.org/] app with Rack::Test
|
22
|
+
|
23
|
+
It is assumed all service applications speak JSON as their serialization format.
|
24
|
+
|
25
|
+
|
26
|
+
== Using the transports
|
27
|
+
|
28
|
+
Let's say you're running a {Sinatra}[http://www.sinatrarb.com/] application that
|
29
|
+
exposes some JSON:
|
30
|
+
|
31
|
+
require 'sinatra'
|
32
|
+
|
33
|
+
get '/ohai' do
|
34
|
+
'{"hello":"world"}'
|
35
|
+
end
|
36
|
+
|
37
|
+
In order to talk to this service, you select a transport to use and make the
|
38
|
+
request:
|
39
|
+
|
40
|
+
require 'songkick/transport'
|
41
|
+
Transport = Songkick::Transport::Curb
|
42
|
+
|
43
|
+
client = Transport.new('http://localhost:4567',
|
44
|
+
:user_agent => 'Test Agent',
|
45
|
+
:timeout => 5)
|
46
|
+
|
47
|
+
response = client.get('/ohai')
|
48
|
+
# => Songkick::Transport::Response::OK
|
49
|
+
|
50
|
+
response.data
|
51
|
+
# => {"hello" => "world"}
|
52
|
+
|
53
|
+
<tt>Songkick::Transport::Curb</tt> and <tt>Songkick::Transport::HttParty</tt>
|
54
|
+
both take a hostname on instantiation. <tt>Songkick::Transport::RackTest</tt>
|
55
|
+
takes a reference to a Rack application, for example:
|
56
|
+
|
57
|
+
require 'songkick/transport'
|
58
|
+
Transport = Songkick::Transport::RackTest
|
59
|
+
|
60
|
+
client = Transport.new(Sinatra::Application,
|
61
|
+
:user_agent => 'Test Agent',
|
62
|
+
:timeout => 5)
|
63
|
+
|
64
|
+
All transports expose exactly the same instance methods.
|
65
|
+
|
66
|
+
The client supports the +get+, +post+, +put+, +delete+ and +head+ methods,
|
67
|
+
which all take a path and an optional +Hash+ of parameters, for example:
|
68
|
+
|
69
|
+
client.post('/users', :username => 'bob', :password => 'foo')
|
70
|
+
|
71
|
+
If the response is successful, meaning there are no errors caused by the server-
|
72
|
+
or client-side software or the network between them, then a response object is
|
73
|
+
returned. If the response contains data, the object's +data+ method exposes it
|
74
|
+
as a parsed data structure.
|
75
|
+
|
76
|
+
The response's headers are exposed through the +headers+ method, which is an
|
77
|
+
immutable hash-like object that normalizes various header conventions.
|
78
|
+
|
79
|
+
response = client.get('/users')
|
80
|
+
|
81
|
+
# These all return 'application/json'
|
82
|
+
response.headers['Content-Type']
|
83
|
+
response.headers['content-type']
|
84
|
+
response.headers['HTTP_CONTENT_TYPE']
|
85
|
+
|
86
|
+
If there is an error caused by our software, the request returns +nil+ and an
|
87
|
+
error is logged. If there is an error caused by user input, a +UserError+
|
88
|
+
response is returned with +data+ and +errors+ attributes.
|
89
|
+
|
90
|
+
|
91
|
+
=== Response conventions
|
92
|
+
|
93
|
+
This library was primarily developed to talk to Songkick's backend services, and
|
94
|
+
as such adopts some conventions that put it at a higher level of abstraction
|
95
|
+
than a vanilla HTTP client.
|
96
|
+
|
97
|
+
It assumes successful responses will all contain JSON data. A response object
|
98
|
+
has the following properties:
|
99
|
+
|
100
|
+
* +data+ -- the result of parsing the body as JSON
|
101
|
+
* +headers+ -- a read-only hash-like object containing response headers
|
102
|
+
* +status+ -- the response's status code
|
103
|
+
|
104
|
+
Only responses with status codes, 200 (OK), 201 (Created), 204 (No Content), and
|
105
|
+
409 (Conflict) yield response objects. All other status codes cause an exception
|
106
|
+
to be raised. We use 409 to indicate user error, i.e. input validation errors as
|
107
|
+
opposed to software/infrastructure errors. The response object is typed for the
|
108
|
+
status code; the possible types are:
|
109
|
+
|
110
|
+
* 200: <tt>Songkick::Transport::Response::OK</tt>
|
111
|
+
* 201: <tt>Songkick::Transport::Response::Created</tt>
|
112
|
+
* 204: <tt>Songkick::Transport::Response::NoContent</tt>
|
113
|
+
* 409: <tt>Songkick::Transport::Response::UserError</tt>
|
114
|
+
|
115
|
+
If the request raises an exception, it will be of one of the following types:
|
116
|
+
|
117
|
+
* <tt>Songkick::Transport::UpstreamError</tt> -- generic base error type
|
118
|
+
* <tt>Songkick::Transport::HostResolutionError</tt> -- the hostname could be
|
119
|
+
resolved using DNS
|
120
|
+
* <tt>Songkick::Transport::ConnectionFailedError</tt> -- a TCP connection could
|
121
|
+
not be made to the host
|
122
|
+
* <tt>Songkick::Transport::TimeoutError</tt> -- the request timed out before a
|
123
|
+
response could be received
|
124
|
+
* <tt>Songkick::Transport::InvalidJSONError</tt> -- the response contained
|
125
|
+
invalid JSON
|
126
|
+
* <tt>Songkick::Transport::HttpError</tt> -- we received a response with a
|
127
|
+
non-successful status code, e.g. 404 or 500
|
128
|
+
|
129
|
+
|
130
|
+
=== Nested parameters
|
131
|
+
|
132
|
+
All transports support serialization of nested parameters, for example you can
|
133
|
+
send this:
|
134
|
+
|
135
|
+
client.post('/venues', :venue => {:name => 'HMV Forum', :city_id => 4})
|
136
|
+
|
137
|
+
and it will send this query string to the server:
|
138
|
+
|
139
|
+
venue[name]=HMV+Forum&venue[city_id]=4
|
140
|
+
|
141
|
+
It can serialize fairly complicated data structures, within the limits of what
|
142
|
+
can represented using query strings, for example this structure:
|
143
|
+
|
144
|
+
{ "lisp" => ["define", {"square" => ["x", "y"]}, "*", "x", "x"] }
|
145
|
+
|
146
|
+
is serialized as:
|
147
|
+
|
148
|
+
lisp[]=define&lisp[][square][]=x&lisp[][square][]=y&lisp[]=%2A&lisp[]=x&lisp[]=x
|
149
|
+
|
150
|
+
Rails and Sinatra will parse this back into the original data structure for you
|
151
|
+
on the server side.
|
152
|
+
|
153
|
+
|
154
|
+
=== Request headers and timeouts
|
155
|
+
|
156
|
+
You can make requests with custom headers using +with_headers+. The return value
|
157
|
+
of +with_headers+ works just like a client object, so you can use it for
|
158
|
+
multiple requests:
|
159
|
+
|
160
|
+
auth = client.with_headers('Authorization' => 'OAuth abc123')
|
161
|
+
auth.get('/me')
|
162
|
+
auth.put('/users/99', :username => 'bob')
|
163
|
+
|
164
|
+
Note that +with_headers+ will normalize Rack-style headers for easy forwarding
|
165
|
+
of input from the front end. For example, +HTTP_USER_AGENT+ is converted to
|
166
|
+
<tt>User-Agent</tt> in the outgoing request.
|
167
|
+
|
168
|
+
Similarly, the request timeout can be adjusted per-request:
|
169
|
+
|
170
|
+
client.with_timeout(10).get('/slow_resource')
|
171
|
+
|
172
|
+
|
173
|
+
=== File uploads
|
174
|
+
|
175
|
+
File uploads are handled transparently for you by the +post+ and +put+ methods.
|
176
|
+
If the value of any parameter (including parameters nested inside hashes) is of
|
177
|
+
type <tt>Songkick::Transport::IO</tt>, the whole request will be treated as
|
178
|
+
<tt>multipart/form-data</tt> and all the data will be serialized for you.
|
179
|
+
|
180
|
+
<tt>Songkick::Transport::IO</tt> must be instantiated with an IO object, a mime
|
181
|
+
type, and a filename, for example:
|
182
|
+
|
183
|
+
file = File.open('concerts.xml')
|
184
|
+
io = Songkick::Transport::IO.new(file, 'application/xml', 'concerts.xml')
|
185
|
+
client.post('/inventories', :inventory => io)
|
186
|
+
file.close
|
187
|
+
|
188
|
+
The file upload can be mixed with normal textual data, and nested hashes, for
|
189
|
+
example:
|
190
|
+
|
191
|
+
client.post('/inventories', :inventory => {:file => io, :date => '2012-03-01'})
|
192
|
+
|
193
|
+
On Sinatra, you get a hash containing both the tempfile and some metadata. You
|
194
|
+
can use this to construct an +IO+ to forward to another service. The complete
|
195
|
+
params look like:
|
196
|
+
|
197
|
+
{
|
198
|
+
:inventory => {
|
199
|
+
:file => {
|
200
|
+
:name => "inventory[file]",
|
201
|
+
:filename => "concerts.xml",
|
202
|
+
:type => "application/xml",
|
203
|
+
:tempfile => #<File:/tmp/RackMultipart20120301-31254-15b6o5r-0>,
|
204
|
+
:head => "Content-Disposition: form-data; name=\"inventory[file]\"; filename=\"concerts.xml\"\r\nContent-Length: 6694\r\nContent-Type: application/xml\r\nContent-Transfer-Encoding: binary\r\n"
|
205
|
+
}
|
206
|
+
:date => "2012-03-01"
|
207
|
+
}
|
208
|
+
}
|
209
|
+
|
210
|
+
file = params[:inventory][:file]
|
211
|
+
io = Songkick::Transport::IO.new(file[:tempfile], file[:type], file[:filename])
|
212
|
+
|
213
|
+
On Rails 2, you just get a tempfile, but it has some additional methods to get
|
214
|
+
what you need. The params look like this:
|
215
|
+
|
216
|
+
{
|
217
|
+
"inventory" => {
|
218
|
+
"file" => #<File:/tmp/CGI20120301-32754-gzgzdy-0>,
|
219
|
+
"date" => "2012-03-01"
|
220
|
+
}
|
221
|
+
}
|
222
|
+
|
223
|
+
file = params["inventory"]["file"]
|
224
|
+
io = Songkick::Transport::IO.new(file, file.content_type, file.original_filename)
|
225
|
+
|
226
|
+
<tt>Songkick::Transport</tt> has a helper for turning both these upload object
|
227
|
+
types into an <tt>IO</tt> for you:
|
228
|
+
|
229
|
+
io = Songkick::Transport.io(params[:inventory][:file])
|
230
|
+
|
231
|
+
You can then use this to forward uploaded files to another service from your
|
232
|
+
Rails or Sinatra application.
|
233
|
+
|
234
|
+
|
235
|
+
=== Logging and reporting
|
236
|
+
|
237
|
+
You can enable basic logging by supplying a logger and switching logging on.
|
238
|
+
|
239
|
+
Songkick::Transport.logger = Logger.new(STDOUT)
|
240
|
+
Songkick::Transport.verbose = true
|
241
|
+
|
242
|
+
The default setting (before you set <tt>Songkick::Transport.verbose = true</tt>
|
243
|
+
is that Transport will warn you about all errors, i.e. any request that raises
|
244
|
+
an exception. With <tt>verbose = true</tt>, it also logs the details of every
|
245
|
+
request made; it logs the requests using a format you can paste into a +curl+
|
246
|
+
command, and logs the status code, data and duration of every response.
|
247
|
+
|
248
|
+
There may be params you don't want in your logs, and you can specify those:
|
249
|
+
|
250
|
+
Songkick::Transport.sanitize 'password', /access_token/
|
251
|
+
|
252
|
+
This method accepts both strings and regexes. Any parameter name (as serialized
|
253
|
+
in a query string) that matches one of these will be logged as e.g.
|
254
|
+
<tt>password=[REMOVED]</tt>.
|
255
|
+
|
256
|
+
There is also a more advanced reporting system that lets you aggregate request
|
257
|
+
statistics. During a request to a web application, many requests to backend
|
258
|
+
services may be involved. The repoting system lets you collect information about
|
259
|
+
all the backend requests that happened while executing a block. For example you
|
260
|
+
can use it to create a logging middleware:
|
261
|
+
|
262
|
+
class Reporter
|
263
|
+
def initialize(app)
|
264
|
+
@app = app
|
265
|
+
end
|
266
|
+
|
267
|
+
def call(env)
|
268
|
+
report = Songkick::Transport.report
|
269
|
+
response = report.execute { @app.call(env) }
|
270
|
+
# write report details somewhere
|
271
|
+
response
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
The +report+ object is an array-like object that contains data for all the
|
276
|
+
requests made during the block's execution. Each request responds to the
|
277
|
+
following API:
|
278
|
+
|
279
|
+
* +endpoint+ -- The origin the request was sent to
|
280
|
+
* +verb+ -- The HTTP method of the request, e.g. <tt>"get"</tt>
|
281
|
+
* +path+ -- The requested path
|
282
|
+
* +params+ -- The hash of parameters used to make the request
|
283
|
+
* +response+ -- The response object the request returned
|
284
|
+
* +error+ -- The exception the request raised, if any
|
285
|
+
* +duration+ -- The request's duration in milliseconds
|
286
|
+
|
287
|
+
The +report+ object itself also responds to +total_duration+, which gives you
|
288
|
+
the total time spent calling backend services during the block.
|
289
|
+
|
290
|
+
|
291
|
+
== License
|
292
|
+
|
293
|
+
The MIT License
|
294
|
+
|
295
|
+
Copyright (c) 2012 Songkick
|
296
|
+
|
297
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
298
|
+
this software and associated documentation files (the "Software"), to deal in
|
299
|
+
the Software without restriction, including without limitation the rights to
|
300
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
301
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
302
|
+
subject to the following conditions:
|
303
|
+
|
304
|
+
The above copyright notice and this permission notice shall be included in all
|
305
|
+
copies or substantial portions of the Software.
|
306
|
+
|
307
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
308
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
309
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
310
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
311
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
312
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
313
|
+
|
data/examples/example.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
$VERBOSE = nil
|
2
|
+
|
3
|
+
dir = File.dirname(__FILE__)
|
4
|
+
require 'rubygems'
|
5
|
+
require dir + '/../lib/songkick/transport'
|
6
|
+
require dir + '/server'
|
7
|
+
|
8
|
+
Client = Songkick::Transport::Curb
|
9
|
+
|
10
|
+
client = Client.new('http://localhost:4567',
|
11
|
+
:user_agent => 'Test Client v1.0',
|
12
|
+
:timeout => 1)
|
13
|
+
|
14
|
+
100.times { client.get('/') }
|
15
|
+
|
16
|
+
p [:result, client.get('/')]
|
17
|
+
p [:result, (client.get('/slow') rescue nil)]
|
18
|
+
p [:result, (client.get('/bad') rescue nil)]
|
19
|
+
|
20
|
+
client = Client.new('http://nosuchhost:8000')
|
21
|
+
p [:result, (client.get('/') rescue nil)]
|
22
|
+
|
23
|
+
=begin
|
24
|
+
|
25
|
+
OUTPUT:
|
26
|
+
|
27
|
+
[:result, #<Songkick::Transport::Response::OK:0x7fe2222a69d8 @data={"hello"=>"world"}>]
|
28
|
+
|
29
|
+
E, [2011-11-24T12:11:46.062361 #12123] ERROR : Request timed out: get http://localhost:4567/slow {}
|
30
|
+
[:result, nil]
|
31
|
+
|
32
|
+
E, [2011-11-24T12:11:46.065107 #12123] ERROR : Request returned invalid JSON: get http://localhost:4567/bad {}
|
33
|
+
[:result, nil]
|
34
|
+
|
35
|
+
E, [2011-11-24T12:11:46.066772 #12123] ERROR : Could not connect to host: http://nosuchhost:8000
|
36
|
+
[:result, nil]
|
37
|
+
|
38
|
+
=end
|
data/examples/loop.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
$VERBOSE = nil
|
2
|
+
|
3
|
+
dir = File.dirname(__FILE__)
|
4
|
+
require 'rubygems'
|
5
|
+
require dir + '/../lib/songkick/transport'
|
6
|
+
require 'eventmachine'
|
7
|
+
|
8
|
+
Client = Songkick::Transport::Curb
|
9
|
+
|
10
|
+
client = Client.new('http://localhost:4567',
|
11
|
+
:user_agent => 'Test Client v1.0',
|
12
|
+
:timeout => 1)
|
13
|
+
|
14
|
+
EM.run {
|
15
|
+
EM.add_periodic_timer(5) { p client.get('/') }
|
16
|
+
}
|
17
|
+
|
data/examples/server.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'eventmachine'
|
3
|
+
|
4
|
+
module Connection
|
5
|
+
HEAD = "HTTP/1.1 200 OK\r\n" +
|
6
|
+
"Content-Type: application/json;charset=utf-8\r\n" +
|
7
|
+
"Connection: keep-alive\r\n"
|
8
|
+
|
9
|
+
def receive_data(data)
|
10
|
+
case data
|
11
|
+
when /slow/
|
12
|
+
EM.add_timer(60) do
|
13
|
+
send_data HEAD +
|
14
|
+
"Content-Length: 17\r\n\r\n" +
|
15
|
+
"{\"hello\":\"world\"}"
|
16
|
+
end
|
17
|
+
when /bad/
|
18
|
+
send_data HEAD +
|
19
|
+
"Content-Length: 16\r\n\r\n" +
|
20
|
+
"{\"hello\":\"world\""
|
21
|
+
|
22
|
+
else
|
23
|
+
send_data HEAD +
|
24
|
+
"Content-Length: 17\r\n\r\n" +
|
25
|
+
"{\"hello\":\"world\"}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def unbind
|
30
|
+
p :connection_closed
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
EM.run {
|
35
|
+
EM.start_server('0.0.0.0', 4567, Connection) do |conn|
|
36
|
+
p :new_connection
|
37
|
+
|
38
|
+
# Close the TCP connection to make sure keep-alive clients reconnect
|
39
|
+
EM.add_timer(15) { conn.close_connection_after_writing }
|
40
|
+
end
|
41
|
+
}
|
42
|
+
|
@@ -0,0 +1,16 @@
|
|
1
|
+
$VERBOSE = nil
|
2
|
+
|
3
|
+
dir = File.dirname(__FILE__)
|
4
|
+
require 'rubygems'
|
5
|
+
require dir + '/../lib/songkick/transport'
|
6
|
+
|
7
|
+
Client = Songkick::Transport::Curb
|
8
|
+
|
9
|
+
client = Client.new('http://localhost:4567', :timeout => 120)
|
10
|
+
|
11
|
+
threads = %w[/ /slow].map do |path|
|
12
|
+
Thread.new { p client.get(path) }
|
13
|
+
end
|
14
|
+
|
15
|
+
threads.each { |t| t.join }
|
16
|
+
|