camper 0.0.5

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