camper 0.0.5 → 0.0.10

Sign up to get free protection for your applications and to get access to all the features.
@@ -38,7 +38,7 @@ module Camper
38
38
 
39
39
  def update_access_token!
40
40
  logger.debug "Update access token using refresh token"
41
-
41
+
42
42
  client = authz_client
43
43
  client.refresh_token = @config.refresh_token
44
44
 
@@ -6,20 +6,22 @@ module Camper
6
6
  Dir[File.expand_path('api/*.rb', __dir__)].each { |f| require f }
7
7
 
8
8
  extend Forwardable
9
-
9
+
10
10
  def_delegators :@config, *(Configuration::VALID_OPTIONS_KEYS)
11
11
  def_delegators :@config, :authz_endpoint, :token_endpoint, :api_endpoint, :base_api_endpoint
12
12
 
13
13
  # Keep in alphabetical order
14
14
  include Authorization
15
- include CommentAPI
15
+ include CommentsAPI
16
16
  include Logging
17
- include MessageAPI
18
- include ProjectAPI
17
+ include MessagesAPI
18
+ include PeopleAPI
19
+ include ProjectsAPI
19
20
  include ResourceAPI
20
- include TodoAPI
21
+ include TodolistsAPI
22
+ include TodosAPI
21
23
 
22
- # Creates a new API.
24
+ # Creates a new Client instance.
23
25
  # @raise [Error:MissingCredentials]
24
26
  def initialize(options = {})
25
27
  @config = Configuration.new(options)
@@ -27,18 +29,19 @@ module Camper
27
29
 
28
30
  %w[get post put delete].each do |method|
29
31
  define_method method do |path, options = {}|
30
- response, result = new_request.send(method, path, options)
31
- return response unless result == Request::Result::AccessTokenExpired
32
-
33
- update_access_token!
32
+ request = new_request(method, path, options)
34
33
 
35
- response, = new_request.send(method, path, options)
36
- response
34
+ loop do
35
+ response, result = request.execute
36
+ logger.debug("Request result: #{result}; Attempt: #{request.attempts}")
37
+ return response unless retry_request?(response, result)
38
+ end
37
39
  end
38
40
  end
39
41
 
40
42
  # Allows setting configuration values for this client
41
- # returns the client instance being configured
43
+ # by yielding the config object to the block
44
+ # @return [Camper::Client] the client instance being configured
42
45
  def configure
43
46
  yield @config
44
47
 
@@ -54,33 +57,34 @@ module Camper
54
57
  inspected
55
58
  end
56
59
 
57
- # Utility method for URL encoding of a string.
58
- # Copied from https://ruby-doc.org/stdlib-2.7.0/libdoc/erb/rdoc/ERB/Util.html
59
- #
60
- # @return [String]
61
- def url_encode(url)
62
- url.to_s.b.gsub(/[^a-zA-Z0-9_\-.~]/n) { |m| sprintf('%%%02X', m.unpack1('C')) } # rubocop:disable Style/FormatString, Style/FormatStringToken
60
+ private
61
+
62
+ def new_request(method, path, options)
63
+ Request.new(self, method, path, options)
63
64
  end
64
65
 
65
- private
66
+ def retry_request?(response, result)
67
+ case result
68
+ when Request::Result::ACCESS_TOKEN_EXPIRED
69
+ update_access_token!
70
+ true
71
+ when Request::Result::TOO_MANY_REQUESTS
72
+ sleep_before_retrying(response)
73
+ true
74
+ else
75
+ false
76
+ end
77
+ end
78
+
79
+ def sleep_before_retrying(response)
80
+ time = response.headers['Retry-After'].to_i
81
+ logger.debug("Sleeping for #{time} seconds before retrying request")
66
82
 
67
- def new_request
68
- Request.new(@config.access_token, @config.user_agent, self)
83
+ sleep(time)
69
84
  end
70
85
 
71
86
  def only_show_last_four_chars(token)
72
87
  "#{'*' * (token.size - 4)}#{token[-4..-1]}"
73
88
  end
74
-
75
- # Utility method for transforming Basecamp Web URLs into API URIs
76
- # e.g 'https://3.basecamp.com/1/buckets/2/todos/3' will be
77
- # converted into 'https://3.basecampapi.com/1/buckets/2/todos/3.json'
78
- #
79
- # @return [String]
80
- def url_transform(url)
81
- api_uri = url.gsub('3.basecamp.com', '3.basecampapi.com')
82
- api_uri += '.json' unless url.end_with? '.json'
83
- api_uri
84
- end
85
89
  end
