ideaoforder-www-delicious 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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