thumbtack 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/thumbtack.rb +2 -1
  3. data/lib/thumbtack/adapters/basic_adapter.rb +83 -0
  4. data/lib/thumbtack/client.rb +26 -19
  5. data/lib/thumbtack/hash_to_digest.rb +12 -7
  6. data/lib/thumbtack/note.rb +1 -1
  7. data/lib/thumbtack/note_summary.rb +1 -1
  8. data/lib/thumbtack/post.rb +1 -1
  9. data/lib/thumbtack/posts.rb +13 -5
  10. data/lib/thumbtack/suggestion.rb +18 -4
  11. data/lib/thumbtack/symbolize_keys.rb +22 -0
  12. data/lib/thumbtack/types/boolean.rb +1 -1
  13. data/lib/thumbtack/types/date.rb +1 -1
  14. data/lib/thumbtack/types/date_time.rb +1 -1
  15. data/lib/thumbtack/types/identity.rb +1 -1
  16. data/lib/thumbtack/types/integer.rb +1 -1
  17. data/lib/thumbtack/types/length_validation.rb +10 -8
  18. data/lib/thumbtack/types/md5.rb +16 -3
  19. data/lib/thumbtack/types/range_validation.rb +5 -3
  20. data/lib/thumbtack/types/tags.rb +16 -3
  21. data/lib/thumbtack/types/text.rb +1 -1
  22. data/lib/thumbtack/types/title.rb +1 -1
  23. data/lib/thumbtack/types/url.rb +1 -1
  24. data/lib/thumbtack/version.rb +1 -1
  25. data/test/test_helper.rb +2 -2
  26. data/test/thumbtack/client_test.rb +29 -1
  27. data/test/thumbtack/integration/basic_adapter_test.rb +24 -0
  28. data/test/thumbtack/note_summary_test.rb +1 -1
  29. data/test/thumbtack/notes_test.rb +34 -34
  30. data/test/thumbtack/post_test.rb +1 -1
  31. data/test/thumbtack/posts_test.rb +113 -95
  32. data/test/thumbtack/specification_test.rb +2 -1
  33. data/test/thumbtack/suggestion_test.rb +4 -4
  34. data/test/thumbtack/tags_test.rb +30 -24
  35. data/test/thumbtack/types/date_test.rb +2 -2
  36. data/test/thumbtack/types/date_time_test.rb +4 -3
  37. data/test/thumbtack/types/identity_test.rb +1 -1
  38. data/test/thumbtack/types/md5_test.rb +3 -3
  39. data/test/thumbtack/types/tags_test.rb +2 -2
  40. data/test/thumbtack/types/text_test.rb +2 -2
  41. data/test/thumbtack/user_test.rb +10 -6
  42. metadata +26 -11
  43. data/lib/thumbtack/types.rb +0 -9
  44. data/test/thumbtack/integration/client_test.rb +0 -29
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 223435e3e89fab503c5c9e5e42cb6debb6f29a5d
4
- data.tar.gz: 769a2c552c39a0e87d35ef21c12afb475ab5029b
3
+ metadata.gz: dfceebca8bd2653fbb6a32085aa3449e3a872c1d
4
+ data.tar.gz: f3be0d3c15e9f9ca487bba144f9192fe300941b2
5
5
  SHA512:
6
- metadata.gz: 86c48c48a0008be9f3cea5d1143c42a7ea87915fd7a92459affc82823d26e355828942974ac9c7af81819998f6d6695c2dd26bb31eb225ef133f34802704c98e
7
- data.tar.gz: 1a5f9d3a0d61918bcd5d4e546deb18dfe2808a281f96eec5e4ee6bfd41f15d3a739b925c55e609f3a338eea2ee7e94aed53fabfb55c34b77cd8a61c26787fb3b
6
+ metadata.gz: 57742f4274a670de859c45b9e4373176205d01ba219a1d8b0eccc3443ef61a8f171881c37917a8bb7a174fc531171c89163df9e7b65172f52c5bc2eef703c6e8
7
+ data.tar.gz: 392b5f79e1ecd490bf8fa7c336f48c150ba47b19c7f8c010c6be8eea4c8327d3239c3279fd5d4b95ce1fec6b1f3552a56e80581992740895c35e7a1e7d7dba8f
data/lib/thumbtack.rb CHANGED
@@ -25,7 +25,6 @@ require 'json'
25
25
  require 'net/http'