86
90
  end
@@ -23,7 +23,7 @@ module Camper
23
23
  attr_accessor(*VALID_OPTIONS_KEYS)
24
24
 
25
25
  def initialize(options = {})
26
- options[:user_agent] ||= DEFAULT_USER_AGENT
26
+ default_from_environment
27
27
  VALID_OPTIONS_KEYS.each do |key|
28
28
  send("#{key}=", options[key]) if options[key]
29
29
  end
@@ -36,10 +36,9 @@ module Camper
36
36
  end
37
37
  end
38
38
 
39
- # rubocop:disable Metrics/AbcSize
40
39
  # Resets all configuration options to the defaults.
41
- def reset
42
- logger.debug 'Resetting attributes to default environment values'
40
+ def default_from_environment
41
+ logger.debug 'Setting attributes to default environment values'
43
42
  self.client_id = ENV['BASECAMP3_CLIENT_ID']
44
43
  self.client_secret = ENV['BASECAMP3_CLIENT_SECRET']
45
44
  self.redirect_uri = ENV['BASECAMP3_REDIRECT_URI']
@@ -48,7 +47,6 @@ module Camper
48
47
  self.access_token = ENV['BASECAMP3_ACCESS_TOKEN']
49
48
  self.user_agent = ENV['BASECAMP3_USER_AGENT'] || DEFAULT_USER_AGENT
50
49
  end
51
- # rubocop:enable Metrics/AbcSize
52
50
 
53
51
  def authz_endpoint
54
52
  'https://launchpad.37signals.com/authorization/new'
@@ -60,7 +58,7 @@ module Camper
60
58
 
61
59
  def api_endpoint
62
60
  raise Camper::Error::InvalidConfiguration, "missing basecamp account" unless self.account_number
63
-
61
+
64
62
  "#{self.base_api_endpoint}/#{self.account_number}"
65
63
  end
66
64
 
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+ # Copied from https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/object/blank.rb
3
+
4
+ require 'concurrent/map'
5
+
6
+ class Object
7
+ # An object is blank if it's false, empty, or a whitespace string.
8
+ # For example, +nil+, '', ' ', [], {}, and +false+ are all blank.
9
+ #
10
+ # This simplifies
11
+ #
12
+ # !address || address.empty?
13
+ #
14
+ # to
15
+ #
16
+ # address.blank?
17
+ #
18
+ # @return [true, false]
19
+ def blank?
20
+ respond_to?(:empty?) ? !!empty? : !self
21
+ end
22
+
23
+ # An object is present if it's not blank.
24
+ #
25
+ # @return [true, false]
26
+ def present?
27
+ !blank?
28
+ end
29
+
30
+ # Returns the receiver if it's present otherwise returns +nil+.
31
+ # <tt>object.presence</tt> is equivalent to
32
+ #
33
+ # object.present? ? object : nil
34
+ #
35
+ # For example, something like
36
+ #
37
+ # state = params[:state] if params[:state].present?
38
+ # country = params[:country] if params[:country].present?
39
+ # region = state || country || 'US'
40
+ #
41
+ # becomes
42
+ #
43
+ # region = params[:state].presence || params[:country].presence || 'US'
44
+ #
45
+ # @return [Object]
46
+ def presence
47
+ self if present?
48
+ end
49
+ end
50
+
51
+ class NilClass
52
+ # +nil+ is blank:
53
+ #
54
+ # nil.blank? # => true
55
+ #
56
+ # @return [true]
57
+ def blank?
58
+ true
59
+ end
60
+ end
61
+
62
+ class FalseClass
63
+ # +false+ is blank:
64
+ #
65
+ # false.blank? # => true
66
+ #
67
+ # @return [true]
68
+ def blank?
69
+ true
70
+ end
71
+ end
72
+
73
+ class TrueClass
74
+ # +true+ is not blank:
75
+ #
76
+ # true.blank? # => false
77
+ #
78
+ # @return [false]
79
+ def blank?
80
+ false
81
+ end
82
+ end
83
+
84
+ class Array
85
+ # An array is blank if it's empty:
86
+ #
87
+ # [].blank? # => true
88
+ # [1,2,3].blank? # => false
89
+ #
90
+ # @return [true, false]
91
+ alias_method :blank?, :empty?
92
+ end
93
+
94
+ class Hash
95
+ # A hash is blank if it's empty:
96
+ #
97
+ # {}.blank? # => true
98
+ # { key: 'value' }.blank? # => false
99
+ #
100
+ # @return [true, false]
101
+ alias_method :blank?, :empty?
102
+ end
103
+
104
+ class String
105
+ BLANK_RE = /\A[[:space:]]*\z/
106
+ ENCODED_BLANKS = Concurrent::Map.new do |h, enc|
107
+ h[enc] = Regexp.new(BLANK_RE.source.encode(enc), BLANK_RE.options | Regexp::FIXEDENCODING)
108
+ end
109
+
110
+ # A string is blank if it's empty or contains whitespaces only:
111
+ #
112
+ # ''.blank? # => true
113
+ # ' '.blank? # => true
114
+ # "\t\n\r".blank? # => true
115
+ # ' blah '.blank? # => false
116
+ #
117
+ # Unicode whitespace is supported:
118
+ #
119
+ # "\u00a0".blank? # => true
120
+ #
121
+ # @return [true, false]
122
+ def blank?
123
+ # The regexp that matches blank strings is expensive. For the case of empty
124
+ # strings we can speed up this method (~3.5x) with an empty? call. The
125
+ # penalty for the rest of strings is marginal.
126
+ empty? ||
127
+ begin
128
+ BLANK_RE.match?(self)
129
+ rescue Encoding::CompatibilityError
130
+ ENCODED_BLANKS[self.encoding].match?(self)
131
+ end
132
+ end
133
+ end
134
+
135
+ class Numeric #:nodoc:
136
+ # No number is blank:
137
+ #
138
+ # 1.blank? # => false
139
+ # 0.blank? # => false
140
+ #
141
+ # @return [false]
142
+ def blank?
143
+ false
144
+ end
145
+ end
146
+
147
+ class Time #:nodoc:
148
+ # No Time is blank:
149
+ #
150
+ # Time.now.blank? # => false
151
+ #
152
+ # @return [false]
153
+ def blank?
154
+ false
155
+ end
156
+ end
@@ -11,9 +11,20 @@ module Camper
11
11
  # Raised when API endpoint credentials not configured.
