camper 0.0.5

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,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
@@ -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