ideaoforder-www-delicious 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. data/CHANGELOG.rdoc +46 -0
  2. data/Manifest +49 -0
  3. data/README.rdoc +209 -0
  4. data/Rakefile +55 -0
  5. data/lib/www/delicious/bundle.rb +73 -0
  6. data/lib/www/delicious/element.rb +73 -0
  7. data/lib/www/delicious/errors.rb +46 -0
  8. data/lib/www/delicious/post.rb +123 -0
  9. data/lib/www/delicious/tag.rb +101 -0
  10. data/lib/www/delicious/version.rb +29 -0
  11. data/lib/www/delicious.rb +949 -0
  12. data/setup.rb +1585 -0
  13. data/test/fixtures/net_response_invalid_account.yml +25 -0
  14. data/test/fixtures/net_response_success.yml +23 -0
  15. data/test/helper.rb +49 -0
  16. data/test/test_all.rb +18 -0
  17. data/test/test_offline.rb +18 -0
  18. data/test/test_online.rb +20 -0
  19. data/test/testcases/element/bundle.xml +1 -0
  20. data/test/testcases/element/invalid_root.xml +2 -0
  21. data/test/testcases/element/post.xml +2 -0
  22. data/test/testcases/element/post_unshared.xml +2 -0
  23. data/test/testcases/element/tag.xml +1 -0
  24. data/test/testcases/response/bundles_all.xml +5 -0
  25. data/test/testcases/response/bundles_all_empty.xml +2 -0
  26. data/test/testcases/response/bundles_delete.xml +2 -0
  27. data/test/testcases/response/bundles_set.xml +2 -0
  28. data/test/testcases/response/bundles_set_error.xml +2 -0
  29. data/test/testcases/response/posts_add.xml +2 -0
  30. data/test/testcases/response/posts_all.xml +12 -0
  31. data/test/testcases/response/posts_dates.xml +14 -0
  32. data/test/testcases/response/posts_dates_with_tag.xml +14 -0
  33. data/test/testcases/response/posts_delete.xml +2 -0
  34. data/test/testcases/response/posts_get.xml +7 -0
  35. data/test/testcases/response/posts_get_with_tag.xml +6 -0
  36. data/test/testcases/response/posts_recent.xml +19 -0
  37. data/test/testcases/response/posts_recent_with_tag.xml +19 -0
  38. data/test/testcases/response/tags_get.xml +5 -0
  39. data/test/testcases/response/tags_get_empty.xml +2 -0
  40. data/test/testcases/response/tags_rename.xml +2 -0
  41. data/test/testcases/response/update.delicious1.xml +2 -0
  42. data/test/testcases/response/update.xml +3 -0
  43. data/test/unit/bundle_test.rb +63 -0
  44. data/test/unit/delicious_test.rb +369 -0
  45. data/test/unit/online/online_test.rb +148 -0
  46. data/test/unit/post_test.rb +68 -0
  47. data/test/unit/tag_test.rb +69 -0
  48. data/www-delicious.gemspec +146 -0
  49. metadata +143 -0
