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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +24 -0
- data/README.md +273 -0
- data/lib/jira/api.rb +22 -0
- data/lib/jira/client/issues.rb +35 -0
- data/lib/jira/client/project_permission_schemes.rb +37 -0
- data/lib/jira/client/projects.rb +33 -0
- data/lib/jira/client.rb +43 -0
- data/lib/jira/configuration.rb +101 -0
- data/lib/jira/error.rb +211 -0
- data/lib/jira/objectified_hash.rb +66 -0
- data/lib/jira/pagination/cursor_paginated_response.rb +92 -0
- data/lib/jira/pagination/paginated_response.rb +96 -0
- data/lib/jira/request/authentication.rb +180 -0
- data/lib/jira/request/paginated_response.rb +4 -0
- data/lib/jira/request/rate_limiting.rb +126 -0
- data/lib/jira/request/request_building.rb +78 -0
- data/lib/jira/request/response_parsing.rb +45 -0
- data/lib/jira/request.rb +153 -0
- data/lib/jira/version.rb +5 -0
- data/lib/jira.rb +65 -0
- metadata +80 -0
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
|