camper 0.0.5 → 0.0.10

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.
@@ -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