@@ -0,0 +1,949 @@
1
+ #
2
+ # = WWW::Delicious
3
+ #
4
+ # Ruby client for del.icio.us API.
5
+ #
6
+ #
7
+ # Category:: WWW
8
+ # Package:: WWW::Delicious
9
+ # Author:: Simone Carletti <weppos@weppos.net>
10
+ # License:: MIT License
11
+ #
12
+ #--
13
+ # SVN: $Id$
14
+ #++
15
+
16
+
17
+ require 'net/https'
18
+ require 'rexml/document'
19
+ require 'time'
20
+ require 'www/delicious/bundle'
21
+ require 'www/delicious/post'
22
+ require 'www/delicious/tag'
23
+ require 'www/delicious/errors'
24
+ require 'www/delicious/version'
25
+
26
+
27
+ module WWW #:nodoc:
28
+
29
+
30
+ #
31
+ # = WWW::Delicious
32
+ #
33
+ # WWW::Delicious is a Ruby client for http://del.icio.us XML API.
34
+ #
35
+ # It provides both read and write functionalities.
36
+ # You can read user Posts, Tags and Bundles
37
+ # but you can create new Posts, Tags and Bundles as well.
38
+ #
39
+ #
40
+ # == Basic Usage
41
+ #
42
+ # The following is just a basic demonstration of the main features.
43
+ # See the README file for a deeper explanation about how to get the best
44
+ # from WWW::Delicious library.
45
+ #
46
+ # The examples in this page make the following assumptions
47
+ # * you have a valid del.icio.us account
48
+ # * +username+ is your account username
49
+ # * +password+ is your account password
50
+ #
51
+ # In order to make a query you first need to create
52
+ # a new WWW::Delicious instance as follows:
53
+ #
54
+ # require 'www/delicious'
55
+ #
56
+ # username = 'my delicious username'
57
+ # password = 'my delicious password'
58
+ #
59
+ # d = WWW::Delicious.new(username, password)
60
+ #
61
+ # The constructor accepts some additional options.
62
+ # For instance, if you want to customize the user agent:
63
+ #
64
+ # d = WWW::Delicious.new(username, password, :user_agent => 'FooAgent')
65
+ #
66
+ # Now you can use any of the API methods available.
67
+ #
68
+ # For example, you may want to know when your account was last updated
69
+ # to check whether someone else made some changes on behalf of you:
70
+ #
71
+ # datetime = d.update # => Wed Mar 12 08:41:20 UTC 2008
72
+ #
73
+ # Because the answer is a valid +Time+ instance, you can format it with +strftime+.
74
+ #
75
+ # datetime = d.update # => Wed Mar 12 08:41:20 UTC 2008
76
+ # datetime.strftime('%Y') # => 2008
77
+ #
78
+ #
79
+ # Category:: WWW
80
+ # Package:: WWW::Delicious
81
+ # Author:: Simone Carletti <weppos@weppos.net>
82
+ #
83
+ class Delicious
84
+
85
+ NAME = 'WWW::Delicious'
86
+ GEM = 'www-delicious'
87
+ AUTHOR = 'Simone Carletti <weppos@weppos.net>'
88
+ VERSION = defined?(Version) ? Version::STRING : nil
89
+ STATUS = 'alpha'
90
+ BUILD = ''.match(/(\d+)/).to_a.first
91
+
92
+ # del.icio.us account username
93
+ attr_reader :username
94
+
95
+ # del.icio.us account password
96
+ attr_reader :password
97
+
98
+ # base URI for del.icio.us API
99
+ attr_reader :base_uri
100
+
101
+
102
+ # API Base URL
103
+ API_BASE_URI = 'https://api.del.icio.us'
104
+
105
+ # API Path Update
106
+ API_PATH_UPDATE = '/v1/posts/update';
107
+
108
+ # API Path All Bundles
109
+ API_PATH_BUNDLES_ALL = '/v1/tags/bundles/all';
110
+ # API Path Set Bundle
111
+ API_PATH_BUNDLES_SET = '/v1/tags/bundles/set';
112
+ # API Path Delete Bundle
113
+ API_PATH_BUNDLES_DELETE = '/v1/tags/bundles/delete';
114
+
115
+ # API Path Get Tags
116
+ API_PATH_TAGS_GET = '/v1/tags/get';
117
+ # API Path Rename Tag
118
+ API_PATH_TAGS_RENAME = '/v1/tags/rename';
119
+
120
+ # API Path Get Posts
121
+ API_PATH_POSTS_GET = '/v1/posts/get';
122
+ # API Path Recent Posts
123
+ API_PATH_POSTS_RECENT = '/v1/posts/recent';
124
+ # API Path All Posts
125
+ API_PATH_POSTS_ALL = '/v1/posts/all';
126
+ # API Path Posts by Dates
127
+ API_PATH_POSTS_DATES = '/v1/posts/dates';
128
+ # API Path Add Post
129
+ API_PATH_POSTS_ADD = '/v1/posts/add';
130
+ # API Path Delete Post
131
+ API_PATH_POSTS_DELETE = '/v1/posts/delete';
132
+
133
+ # Time to wait before sending a new request, in seconds
134
+ SECONDS_BEFORE_NEW_REQUEST = 1
135
+
136
+ # Time converter converts a Time instance into the format
137
+ # requested by Delicious API
138
+ TIME_CONVERTER = lambda { |time| time.iso8601() }
139
+
140
+
141
+ #
142
+ # Constructs a new <tt>WWW::Delicious</tt> object
143
+ # with given +username+ and +password+.
144
+ #
145
+ # # create a new object with username 'user' and password 'psw
146
+ # obj = WWW::Delicious('user', 'psw')
147
+ # # => self
148
+ #
149
+ # If a block is given, the instance is passed to the block
150
+ # but this method always returns the instance itself.
151
+ #
152
+ # WWW::Delicious('user', 'psw') do |d|
153
+ # d.update() # => Fri May 02 18:02:48 UTC 2008
154
+ # end
155
+ # # => self
156
+ #
157
+ # You can also specify some additional options, including a custom user agent
158
+ # or the base URI for del.icio.us API.
159
+ #
160
+ # WWW::Delicious('user', 'psw', :base_uri => 'https://ma.gnolia.com/api/mirrord') do |d|
161
+ # # the following call is mirrored by ma.gnolia
162
+ # d.update() # => Fri May 02 18:02:48 UTC 2008
163
+ # end
164
+ # # => self
165
+ #
166
+ # === Options
167
+ # This class accepts a Hash with additional options.
168
+ # Here's the list of valid keys:
169
+ #
170
+ # <tt>:user_agent</tt>:: User agent to display in HTTP requests.
171
+ # <tt>:base_uri</tt>:: The base URI to del.icio.us API.
172
+ #
173
+ def initialize(username, password, options = {}, &block) # :yields: delicious
174
+ @username, @password = username.to_s, password.to_s
175
+
176
+ # set API base URI
177
+ @base_uri = URI.parse(options[:base_uri] || API_BASE_URI)
178
+
179
+ init_user_agent(options)
180
+ init_http_client(options)
181
+
182
+ yield self if block_given?
183
+ self # ensure to always return self even if block is given
184
+ end
185
+
186
+
187
+ #
188
+ # Returns the reference to current <tt>@http_client</tt>.
189
+ # The http is always valid unless it has been previously set to +nil+.
190
+ #
191
+ # # nil client
192
+ # obj.http_client # => nil
193
+ #
194
+ # # valid client
195
+ # obj.http_client # => Net::HTTP
196
+ #
197
+ def http_client()
198
+ return @http_client
199
+ end
200
+
201
+ #
202
+ # Sets the internal <tt>@http_client</tt> to +client+.
203
+ #
204
+ # # nil client
205
+ # obj.http_client = nil
206
+ #
207
+ # # http client
208
+ # obj.http_client = Net::HTTP.new()
209
+ #
210
+ # # invalid client
211
+ # obj.http_client = 'foo' # => ArgumentError
212
+ #
213
+ def http_client=(client)
214
+ unless client.kind_of?(Net::HTTP) or client.nil?
215
+ raise ArgumentError, "`client` expected to be a kind of `Net::HTTP`, `#{client.class}` given"
216
+ end
217
+ @http_client = client
218
+ end
219
+
220
+ # Returns current user agent string.
221
+ def user_agent()
222
+ return @headers['User-Agent']
223
+ end
224
+
225
+
226
+ #
227
+ # Returns true if given account credentials are valid.
228
+ #
229
+ # d = WWW::Delicious.new('username', 'password')
230
+ # d.valid_account? # => true
231
+ #
232
+ # d = WWW::Delicious.new('username', 'invalid_password')
233
+ # d.valid_account? # => false
234
+ #
235
+ # This method is not "exception safe".
236
+ # It doesn't return false if an HTTP error or any kind of other error occurs,
237
+ # it raises back the exception to the caller instead.
238
+ #
239
+ #
240
+ # Raises:: WWW::Delicious::Error
241
+ # Raises:: WWW::Delicious::HTTPError
242
+ # Raises:: WWW::Delicious::ResponseError
243
+ #
244
+ def valid_account?
245
+ update()
246
+ return true
247
+ rescue HTTPError => e
248
+ return false if e.message =~ /invalid username or password/i
249
+ raise
250
+ end
251
+
252
+ #
253
+ # Checks to see when a user last posted an item
254
+ # and returns the last update +Time+ for the user.
255
+ #
256
+ # d.update() # => Fri May 02 18:02:48 UTC 2008
257
+ #
258
+ #
259
+ # Raises:: WWW::Delicious::Error
260
+ # Raises:: WWW::Delicious::HTTPError
261
+ # Raises:: WWW::Delicious::ResponseError
262
+ #
263
+ def update()
264
+ response = request(API_PATH_UPDATE)
265
+ return parse_update_response(response.body)
266
+ end
267
+
268
+ #
269
+ # Retrieves all of a user's bundles
270
+ # and returns an array of <tt>WWW::Delicious::Bundle</tt>.
271
+ #
272
+ # d.bundles_all() # => [#<WWW::Delicious::Bundle>, #<WWW::Delicious::Bundle>, ...]
273
+ # d.bundles_all() # => []
274
+ #
275
+ #
276
+ # Raises:: WWW::Delicious::Error
277
+ # Raises:: WWW::Delicious::HTTPError
278
+ # Raises:: WWW::Delicious::ResponseError
279
+ #
280
+ def bundles_all()
281
+ response = request(API_PATH_BUNDLES_ALL)
282
+ return parse_bundle_collection(response.body)
283
+ end
284
+
285
+ #
286
+ # Assignes a set of tags to a single bundle,
287
+ # wipes away previous settings for bundle.
288
+ #
289
+ # # create from a bundle
290
+ # d.bundles_set(WWW::Delicious::Bundle.new('MyBundle'), %w(foo bar))
291
+ #
292
+ # # create from a string
293
+ # d.bundles_set('MyBundle', %w(foo bar))
294
+ #
295
+ #
296
+ # Raises:: WWW::Delicious::Error
297
+ # Raises:: WWW::Delicious::HTTPError
298
+ # Raises:: WWW::Delicious::ResponseError
299
+ #
300
+ def bundles_set(bundle_or_name, tags = [])
301
+ params = prepare_bundles_set_params(bundle_or_name, tags)
302
+ response = request(API_PATH_BUNDLES_SET, params)
303
+ return parse_and_eval_execution_response(response.body)
304
+ end
305
+
306
+ #
307
+ # Deletes +bundle_or_name+ bundle from del.icio.us.
308
+ # +bundle_or_name+ can be either a WWW::Delicious::Bundle instance
309
+ # or a string with the name of the bundle.
310
+ #
311
+ # This method doesn't care whether the exists.
312
+ # If not, the execution will silently return without rising any error.
313
+ #
314
+ # # delete from a bundle
315
+ # d.bundles_delete(WWW::Delicious::Bundle.new('MyBundle'))
316
+ #
317
+ # # delete from a string
318
+ # d.bundles_delete('MyBundle', %w(foo bar))
319
+ #
320
+ #
321
+ # Raises:: WWW::Delicious::Error
322
+ # Raises:: WWW::Delicious::HTTPError
323
+ # Raises:: WWW::Delicious::ResponseError
324
+ #
325
+ def bundles_delete(bundle_or_name)
326
+ params = prepare_bundles_delete_params(bundle_or_name)
327
+ response = request(API_PATH_BUNDLES_DELETE, params)
328
+ return parse_and_eval_execution_response(response.body)
329
+ end
330
+
331
+ #
332
+ # Retrieves the list of tags and number of times used by the user
333
+ # and returns an array of <tt>WWW::Delicious::Tag</tt>.
334
+ #
335
+ # d.tags_get() # => [#<WWW::Delicious::Tag>, #<WWW::Delicious::Tag>, ...]
336
+ # d.tags_get() # => []
337
+ #
338
+ #
339
+ # Raises:: WWW::Delicious::Error
340
+ # Raises:: WWW::Delicious::HTTPError
341
+ # Raises:: WWW::Delicious::ResponseError
342
+ #
343
+ def tags_get()
344
+ response = request(API_PATH_TAGS_GET)
345
+ return parse_tag_collection(response.body)
346
+ end
347
+
348
+ #
349
+ # Renames an existing tag with a new tag name.
350
+ #
351
+ # # rename from a tag
352
+ # d.bundles_set(WWW::Delicious::Tag.new('old'), WWW::Delicious::Tag.new('new'))
353
+ #
354
+ # # rename from a string
355
+ # d.bundles_set('old', 'new')
356
+ #
357
+ #
358
+ # Raises:: WWW::Delicious::Error
359
+ # Raises:: WWW::Delicious::HTTPError
360
+ # Raises:: WWW::Delicious::ResponseError
361
+ #
362
+ def tags_rename(from_name_or_tag, to_name_or_tag)
363
+ params = prepare_tags_rename_params(from_name_or_tag, to_name_or_tag)
364
+ response = request(API_PATH_TAGS_RENAME, params)
365
+ return parse_and_eval_execution_response(response.body)
366
+ end
367
+
368
+ #
369
+ # Returns an array of <tt>WWW::Delicious::Post</tt> matching +options+.
370
+ # If no option is given, the last post is returned.
371
+ # If no date or url is given, most recent date will be used.
372
+ #
373
+ # d.posts_get() # => [#<WWW::Delicious::Post>, #<WWW::Delicious::Post>, ...]
374
+ # d.posts_get() # => []
375
+ #
376
+ # # get all posts tagged with ruby
377
+ # d.posts_get(:tag => WWW::Delicious::Tag.new('ruby))
378
+ #
379
+ # # get all posts matching URL 'http://www.simonecarletti.com'
380
+ # d.posts_get(:url => URI.parse('http://www.simonecarletti.com'))
381
+ #
382
+ # # get all posts tagged with ruby and matching URL 'http://www.simonecarletti.com'
383
+ # d.posts_get(:tag => WWW::Delicious::Tag.new('ruby),
384
+ # :url => URI.parse('http://www.simonecarletti.com'))
385
+ #
386
+ #
387
+ # === Options
388
+ # <tt>:tag</tt>:: a tag to filter by. It can be either a <tt>WWW::Delicious::Tag</tt> or a +String+.
389
+ # <tt>:dt</tt>:: a +Time+ with a date to filter by.
390
+ # <tt>:url</tt>:: a valid URI to filter by. It can be either an instance of +URI+ or a +String+.
391
+ #
392
+ # Raises:: WWW::Delicious::Error
393
+ # Raises:: WWW::Delicious::HTTPError
394
+ # Raises:: WWW::Delicious::ResponseError
395
+ #
396
+ def posts_get(options = {})
397
+ params = prepare_posts_params(options.clone, [:dt, :tag, :url])
398
+ response = request(API_PATH_POSTS_GET, params)
399
+ return parse_post_collection(response.body)
400
+ end
401
+
402
+ #
403
+ # Returns a list of the most recent posts, filtered by argument.
404
+ #
405
+ # # get the most recent posts
406
+ # d.posts_recent()
407
+ #
408
+ # # get the 10 most recent posts
409
+ # d.posts_recent(:count => 10)
410
+ #
411
+ #
412
+ # === Options
413
+ # <tt>:tag</tt>:: a tag to filter by. It can be either a <tt>WWW::Delicious::Tag</tt> or a +String+.
414
+ # <tt>:count</tt>:: number of items to retrieve. (default: 15, maximum: 100).
415
+ #
416
+ def posts_recent(options = {})
417
+ params = prepare_posts_params(options.clone, [:count, :tag])
418
+ response = request(API_PATH_POSTS_RECENT, params)
419
+ return parse_post_collection(response.body)
420
+ end
421
+
422
+ #
423
+ # Returns a list of all posts, filtered by argument.
424
+ #
425
+ # # get all (this is a very expensive query)
426
+ # d.posts_all
427
+ #
428
+ # # get all posts matching ruby
429
+ # d.posts_all(:tag => WWW::Delicious::Tag.new('ruby'))
430
+ #
431
+ #
432
+ # === Options
433
+ # <tt>:tag</tt>:: a tag to filter by. It can be either a <tt>WWW::Delicious::Tag</tt> or a +String+.
434
+ #
435
+ def posts_all(options = {})
436
+ params = prepare_posts_params(options.clone, [:tag])
437
+ response = request(API_PATH_POSTS_ALL, params)
438
+ return parse_post_collection(response.body)
439
+ end
440
+
441
+ #
442
+ # Returns a list of dates with the number of posts at each date.
443
+ #
444
+ # # get number of posts per date
445
+ # d.posts_dates
446
+ # # => { '2008-05-05' => 12, '2008-05-06' => 3, ... }
447
+ #
448
+ # # get number posts per date tagged as ruby
449
+ # d.posts_dates(:tag => WWW::Delicious::Tag.new('ruby'))
450
+ # # => { '2008-05-05' => 10, '2008-05-06' => 3, ... }
451
+ #
452
+ #
453
+ # === Options
454
+ # <tt>:tag</tt>:: a tag to filter by. It can be either a <tt>WWW::Delicious::Tag</tt> or a +String+.
455
+ #
456
+ def posts_dates(options = {})
457
+ params = prepare_posts_params(options.clone, [:tag])
458
+ response = request(API_PATH_POSTS_DATES, params)
459
+ return parse_posts_dates_response(response.body)
460
+ end
461
+
462
+ #
463
+ # Add a post to del.icio.us.
464
+ # +post_or_values+ can be either a +WWW::Delicious::Post+ instance
465
+ # or a Hash of params. This method accepts all params available
466
+ # to initialize a new +WWW::Delicious::Post+.
467
+ #
468
+ # # add a post from WWW::Delicious::Post
469
+ # d.posts_add(WWW::Delicious::Post.new(:url => 'http://www.foobar.com', :title => 'Hello world!'))
470
+ #
471
+ # # add a post from values
472
+ # d.posts_add(:url => 'http://www.foobar.com', :title => 'Hello world!')
473
+ #
474
+ #
475
+ def posts_add(post_or_values)
476
+ params = prepare_param_post(post_or_values).to_params
477
+ response = request(API_PATH_POSTS_ADD, params)
478
+ return parse_and_eval_execution_response(response.body)
479
+ end
480
+
481
+ #
482
+ # Deletes the post matching given +url+ from del.icio.us.
483
+ # +url+ can be either an URI instance or a string representation of a valid URL.
484
+ #
485
+ # This method doesn't care whether a post with given +url+ exists.
486
+ # If not, the execution will silently return without rising any error.
487
+ #
488
+ # # delete a post from URI
489
+ # d.post_delete(URI.parse('http://www.foobar.com/'))
490
+ #
491
+ # # delete a post from a string
492
+ # d.post_delete('http://www.foobar.com/')
493
+ #
494
+ #
495
+ def posts_delete(url)
496
+ params = prepare_posts_params({:url => url}, [:url])
497
+ response = request(API_PATH_POSTS_DELETE, params)
498
+ return parse_and_eval_execution_response(response.body)
499
+ end
500
+
501
+
502
+ protected
503
+
504
+ # Initializes the HTTP client.
505
+ # It automatically enable +use_ssl+ flag according to +@base_uri+ scheme.
506
+ def init_http_client(options)
507
+ http = Net::HTTP.new(@base_uri.host, 443)
508
+ http.use_ssl = true if @base_uri.scheme == "https"
509
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE # FIXME: not 100% supported
510
+ self.http_client = http
511
+ end
512
+
513
+ # Initializes user agent value for HTTP requests.
514
+ def init_user_agent(options)
515
+ user_agent = options[:user_agent] || default_user_agent()
516
+ @headers ||= {}
517
+ @headers['User-Agent'] = user_agent
518
+ end
519
+
520
+ #
521
+ # Creates and returns the default user agent string.
522
+ #
523
+ # By default, the user agent is composed by the following schema:
524
+ # <tt>NAME/VERSION (Ruby/RUBY_VERSION)</tt>
525
+ #
526
+ # * +NAME+ is the constant representing this library name
527
+ # * +VERSION+ is the constant representing current library version
528
+ # * +RUBY_VERSION+ is the version of Ruby interpreter the library is interpreted by
529
+ #
530
+ # default_user_agent
531
+ # # => WWW::Delicious/0.1.0 (Ruby/1.8.6)
532
+ #
533
+ def default_user_agent
534
+ return "#{NAME}/#{VERSION} (Ruby/#{RUBY_VERSION})"
535
+ end
536
+
537
+
538
+ #
539
+ # Composes an HTTP query string from an hash of +options+.
540
+ # The result is URI encoded.
541
+ #
542
+ # http_build_query(:foo => 'baa', :bar => 'boo')
543
+ # # => foo=baa&bar=boo
544
+ #
545
+ def http_build_query(params = {})
546
+ return params.collect do |k,v|
547
+ "#{URI.encode(k.to_s)}=#{URI.encode(v.to_s)}" unless v.nil?
548
+ end.compact.join('&')
549
+ end
550
+
551
+ #
552
+ # Sends an HTTP GET request to +path+ and appends given +params+.
553
+ #
554
+ # This method is 100% compliant with Delicious API reference.
555
+ # It waits at least 1 second between each HTTP request and
556
+ # provides an identifiable user agent by default,
557
+ # or the custom user agent set by +user_agent+ option
558
+ # when this istance has been created.
559
+ #
560
+ # request('/v1/api/path', :foo => 1, :bar => 2)
561
+ # # => sends a GET request to /v1/api/path?foo=1&bar=2
562
+ #
563
+ def request(path, params = {})
564
+ raise Error, 'Invalid HTTP Client' unless http_client
565
+ wait_before_new_request
566
+
567
+ uri = @base_uri.merge(path)
568
+ uri.query = http_build_query(params) unless params.empty?
569
+
570
+ begin
571
+ @last_request = Time.now # see #wait_before_new_request
572
+ @last_request_uri = uri # useful for debug
573
+ response = make_request(uri)
574
+ rescue => e # catch EOFError, SocketError and more
575
+ raise HTTPError, e.message
576
+ end
577
+
578
+ case response
579
+ when Net::HTTPSuccess
580
+ return response
581
+ when Net::HTTPUnauthorized # 401
582
+ raise HTTPError, 'Invalid username or password'
583
+ when Net::HTTPServiceUnavailable # 503
584
+ raise HTTPError, 'You have been throttled.' +
585
+ 'Please ensure you are waiting at least one second before each request.'
586
+ else
587
+ raise HTTPError, "HTTP #{response.code}: #{response.message}"
588
+ end
589
+ end
590
+
591
+ # Makes the real HTTP request to given +uri+ and returns the +response+.
592
+ # This method exists basically to simplify unit testing with mocha.
593
+ def make_request(uri)
594
+ http_client.start do |http|
595
+ req = Net::HTTP::Get.new(uri.request_uri, @headers)
596
+ req.basic_auth(@username, @password)
597
+ http.request(req)
598
+ end
599
+ end
600
+
601
+ #
602
+ # Delicious API reference requests to wait AT LEAST ONE SECOND
603
+ # between queries or the client is likely to get automatically throttled.
604
+ #
605
+ # This method calculates the difference between current time
606
+ # and the last request time and wait for the necessary time to meet
607
+ # SECONDS_BEFORE_NEW_REQUEST requirement.
608
+ #
609
+ # The difference is not rounded. If you only have to wait for 0.034 seconds
610
+ # then your don't have to wait 0 or 1 seconds, but 0.034 seconds!
611
+ #
612
+ def wait_before_new_request
613
+ return unless @last_request # this is the first request
614
+ # puts "Last request at #{TIME_CONVERTER.call(@last_request)}" if debug?
615
+ diff = Time.now - @last_request
616
+ if diff < SECONDS_BEFORE_NEW_REQUEST
617
+ # puts "Sleeping for #{diff} before new request..." if debug?
618
+ sleep(SECONDS_BEFORE_NEW_REQUEST - diff)
619
+ end
620
+ end
621
+
622
+
623
+ #
624
+ # Parses the response <tt>body</tt> and runs a common set of validators.
625
+ # Returns <tt>body</tt> as parsed REXML::Document on success.
626
+ #
627
+ # Raises:: WWW::Delicious::ResponseError in case of invalid response.
628
+ #
629
+ def parse_and_validate_response(body, options = {})
630
+ dom = REXML::Document.new(body)
631
+
632
+ if (value = options[:root_name]) && dom.root.name != value
633
+ raise ResponseError, "Invalid response, root node is not `#{value}`"
634
+ end
635
+ if (value = options[:root_text]) && dom.root.text != value
636
+ raise ResponseError, value
637
+ end
638
+
639
+ return dom
640
+ end
641
+
642
+ #
643
+ # Parses and evaluates the response returned by an execution,
644
+ # usually an update/delete/insert operation.
645
+ #
646
+ # Raises:: WWW::Delicious::ResponseError in case of invalid response
647
+ # Raises:: WWW::Delicious::Error in case of execution error
648
+ #
649
+ def parse_and_eval_execution_response(body)
650
+ dom = parse_and_validate_response(body, :root_name => 'result')
651
+ response = dom.root.if_attribute_value(:code)
652
+ response = dom.root.text if response.nil?
653
+ raise Error, "Invalid response, #{response}" unless %w(done ok).include?(response)
654
+ true
655
+ end
656
+
657
+ # Parses the response of an Update request
658
+ # and returns the update Timestamp.
659
+ def parse_update_response(body)
660
+ dom = parse_and_validate_response(body, :root_name => 'update')
661
+ dom.root.if_attribute_value(:time) { |v| Time.parse(v) }
662
+ end
663
+
664
+ # Parses a response containing a collection of Bundles
665
+ # and returns an array of <tt>WWW::Delicious::Bundle</tt>.
666
+ def parse_bundle_collection(body)
667
+ dom = parse_and_validate_response(body, :root_name => 'bundles')
668
+ dom.root.elements.collect('bundle') { |xml| Bundle.from_rexml(xml) }
669
+ end
670
+
671
+ # Parses a response containing a collection of Tags
672
+ # and returns an array of <tt>WWW::Delicious::Tag</tt>.
673
+ def parse_tag_collection(body)
674
+ dom = parse_and_validate_response(body, :root_name => 'tags')
675
+ dom.root.elements.collect('tag') { |xml| Tag.from_rexml(xml) }
676
+ end
677
+
678
+ # Parses a response containing a collection of Posts
679
+ # and returns an array of <tt>WWW::Delicious::Post</tt>.
680
+ def parse_post_collection(body)
681
+ dom = parse_and_validate_response(body, :root_name => 'posts')
682
+ dom.root.elements.collect('post') { |xml| Post.from_rexml(xml) }
683
+ end
684
+
685
+ # Parses the response of a <tt>posts_dates</tt> request
686
+ # and returns a +Hash+ of date => count.
687
+ def parse_posts_dates_response(body)
688
+ dom = parse_and_validate_response(body, :root_name => 'dates')
689
+ return dom.root.get_elements('date').inject({}) do |collection, xml|
690
+ date = xml.if_attribute_value(:date)
691
+ count = xml.if_attribute_value(:count)
692
+ collection.merge({ date => count })
693
+ end
694
+ end
695
+
696
+
697
+ #
698
+ # Prepares the params for a `bundles_set` call
699
+ # and returns a Hash with the params ready for the HTTP request.
700
+ #
701
+ # Raises:: WWW::Delicious::Error
702
+ #
703
+ def prepare_bundles_set_params(name_or_bundle, tags = [])
704
+ bundle = prepare_param_bundle(name_or_bundle, tags) do |b|
705
+ raise Error, "Bundle name is empty" if b.name.empty?
706
+ raise Error, "Bundle must contain at least one tag" if b.tags.empty?
707
+ end
708
+ return { :bundle => bundle.name, :tags => bundle.tags.join(' ') }
709
+ end
710
+
711
+ #
712
+ # Prepares the params for a `bundles_set` call
713
+ # and returns a Hash with the params ready for the HTTP request.
714
+ #
715
+ # Raises:: WWW::Delicious::Error
716
+ #
717
+ def prepare_bundles_delete_params(name_or_bundle)
718
+ bundle = prepare_param_bundle(name_or_bundle) do |b|
719
+ raise Error, "Bundle name is empty" if b.name.empty?
720
+ end
721
+ return { :bundle => bundle.name }
722
+ end
723
+
724
+ #
725
+ # Prepares the params for a `tags_rename` call
726
+ # and returns a Hash with the params ready for the HTTP request.
727
+ #
728
+ # Raises:: WWW::Delicious::Error
729
+ #
730
+ def prepare_tags_rename_params(from_name_or_tag, to_name_or_tag)
731
+ from, to = [from_name_or_tag, to_name_or_tag].collect do |v|
732
+ prepare_param_tag(v)
733
+ end
734
+ return { :old => from, :new => to }
735
+ end
736
+
737
+ #
738
+ # Prepares the params for a `post_*` call
739
+ # and returns a Hash with the params ready for the HTTP request.
740
+ #
741
+ # Raises:: WWW::Delicious::Error
742
+ #
743
+ def prepare_posts_params(params, allowed_params = [])
744
+ compare_params(params, allowed_params)
745
+
746
+ # we don't need to check whether the following parameters
747
+ # are valid for this request because compare_params
748
+ # would raise if an invalid param is supplied
749
+
750
+ params[:tag] = prepare_param_tag(params[:tag]) if params[:tag]
751
+ params[:dt] = TIME_CONVERTER.call(params[:dt]) if params[:dt]
752
+ params[:url] = URI.parse(params[:url]) if params[:url]
753
+ params[:count] = if value = params[:count]
754
+ raise Error, 'Expected `count` <= 100' if value.to_i() > 100 # requirement
755
+ value.to_i
756
+ else
757
+ 15 # default value
758
+ end
759
+
760
+ return params
761
+ end
762
+
763
+
764
+ #
765
+ # Prepares the +post+ param for an API request.
766
+ #
767
+ # Creates and returns a <tt>WWW::Delicious::Post</tt> instance from <tt>post_or_values</tt>.
768
+ # <tt>post_or_values</tt> can be either an Hash with post attributes
769
+ # or a <tt>WWW::Delicious::Post</tt> instance.
770
+ #
771
+ def prepare_param_post(post_or_values, &block)
772
+ post = case post_or_values
773
+ when WWW::Delicious::Post
774
+ post_or_values
775
+ when Hash
776
+ Post.new(post_or_values)
777
+ else
778
+ raise ArgumentError, 'Expected `args` to be `WWW::Delicious::Post` or `Hash`'
779
+ end
780
+
781
+ yield(post) if block_given?
782
+ # TODO: validate post with post.validate!
783
+ raise ArgumentError, 'Both `url` and `title` are required' unless post.api_valid?
784
+ post
785
+ end
786
+
787
+ #
788
+ # Prepares the +bundle+ param for an API request.
789
+ #
790
+ # Creates and returns a <tt>WWW::Delicious::Bundle</tt> instance from <tt>name_or_bundle</tt>.
791
+ # <tt>name_or_bundle</tt> can be either a string holding bundle name
792
+ # or a <tt>WWW::Delicious::Bundle</tt> instance.
793
+ #
794
+ def prepare_param_bundle(name_or_bundle, tags = [], &block) # :yields: bundle
795
+ bundle = case name_or_bundle
796
+ when WWW::Delicious::Bundle
797
+ name_or_bundle
798
+ else
799
+ Bundle.new(:name => name_or_bundle, :tags => tags)
800
+ end
801
+
802
+ yield(bundle) if block_given?
803
+ # TODO: validate bundle with bundle.validate!
804
+ bundle
805
+ end
806
+
807
+ #
808
+ # Prepares the +tag+ param for an API request.
809
+ #
810
+ # Creates and returns a <tt>WWW::Delicious::Tag</tt> instance from <tt>name_or_tag</tt>.
811
+ # <tt>name_or_tag</tt> can be either a string holding tag name
812
+ # or a <tt>WWW::Delicious::Tag</tt> instance.
813
+ #
814
+ def prepare_param_tag(name_or_tag, &block) # :yields: tag
815
+ tag = case name_or_tag
816
+ when WWW::Delicious::Tag
817
+ name_or_tag
818
+ else
819
+ Tag.new(:name => name_or_tag.to_s)
820
+ end
821
+
822
+ yield(tag) if block_given?
823
+ # TODO: validate tag with tag.validate!
824
+ raise "Invalid `tag` value supplied" unless tag.api_valid?
825
+ tag
826
+ end
827
+
828
+ #
829
+ # Checks whether user given +params+ are valid against a defined collection of +valid_params+.
830
+ #
831
+ # === Examples
832
+ #
833
+ # params = {:foo => 1, :bar => 2}
834
+ #
835
+ # compare_params(params, [:foo, :bar])
836
+ # # => valid
837
+ #
838
+ # compare_params(params, [:foo, :bar, :baz])
839
+ # # => raises
840
+ #
841
+ # compare_params(params, [:foo])
842
+ # # => raises
843
+ #
844
+ # Raises:: WWW::Delicious::Error
845
+ #
846
+ def compare_params(params, valid_params)
847
+ raise ArgumentError, "Expected `params` to be a kind of `Hash`" unless params.kind_of?(Hash)
848
+ raise ArgumentError, "Expected `valid_params` to be a kind of `Array`" unless valid_params.kind_of?(Array)
849
+
850
+ # compute options difference
851
+ difference = params.keys - valid_params
852
+ raise Error, "Invalid params: `#{difference.join('`, `')}`" unless difference.empty?
853
+ end
854
+
855
+
856
+ module XMLUtils #:nodoc:
857
+
858
+ #
859
+ # Returns the +xmlattr+ attribute value for current <tt>REXML::Element</tt>.
860
+ #
861
+ # If block is given and attribute value is not nil,
862
+ # the content of the block is executed.
863
+ #
864
+ # === Examples
865
+ #
866
+ # dom = REXML::Document.new('<a name="1"><b>foo</b><b>bar</b></a>')
867
+ #
868
+ # dom.root.if_attribute_value(:name)
869
+ # # => "1"
870
+ #
871
+ # dom.root.if_attribute_value(:name) { |v| v.to_i }
872
+ # # => 1
873
+ #
874
+ # dom.root.if_attribute_value(:foo)
875
+ # # => nil
876
+ #
877
+ # dom.root.if_attribute_value(:name) { |v| v.to_i }
878
+ # # => nil
879
+ #
880
+ def if_attribute_value(xmlattr, &block) #:nodoc:
881
+ value = if attr = self.attribute(xmlattr.to_s)
882
+ attr.value
883
+ else
884
+ nil
885
+ end
886
+ value = yield value if !value.nil? and block_given?
887
+ value
888
+ end
889
+
890
+ #
891
+ # Returns the value of +expression+ child of this element, if it exists.
892
+ # If blog is given, block is called on +expression+ element value
893
+ # and the result is returned.
894
+ #
895
+ def if_element_value(expression, &block)
896
+ if_element(expression) do |element|
897
+ value = element.text
898
+ value = yield value if block_given?
899
+ value
900
+ end
901
+ end
902
+
903
+ #
904
+ # Executes the content of +block+ on +expression+
905
+ # child of this element, if it exists.
906
+ # Returns the result or +nil+ if +xmlelement+ doesn't exist.
907
+ #
908
+ def if_element(expression, &block)
909
+ raise LocalJumpError, "no block given" unless block_given?
910
+ if element = self.elements[expression.to_s]
911
+ yield element
912
+ else
913
+ nil
914
+ end
915
+ end
916
+
917
+ end # XMLUtils
918
+
919
+ end
920
+ end
921
+
922
+
923
+ class Object
924
+
925
+ # An object is blank if it's false, empty, or a whitespace string.
926
+ # For example, "", " ", +nil+, [], and {} are blank.
927
+ #
928
+ # This simplifies
929
+ #
930
+ # if !address.nil? && !address.empty?
931
+ #
932
+ # to
933
+ #
934
+ # if !address.blank?
935
+ #
936
+ # Object#blank? comes from the GEM ActiveSupport 2.1.
937
+ #
938
+ def blank?
939
+ respond_to?(:empty?) ? empty? : !self
940
+ end unless Object.method_defined? :blanks?
941
+
942
+ end
943
+
944
+
945
+ module REXML # :nodoc:
946
+ class Element < Parent # :nodoc:
947
+ include WWW::Delicious::XMLUtils
948
+ end
949
+ end