camper 0.0.5
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 +7 -0
- data/.editorconfig +8 -0
- data/.github/dependabot.yml +6 -0
- data/.github/workflows/ci.yml +43 -0
- data/.github/workflows/ci_changelog.yml +29 -0
- data/.github/workflows/release.yml +91 -0
- data/.gitignore +60 -0
- data/.rspec +3 -0
- data/.rubocop.yml +47 -0
- data/.rubocop_todo.yml +249 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +69 -0
- data/CONTRIBUTING.md +183 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +103 -0
- data/LICENSE +21 -0
- data/README.md +113 -0
- data/Rakefile +11 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/camper.gemspec +34 -0
- data/examples/comments.rb +35 -0
- data/examples/messages.rb +24 -0
- data/examples/oauth.rb +22 -0
- data/examples/obtain_acces_token.rb +13 -0
- data/examples/todos.rb +27 -0
- data/lib/camper.rb +32 -0
- data/lib/camper/api/comment.rb +13 -0
- data/lib/camper/api/message.rb +9 -0
- data/lib/camper/api/project.rb +20 -0
- data/lib/camper/api/resource.rb +11 -0
- data/lib/camper/api/todo.rb +14 -0
- data/lib/camper/authorization.rb +64 -0
- data/lib/camper/client.rb +86 -0
- data/lib/camper/configuration.rb +75 -0
- data/lib/camper/error.rb +146 -0
- data/lib/camper/logging.rb +28 -0
- data/lib/camper/paginated_response.rb +67 -0
- data/lib/camper/pagination_data.rb +47 -0
- data/lib/camper/request.rb +116 -0
- data/lib/camper/resource.rb +83 -0
- data/lib/camper/resources/project.rb +14 -0
- data/lib/camper/version.rb +5 -0
- metadata +143 -0
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camper
|
4
|
+
# Defines constants and methods related to configuration.
|
5
|
+
class Configuration
|
6
|
+
include Logging
|
7
|
+
|
8
|
+
# An array of valid keys in the options hash when configuring a Basecamp::API.
|
9
|
+
VALID_OPTIONS_KEYS = %i[
|
10
|
+
client_id
|
11
|
+
client_secret
|
12
|
+
redirect_uri
|
13
|
+
account_number
|
14
|
+
refresh_token
|
15
|
+
access_token
|
16
|
+
user_agent
|
17
|
+
].freeze
|
18
|
+
|
19
|
+
# The user agent that will be sent to the API endpoint if none is set.
|
20
|
+
DEFAULT_USER_AGENT = "Camper Ruby Gem #{Camper::VERSION}"
|
21
|
+
|
22
|
+
# @private
|
23
|
+
attr_accessor(*VALID_OPTIONS_KEYS)
|
24
|
+
|
25
|
+
def initialize(options = {})
|
26
|
+
options[:user_agent] ||= DEFAULT_USER_AGENT
|
27
|
+
VALID_OPTIONS_KEYS.each do |key|
|
28
|
+
send("#{key}=", options[key]) if options[key]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Creates a hash of options and their values.
|
33
|
+
def options
|
34
|
+
VALID_OPTIONS_KEYS.inject({}) do |option, key|
|
35
|
+
option.merge!(key => send(key))
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# rubocop:disable Metrics/AbcSize
|
40
|
+
# Resets all configuration options to the defaults.
|
41
|
+
def reset
|
42
|
+
logger.debug 'Resetting attributes to default environment values'
|
43
|
+
self.client_id = ENV['BASECAMP3_CLIENT_ID']
|
44
|
+
self.client_secret = ENV['BASECAMP3_CLIENT_SECRET']
|
45
|
+
self.redirect_uri = ENV['BASECAMP3_REDIRECT_URI']
|
46
|
+
self.account_number = ENV['BASECAMP3_ACCOUNT_NUMBER']
|
47
|
+
self.refresh_token = ENV['BASECAMP3_REFRESH_TOKEN']
|
48
|
+
self.access_token = ENV['BASECAMP3_ACCESS_TOKEN']
|
49
|
+
self.user_agent = ENV['BASECAMP3_USER_AGENT'] || DEFAULT_USER_AGENT
|
50
|
+
end
|
51
|
+
# rubocop:enable Metrics/AbcSize
|
52
|
+
|
53
|
+
def authz_endpoint
|
54
|
+
'https://launchpad.37signals.com/authorization/new'
|
55
|
+
end
|
56
|
+
|
57
|
+
def token_endpoint
|
58
|
+
'https://launchpad.37signals.com/authorization/token'
|
59
|
+
end
|
60
|
+
|
61
|
+
def api_endpoint
|
62
|
+
raise Camper::Error::InvalidConfiguration, "missing basecamp account" unless self.account_number
|
63
|
+
|
64
|
+
"#{self.base_api_endpoint}/#{self.account_number}"
|
65
|
+
end
|
66
|
+
|
67
|
+
def base_api_endpoint
|
68
|
+
self.class.base_api_endpoint
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.base_api_endpoint
|
72
|
+
"https://3.basecampapi.com"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
data/lib/camper/error.rb
ADDED
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camper
|
4
|
+
module Error
|
5
|
+
# Custom error class for rescuing from all Camper errors.
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
# Raised when there is a configuration error
|
9
|
+
class InvalidConfiguration < Error; end
|
10
|
+
|
11
|
+
# Raised when API endpoint credentials not configured.
|
12
|
+
class MissingCredentials < Error; end
|
13
|
+
|
14
|
+
# Raised when impossible to parse response body.
|
15
|
+
class Parsing < Error; end
|
16
|
+
|
17
|
+
# Custom error class for rescuing from HTTP response errors.
|
18
|
+
class ResponseError < Error
|
19
|
+
POSSIBLE_MESSAGE_KEYS = %i[message error_description error].freeze
|
20
|
+
|
21
|
+
def initialize(response)
|
22
|
+
@response = response
|
23
|
+
super(build_error_message)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Status code returned in the HTTP response.
|
27
|
+
#
|
28
|
+
# @return [Integer]
|
29
|
+
def response_status
|
30
|
+
@response.code
|
31
|
+
end
|
32
|
+
|
33
|
+
# Body content returned in the HTTP response
|
34
|
+
#
|
35
|
+
# @return [String]
|
36
|
+
def response_message
|
37
|
+
@response.parsed_response.message
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# Human friendly message.
|
43
|
+
#
|
44
|
+
# @return [String]
|
45
|
+
def build_error_message
|
46
|
+
parsed_response = classified_response
|
47
|
+
message = check_error_keys(parsed_response)
|
48
|
+
"Server responded with code #{@response.code}, message: " \
|
49
|
+
"#{handle_message(message)}. " \
|
50
|
+
"Request URI: #{@response.request.base_uri}#{@response.request.path}"
|
51
|
+
end
|
52
|
+
|
53
|
+
# Error keys vary across the API, find the first key that the parsed_response
|
54
|
+
# object responds to and return that, otherwise return the original.
|
55
|
+
def check_error_keys(resp)
|
56
|
+
key = POSSIBLE_MESSAGE_KEYS.find { |k| resp.respond_to?(k) }
|
57
|
+
key ? resp.send(key) : resp
|
58
|
+
end
|
59
|
+
|
60
|
+
# Parse the body based on the classification of the body content type
|
61
|
+
#
|
62
|
+
# @return parsed response
|
63
|
+
def classified_response
|
64
|
+
if @response.respond_to?('headers')
|
65
|
+
@response.headers['content-type'] == 'text/plain' ? { message: @response.to_s } : @response.parsed_response
|
66
|
+
else
|
67
|
+
@response.parsed_response
|
68
|
+
end
|
69
|
+
rescue Camper::Error::Parsing
|
70
|
+
# Return stringified response when receiving a
|
71
|
+
# parsing error to avoid obfuscation of the
|
72
|
+
# api error.
|
73
|
+
#
|
74
|
+
# note: The Camper API does not always return valid
|
75
|
+
# JSON when there are errors.
|
76
|
+
@response.to_s
|
77
|
+
end
|
78
|
+
|
79
|
+
# Handle error response message in case of nested hashes
|
80
|
+
def handle_message(message)
|
81
|
+
case message
|
82
|
+
when Camper::Resource
|
83
|
+
message.to_h.sort.map do |key, val|
|
84
|
+
"'#{key}' #{(val.is_a?(Hash) ? val.sort.map { |k, v| "(#{k}: #{v.join(' ')})" } : [val].flatten).join(' ')}"
|
85
|
+
end.join(', ')
|
86
|
+
when Array
|
87
|
+
message.join(' ')
|
88
|
+
else
|
89
|
+
message
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Raised when API endpoint returns the HTTP status code 400.
|
95
|
+
class BadRequest < ResponseError; end
|
96
|
+
|
97
|
+
# Raised when API endpoint returns the HTTP status code 401.
|
98
|
+
class Unauthorized < ResponseError; end
|
99
|
+
|
100
|
+
# Raised when API endpoint returns the HTTP status code 403.
|
101
|
+
class Forbidden < ResponseError; end
|
102
|
+
|
103
|
+
# Raised when API endpoint returns the HTTP status code 404.
|
104
|
+
class NotFound < ResponseError; end
|
105
|
+
|
106
|
+
# Raised when API endpoint returns the HTTP status code 405.
|
107
|
+
class MethodNotAllowed < ResponseError; end
|
108
|
+
|
109
|
+
# Raised when API endpoint returns the HTTP status code 406.
|
110
|
+
class NotAcceptable < ResponseError; end
|
111
|
+
|
112
|
+
# Raised when API endpoint returns the HTTP status code 409.
|
113
|
+
class Conflict < ResponseError; end
|
114
|
+
|
115
|
+
# Raised when API endpoint returns the HTTP status code 422.
|
116
|
+
class Unprocessable < ResponseError; end
|
117
|
+
|
118
|
+
# Raised when API endpoint returns the HTTP status code 429.
|
119
|
+
class TooManyRequests < ResponseError; end
|
120
|
+
|
121
|
+
# Raised when API endpoint returns the HTTP status code 500.
|
122
|
+
class InternalServerError < ResponseError; end
|
123
|
+
|
124
|
+
# Raised when API endpoint returns the HTTP status code 502.
|
125
|
+
class BadGateway < ResponseError; end
|
126
|
+
|
127
|
+
# Raised when API endpoint returns the HTTP status code 503.
|
128
|
+
class ServiceUnavailable < ResponseError; end
|
129
|
+
|
130
|
+
# HTTP status codes mapped to error classes.
|
131
|
+
STATUS_MAPPINGS = {
|
132
|
+
400 => BadRequest,
|
133
|
+
401 => Unauthorized,
|
134
|
+
403 => Forbidden,
|
135
|
+
404 => NotFound,
|
136
|
+
405 => MethodNotAllowed,
|
137
|
+
406 => NotAcceptable,
|
138
|
+
409 => Conflict,
|
139
|
+
422 => Unprocessable,
|
140
|
+
429 => TooManyRequests,
|
141
|
+
500 => InternalServerError,
|
142
|
+
502 => BadGateway,
|
143
|
+
503 => ServiceUnavailable
|
144
|
+
}.freeze
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
module Camper
|
6
|
+
module Logging
|
7
|
+
|
8
|
+
class << self
|
9
|
+
attr_writer :logger
|
10
|
+
end
|
11
|
+
|
12
|
+
# @!attribute [rw] logger
|
13
|
+
# @return [Logger] The logger.
|
14
|
+
def logger
|
15
|
+
@logger ||= default_logger
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
# Create and configure a logger
|
21
|
+
# @return [Logger]
|
22
|
+
def default_logger
|
23
|
+
logger = Logger.new($stdout)
|
24
|
+
logger.level = ENV['BASECAMP3_LOG_LEVEL'] || Logger::WARN
|
25
|
+
logger
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camper
|
4
|
+
# Wrapper class of paginated response.
|
5
|
+
class PaginatedResponse
|
6
|
+
include Logging
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
def_delegators :@array, :to_ary, :first
|
10
|
+
|
11
|
+
attr_accessor :client
|
12
|
+
|
13
|
+
def initialize(array)
|
14
|
+
@array = array
|
15
|
+
end
|
16
|
+
|
17
|
+
def ==(other)
|
18
|
+
@array == other
|
19
|
+
end
|
20
|
+
|
21
|
+
def count
|
22
|
+
@pagination_data.total_count
|
23
|
+
end
|
24
|
+
|
25
|
+
def inspect
|
26
|
+
@array.inspect
|
27
|
+
end
|
28
|
+
|
29
|
+
def parse_headers!(headers)
|
30
|
+
@pagination_data = PaginationData.new headers
|
31
|
+
logger.debug("Pagination data: #{@pagination_data.inspect}")
|
32
|
+
end
|
33
|
+
|
34
|
+
def auto_paginate(limit = nil, &block)
|
35
|
+
limit = count if limit.nil?
|
36
|
+
return lazy_paginate.take(limit).to_a unless block_given?
|
37
|
+
|
38
|
+
lazy_paginate.take(limit).each(&block)
|
39
|
+
end
|
40
|
+
|
41
|
+
def next_page?
|
42
|
+
!(@pagination_data.nil? || @pagination_data.next.nil?)
|
43
|
+
end
|
44
|
+
alias has_next_page? next_page?
|
45
|
+
|
46
|
+
def next_page
|
47
|
+
return nil if @client.nil? || !has_next_page?
|
48
|
+
|
49
|
+
@client.get(@pagination_data.next, override_path: true)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def lazy_paginate
|
55
|
+
to_enum(:each_page).lazy.flat_map(&:to_ary)
|
56
|
+
end
|
57
|
+
|
58
|
+
def each_page
|
59
|
+
current = self
|
60
|
+
yield current
|
61
|
+
while current.has_next_page?
|
62
|
+
current = current.next_page
|
63
|
+
yield current
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camper
|
4
|
+
# Parses link header.
|
5
|
+
#
|
6
|
+
# @private
|
7
|
+
class PaginationData
|
8
|
+
include Logging
|
9
|
+
|
10
|
+
HEADER_LINK = 'Link'
|
11
|
+
HEADER_TOTAL_COUNT = 'X-Total-Count'
|
12
|
+
|
13
|
+
DELIM_LINKS = ','
|
14
|
+
LINK_REGEX = /<([^>]+)>; rel="([^"]+)"/.freeze
|
15
|
+
METAS = %w[next].freeze
|
16
|
+
|
17
|
+
attr_accessor(*METAS)
|
18
|
+
attr_accessor :total_count
|
19
|
+
|
20
|
+
def initialize(headers)
|
21
|
+
link_header = headers[HEADER_LINK]
|
22
|
+
|
23
|
+
@total_count = headers[HEADER_TOTAL_COUNT].to_i
|
24
|
+
|
25
|
+
extract_links(link_header) if link_header && link_header =~ /(next)/
|
26
|
+
end
|
27
|
+
|
28
|
+
def inspect
|
29
|
+
"Next URL: #{@next}; Total Count: #{@total_count}"
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def extract_links(header)
|
35
|
+
header.split(DELIM_LINKS).each do |link|
|
36
|
+
LINK_REGEX.match(link.strip) do |match|
|
37
|
+
url = match[1]
|
38
|
+
meta = match[2]
|
39
|
+
next if !url || !meta || METAS.index(meta).nil?
|
40
|
+
|
41
|
+
send("#{meta}=", url)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'httparty'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module Camper
|
7
|
+
class Request
|
8
|
+
include HTTParty
|
9
|
+
include Logging
|
10
|
+
format :json
|
11
|
+
headers 'Accept' => 'application/json', 'Content-Type' => 'application/json'
|
12
|
+
parser(proc { |body, _| parse(body) })
|
13
|
+
|
14
|
+
module Result
|
15
|
+
ACCESS_TOKEN_EXPIRED = 'AccessTokenExpired'
|
16
|
+
|
17
|
+
VALID = 'Valid'
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(access_token, user_agent, client)
|
21
|
+
@access_token = access_token
|
22
|
+
@client = client
|
23
|
+
|
24
|
+
self.class.headers 'User-Agent' => user_agent
|
25
|
+
end
|
26
|
+
|
27
|
+
# Converts the response body to a Resource.
|
28
|
+
def self.parse(body)
|
29
|
+
body = decode(body)
|
30
|
+
|
31
|
+
if body.is_a? Hash
|
32
|
+
Resource.create(body)
|
33
|
+
elsif body.is_a? Array
|
34
|
+
PaginatedResponse.new(body.collect! { |e| Resource.create(e) })
|
35
|
+
elsif body
|
36
|
+
true
|
37
|
+
elsif !body
|
38
|
+
false
|
39
|
+
elsif body.nil?
|
40
|
+
false
|
41
|
+
else
|
42
|
+
raise Error::Parsing, "Couldn't parse a response body"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Decodes a JSON response into Ruby object.
|
47
|
+
def self.decode(response)
|
48
|
+
response ? JSON.parse(response) : {}
|
49
|
+
rescue JSON::ParserError
|
50
|
+
raise Error::Parsing, 'The response is not a valid JSON'
|
51
|
+
end
|
52
|
+
|
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)
|
57
|
+
|
58
|
+
params[:headers] ||= {}
|
59
|
+
|
60
|
+
full_endpoint = override_path ? path : @client.api_endpoint + path
|
61
|
+
|
62
|
+
execute_request(method, full_endpoint, params)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
# Executes the request
|
69
|
+
def execute_request(method, endpoint, params)
|
70
|
+
params[:headers].merge!(self.class.headers)
|
71
|
+
params[:headers].merge!(authorization_header)
|
72
|
+
|
73
|
+
logger.debug("Method: #{method}; URL: #{endpoint}")
|
74
|
+
response, result = validate self.class.send(method, endpoint, params)
|
75
|
+
|
76
|
+
response = extract_parsed(response) if result == Result::VALID
|
77
|
+
|
78
|
+
return response, result
|
79
|
+
end
|
80
|
+
|
81
|
+
# Checks the response code for common errors.
|
82
|
+
# Informs that a retry needs to happen if request failed due to access token expiration
|
83
|
+
# @raise [Error::ResponseError] if response is an HTTP error
|
84
|
+
# @return [Response, Request::Result]
|
85
|
+
def validate(response)
|
86
|
+
error_klass = Error::STATUS_MAPPINGS[response.code]
|
87
|
+
|
88
|
+
if error_klass == Error::Unauthorized && response.parsed_response.error.include?('OAuth token expired (old age)')
|
89
|
+
logger.debug('Access token expired. Please obtain a new access token')
|
90
|
+
return response, Result::ACCESS_TOKEN_EXPIRED
|
91
|
+
end
|
92
|
+
|
93
|
+
raise error_klass, response if error_klass
|
94
|
+
|
95
|
+
return response, Result::Valid
|
96
|
+
end
|
97
|
+
|
98
|
+
def extract_parsed(response)
|
99
|
+
parsed = response.parsed_response
|
100
|
+
|
101
|
+
parsed.client = @client if parsed.respond_to?(:client=)
|
102
|
+
parsed.parse_headers!(response.headers) if parsed.respond_to?(:parse_headers!)
|
103
|
+
|
104
|
+
parsed
|
105
|
+
end
|
106
|
+
|
107
|
+
# Returns an Authorization header hash
|
108
|
+
#
|
109
|
+
# @raise [Error::MissingCredentials] if access_token and auth_token are not set.
|
110
|
+
def authorization_header
|
111
|
+
raise Error::MissingCredentials, 'Please provide a access_token' if @access_token.to_s.empty?
|
112
|
+
|
113
|
+
{ 'Authorization' => "Bearer #{@access_token}" }
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|