camp3 0.0.1
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/workflows/ci.yml +36 -0
- data/.github/workflows/gem-push.yml +27 -0
- data/.gitignore +60 -0
- data/.rspec +3 -0
- data/.rubocop.yml +38 -0
- data/.rubocop_todo.yml +250 -0
- data/.ruby-version +1 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +102 -0
- data/LICENSE +21 -0
- data/README.md +35 -0
- data/Rakefile +11 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/camp3.gemspec +33 -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/camp3.rb +64 -0
- data/lib/camp3/api/message.rb +9 -0
- data/lib/camp3/api/project.rb +20 -0
- data/lib/camp3/api/resource.rb +11 -0
- data/lib/camp3/api/todo.rb +14 -0
- data/lib/camp3/authorization.rb +66 -0
- data/lib/camp3/client.rb +52 -0
- data/lib/camp3/configuration.rb +72 -0
- data/lib/camp3/error.rb +146 -0
- data/lib/camp3/logging.rb +29 -0
- data/lib/camp3/objectified_hash.rb +54 -0
- data/lib/camp3/page_links.rb +36 -0
- data/lib/camp3/paginated_response.rb +110 -0
- data/lib/camp3/request.rb +77 -0
- data/lib/camp3/resource.rb +22 -0
- data/lib/camp3/resources/project.rb +14 -0
- data/lib/camp3/version.rb +5 -0
- metadata +139 -0
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camp3
|
4
|
+
module Authorization
|
5
|
+
|
6
|
+
def authorization_uri
|
7
|
+
client = authz_client
|
8
|
+
|
9
|
+
client.authorization_uri type: :web_server
|
10
|
+
end
|
11
|
+
|
12
|
+
def authz_client
|
13
|
+
Rack::OAuth2::Client.new(
|
14
|
+
identifier: Camp3.client_id,
|
15
|
+
secret: Camp3.client_secret,
|
16
|
+
redirect_uri: Camp3.redirect_uri,
|
17
|
+
authorization_endpoint: Camp3.authz_endpoint,
|
18
|
+
token_endpoint: Camp3.token_endpoint,
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def authorize!(auth_code)
|
23
|
+
client = authz_client
|
24
|
+
client.authorization_code = auth_code
|
25
|
+
|
26
|
+
# Passing secrets as query string
|
27
|
+
token = client.access_token!(
|
28
|
+
client_auth_method: nil,
|
29
|
+
client_id: Camp3.client_id,
|
30
|
+
client_secret: Camp3.client_secret,
|
31
|
+
type: :web_server
|
32
|
+
# code: auth_code
|
33
|
+
)
|
34
|
+
|
35
|
+
store_tokens(token)
|
36
|
+
|
37
|
+
token
|
38
|
+
end
|
39
|
+
|
40
|
+
def update_access_token!(refresh_token = nil)
|
41
|
+
Camp3.logger.debug "Update access token using refresh token"
|
42
|
+
|
43
|
+
refresh_token = Camp3.refresh_token unless refresh_token
|
44
|
+
client = authz_client
|
45
|
+
client.refresh_token = refresh_token
|
46
|
+
|
47
|
+
token = client.access_token!(
|
48
|
+
client_auth_method: nil,
|
49
|
+
client_id: Camp3.client_id,
|
50
|
+
client_secret: Camp3.client_secret,
|
51
|
+
type: :refresh
|
52
|
+
)
|
53
|
+
|
54
|
+
store_tokens(token)
|
55
|
+
|
56
|
+
token
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def store_tokens(token)
|
62
|
+
Camp3.access_token = token.access_token
|
63
|
+
Camp3.refresh_token = token.refresh_token
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
data/lib/camp3/client.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camp3
|
4
|
+
# Wrapper for the Gitlab REST API.
|
5
|
+
class Client < Request
|
6
|
+
Dir[File.expand_path('api/*.rb', __dir__)].each { |f| require f }
|
7
|
+
|
8
|
+
# Keep in alphabetical order
|
9
|
+
include Authorization
|
10
|
+
include ProjectAPI
|
11
|
+
include MessageAPI
|
12
|
+
include TodoAPI
|
13
|
+
|
14
|
+
# @private
|
15
|
+
attr_accessor(*Configuration::VALID_OPTIONS_KEYS)
|
16
|
+
|
17
|
+
# Creates a new API.
|
18
|
+
# @raise [Error:MissingCredentials]
|
19
|
+
def initialize(options = {})
|
20
|
+
options = Camp3.options.merge(options)
|
21
|
+
|
22
|
+
(Configuration::VALID_OPTIONS_KEYS).each do |key|
|
23
|
+
send("#{key}=", options[key]) if options[key]
|
24
|
+
end
|
25
|
+
|
26
|
+
self.class.headers 'User-Agent' => user_agent
|
27
|
+
end
|
28
|
+
|
29
|
+
# Text representation of the client, masking private token.
|
30
|
+
#
|
31
|
+
# @return [String]
|
32
|
+
def inspect
|
33
|
+
inspected = super
|
34
|
+
inspected.sub! @access_token, only_show_last_four_chars(@access_token) if @access_token
|
35
|
+
inspected
|
36
|
+
end
|
37
|
+
|
38
|
+
# Utility method for URL encoding of a string.
|
39
|
+
# Copied from https://ruby-doc.org/stdlib-2.7.0/libdoc/erb/rdoc/ERB/Util.html
|
40
|
+
#
|
41
|
+
# @return [String]
|
42
|
+
def url_encode(url)
|
43
|
+
url.to_s.b.gsub(/[^a-zA-Z0-9_\-.~]/n) { |m| sprintf('%%%02X', m.unpack1('C')) } # rubocop:disable Style/FormatString, Style/FormatStringToken
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def only_show_last_four_chars(token)
|
49
|
+
"#{'*' * (token.size - 4)}#{token[-4..-1]}"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camp3
|
4
|
+
# Defines constants and methods related to configuration.
|
5
|
+
module 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 = "Camp3 Ruby Gem #{Camp3::VERSION}"
|
21
|
+
|
22
|
+
# @private
|
23
|
+
attr_accessor(*VALID_OPTIONS_KEYS)
|
24
|
+
|
25
|
+
def configure
|
26
|
+
yield self
|
27
|
+
end
|
28
|
+
|
29
|
+
# Sets all configuration options to their default values
|
30
|
+
# when this module is extended.
|
31
|
+
def self.extended(base)
|
32
|
+
base.reset
|
33
|
+
end
|
34
|
+
|
35
|
+
# Creates a hash of options and their values.
|
36
|
+
def options
|
37
|
+
VALID_OPTIONS_KEYS.inject({}) do |option, key|
|
38
|
+
option.merge!(key => send(key))
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Resets all configuration options to the defaults.
|
43
|
+
def reset
|
44
|
+
logger.debug "Resetting attributes to default environment values"
|
45
|
+
self.client_id = ENV['BASECAMP3_CLIENT_ID']
|
46
|
+
self.client_secret = ENV['BASECAMP3_CLIENT_SECRET']
|
47
|
+
self.redirect_uri = ENV['BASECAMP3_REDIRECT_URI']
|
48
|
+
self.account_number = ENV['BASECAMP3_ACCOUNT_NUMBER']
|
49
|
+
self.refresh_token = ENV['BASECAMP3_REFRESH_TOKEN']
|
50
|
+
self.access_token = ENV['BASECAMP3_ACCESS_TOKEN']
|
51
|
+
self.user_agent = ENV['BASECAMP3_USER_AGENT'] || DEFAULT_USER_AGENT
|
52
|
+
end
|
53
|
+
|
54
|
+
def authz_endpoint
|
55
|
+
'https://launchpad.37signals.com/authorization/new'
|
56
|
+
end
|
57
|
+
|
58
|
+
def token_endpoint
|
59
|
+
'https://launchpad.37signals.com/authorization/token'
|
60
|
+
end
|
61
|
+
|
62
|
+
def api_endpoint
|
63
|
+
raise Camp3::Error::InvalidConfiguration, "missing basecamp account" unless self.account_number
|
64
|
+
|
65
|
+
"#{self.base_api_endpoint}/#{self.account_number}"
|
66
|
+
end
|
67
|
+
|
68
|
+
def base_api_endpoint
|
69
|
+
"https://3.basecampapi.com"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
data/lib/camp3/error.rb
ADDED
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camp3
|
4
|
+
module Error
|
5
|
+
# Custom error class for rescuing from all Camp3 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 Camp3::Error::Parsing
|
70
|
+
# Return stringified response when receiving a
|
71
|
+
# parsing error to avoid obfuscation of the
|
72
|
+
# api error.
|
73
|
+
#
|
74
|
+
# note: The Camp3 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 Camp3::ObjectifiedHash
|
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,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
module Camp3
|
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
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
# Create and configure a logger
|
22
|
+
# @return [Logger]
|
23
|
+
def default_logger
|
24
|
+
logger = Logger.new($stdout)
|
25
|
+
logger.level = ENV['BASECAMP3_LOG_LEVEL'] || Logger::WARN
|
26
|
+
logger
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camp3
|
4
|
+
# Converts hashes to the objects.
|
5
|
+
class ObjectifiedHash
|
6
|
+
# Creates a new ObjectifiedHash object.
|
7
|
+
def initialize(hash)
|
8
|
+
@hash = hash
|
9
|
+
@data = objectify_data
|
10
|
+
end
|
11
|
+
|
12
|
+
# @return [Hash] The original hash.
|
13
|
+
def to_hash
|
14
|
+
hash
|
15
|
+
end
|
16
|
+
alias to_h to_hash
|
17
|
+
|
18
|
+
# @return [String] Formatted string with the class name, object id and original hash.
|
19
|
+
def inspect
|
20
|
+
"#<#{self.class}:#{object_id} {hash: #{hash.inspect}}"
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :hash, :data
|
26
|
+
|
27
|
+
def objectify_data
|
28
|
+
result = @hash.each_with_object({}) do |(key, value), data|
|
29
|
+
value = objectify_value(value)
|
30
|
+
|
31
|
+
data[key.to_s] = value
|
32
|
+
end
|
33
|
+
|
34
|
+
result
|
35
|
+
end
|
36
|
+
|
37
|
+
def objectify_value(input)
|
38
|
+
return ObjectifiedHash.new(input) if input.is_a? Hash
|
39
|
+
|
40
|
+
return input unless input.is_a? Array
|
41
|
+
|
42
|
+
input.map { |curr| objectify_value(curr) }
|
43
|
+
end
|
44
|
+
|
45
|
+
# Respond to messages for which `self.data` has a key
|
46
|
+
def method_missing(method_name, *args, &block)
|
47
|
+
@data.key?(method_name.to_s) ? @data[method_name.to_s] : super
|
48
|
+
end
|
49
|
+
|
50
|
+
def respond_to_missing?(method_name, include_private = false)
|
51
|
+
@hash.keys.map(&:to_sym).include?(method_name.to_sym) || super
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camp3
|
4
|
+
# Parses link header.
|
5
|
+
#
|
6
|
+
# @private
|
7
|
+
class PageLinks
|
8
|
+
HEADER_LINK = 'Link'
|
9
|
+
DELIM_LINKS = ','
|
10
|
+
LINK_REGEX = /<([^>]+)>; rel="([^"]+)"/.freeze
|
11
|
+
METAS = %w[last next first prev].freeze
|
12
|
+
|
13
|
+
attr_accessor(*METAS)
|
14
|
+
|
15
|
+
def initialize(headers)
|
16
|
+
link_header = headers[HEADER_LINK]
|
17
|
+
|
18
|
+
extract_links(link_header) if link_header && link_header =~ /(next|first|last|prev)/
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def extract_links(header)
|
24
|
+
header.split(DELIM_LINKS).each do |link|
|
25
|
+
LINK_REGEX.match(link.strip) do |match|
|
26
|
+
url = match[1]
|
27
|
+
meta = match[2]
|
28
|
+
next if !url || !meta || METAS.index(meta).nil?
|
29
|
+
|
30
|
+
send("#{meta}=", url)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|