26
26
  require 'uri'
27
27
 
28
- require 'thumbtack/types'
29
28
  require 'thumbtack/types/range_validation'
30
29
  require 'thumbtack/types/length_validation'
31
30
  require 'thumbtack/types/boolean'
@@ -45,8 +44,10 @@ require 'thumbtack/posts'
45
44
  require 'thumbtack/tags'
46
45
  require 'thumbtack/user'
47
46
  require 'thumbtack/hash_to_digest'
47
+ require 'thumbtack/symbolize_keys'
48
48
  require 'thumbtack/note'
49
49
  require 'thumbtack/note_summary'
50
50
  require 'thumbtack/notes'
51
+ require 'thumbtack/adapters/basic_adapter'
51
52
  require 'thumbtack/client'
52
53
  require 'thumbtack/version'
@@ -0,0 +1,83 @@
1
+ # encoding: utf-8
2
+
3
+ module Thumbtack
4
+ module Adapters
5
+ # A basic adapter using Ruby's builtin HTTP and JSON parsing libraries
6
+ class BasicAdapter
7
+ # The status code for rate limited responses from the Pinboard API
8
+ TOO_MANY_REQUESTS_CODE = '429'.freeze
9
+
10
+ # The response format requested from the Pinboard API
11
+ RESPONSE_FORMAT = 'json'.freeze
12
+
13
+ # The base Pinboard API URL.
14
+ BASE_URL = 'https://api.pinboard.in/v1'.freeze
15
+
16
+ # Initialize a BasicAdapter
17
+ #
18
+ # @example
19
+ # adapter = BasicAdapter.new(username, token)
20
+ #
21
+ # @param [String] username
22
+ # the user to authenticate with
23
+ # @param [String] token
24
+ # the API token for the user, found on the Pinboard settings page
25
+ #
26
+ # @api public
27
+ def initialize(username, token)
28
+ @username = username
29
+ @token = token
30
+ end
31
+
32
+ # Retrieve JSON from the Pinboard API
33
+ #
34
+ # @param [String] path
35
+ # the path to fetch from, relative to the base Pinboard API URL
36
+ #
37
+ # @param [Hash] params
38
+ # query parameters to append to the URL
39
+ #
40
+ # @return [Hash] the response parsed from the JSON
41
+ #
42
+ # @raise [RateLimitError] if the response is rate-limited
43
+ #
44
+ # @api private
45
+ def get(path, params = EMPTY_HASH)
46
+ uri = URI("#{BASE_URL}#{path}")
47
+ uri.query = extend_parameters(params)
48
+ JSON.parse(http_response(uri).body)
49
+ end
50
+
51
+ private
52
+
53
+ # Retrieve HTTP response from a URI
54
+ #
55
+ # @param [URI] uri
56
+ # the uri to fetch from
57
+ # @return [Net::HTTPResponse]
58
+ # the response
59
+ #
60
+ # @api private
61
+ # @raise [RateLimitError] if the response is rate-limited
62
+ def http_response(uri)
63
+ Net::HTTP.get_response(uri).tap do |response|
64
+ fail RateLimitError if response.code == TOO_MANY_REQUESTS_CODE
65
+ end
66
+ end
67
+
68
+ # Extend parameters with authentication and format parameters
69
+ #
70
+ # @param [Hash{#to_s => #to_s}] parameters
71
+ # the parameters to extend
72
+ # @return [Hash{#to_s => #to_s}]
73
+ # the extended parameters
74
+ #
75
+ # @api private
76
+ def extend_parameters(parameters)
77
+ base_params = { auth_token: "#{@username}:#{@token}",
78
+ format: RESPONSE_FORMAT }
79
+ URI.encode_www_form(parameters.merge(base_params))
80
+ end
81
+ end
82
+ end
83
+ end
@@ -3,12 +3,6 @@
3
3
  module Thumbtack
4
4
  # Wraps each interaction with the Pinboard API
5
5
  class Client
