thumbtack 1.0.0 → 1.1.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 (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