camp3 0.0.1
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/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
|
+
|