6
- # The status code for rate limited responses from the Pinboard API
7
- TOO_MANY_REQUESTS_CODE = '429'.freeze
8
-
9
- # The base Pinboard API URL.
10
- BASE_URL = 'https://api.pinboard.in/v1'.freeze
11
-
12
6
  # Username used by the client to make authenticated requests
13
7
  #
14
8
  # @example
@@ -29,6 +23,16 @@ module Thumbtack
29
23
  # @api public
30
24
  attr_reader :token
31
25
 
26
+ # Token used by the client to make authenticated requests
27
+ #
28
+ # @example
29
+ # client.adapter # => #<Thumbtack::Adapters::BasicAdapter...
30
+ #
31
+ # @return [Adapter]
32
+ #
33
+ # @api public
34
+ attr_reader :adapter
35
+
32
36
  # Initialize a Client
33
37
  #
34
38
  # @example
@@ -38,10 +42,18 @@ module Thumbtack
38
42
  # the user to authenticate with
39
43
  # @param [String] token
40
44
  # the API token for the user account, found on the Pinboard settings page
45
+ # @param [Hash] options
46
+ # options for the construction of the client
47
+ # @option options [BasicAdapter] :adapter
48
+ # an adapter to use for communicating with the Pinboard API
41
49
  #
42
50
  # @api public
43
- def initialize(username, token)
44
- @username, @token = username, token
51
+ def initialize(username, token, options = EMPTY_HASH)
52
+ @username = username
53
+ @token = token
54
+ @adapter = options.fetch(:adapter) do
55
+ Adapters::BasicAdapter.new(@username, @token)
56
+ end
45
57
  end
46
58
 
47
59
  # Retrieve JSON from the Pinboard API
@@ -58,14 +70,7 @@ module Thumbtack
58
70
  #
59
71
  # @api private
60
72
  def get(path, params = EMPTY_HASH)
61
- uri = URI("#{BASE_URL}#{path}")
62
-
63
- base_params = { auth_token: "#{@username}:#{@token}", format: 'json' }
64
- uri.query = URI.encode_www_form(params.merge(base_params))
65
-
66
- response = Net::HTTP.get_response(uri)
67
- fail RateLimitError if response.code == TOO_MANY_REQUESTS_CODE
68
- JSON.parse(response.body)
73
+ @adapter.get(path, params)
69
74
  end
70
75
 
71
76
  # Perform an action request against the Pinboard API
@@ -83,9 +88,11 @@ module Thumbtack
83
88
  #
84
89
  # @api private
85
90
  # @see https://pinboard.in/api/#errors
86
- def action(path, params = EMPTY_HASH)
87
- response = get(path, params)
88
- fail ResultError, response['result'] unless response['result'] == 'done'
91
+ def action(path, params)
92
+ response = @adapter.get(path, params)
93
+ unless response['result_code'] == 'done'
94
+ fail ResultError, response['result_code']
95
+ end
89
96
  self
90
97
  end
91
98
 
@@ -1,22 +1,27 @@
1
+ # encoding: utf-8
2
+
1
3
  module Thumbtack
2
4
  # Handles renaming the hash attribute to digest from response hashes.
3
5
  #
4
6
  # @api private
5
7
  class HashToDigest
8
+ HASH = 'hash'.freeze
9
+ DIGEST = 'digest'.freeze
10
+
6
11
  # Rename any attribute called hash to digest
7
12
  #
8
13
  # @example
9
- # HashToDigest.rename('hash' => '1234') # => { :digest => '1234' }
14
+ # HashToDigest.rename('hash' => '1234') # => { 'digest' => '1234' }
10
15
  #
11
- # @param [Hash{String => Object}]
12
- # reponse hash
16
+ # @param [Hash{String => Object}] hash
17
+ # the response hash
13
18
  #
14
- # @return [Hash{Symbol => Object}]
15
- # a hash with any key named hash renamed to digest
19
+ # @return [Hash{String => Object}]
20
+ # a hash with any key 'hash' renamed to 'digest'
16
21
  def self.rename(hash)
17
22
  attrs = hash.dup
18
- digest = attrs.delete('hash')
19
- Hash[attrs.map { |k, v| [k.to_sym, v] }].merge(digest: digest)
23
+ digest = attrs.delete(HASH)
24
+ attrs.merge(DIGEST => digest)
20
25
  end