12
12
  class MissingCredentials < Error; end
13
13
 
14
+ class MissingBody < Error; end
15
+
16
+ class ResourceCannotBeCommented < Error; end
17
+
18
+ class RequestIsMissingParameters < Error; end
19
+
20
+ class InvalidParameter < Error; end
21
+
14
22
  # Raised when impossible to parse response body.
15
23
  class Parsing < Error; end
16
24
 
25
+ # Raised when too many attempts for the same request
26
+ class TooManyRetries < Error; end
27
+
17
28
  # Custom error class for rescuing from HTTP response errors.
18
29
  class ResponseError < Error
19
30
  POSSIBLE_MESSAGE_KEYS = %i[message error_description error].freeze
@@ -70,9 +81,6 @@ module Camper
70
81
  # Return stringified response when receiving a
71
82
  # parsing error to avoid obfuscation of the
72
83
  # api error.
73
- #
74
- # note: The Camper API does not always return valid
75
- # JSON when there are errors.
76
84
  @response.to_s
77
85
  end
78
86
 
@@ -127,6 +135,8 @@ module Camper
127
135
  # Raised when API endpoint returns the HTTP status code 503.
128
136
  class ServiceUnavailable < ResponseError; end
129
137
 
138
+ class GatewayTimeout < ResponseError; end
139
+
130
140
  # HTTP status codes mapped to error classes.
131
141
  STATUS_MAPPINGS = {
132
142
  400 => BadRequest,
@@ -140,7 +150,8 @@ module Camper
140
150
  429 => TooManyRequests,
141
151
  500 => InternalServerError,
142
152
  502 => BadGateway,
143
- 503 => ServiceUnavailable
153
+ 503 => ServiceUnavailable,
154
+ 504 => GatewayTimeout
144
155
  }.freeze
145
156
  end
146
157
  end
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Camper
4
- # Parses link header.
5
- #
6
- # @private
7
4
  class PaginationData
8
5
  include Logging
9
6
 
@@ -11,17 +11,26 @@ module Camper
11
11
  headers 'Accept' => 'application/json', 'Content-Type' => 'application/json'
12
12
  parser(proc { |body, _| parse(body) })
