ruby-jira 0.1.0

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.
data/lib/jira/error.rb ADDED
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jira
4
+ module Error
5
+ # Base class for all Jira errors.
6
+ class Base < StandardError; end
7
+
8
+ # Raised when API endpoint credentials not configured.
9
+ class MissingCredentials < Base; end
10
+
11
+ # Raised when impossible to parse response body.
12
+ class Parsing < Base; end
13
+
14
+ # Custom error class for rescuing from HTTP response errors.
15
+ class ResponseError < Base
16
+ POSSIBLE_MESSAGE_KEYS = %i[message error_description error].freeze
17
+
18
+ def initialize(response)
19
+ @response = response
20
+ super(build_error_message)
21
+ end
22
+
23
+ # Status code returned in the HTTP response.
24
+ #
25
+ # @return [Integer]
26
+ def response_status
27
+ @response.code
28
+ end
29
+
30
+ # Body content returned in the HTTP response
31
+ #
32
+ # @return [String]
33
+ def response_message
34
+ parsed_response = @response.parsed_response
35
+ return parsed_response[:message] || parsed_response["message"] if parsed_response.is_a?(Hash)
36
+
37
+ parsed_response.respond_to?(:message) ? parsed_response.message : parsed_response
38
+ end
39
+
40
+ # Additional error context returned by some API endpoints
41
+ #
42
+ # @return [String]
43
+ def error_code
44
+ if @response.respond_to?(:error_code)
45
+ @response.error_code
46
+ else
47
+ ""
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # Human friendly message.
54
+ #
55
+ # @return [String]
56
+ def build_error_message
57
+ parsed_response = classified_response
58
+ message = check_error_keys(parsed_response)
59
+ "Server responded with code #{@response.code}, message: " \
60
+ "#{handle_message(message)}. " \
61
+ "Request URI: #{@response.request.base_uri}#{@response.request.path}"
62
+ end
63
+
64
+ # Error keys vary across the API, find the first available key and return it.
65
+ def check_error_keys(response)
66
+ return hash_message(response) if response.is_a?(Hash)
67
+
68
+ object_message(response)
69
+ end
70
+
71
+ # Parse the body based on the classification of the body content type.
72
+ #
73
+ # @return [Object]
74
+ def classified_response
75
+ if @response.respond_to?(:headers)
76
+ @response.headers["content-type"] == "text/plain" ? { message: @response.to_s } : @response.parsed_response
77
+ else
78
+ @response.parsed_response
79
+ end
80
+ rescue Jira::Error::Parsing
81
+ @response.to_s
82
+ end
83
+
84
+ # Handle error response message in case of nested hashes.
85
+ def handle_message(message)
86
+ case message
87
+ when Hash
88
+ message.to_h.sort.map do |key, value|
89
+ "'#{key}' #{formatted_hash_value(value)}"
90
+ end.join(", ")
91
+ when Array
92
+ message.join(" ")
93
+ else
94
+ message
95
+ end
96
+ end
97
+
98
+ def formatted_hash_value(value)
99
+ if value.is_a?(Hash)
100
+ value.sort.map { |key, nested_value| "(#{key}: #{Array(nested_value).join(" ")})" }.join(" ")
101
+ else
102
+ Array(value).join(" ")
103
+ end
104
+ end
105
+
106
+ def present_value?(value)
107
+ return false if value.nil?
108
+ return !value.empty? if value.respond_to?(:empty?)
109
+
110
+ true
111
+ end
112
+
113
+ def hash_message(response)
114
+ POSSIBLE_MESSAGE_KEYS.each do |key|
115
+ symbol_value = response[key]
116
+ return symbol_value if present_value?(symbol_value)
117
+
118
+ string_value = response[key.to_s]
119
+ return string_value if present_value?(string_value)
120
+ end
121
+
122
+ response
123
+ end
124
+
125
+ def object_message(response)
126
+ POSSIBLE_MESSAGE_KEYS.each do |candidate|
127
+ next unless response.respond_to?(candidate)
128
+
129
+ value = response.send(candidate)
130
+ return value if present_value?(value)
131
+ end
132
+
133
+ compact_hash_response(response) || response
134
+ end
135
+
136
+ def compact_hash_response(response)
137
+ return nil if response.is_a?(Array) || !response.respond_to?(:to_h)
138
+
139
+ hash_response = response.to_h.compact
140
+ hash_response.empty? ? nil : hash_response
141
+ end
142
+ end
143
+
144
+ # Raised when API endpoint returns the HTTP status code 400.
145
+ class BadRequest < ResponseError; end
146
+
147
+ # Raised when API endpoint returns the HTTP status code 401.
148
+ class Unauthorized < ResponseError; end
149
+
150
+ # Raised when API endpoint returns the HTTP status code 403.
151
+ class Forbidden < ResponseError; end
152
+
153
+ # Raised when API endpoint returns the HTTP status code 404.
154
+ class NotFound < ResponseError; end
155
+
156
+ # Raised when API endpoint returns the HTTP status code 405.
157
+ class MethodNotAllowed < ResponseError; end
158
+
159
+ # Raised when API endpoint returns the HTTP status code 406.
160
+ class NotAcceptable < ResponseError; end
161
+
162
+ # Raised when API endpoint returns the HTTP status code 409.
163
+ class Conflict < ResponseError; end
164
+
165
+ # Raised when API endpoint returns the HTTP status code 422.
166
+ class Unprocessable < ResponseError; end
167
+
168
+ # Raised when API endpoint returns the HTTP status code 429.
169
+ class TooManyRequests < ResponseError; end
170
+
171
+ # Raised when API endpoint returns the HTTP status code 500.
172
+ class InternalServerError < ResponseError; end
173
+
174
+ # Raised when API endpoint returns the HTTP status code 502.
175
+ class BadGateway < ResponseError; end
176
+
177
+ # Raised when API endpoint returns the HTTP status code 503.
178
+ class ServiceUnavailable < ResponseError; end
179
+
180
+ # Raised when API endpoint returns the HTTP status code 522.
181
+ class ConnectionTimedOut < ResponseError; end
182
+
183
+ STATUS_MAPPINGS = {
184
+ 400 => BadRequest,
185
+ 401 => Unauthorized,
186
+ 403 => Forbidden,
187
+ 404 => NotFound,
188
+ 405 => MethodNotAllowed,
189
+ 406 => NotAcceptable,
190
+ 409 => Conflict,
191
+ 422 => Unprocessable,
192
+ 429 => TooManyRequests,
193
+ 500 => InternalServerError,
194
+ 502 => BadGateway,
195
+ 503 => ServiceUnavailable,
196
+ 522 => ConnectionTimedOut
197
+ }.freeze
198
+
199
+ # Returns error class that should be raised for this response. Returns nil
200
+ # if the response status code is not 4xx or 5xx.
201
+ #
202
+ # @param response [HTTParty::Response] The response object.
203
+ # @return [Class<Jira::Error::ResponseError>, nil]
204
+ def self.klass(response)
205
+ error_klass = STATUS_MAPPINGS[response.code]
206
+ return error_klass if error_klass
207
+
208
+ ResponseError if response.server_error? || response.client_error?
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jira
4
+ # Wraps a Hash to allow dot-notation access alongside bracket access.
5
+ #
6
+ # @example
7
+ # issue = Jira::ObjectifiedHash.new({ key: "TEST-1", fields: { summary: "Bug" } })
8
+ # issue.key # => "TEST-1"
9
+ # issue[:key] # => "TEST-1"
10
+ # issue.fields # => <Jira::ObjectifiedHash ...>
11
+ # issue.fields.summary # => "Bug"
12
+ # issue.to_h # => { key: "TEST-1", ... }
13
+ class ObjectifiedHash
14
+ def initialize(hash)
15
+ @hash = hash
16
+ @data = hash.each_with_object({}) do |(key, value), data|
17
+ sym_key = key.to_sym
18
+ value = self.class.new(value) if value.is_a?(Hash)
19
+ value = value.map { |v| v.is_a?(Hash) ? self.class.new(v) : v } if value.is_a?(Array)
20
+ data[sym_key] = value
21
+ end
22
+ end
23
+
24
+ # @return [Hash] The original hash with original key types.
25
+ def to_hash
26
+ @hash
27
+ end
28
+ alias to_h to_hash
29
+
30
+ def inspect
31
+ "#<#{self.class}:#{object_id} {hash: #{@hash.inspect}}>"
32
+ end
33
+
34
+ # Supports both symbol and string key access.
35
+ def [](key)
36
+ @data[key.to_sym]
37
+ end
38
+
39
+ # Supports nested key traversal, mirroring Hash#dig.
40
+ def dig(key, *rest)
41
+ value = self[key]
42
+ return value if rest.empty?
43
+ return nil unless value.respond_to?(:dig)
44
+
45
+ value.dig(*rest)
46
+ end
47
+
48
+ def ==(other)
49
+ return @hash == other.to_h if other.is_a?(self.class)
50
+
51
+ @hash == other
52
+ end
53
+
54
+ private
55
+
56
+ def method_missing(method_name, *, &)
57
+ return @data[method_name] if @data.key?(method_name)
58
+
59
+ super
60
+ end
61
+
62
+ def respond_to_missing?(method_name, include_private = false)
63
+ @data.key?(method_name) || super
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jira
4
+ # Wrapper for Jira cursor-paginated responses (nextPageToken style).
5
+ #
6
+ # Endpoints like POST /search/jql return a body of:
7
+ # { nextPageToken: "token", total: int, <items_key>: [...] }
8
+ #
9
+ # The items array key varies by endpoint (e.g. "issues", "worklogs").
10
+ # This class detects it automatically as the first non-metadata Array value.
11
+ #
12
+ # Pagination is driven by a +next_page_fetcher+ proc set by the Request layer,
13
+ # which re-issues the original request with +nextPageToken+ injected.
14
+ class CursorPaginatedResponse
15
+ METADATA_KEYS = %i[nextPageToken total self].freeze
16
+
17
+ attr_accessor :client, :next_page_fetcher
18
+ attr_reader :next_page_token, :total
19
+
20
+ def initialize(body)
21
+ @body = body
22
+ @next_page_token = body[:nextPageToken]
23
+ @total = body.fetch(:total, 0).to_i
24
+ @array = wrap_items(detect_items_array(body))
25
+ end
26
+
27
+ def inspect
28
+ @array.inspect
29
+ end
30
+
31
+ def method_missing(name, *, &)
32
+ return @array.send(name, *, &) if @array.respond_to?(name)
33
+
34
+ super
35
+ end
36
+
37
+ def respond_to_missing?(method_name, include_private = false)
38
+ super || @array.respond_to?(method_name, include_private)
39
+ end
40
+
41
+ def each_page
42
+ current = self
43
+ yield current
44
+ while current.has_next_page?
45
+ current = current.next_page
46
+ yield current
47
+ end
48
+ end
49
+
50
+ def lazy_paginate
51
+ to_enum(:each_page).lazy.flat_map(&:to_ary)
52
+ end
53
+
54
+ def auto_paginate(&block)
55
+ return lazy_paginate.to_a unless block
56
+
57
+ lazy_paginate.each(&block)
58
+ end
59
+
60
+ def paginate_with_limit(limit, &block)
61
+ return lazy_paginate.take(limit).to_a unless block
62
+
63
+ lazy_paginate.take(limit).each(&block)
64
+ end
65
+
66
+ def next_page?
67
+ !@next_page_token.to_s.empty?
68
+ end
69
+ alias has_next_page? next_page?
70
+
71
+ def next_page
72
+ return nil unless has_next_page?
73
+ raise Error::MissingCredentials, "next_page_fetcher not set on CursorPaginatedResponse" unless @next_page_fetcher
74
+
75
+ @next_page_fetcher.call(@next_page_token)
76
+ end
77
+
78
+ private
79
+
80
+ def detect_items_array(body)
81
+ body.each do |key, value|
82
+ next if METADATA_KEYS.include?(key)
83
+ return value if value.is_a?(Array)
84
+ end
85
+ []
86
+ end
87
+
88
+ def wrap_items(items)
89
+ items.map { |item| item.is_a?(Hash) ? ObjectifiedHash.new(item) : item }
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Jira
6
+ # Wrapper for Jira offset-paginated responses (values/isLast style).
7
+ #
8
+ # Endpoints like GET /project/search and GET /workflow/search return a body of:
9
+ # { values: [...], isLast: bool, total: int, nextPage: url, startAt: int, maxResults: int }
10
+ class PaginatedResponse
11
+ attr_accessor :client
12
+ attr_reader :total, :max_results, :start_at, :self_url
13
+
14
+ def initialize(body)
15
+ @body = body
16
+ @array = wrap_items(body.fetch(:values, []))
17
+ @is_last = body.fetch(:isLast, false)
18
+ @max_results = body.fetch(:maxResults, 0).to_i
19
+ @next_page = body.fetch(:nextPage, "")
20
+ @self_url = body.fetch(:self, "")
21
+ @start_at = body.fetch(:startAt, 0).to_i
22
+ @total = body.fetch(:total, 0).to_i
23
+ end
24
+
25
+ def inspect
26
+ @array.inspect
27
+ end
28
+
29
+ def method_missing(name, *, &)
30
+ return @array.send(name, *, &) if @array.respond_to?(name)
31
+
32
+ super
33
+ end
34
+
35
+ def respond_to_missing?(method_name, include_private = false)
36
+ super || @array.respond_to?(method_name, include_private)
37
+ end
38
+
39
+ def each_page
40
+ current = self
41
+ yield current
42
+ while current.has_next_page?
43
+ current = current.next_page
44
+ yield current
45
+ end
46
+ end
47
+
48
+ def lazy_paginate
49
+ to_enum(:each_page).lazy.flat_map(&:to_ary)
50
+ end
51
+
52
+ def auto_paginate(&block)
53
+ return lazy_paginate.to_a unless block
54
+
55
+ lazy_paginate.each(&block)
56
+ end
57
+
58
+ def paginate_with_limit(limit, &block)
59
+ return lazy_paginate.take(limit).to_a unless block
60
+
61
+ lazy_paginate.take(limit).each(&block)
62
+ end
63
+
64
+ def last_page?
65
+ @is_last == true
66
+ end
67
+ alias has_last_page? last_page?
68
+
69
+ def first_page?
70
+ @start_at.zero?
71
+ end
72
+ alias has_first_page? first_page?
73
+
74
+ def next_page?
75
+ @is_last == false && !@next_page.to_s.empty?
76
+ end
77
+ alias has_next_page? next_page?
78
+
79
+ def next_page
80
+ return nil unless has_next_page?
81
+
82
+ @client.get(client_relative_path(@next_page))
83
+ end
84
+
85
+ def client_relative_path(link)
86
+ client_endpoint_path = @client.api_request_path
87
+ URI.parse(link).request_uri.sub(client_endpoint_path, "")
88
+ end
89
+
90
+ private
91
+
92
+ def wrap_items(items)
93
+ items.map { |item| item.is_a?(Hash) ? ObjectifiedHash.new(item) : item }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jira
4
+ class Request
5
+ class OAuthTokenClient
6
+ TOKEN_ENDPOINT_HEADERS = {
7
+ "Accept" => "application/json",
8
+ "Content-Type" => "application/json"
9
+ }.freeze
10
+
11
+ def initialize(request:)
12
+ @request = request
13
+ end
14
+
15
+ def fetch!(payload)
16
+ response = HTTParty.post(token_endpoint, **request_options(payload))
17
+ body = JSON.parse(response.body.to_s, symbolize_names: true)
18
+ return body if response.code.to_i.between?(200, 299)
19
+
20
+ message = body[:error_description] || body[:error] || response.body
21
+ raise Error::MissingCredentials, "OAuth token refresh failed: #{message}"
22
+ rescue JSON::ParserError
23
+ raise Error::MissingCredentials, "OAuth token refresh failed: invalid JSON response"
24
+ end
25
+
26
+ private
27
+
28
+ def token_endpoint
29
+ @request.oauth_token_endpoint || Configuration::DEFAULT_OAUTH_TOKEN_ENDPOINT
30
+ end
31
+
32
+ # Build HTTParty options, forwarding any proxy config set on Jira::Request.
33
+ def request_options(payload)
34
+ opts = { body: payload.to_json, headers: TOKEN_ENDPOINT_HEADERS }
35
+ proxy = proxy_options
36
+ opts.merge!(proxy) if proxy.any?
37
+ opts
38
+ end
39
+
40
+ def proxy_options
41
+ defaults = Jira::Request.default_options
42
+ {
43
+ http_proxyaddr: defaults[:http_proxyaddr],
44
+ http_proxyport: defaults[:http_proxyport],
45
+ http_proxyuser: defaults[:http_proxyuser],
46
+ http_proxypass: defaults[:http_proxypass]
47
+ }.compact
48
+ end
49
+ end
50
+
51
+ class Authenticator
52
+ OAUTH_TOKEN_EXPIRY_BUFFER = 30
53
+ SUPPORTED_OAUTH_GRANT_TYPES = %w[client_credentials refresh_token].freeze
54
+ OAUTH_MISSING_CREDENTIALS_MESSAGE = [
55
+ "Please provide oauth_access_token or",
56
+ "oauth_client_id/oauth_client_secret (and oauth_refresh_token for refresh_token grant) for :oauth2 auth"
57
+ ].join(" ").freeze
58
+
59
+ def initialize(request:, token_client: OAuthTokenClient.new(request: request))
60
+ @request = request
61
+ @token_client = token_client
62
+ end
63
+
64
+ def auth_type
65
+ type = (@request.auth_type || Configuration::DEFAULT_AUTH_TYPE).to_sym
66
+ return type if %i[basic oauth2].include?(type)
67
+
68
+ raise Error::MissingCredentials, "Unsupported auth_type '#{type}'. Use :basic or :oauth2"
69
+ end
70
+
71
+ def validate!
72
+ case auth_type
73
+ when :basic
74
+ validate_basic_auth!
75
+ when :oauth2
76
+ validate_oauth2_auth!
77
+ end
78
+ end
79
+
80
+ def authorization_header
81
+ case auth_type
82
+ when :basic
83
+ validate_basic_auth!
84
+ credentials = Base64.strict_encode64("#{@request.email}:#{@request.api_token}")
85
+ { "Authorization" => "Basic #{credentials}" }
86
+ when :oauth2
87
+ validate_oauth2_auth!
88
+ { "Authorization" => "Bearer #{oauth_access_token!}" }
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def validate_basic_auth!
95
+ raise Error::MissingCredentials, "Please provide email for :basic auth" if @request.email.to_s.strip.empty?
96
+ return unless @request.api_token.to_s.strip.empty?
97
+
98
+ raise Error::MissingCredentials, "Please provide api_token for :basic auth"
99
+ end
100
+
101
+ def validate_oauth2_auth!
102
+ if !oauth_access_token_available? && !oauth_client_credentials_available?
103
+ raise Error::MissingCredentials, OAUTH_MISSING_CREDENTIALS_MESSAGE
104
+ end
105
+ return unless @request.cloud_id.to_s.strip.empty?
106
+
107
+ raise Error::MissingCredentials, "Please provide cloud_id for :oauth2 auth"
108
+ end
109
+
110
+ def oauth_access_token_available?
111
+ !@request.oauth_access_token.to_s.strip.empty?
112
+ end
113
+
114
+ def oauth_client_credentials_available?
115
+ return false if @request.oauth_client_id.to_s.strip.empty? || @request.oauth_client_secret.to_s.strip.empty?
116
+ return true if oauth_grant_type == "client_credentials"
117
+
118
+ !@request.oauth_refresh_token.to_s.strip.empty?
119
+ end
120
+
121
+ def oauth_access_token!
122
+ return @request.oauth_access_token if oauth_access_token_valid?
123
+ return refresh_oauth_access_token! if oauth_client_credentials_available?
124
+
125
+ raise Error::MissingCredentials, OAUTH_MISSING_CREDENTIALS_MESSAGE
126
+ end
127
+
128
+ def oauth_access_token_valid?
129
+ return false unless oauth_access_token_available?
130
+ return true if @request.oauth_access_token_expires_at.nil?
131
+
132
+ Time.now < @request.oauth_access_token_expires_at
133
+ end
134
+
135
+ def refresh_oauth_access_token!
136
+ body = @token_client.fetch!(refresh_token_payload)
137
+ apply_oauth_tokens!(body)
138
+ @request.oauth_access_token
139
+ end
140
+
141
+ def refresh_token_payload
142
+ payload = {
143
+ grant_type: oauth_grant_type,
144
+ client_id: @request.oauth_client_id,
145
+ client_secret: @request.oauth_client_secret
146
+ }
147
+ return payload if oauth_grant_type == "client_credentials"
148
+
149
+ payload.merge(refresh_token: @request.oauth_refresh_token)
150
+ end
151
+
152
+ def apply_oauth_tokens!(body)
153
+ token = body[:access_token]
154
+ raise Error::MissingCredentials, "OAuth token endpoint did not return access_token" if token.to_s.strip.empty?
155
+
156
+ @request.oauth_access_token = token
157
+ @request.oauth_refresh_token = body[:refresh_token] if body[:refresh_token]
158
+ update_oauth_expiry(body[:expires_in])
159
+ end
160
+
161
+ def update_oauth_expiry(expires_in)
162
+ return if expires_in.to_i <= 0
163
+
164
+ @request.oauth_access_token_expires_at = Time.now + [expires_in.to_i - OAUTH_TOKEN_EXPIRY_BUFFER, 0].max
165
+ end
166
+
167
+ def oauth_grant_type
168
+ type = @request.oauth_grant_type.to_s.strip
169
+ return "refresh_token" if type.empty? && !@request.oauth_refresh_token.to_s.strip.empty?
170
+ return "client_credentials" if type.empty?
171
+
172
+ unless SUPPORTED_OAUTH_GRANT_TYPES.include?(type)
173
+ raise Error::MissingCredentials, "Unsupported oauth_grant_type '#{type}'"
174
+ end
175
+
176
+ type
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is intentionally empty.
4
+ # PaginatedResponse was moved to Jira::PaginatedResponse (lib/jira/paginated_response.rb).