basecamp-sdk 0.2.1
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/.rubocop.yml +14 -0
- data/.yardopts +6 -0
- data/README.md +293 -0
- data/Rakefile +26 -0
- data/basecamp-sdk.gemspec +46 -0
- data/lib/basecamp/auth_strategy.rb +38 -0
- data/lib/basecamp/chain_hooks.rb +45 -0
- data/lib/basecamp/client.rb +428 -0
- data/lib/basecamp/config.rb +143 -0
- data/lib/basecamp/errors.rb +289 -0
- data/lib/basecamp/generated/metadata.json +2281 -0
- data/lib/basecamp/generated/services/attachments_service.rb +24 -0
- data/lib/basecamp/generated/services/boosts_service.rb +70 -0
- data/lib/basecamp/generated/services/campfires_service.rb +122 -0
- data/lib/basecamp/generated/services/card_columns_service.rb +103 -0
- data/lib/basecamp/generated/services/card_steps_service.rb +57 -0
- data/lib/basecamp/generated/services/card_tables_service.rb +20 -0
- data/lib/basecamp/generated/services/cards_service.rb +66 -0
- data/lib/basecamp/generated/services/checkins_service.rb +157 -0
- data/lib/basecamp/generated/services/client_approvals_service.rb +28 -0
- data/lib/basecamp/generated/services/client_correspondences_service.rb +28 -0
- data/lib/basecamp/generated/services/client_replies_service.rb +30 -0
- data/lib/basecamp/generated/services/client_visibility_service.rb +21 -0
- data/lib/basecamp/generated/services/comments_service.rb +49 -0
- data/lib/basecamp/generated/services/documents_service.rb +52 -0
- data/lib/basecamp/generated/services/events_service.rb +20 -0
- data/lib/basecamp/generated/services/forwards_service.rb +67 -0
- data/lib/basecamp/generated/services/lineup_service.rb +44 -0
- data/lib/basecamp/generated/services/message_boards_service.rb +20 -0
- data/lib/basecamp/generated/services/message_types_service.rb +59 -0
- data/lib/basecamp/generated/services/messages_service.rb +75 -0
- data/lib/basecamp/generated/services/people_service.rb +73 -0
- data/lib/basecamp/generated/services/projects_service.rb +63 -0
- data/lib/basecamp/generated/services/recordings_service.rb +64 -0
- data/lib/basecamp/generated/services/reports_service.rb +56 -0
- data/lib/basecamp/generated/services/schedules_service.rb +92 -0
- data/lib/basecamp/generated/services/search_service.rb +31 -0
- data/lib/basecamp/generated/services/subscriptions_service.rb +50 -0
- data/lib/basecamp/generated/services/templates_service.rb +82 -0
- data/lib/basecamp/generated/services/timeline_service.rb +20 -0
- data/lib/basecamp/generated/services/timesheets_service.rb +81 -0
- data/lib/basecamp/generated/services/todolist_groups_service.rb +41 -0
- data/lib/basecamp/generated/services/todolists_service.rb +53 -0
- data/lib/basecamp/generated/services/todos_service.rb +106 -0
- data/lib/basecamp/generated/services/todosets_service.rb +20 -0
- data/lib/basecamp/generated/services/tools_service.rb +80 -0
- data/lib/basecamp/generated/services/uploads_service.rb +61 -0
- data/lib/basecamp/generated/services/vaults_service.rb +49 -0
- data/lib/basecamp/generated/services/webhooks_service.rb +63 -0
- data/lib/basecamp/generated/types.rb +3196 -0
- data/lib/basecamp/hooks.rb +70 -0
- data/lib/basecamp/http.rb +440 -0
- data/lib/basecamp/logger_hooks.rb +46 -0
- data/lib/basecamp/noop_hooks.rb +9 -0
- data/lib/basecamp/oauth/discovery.rb +123 -0
- data/lib/basecamp/oauth/errors.rb +35 -0
- data/lib/basecamp/oauth/exchange.rb +291 -0
- data/lib/basecamp/oauth/pkce.rb +68 -0
- data/lib/basecamp/oauth/types.rb +133 -0
- data/lib/basecamp/oauth.rb +56 -0
- data/lib/basecamp/oauth_token_provider.rb +108 -0
- data/lib/basecamp/operation_info.rb +17 -0
- data/lib/basecamp/request_info.rb +10 -0
- data/lib/basecamp/request_result.rb +14 -0
- data/lib/basecamp/security.rb +112 -0
- data/lib/basecamp/services/attachments_service.rb +33 -0
- data/lib/basecamp/services/authorization_service.rb +47 -0
- data/lib/basecamp/services/base_service.rb +146 -0
- data/lib/basecamp/services/campfires_service.rb +141 -0
- data/lib/basecamp/services/card_columns_service.rb +106 -0
- data/lib/basecamp/services/card_steps_service.rb +86 -0
- data/lib/basecamp/services/card_tables_service.rb +23 -0
- data/lib/basecamp/services/cards_service.rb +93 -0
- data/lib/basecamp/services/checkins_service.rb +127 -0
- data/lib/basecamp/services/client_approvals_service.rb +33 -0
- data/lib/basecamp/services/client_correspondences_service.rb +33 -0
- data/lib/basecamp/services/client_replies_service.rb +35 -0
- data/lib/basecamp/services/comments_service.rb +63 -0
- data/lib/basecamp/services/documents_service.rb +74 -0
- data/lib/basecamp/services/events_service.rb +27 -0
- data/lib/basecamp/services/forwards_service.rb +80 -0
- data/lib/basecamp/services/lineup_service.rb +67 -0
- data/lib/basecamp/services/message_boards_service.rb +24 -0
- data/lib/basecamp/services/message_types_service.rb +79 -0
- data/lib/basecamp/services/messages_service.rb +133 -0
- data/lib/basecamp/services/people_service.rb +73 -0
- data/lib/basecamp/services/projects_service.rb +67 -0
- data/lib/basecamp/services/recordings_service.rb +127 -0
- data/lib/basecamp/services/reports_service.rb +80 -0
- data/lib/basecamp/services/schedules_service.rb +156 -0
- data/lib/basecamp/services/search_service.rb +36 -0
- data/lib/basecamp/services/subscriptions_service.rb +67 -0
- data/lib/basecamp/services/templates_service.rb +96 -0
- data/lib/basecamp/services/timeline_service.rb +62 -0
- data/lib/basecamp/services/timesheet_service.rb +68 -0
- data/lib/basecamp/services/todolist_groups_service.rb +100 -0
- data/lib/basecamp/services/todolists_service.rb +104 -0
- data/lib/basecamp/services/todos_service.rb +156 -0
- data/lib/basecamp/services/todosets_service.rb +23 -0
- data/lib/basecamp/services/tools_service.rb +89 -0
- data/lib/basecamp/services/uploads_service.rb +84 -0
- data/lib/basecamp/services/vaults_service.rb +84 -0
- data/lib/basecamp/services/webhooks_service.rb +88 -0
- data/lib/basecamp/static_token_provider.rb +24 -0
- data/lib/basecamp/token_provider.rb +42 -0
- data/lib/basecamp/version.rb +6 -0
- data/lib/basecamp/webhooks/event.rb +52 -0
- data/lib/basecamp/webhooks/rack_middleware.rb +49 -0
- data/lib/basecamp/webhooks/receiver.rb +161 -0
- data/lib/basecamp/webhooks/verify.rb +36 -0
- data/lib/basecamp.rb +107 -0
- data/scripts/generate-metadata.rb +106 -0
- data/scripts/generate-services.rb +778 -0
- data/scripts/generate-types.rb +191 -0
- metadata +316 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Interface for observability hooks.
|
|
5
|
+
# Implement this to add logging, metrics, or tracing to HTTP requests.
|
|
6
|
+
#
|
|
7
|
+
# @example Custom hooks with logging
|
|
8
|
+
# class LoggingHooks
|
|
9
|
+
# include Basecamp::Hooks
|
|
10
|
+
#
|
|
11
|
+
# def on_request_start(info)
|
|
12
|
+
# puts "Starting #{info.method} #{info.url}"
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# def on_request_end(info, result)
|
|
16
|
+
# puts "Completed #{info.method} #{info.url} - #{result.status_code} (#{result.duration}s)"
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# client = Basecamp::Client.new(config: config, token_provider: provider, hooks: LoggingHooks.new)
|
|
21
|
+
module Hooks
|
|
22
|
+
# Called when a service operation starts (e.g., projects.list, todos.create).
|
|
23
|
+
# @param info [OperationInfo] operation information
|
|
24
|
+
# @return [void]
|
|
25
|
+
def on_operation_start(info)
|
|
26
|
+
# Override in implementation
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Called when a service operation completes (success or failure).
|
|
30
|
+
# @param info [OperationInfo] operation information
|
|
31
|
+
# @param result [OperationResult] result information
|
|
32
|
+
# @return [void]
|
|
33
|
+
def on_operation_end(info, result)
|
|
34
|
+
# Override in implementation
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Called when an HTTP request starts.
|
|
38
|
+
# @param info [RequestInfo] request information
|
|
39
|
+
# @return [void]
|
|
40
|
+
def on_request_start(info)
|
|
41
|
+
# Override in implementation
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Called when an HTTP request completes (success or failure).
|
|
45
|
+
# @param info [RequestInfo] request information
|
|
46
|
+
# @param result [RequestResult] result information
|
|
47
|
+
# @return [void]
|
|
48
|
+
def on_request_end(info, result)
|
|
49
|
+
# Override in implementation
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Called when a request is retried.
|
|
53
|
+
# @param info [RequestInfo] request information
|
|
54
|
+
# @param attempt [Integer] the next attempt number
|
|
55
|
+
# @param error [Exception] the error that triggered the retry
|
|
56
|
+
# @param delay [Float] seconds until retry
|
|
57
|
+
# @return [void]
|
|
58
|
+
def on_retry(info, attempt, error, delay)
|
|
59
|
+
# Override in implementation
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Called when pagination fetches the next page.
|
|
63
|
+
# @param url [String] the next page URL
|
|
64
|
+
# @param page [Integer] the page number
|
|
65
|
+
# @return [void]
|
|
66
|
+
def on_paginate(url, page)
|
|
67
|
+
# Override in implementation
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Basecamp
|
|
9
|
+
# HTTP client layer with retry, backoff, and caching support.
|
|
10
|
+
# This is an internal class used by Client; you typically don't use it directly.
|
|
11
|
+
class Http
|
|
12
|
+
# Default User-Agent header
|
|
13
|
+
USER_AGENT = "basecamp-sdk-ruby/#{VERSION} (api:#{API_VERSION})".freeze
|
|
14
|
+
|
|
15
|
+
# @param config [Config] configuration settings
|
|
16
|
+
# @param token_provider [TokenProvider, nil] OAuth token provider (deprecated, use auth_strategy)
|
|
17
|
+
# @param auth_strategy [AuthStrategy, nil] authentication strategy
|
|
18
|
+
# @param hooks [Hooks] observability hooks
|
|
19
|
+
def initialize(config:, token_provider: nil, auth_strategy: nil, hooks: nil)
|
|
20
|
+
@config = config
|
|
21
|
+
@auth_strategy = auth_strategy || BearerAuth.new(token_provider)
|
|
22
|
+
@token_provider = token_provider || (@auth_strategy.is_a?(BearerAuth) ? @auth_strategy.token_provider : nil)
|
|
23
|
+
@hooks = hooks || NoopHooks.new
|
|
24
|
+
@faraday = build_faraday_client
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String] the configured base URL
|
|
28
|
+
def base_url
|
|
29
|
+
@config.base_url
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Performs a GET request.
|
|
33
|
+
# @param path [String] URL path
|
|
34
|
+
# @param params [Hash] query parameters
|
|
35
|
+
# @return [Response]
|
|
36
|
+
def get(path, params: {})
|
|
37
|
+
request(:get, path, params: params)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Performs a GET request to an absolute URL.
|
|
41
|
+
# Used for endpoints not on the base API (e.g., Launchpad).
|
|
42
|
+
# @param url [String] absolute URL
|
|
43
|
+
# @param params [Hash] query parameters
|
|
44
|
+
# @return [Response]
|
|
45
|
+
def get_absolute(url, params: {})
|
|
46
|
+
request(:get, url, params: params)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Performs a POST request.
|
|
50
|
+
# @param path [String] URL path
|
|
51
|
+
# @param body [Hash, nil] request body
|
|
52
|
+
# @return [Response]
|
|
53
|
+
def post(path, body: nil)
|
|
54
|
+
request(:post, path, body: body)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Performs a PUT request.
|
|
58
|
+
# @param path [String] URL path
|
|
59
|
+
# @param body [Hash, nil] request body
|
|
60
|
+
# @return [Response]
|
|
61
|
+
def put(path, body: nil)
|
|
62
|
+
request(:put, path, body: body)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Performs a DELETE request.
|
|
66
|
+
# @param path [String] URL path
|
|
67
|
+
# @return [Response]
|
|
68
|
+
def delete(path)
|
|
69
|
+
request(:delete, path)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Performs a POST request with raw binary data.
|
|
73
|
+
# Used for file uploads (attachments).
|
|
74
|
+
# @param path [String] URL path
|
|
75
|
+
# @param body [String, IO] raw binary data
|
|
76
|
+
# @param content_type [String] MIME content type
|
|
77
|
+
# @return [Response]
|
|
78
|
+
def post_raw(path, body:, content_type:)
|
|
79
|
+
url = build_url(path)
|
|
80
|
+
single_request_raw(:post, url, body: body, content_type: content_type, attempt: 1)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fetches all pages of a paginated resource.
|
|
84
|
+
# @param path [String] initial URL path
|
|
85
|
+
# @param params [Hash] query parameters
|
|
86
|
+
# @yield [Hash] each item from the response
|
|
87
|
+
# @return [Enumerator] if no block given
|
|
88
|
+
def paginate(path, params: {}, &block)
|
|
89
|
+
return to_enum(:paginate, path, params: params) unless block
|
|
90
|
+
|
|
91
|
+
base_url = build_url(path)
|
|
92
|
+
url = base_url
|
|
93
|
+
page = 0
|
|
94
|
+
|
|
95
|
+
loop do
|
|
96
|
+
page += 1
|
|
97
|
+
break if page > @config.max_pages
|
|
98
|
+
|
|
99
|
+
@hooks.on_paginate(url, page)
|
|
100
|
+
response = get(url, params: page == 1 ? params : {})
|
|
101
|
+
|
|
102
|
+
Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
items = JSON.parse(response.body)
|
|
106
|
+
rescue JSON::ParserError => e
|
|
107
|
+
raise Basecamp::APIError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
|
|
108
|
+
end
|
|
109
|
+
items.each(&block)
|
|
110
|
+
|
|
111
|
+
next_url = parse_next_link(response.headers["Link"])
|
|
112
|
+
break if next_url.nil?
|
|
113
|
+
|
|
114
|
+
next_url = Security.resolve_url(url, next_url)
|
|
115
|
+
|
|
116
|
+
unless Security.same_origin?(next_url, base_url)
|
|
117
|
+
raise Basecamp::APIError.new(
|
|
118
|
+
"Pagination Link header points to different origin: #{Security.truncate(next_url)}"
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
url = next_url
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Fetches all pages of a paginated resource, extracting items from a key.
|
|
127
|
+
# Use this for endpoints that return objects like { "events": [...] }.
|
|
128
|
+
# @param path [String] initial URL path
|
|
129
|
+
# @param key [String] the key containing the array of items
|
|
130
|
+
# @param params [Hash] query parameters
|
|
131
|
+
# @yield [Hash] each item from the response
|
|
132
|
+
# @return [Enumerator] if no block given
|
|
133
|
+
def paginate_key(path, key:, params: {}, &block)
|
|
134
|
+
return to_enum(:paginate_key, path, key: key, params: params) unless block
|
|
135
|
+
|
|
136
|
+
base_url = build_url(path)
|
|
137
|
+
url = base_url
|
|
138
|
+
page = 0
|
|
139
|
+
|
|
140
|
+
loop do
|
|
141
|
+
page += 1
|
|
142
|
+
break if page > @config.max_pages
|
|
143
|
+
|
|
144
|
+
@hooks.on_paginate(url, page)
|
|
145
|
+
response = get(url, params: page == 1 ? params : {})
|
|
146
|
+
|
|
147
|
+
Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
|
|
148
|
+
|
|
149
|
+
begin
|
|
150
|
+
data = JSON.parse(response.body)
|
|
151
|
+
rescue JSON::ParserError => e
|
|
152
|
+
raise Basecamp::APIError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
|
|
153
|
+
end
|
|
154
|
+
unless data.key?(key)
|
|
155
|
+
warn "[Basecamp SDK] paginate_key: expected key '#{key}' not found in response (page #{page})"
|
|
156
|
+
end
|
|
157
|
+
items = data[key] || []
|
|
158
|
+
items.each(&block)
|
|
159
|
+
|
|
160
|
+
next_url = parse_next_link(response.headers["Link"])
|
|
161
|
+
break if next_url.nil?
|
|
162
|
+
|
|
163
|
+
next_url = Security.resolve_url(url, next_url)
|
|
164
|
+
|
|
165
|
+
unless Security.same_origin?(next_url, base_url)
|
|
166
|
+
raise Basecamp::APIError.new(
|
|
167
|
+
"Pagination Link header points to different origin: #{Security.truncate(next_url)}"
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
url = next_url
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def build_faraday_client
|
|
178
|
+
Faraday.new(url: @config.base_url) do |f|
|
|
179
|
+
f.options.timeout = @config.timeout
|
|
180
|
+
f.options.open_timeout = 10
|
|
181
|
+
f.request :json
|
|
182
|
+
f.response :raise_error
|
|
183
|
+
f.adapter Faraday.default_adapter
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def request(method, path, params: {}, body: nil)
|
|
188
|
+
url = build_url(path)
|
|
189
|
+
|
|
190
|
+
# Mutations don't retry on 429/5xx to avoid duplicating data
|
|
191
|
+
if method == :get
|
|
192
|
+
request_with_retry(method, url, params: params)
|
|
193
|
+
else
|
|
194
|
+
single_request(method, url, params: params, body: body, attempt: 1)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def request_with_retry(method, url, params: {})
|
|
199
|
+
attempt = 0
|
|
200
|
+
last_error = nil
|
|
201
|
+
|
|
202
|
+
loop do
|
|
203
|
+
attempt += 1
|
|
204
|
+
break if attempt > @config.max_retries
|
|
205
|
+
|
|
206
|
+
begin
|
|
207
|
+
return single_request(method, url, params: params, body: nil, attempt: attempt)
|
|
208
|
+
rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::APIError => e
|
|
209
|
+
raise e unless e.retryable?
|
|
210
|
+
|
|
211
|
+
last_error = e
|
|
212
|
+
|
|
213
|
+
# Don't sleep if this was the last attempt
|
|
214
|
+
break if attempt >= @config.max_retries
|
|
215
|
+
|
|
216
|
+
delay = calculate_delay(attempt, e.retry_after)
|
|
217
|
+
|
|
218
|
+
@hooks.on_retry(RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt), attempt + 1, e,
|
|
219
|
+
delay)
|
|
220
|
+
sleep(delay)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
raise last_error || Basecamp::APIError.new("Request failed after #{@config.max_retries} retries")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def single_request(method, url, params:, body:, attempt:, retry_count: 0)
|
|
228
|
+
info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
|
|
229
|
+
@hooks.on_request_start(info)
|
|
230
|
+
|
|
231
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
232
|
+
|
|
233
|
+
begin
|
|
234
|
+
response = @faraday.run_request(method, url, body, request_headers) do |req|
|
|
235
|
+
req.params.merge!(params) if params.any?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
239
|
+
result = RequestResult.new(status_code: response.status, duration: duration)
|
|
240
|
+
@hooks.on_request_end(info, result)
|
|
241
|
+
|
|
242
|
+
Response.new(
|
|
243
|
+
body: response.body,
|
|
244
|
+
status: response.status,
|
|
245
|
+
headers: response.headers
|
|
246
|
+
)
|
|
247
|
+
rescue Faraday::ClientError => e
|
|
248
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
249
|
+
error = handle_error(e)
|
|
250
|
+
result = RequestResult.new(
|
|
251
|
+
status_code: e.response&.dig(:status),
|
|
252
|
+
duration: duration,
|
|
253
|
+
error: error,
|
|
254
|
+
retry_after: error.respond_to?(:retry_after) ? error.retry_after : nil
|
|
255
|
+
)
|
|
256
|
+
@hooks.on_request_end(info, result)
|
|
257
|
+
|
|
258
|
+
# After a successful token refresh on 401, retry the request once
|
|
259
|
+
if error.is_a?(Basecamp::AuthError) && error.http_status == 401 && retry_count < 1 && @token_refreshed
|
|
260
|
+
@token_refreshed = false
|
|
261
|
+
return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
raise error
|
|
265
|
+
rescue Faraday::Error => e
|
|
266
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
267
|
+
error = Basecamp::NetworkError.new("Connection failed", cause: e)
|
|
268
|
+
result = RequestResult.new(duration: duration, error: error)
|
|
269
|
+
@hooks.on_request_end(info, result)
|
|
270
|
+
raise error
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def request_headers
|
|
275
|
+
headers = {
|
|
276
|
+
"User-Agent" => USER_AGENT,
|
|
277
|
+
"Accept" => "application/json"
|
|
278
|
+
}
|
|
279
|
+
@auth_strategy.authenticate(headers)
|
|
280
|
+
headers
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def single_request_raw(method, url, body:, content_type:, attempt:)
|
|
284
|
+
info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
|
|
285
|
+
@hooks.on_request_start(info)
|
|
286
|
+
|
|
287
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
288
|
+
|
|
289
|
+
begin
|
|
290
|
+
headers = request_headers.merge("Content-Type" => content_type)
|
|
291
|
+
response = @faraday.run_request(method, url, body, headers)
|
|
292
|
+
|
|
293
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
294
|
+
result = RequestResult.new(status_code: response.status, duration: duration)
|
|
295
|
+
@hooks.on_request_end(info, result)
|
|
296
|
+
|
|
297
|
+
Response.new(
|
|
298
|
+
body: response.body,
|
|
299
|
+
status: response.status,
|
|
300
|
+
headers: response.headers
|
|
301
|
+
)
|
|
302
|
+
rescue Faraday::ClientError => e
|
|
303
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
304
|
+
error = handle_error(e)
|
|
305
|
+
result = RequestResult.new(
|
|
306
|
+
status_code: e.response&.dig(:status),
|
|
307
|
+
duration: duration,
|
|
308
|
+
error: error,
|
|
309
|
+
retry_after: error.respond_to?(:retry_after) ? error.retry_after : nil
|
|
310
|
+
)
|
|
311
|
+
@hooks.on_request_end(info, result)
|
|
312
|
+
raise error
|
|
313
|
+
rescue Faraday::Error => e
|
|
314
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
315
|
+
error = Basecamp::NetworkError.new("Connection failed", cause: e)
|
|
316
|
+
result = RequestResult.new(duration: duration, error: error)
|
|
317
|
+
@hooks.on_request_end(info, result)
|
|
318
|
+
raise error
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def handle_error(error)
|
|
323
|
+
status = error.response&.dig(:status)
|
|
324
|
+
body = error.response&.dig(:body)
|
|
325
|
+
headers = error.response&.dig(:headers) || {}
|
|
326
|
+
|
|
327
|
+
retry_after = parse_retry_after(headers["Retry-After"] || headers["retry-after"])
|
|
328
|
+
|
|
329
|
+
case status
|
|
330
|
+
when 401
|
|
331
|
+
# Try token refresh; flag for caller to retry
|
|
332
|
+
@token_refreshed = @token_provider&.refreshable? && @token_provider.refresh
|
|
333
|
+
|
|
334
|
+
Basecamp::AuthError.new("Authentication failed")
|
|
335
|
+
when 403
|
|
336
|
+
Basecamp::ForbiddenError.new("Access denied")
|
|
337
|
+
when 404
|
|
338
|
+
Basecamp::NotFoundError.new("Resource", "unknown")
|
|
339
|
+
when 429
|
|
340
|
+
Basecamp::RateLimitError.new(retry_after: retry_after)
|
|
341
|
+
when 400, 422
|
|
342
|
+
message = Security.truncate(Basecamp.parse_error_message(body) || "Validation failed")
|
|
343
|
+
Basecamp::ValidationError.new(message, http_status: status)
|
|
344
|
+
when 500
|
|
345
|
+
Basecamp::APIError.new("Server error (500)", http_status: 500)
|
|
346
|
+
when 502, 503, 504
|
|
347
|
+
Basecamp::APIError.new("Gateway error (#{status})", http_status: status, retryable: true)
|
|
348
|
+
else
|
|
349
|
+
message = Security.truncate(Basecamp.parse_error_message(body) || "Request failed (HTTP #{status})")
|
|
350
|
+
Basecamp::APIError.from_status(status || 0, message)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def build_url(path)
|
|
355
|
+
if path.start_with?("https://")
|
|
356
|
+
return path
|
|
357
|
+
elsif path.start_with?("http://")
|
|
358
|
+
raise Basecamp::UsageError.new("URL must use HTTPS: #{path}")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
362
|
+
"#{@config.base_url}#{path}"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def calculate_delay(attempt, server_retry_after)
|
|
366
|
+
return server_retry_after if server_retry_after&.positive?
|
|
367
|
+
|
|
368
|
+
# Exponential backoff: base_delay * 2^(attempt-1) + jitter
|
|
369
|
+
base = @config.base_delay * (2**(attempt - 1))
|
|
370
|
+
jitter = rand * @config.max_jitter
|
|
371
|
+
base + jitter
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def parse_retry_after(value)
|
|
375
|
+
return nil if value.nil? || value.empty?
|
|
376
|
+
|
|
377
|
+
# Try parsing as seconds (integer)
|
|
378
|
+
seconds = Integer(value, exception: false)
|
|
379
|
+
return seconds if seconds&.positive?
|
|
380
|
+
|
|
381
|
+
# Try parsing as HTTP-date
|
|
382
|
+
begin
|
|
383
|
+
date = Time.httpdate(value)
|
|
384
|
+
diff = (date - Time.now).to_i
|
|
385
|
+
return diff if diff.positive?
|
|
386
|
+
rescue ArgumentError
|
|
387
|
+
# Not a valid HTTP-date
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
nil
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def parse_next_link(link_header)
|
|
394
|
+
return nil if link_header.nil? || link_header.empty?
|
|
395
|
+
|
|
396
|
+
link_header.split(",").each do |part|
|
|
397
|
+
part = part.strip
|
|
398
|
+
next unless part.include?('rel="next"')
|
|
399
|
+
|
|
400
|
+
match = part.match(/<([^>]+)>/)
|
|
401
|
+
return match[1] if match
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
nil
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Wraps an HTTP response.
|
|
409
|
+
class Response
|
|
410
|
+
# @return [String] response body
|
|
411
|
+
attr_reader :body
|
|
412
|
+
|
|
413
|
+
# @return [Integer] HTTP status code
|
|
414
|
+
attr_reader :status
|
|
415
|
+
|
|
416
|
+
# @return [Hash] response headers
|
|
417
|
+
attr_reader :headers
|
|
418
|
+
|
|
419
|
+
def initialize(body:, status:, headers:)
|
|
420
|
+
@body = body
|
|
421
|
+
@status = status
|
|
422
|
+
@headers = headers
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Parses the response body as JSON.
|
|
426
|
+
# @return [Hash, Array]
|
|
427
|
+
def json
|
|
428
|
+
@json ||= begin
|
|
429
|
+
Security.check_body_size!(@body, Security::MAX_RESPONSE_BODY_BYTES)
|
|
430
|
+
JSON.parse(@body)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Returns whether the response was successful (2xx).
|
|
435
|
+
# @return [Boolean]
|
|
436
|
+
def success?
|
|
437
|
+
status >= 200 && status < 300
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Hooks implementation that logs to Ruby's Logger.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# require "logger"
|
|
8
|
+
# logger = Logger.new($stdout)
|
|
9
|
+
# hooks = Basecamp::LoggerHooks.new(logger)
|
|
10
|
+
# client = Basecamp::Client.new(config: config, token_provider: provider, hooks: hooks)
|
|
11
|
+
class LoggerHooks
|
|
12
|
+
include Hooks
|
|
13
|
+
|
|
14
|
+
# @param logger [Logger] Ruby logger instance
|
|
15
|
+
# @param level [Symbol] log level (:debug, :info, :warn, :error)
|
|
16
|
+
def initialize(logger, level: :debug)
|
|
17
|
+
@logger = logger
|
|
18
|
+
@level = level
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def on_request_start(info)
|
|
22
|
+
@logger.send(@level, "HTTP #{info.method} #{info.url} (attempt #{info.attempt})")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on_request_end(info, result)
|
|
26
|
+
if result.error
|
|
27
|
+
@logger.send(@level, "HTTP #{info.method} #{info.url} failed: #{result.error.message}")
|
|
28
|
+
else
|
|
29
|
+
cache_info = result.from_cache ? " (cached)" : ""
|
|
30
|
+
@logger.send(@level,
|
|
31
|
+
"HTTP #{info.method} #{info.url} -> #{result.status_code}#{cache_info} (#{format("%.3f",
|
|
32
|
+
result.duration)}s)")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def on_retry(info, attempt, error, delay)
|
|
37
|
+
@logger.send(@level,
|
|
38
|
+
"Retrying #{info.method} #{info.url} (attempt #{attempt}) in #{format("%.2f",
|
|
39
|
+
delay)}s: #{error.message}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def on_paginate(url, page)
|
|
43
|
+
@logger.send(@level, "Fetching page #{page}: #{url}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Basecamp
|
|
7
|
+
# OAuth 2 helpers for Basecamp authentication.
|
|
8
|
+
module Oauth
|
|
9
|
+
# Default Basecamp/Launchpad OAuth server URL.
|
|
10
|
+
LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com"
|
|
11
|
+
|
|
12
|
+
# Discoverer fetches OAuth 2 server configuration from discovery endpoints.
|
|
13
|
+
class Discoverer
|
|
14
|
+
# @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil)
|
|
15
|
+
# @param timeout [Integer] Request timeout in seconds (default: 10)
|
|
16
|
+
def initialize(http_client: nil, timeout: 10)
|
|
17
|
+
@http_client = http_client || build_default_client(timeout)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Discovers OAuth configuration from the well-known endpoint.
|
|
21
|
+
#
|
|
22
|
+
# Fetches the OAuth 2 Authorization Server Metadata from:
|
|
23
|
+
# `{base_url}/.well-known/oauth-authorization-server`
|
|
24
|
+
#
|
|
25
|
+
# @param base_url [String] The OAuth server's base URL (e.g., "https://launchpad.37signals.com")
|
|
26
|
+
# @return [Config] The OAuth server configuration
|
|
27
|
+
# @raise [OAuthError] on network or parsing errors
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# discoverer = Basecamp::Oauth::Discoverer.new
|
|
31
|
+
# config = discoverer.discover("https://launchpad.37signals.com")
|
|
32
|
+
# puts config.token_endpoint
|
|
33
|
+
# # => "https://launchpad.37signals.com/authorization/token"
|
|
34
|
+
def discover(base_url)
|
|
35
|
+
Basecamp::Security.require_https_unless_localhost!(base_url, "discovery base URL")
|
|
36
|
+
|
|
37
|
+
normalized_base = base_url.chomp("/")
|
|
38
|
+
discovery_url = "#{normalized_base}/.well-known/oauth-authorization-server"
|
|
39
|
+
|
|
40
|
+
response = @http_client.get(discovery_url) do |req|
|
|
41
|
+
req.headers["Accept"] = "application/json"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
unless response.success?
|
|
45
|
+
raise OAuthError.new(
|
|
46
|
+
"network",
|
|
47
|
+
"OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(response.body)}",
|
|
48
|
+
http_status: response.status
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Basecamp::Security.check_body_size!(response.body, Basecamp::Security::MAX_ERROR_BODY_BYTES, "Discovery")
|
|
53
|
+
|
|
54
|
+
data = JSON.parse(response.body)
|
|
55
|
+
validate_discovery_response!(data)
|
|
56
|
+
|
|
57
|
+
Config.new(
|
|
58
|
+
issuer: data["issuer"],
|
|
59
|
+
authorization_endpoint: data["authorization_endpoint"],
|
|
60
|
+
token_endpoint: data["token_endpoint"],
|
|
61
|
+
registration_endpoint: data["registration_endpoint"],
|
|
62
|
+
scopes_supported: data["scopes_supported"]
|
|
63
|
+
)
|
|
64
|
+
rescue Faraday::Error => e
|
|
65
|
+
raise OAuthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true)
|
|
66
|
+
rescue JSON::ParserError => e
|
|
67
|
+
raise OAuthError.new("api_error", "Failed to parse discovery response: #{e.message}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def build_default_client(timeout)
|
|
73
|
+
Faraday.new do |conn|
|
|
74
|
+
conn.options.timeout = timeout
|
|
75
|
+
conn.options.open_timeout = timeout
|
|
76
|
+
conn.adapter Faraday.default_adapter
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_discovery_response!(data)
|
|
81
|
+
missing = []
|
|
82
|
+
missing << "issuer" unless data["issuer"]
|
|
83
|
+
missing << "authorization_endpoint" unless data["authorization_endpoint"]
|
|
84
|
+
missing << "token_endpoint" unless data["token_endpoint"]
|
|
85
|
+
|
|
86
|
+
return if missing.empty?
|
|
87
|
+
|
|
88
|
+
raise OAuthError.new(
|
|
89
|
+
"api_error",
|
|
90
|
+
"Invalid OAuth discovery response: missing required fields: #{missing.join(", ")}"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
module_function
|
|
96
|
+
|
|
97
|
+
# Discovers OAuth configuration from the well-known endpoint.
|
|
98
|
+
#
|
|
99
|
+
# @param base_url [String] The OAuth server's base URL
|
|
100
|
+
# @param timeout [Integer] Request timeout in seconds (default: 10)
|
|
101
|
+
# @return [Config] The OAuth server configuration
|
|
102
|
+
#
|
|
103
|
+
# @example
|
|
104
|
+
# config = Basecamp::Oauth.discover("https://launchpad.37signals.com")
|
|
105
|
+
def discover(base_url, timeout: 10)
|
|
106
|
+
Discoverer.new(timeout: timeout).discover(base_url)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Discovers OAuth configuration from Basecamp's Launchpad server.
|
|
110
|
+
#
|
|
111
|
+
# Convenience function that calls discover() with the Launchpad base URL.
|
|
112
|
+
#
|
|
113
|
+
# @param timeout [Integer] Request timeout in seconds (default: 10)
|
|
114
|
+
# @return [Config] The OAuth server configuration
|
|
115
|
+
#
|
|
116
|
+
# @example
|
|
117
|
+
# config = Basecamp::Oauth.discover_launchpad
|
|
118
|
+
# # Use config.authorization_endpoint to start OAuth flow
|
|
119
|
+
def discover_launchpad(timeout: 10)
|
|
120
|
+
discover(LAUNCHPAD_BASE_URL, timeout: timeout)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|