21
26
  end
22
27
  end
@@ -102,7 +102,7 @@ module Thumbtack
102
102
  # @api private
103
103
  # @see Client#get
104
104
  def self.from_hash(hash)
105
- new(HashToDigest.rename(hash))
105
+ new(SymbolizeKeys.symbolize(HashToDigest.rename(hash)))
106
106
  end
107
107
 
108
108
  # Initialize a Note
@@ -92,7 +92,7 @@ module Thumbtack
92
92
  # @api private
93
93
  # @see Client#get
94
94
  def self.from_hash(hash)
95
- new(HashToDigest.rename(hash))
95
+ new(SymbolizeKeys.symbolize(HashToDigest.rename(hash)))
96
96
  end
97
97
 
98
98
  # Initialize a NoteSummary
@@ -128,7 +128,7 @@ module Thumbtack
128
128
  # @api private
129
129
  # @see Client#get
130
130
  def self.from_hash(hash)
131
- new(HashToDigest.rename(hash))
131
+ new(SymbolizeKeys.symbolize(HashToDigest.rename(hash)))
132
132
  end
133
133
 
134
134
  # Initialize a Post
@@ -226,11 +226,7 @@ module Thumbtack
226
226
  def dates(options = EMPTY_HASH)
227
227
  parameters = Specification.new(tag: Types::Tags).parameters(options)
228
228
  response = @client.get('/posts/dates', parameters)
229
- Hash[
230
- response.fetch('dates', EMPTY_HASH).map do |date, count|
231
- [Types::Date.from_parameter(date), count.to_i]
232
- end
233
- ]
229
+ dates_with_counts_from(response)
234
230
  end
235
231
 
236
232
  private
@@ -245,5 +241,17 @@ module Thumbtack
245
241
  Post.from_hash(post_hash)
246
242
  end
247
243
  end
244
+
245
+ # Create Hash of dates to counts from dates response
246
+ #
247
+ # @return [Hash{Date => Integer}]
248
+ #
249
+ # @api private
250
+ def dates_with_counts_from(response)
251
+ entries = response.fetch('dates', EMPTY_HASH).map do |date, count|
252
+ [Types::Date.from_parameter(date), count.to_i]
253
+ end
254
+ Hash[entries]
255
+ end
248
256
  end
249
257
  end
@@ -56,10 +56,24 @@ module Thumbtack
56
56
  # @api private
57
57
  # @see Client#get
58
58
  def self.from_array(array)
59
- popular = array.find { |hash| hash.key?(POPULAR_KEY) }
60
- recommended = array.find { |hash| hash.key?(RECOMMENDED_KEY) }
61
- new(popular: popular.fetch(POPULAR_KEY, EMPTY_ARRAY),
62
- recommended: recommended.fetch(RECOMMENDED_KEY, EMPTY_ARRAY))
59
+ new(popular: entries_for(POPULAR_KEY, array),
60
+ recommended: entries_for(RECOMMENDED_KEY, array))
63
61
  end
62
+
63
+ # Extracts the entries for a given key in an array of hashes
64
+ #
65
+ # @param [String] key
66
+ # A key from which to extract the entries
67
+ #
68
+ # @param [Array<Hash{String => Array<String>}>] array
69
+ # An array of strings associated with the key
70
+ #
71
+ # @return [Array[String]]
72
+ #
73
+ # @api private
74
+ def self.entries_for(key, array)
75
+ array.find { |hash| hash.key?(key) }.fetch(key, EMPTY_ARRAY)
76
+ end
77
+ private_class_method :entries_for
64
78
  end
65
79
  end
@@ -0,0 +1,22 @@
1
+ # encoding: utf-8
2
+
3
+ module Thumbtack
4
+ # Handles converting string keys to symbols in a response hash
5
+ #
6
+ # @api private
7
+ class SymbolizeKeys
8
+ # Convert any keys to symbols
9
+ #
10
+ # @example
11
+ # SymbolizeKeys.symbolize('digest' => '1234') # => { :digest => '1234' }
12
+ #
13
+ # @param [Hash{#to_sym => Object}] hash
14
+ # the response hash
15
+ #
16
+ # @return [Hash{Symbol => Object}]
17
+ # a hash with symbol keys
18
+ def self.symbolize(hash)
19
+ Hash[hash.map { |key, value| [key.to_sym, value] }]
20
+ end
21
+ end
22
+ end
@@ -12,7 +12,7 @@ module Thumbtack
12
12
  # @param [Boolean] value
