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 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
+
@@ -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
+
@@ -0,0 +1,15 @@
1
+ require 'rubygems'
2
+ require 'sinatra'
3
+
4
+ get '/' do
5
+ '{"hello":"world"}'
6
+ end
7
+
8
+ get '/slow' do
9
+ sleep 60
10
+ '{"helloooooo":"world"}'
11
+ end
12
+
13
+ get '/bad' do
14
+ '"hello":"world"'
15
+ end
@@ -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
+