13
13
 
14
+ attr_reader :attempts
15
+
16
+ MAX_RETRY_ATTEMPTS = 5
17
+
14
18
  module Result
15
19
  ACCESS_TOKEN_EXPIRED = 'AccessTokenExpired'
16
20
 
21
+ TOO_MANY_REQUESTS = 'TooManyRequests'
22
+
17
23
  VALID = 'Valid'
18
24
  end
19
25
 
20
- def initialize(access_token, user_agent, client)
21
- @access_token = access_token
26
+ def initialize(client, method, path, options = {})
22
27
  @client = client
28
+ @path = path
29
+ @options = options
30
+ @attempts = 0
31
+ @method = method
23
32
 
24
- self.class.headers 'User-Agent' => user_agent
33
+ self.class.headers 'User-Agent' => @client.user_agent
25
34
  end
26
35
 
27
36
  # Converts the response body to a Resource.
@@ -50,32 +59,43 @@ module Camper
50
59
  raise Error::Parsing, 'The response is not a valid JSON'
51
60
  end
52
61
 
53
- %w[get post put delete].each do |method|
54
- define_method method do |path, options = {}|
55
- params = options.dup
56
- override_path = params.delete(:override_path)
62
+ # Executes the request
63
+ def execute
64
+ endpoint, params = prepare_request_data
57
65
 
58
- params[:headers] ||= {}
66
+ raise Error::TooManyRetries, endpoint if maxed_attempts?
59
67
 
60
- full_endpoint = override_path ? path : @client.api_endpoint + path
68
+ @attempts += 1
61
69
 
62
- execute_request(method, full_endpoint, params)
63
- end
70
+ logger.debug("Method: #{@method}; URL: #{endpoint}")
71
+
72
+ response, result = validate self.class.send(@method, endpoint, params)
73
+ response = extract_parsed(response) if result == Result::VALID
74
+
75
+ return response, result
76
+ end
77
+
78
+ def maxed_attempts?
79
+ @attempts >= MAX_RETRY_ATTEMPTS
64
80
  end
65
81
 
66
82
  private
67
83
 
68
- # Executes the request
69
- def execute_request(method, endpoint, params)
84
+ def prepare_request_data
85
+ params = @options.dup
86
+ override_path = params.delete(:override_path)
87
+
88
+ params[:body] = params[:body].to_json if body_to_json?(params)
89
+
90
+ params[:headers] ||= {}
70
91
  params[:headers].merge!(self.class.headers)
71
92
  params[:headers].merge!(authorization_header)
72
93
 
73
- logger.debug("Method: #{method}; URL: #{endpoint}")
74
- response, result = validate self.class.send(method, endpoint, params)
94
+ full_endpoint = override_path ? @path : @client.api_endpoint + @path
75
95
 
76
- response = extract_parsed(response) if result == Result::VALID
96
+ full_endpoint = UrlUtils.transform(full_endpoint)
77
97
 
78
- return response, result
98
+ return full_endpoint, params
79
99
  end
80
100
 
81
101
  # Checks the response code for common errors.
@@ -90,9 +110,14 @@ module Camper
90
110
  return response, Result::ACCESS_TOKEN_EXPIRED
91
111
  end
92
112
 
113
+ if error_klass == Error::TooManyRequests
114
+ logger.debug('Too many request. Please check the Retry-After header for subsequent requests')
115
+ return response, Result::TOO_MANY_REQUESTS
116
+ end
117
+
93
118
  raise error_klass, response if error_klass
94
119
 
95
- return response, Result::Valid
120
+ return response, Result::VALID
96
121
  end
97
122
 
98
123
  def extract_parsed(response)
@@ -108,9 +133,13 @@ module Camper
108
133
  #
109
134
  # @raise [Error::MissingCredentials] if access_token and auth_token are not set.
110
135
  def authorization_header
111
- raise Error::MissingCredentials, 'Please provide a access_token' if @access_token.to_s.empty?
136
+ raise Error::MissingCredentials, 'Please provide a access_token' if @client.access_token.to_s.empty?
137
+
138
+ { 'Authorization' => "Bearer #{@client.access_token}" }
139
+ end
112
140
 
113
- { 'Authorization' => "Bearer #{@access_token}" }
141
+ def body_to_json?(params)
142
+ %w[post put].include?(@method) && params.key?(:body)
114
143
  end
115
144
  end
116
145
  end