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.
- checksums.yaml +4 -4
- data/.gitignore +1 -1
- data/.rubocop.yml +5 -1
- data/.yardopts +4 -0
- data/CHANGELOG.md +44 -1
- data/Gemfile.lock +14 -11
- data/README.md +53 -22
- data/camper.gemspec +2 -0
- data/examples/comments.rb +1 -1
- data/examples/create_and_complete_todo.rb +24 -0
- data/examples/people.rb +11 -0
- data/examples/projects.rb +12 -0
- data/examples/todolists.rb +32 -0
- data/examples/todos.rb +8 -5
- data/lib/camper.rb +1 -0
- data/lib/camper/api/{comment.rb → comments.rb} +5 -3
- data/lib/camper/api/{message.rb → messages.rb} +1 -1
- data/lib/camper/api/people.rb +97 -0
- data/lib/camper/api/projects.rb +120 -0
- data/lib/camper/api/resource.rb +1 -3
- data/lib/camper/api/todolists.rb +81 -0
- data/lib/camper/api/todos.rb +133 -0
- data/lib/camper/authorization.rb +1 -1
- data/lib/camper/client.rb +37 -33
- data/lib/camper/configuration.rb +4 -6
- data/lib/camper/core_extensions/object.rb +156 -0
- data/lib/camper/error.rb +15 -4
- data/lib/camper/pagination_data.rb +0 -3
- data/lib/camper/request.rb +49 -20
- data/lib/camper/resource.rb +0 -2
- data/lib/camper/url_utils.rb +26 -0
- data/lib/camper/version.rb +1 -1
- metadata +44 -7
- data/lib/camper/api/project.rb +0 -20
- data/lib/camper/api/todo.rb +0 -14
data/lib/camper/authorization.rb
CHANGED
data/lib/camper/client.rb
CHANGED
@@ -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
|
15
|
+
include CommentsAPI
|
16
16
|
include Logging
|
17
|
-
include
|
18
|
-
include
|
17
|
+
include MessagesAPI
|
18
|
+
include PeopleAPI
|
19
|
+
include ProjectsAPI
|
19
20
|
include ResourceAPI
|
20
|
-
include
|
21
|
+
include TodolistsAPI
|
22
|
+
include TodosAPI
|
21
23
|
|
22
|
-
# Creates a new
|
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
|
-
|
31
|
-
return response unless result == Request::Result::AccessTokenExpired
|
32
|
-
|
33
|
-
update_access_token!
|
32
|
+
request = new_request(method, path, options)
|
34
33
|
|
35
|
-
|
36
|
-
|
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
|
-
#
|
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
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/camper/configuration.rb
CHANGED
@@ -23,7 +23,7 @@ module Camper
|
|
23
23
|
attr_accessor(*VALID_OPTIONS_KEYS)
|
24
24
|
|
25
25
|
def initialize(options = {})
|
26
|
-
|
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
|
42
|
-
logger.debug '
|
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
|
data/lib/camper/error.rb
CHANGED
@@ -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
|
data/lib/camper/request.rb
CHANGED
@@ -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(
|
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
|
-
|
54
|
-
|
55
|
-
|
56
|
-
override_path = params.delete(:override_path)
|
62
|
+
# Executes the request
|
63
|
+
def execute
|
64
|
+
endpoint, params = prepare_request_data
|
57
65
|
|
58
|
-
|
66
|
+
raise Error::TooManyRetries, endpoint if maxed_attempts?
|
59
67
|
|
60
|
-
|
68
|
+
@attempts += 1
|
61
69
|
|
62
|
-
|
63
|
-
|
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
|
-
|
69
|
-
|
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
|
-
|
74
|
-
response, result = validate self.class.send(method, endpoint, params)
|
94
|
+
full_endpoint = override_path ? @path : @client.api_endpoint + @path
|
75
95
|
|
76
|
-
|
96
|
+
full_endpoint = UrlUtils.transform(full_endpoint)
|
77
97
|
|
78
|
-
return
|
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::
|
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
|
-
|
141
|
+
def body_to_json?(params)
|
142
|
+
%w[post put].include?(@method) && params.key?(:body)
|
114
143
|
end
|
115
144
|
end
|
116
145
|
end
|