13
13
  # the value to validate
14
14
  #
15
- # @return [undefined]
15
+ # @return [self]
16
16
  #
17
17
  # @raise [Types::ValidationError]
18
18
  # if the value is not true or false
@@ -17,7 +17,7 @@ module Thumbtack
17
17
  # @param [Date] value
18
18
  # the date to validate
19
19
  #
20
- # @return [undefined]
20
+ # @return [self]
21
21
  #
22
22
  # @raise [Types::ValidationError]
23
23
  # if the date is not between 0001-01-01 and 2100-01-01
@@ -19,7 +19,7 @@ module Thumbtack
19
19
  # @param [DateTime] value
20
20
  # The time to validate
21
21
  #
22
- # @return [undefined]
22
+ # @return [self]
23
23
  #
24
24
  # @raise [Types::ValidationError]
25
25
  # if the time is not between 0001-01-01 00:00:00 and 2100-01-01 00:00:00
@@ -8,7 +8,7 @@ module Thumbtack
8
8
  class Identity
9
9
  # Any value passed is valid
10
10
  #
11
- # @return [undefined]
11
+ # @return [self]
12
12
  def self.validate(*)
13
13
  self
14
14
  end
@@ -16,7 +16,7 @@ module Thumbtack
16
16
  # @param [Integer] value
17
17
  # the integer to validate
18
18
  #
19
- # @return [undefined]
19
+ # @return [self]
20
20
  #
21
21
  # @raise [Types::ValidationError]
22
22
  # if the value is not between 0 and 2^32
@@ -1,26 +1,28 @@
1
+ # encoding: utf-8
2
+
1
3
  module Thumbtack
2
4
  module Types
3
- # Handles validation of values within a certain range
4
- # Pinboard
5
+ # Handles validation of the length of values
5
6
  #
6
7
  # @api private
7
8
  class LengthValidation
8
9
  # Validate a value
9
10
  #
10
- # @param [Object] length
11
+ # @param [Object] maximum_length
11
12
  # the maximum length
12
13
  # @param [Object] value
13
14
  # the value to validate
14
15
  #
15
- # @return [undefined]
16
+ # @return [self]
16
17
  #
17
18
  # @raise [Types::ValidationError]
18
- # if the value is not between 0001-01-01 and 2100-01-01
19
- def self.validate(value, length)
20
- unless value.length <= length
19
+ # if the value is not less or equal to the maximum length
20
+ def self.validate(value, maximum_length)
21
+ unless value.length <= maximum_length
21
22
  fail ValidationError,
22
- "#{value} cannot be greater than #{length} characters"
23
+ "#{value} cannot be greater than #{maximum_length} characters"
23
24
  end
25
+ self
24
26
  end
25
27
  end
26
28
  end
@@ -16,18 +16,31 @@ module Thumbtack
16
16
  # @param [String] value
17
17
  # the MD5 to validate
18
18
  #
19
- # @return [undefined]
19
+ # @return [self]
20
20
  #
21
21
  # @raise [Types::ValidationError]
22
22
  # if the value is not a 32 character hexadecimal MD5 hash
23
23
  def self.validate(value)
24
- unless value.length == 32 &&
25
- value.each_char.all? { |char| CHARACTERS.include?(char) }
24
+ unless valid_md5?(value)
26
25
  fail ValidationError,
27
26
  "#{value} must be a 32 character hexadecimal MD5 hash"
28
27
  end
29
28
  self
30
29
  end
30
+
31
+ # If true, the value is a valid MD5 string
32
+ #
33
+ # @param [String] value
34
+ # the MD5 to validate
35
+ #
36
+ # @return [Boolean]
37
+ #
38
+ # @api private
39
+ def self.valid_md5?(value)
40
+ value.length == LENGTH &&
41
+ value.each_char.all? { |char| CHARACTERS.include?(char) }
42
+ end
43
+ private_class_method :valid_md5?
31
44
  end
32
45
  end
33
46
  end