camper 0.0.5
Sign up to get free protection for your applications and to get access to all the features.
- 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
|