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.
@@ -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
@@ -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
@@ -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
+