camp3 0.0.1

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