laco-www-delicious 0.3.2

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