basecamp-sdk 0.14.0 → 0.16.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 +4 -4
- data/README.md +1 -1
- data/lib/basecamp/api_error.rb +3 -2
- data/lib/basecamp/client.rb +54 -4
- data/lib/basecamp/config.rb +25 -3
- data/lib/basecamp/generated/metadata.json +115 -1
- data/lib/basecamp/generated/services/bubble_ups_service.rb +36 -0
- data/lib/basecamp/generated/services/projects_service.rb +18 -0
- data/lib/basecamp/generated/services/recordings_service.rb +19 -0
- data/lib/basecamp/generated/services/templates_service.rb +28 -0
- data/lib/basecamp/generated/types.rb +240 -28
- data/lib/basecamp/http.rb +94 -25
- data/lib/basecamp/oauth/exchange.rb +152 -25
- data/lib/basecamp/oauth/fetcher.rb +1 -1
- data/lib/basecamp/oauth_token_provider.rb +50 -13
- data/lib/basecamp/people_confirmation_required_error.rb +14 -0
- data/lib/basecamp/rate_limit_error.rb +4 -2
- data/lib/basecamp/security.rb +28 -2
- data/lib/basecamp/services/authorization_service.rb +5 -3
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +63 -11
- data/scripts/generate-services.rb +7 -1
- metadata +4 -2
|
@@ -2,16 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "json"
|
|
5
|
+
require "timeout"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
7
8
|
module Basecamp
|
|
8
9
|
module Oauth
|
|
9
10
|
# Handles OAuth 2 token exchange and refresh operations.
|
|
11
|
+
#
|
|
12
|
+
# Both operations POST credentials — the authorization code and client
|
|
13
|
+
# secret, or the refresh token — to a token endpoint the caller names,
|
|
14
|
+
# which may be one that discovery's metadata chose. The POST therefore
|
|
15
|
+
# rides the same hardened transport discipline as the device flow
|
|
16
|
+
# (SPEC §16 "Token-Endpoint Transport Policy"): redirects are refused
|
|
17
|
+
# rather than followed, the whole request is wall-clock bounded, and the
|
|
18
|
+
# body reads under the shared streaming cap.
|
|
10
19
|
class Exchange
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
# The redirect statuses a token endpoint response is refused for
|
|
21
|
+
# (SPEC §16 "Token-Endpoint Transport Policy") — the same set the signed
|
|
22
|
+
# download hop refuses (SPEC §14). 304 is deliberately absent: it is a
|
|
23
|
+
# cache validator, not a redirect-with-Location, and stays on the
|
|
24
|
+
# generic non-success path.
|
|
25
|
+
REDIRECT_STATUSES = [ 301, 302, 303, 307, 308 ].freeze
|
|
26
|
+
|
|
27
|
+
# Default per-request timeout in seconds — the shared credential-POST
|
|
28
|
+
# default every SDK's token and device POSTs converge on (SPEC §16).
|
|
29
|
+
DEFAULT_TIMEOUT = 30
|
|
30
|
+
|
|
31
|
+
# Cap on a token response body (1 MiB), matching the device flow's and
|
|
32
|
+
# the other SDKs' token-response bound.
|
|
33
|
+
MAX_BODY_BYTES = 1 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
# @param http_client [Faraday::Connection, nil] HTTP client. Nil selects
|
|
36
|
+
# the headers-first default transport ({Fetcher.stream_http}); an
|
|
37
|
+
# injected connection is refused unless its stack is verifiably
|
|
38
|
+
# redirect-free (adapter-only), and keeps the injected-client fidelity
|
|
39
|
+
# tier: status classification only after the (bounded) read completes,
|
|
40
|
+
# deadline enforced wall-clock around the call.
|
|
41
|
+
# @param timeout [Numeric] Request timeout in seconds (default: 30).
|
|
42
|
+
# Invalid values and values beyond the shared 3600 s ceiling fall back
|
|
43
|
+
# to the default rather than disabling the bound.
|
|
44
|
+
def initialize(http_client: nil, timeout: DEFAULT_TIMEOUT)
|
|
45
|
+
# An injected connection is the caller's transport, but redirect
|
|
46
|
+
# suppression is not negotiable on a credential POST: refuse a stack
|
|
47
|
+
# that could follow (or rewrite) before any request is issued — the
|
|
48
|
+
# same guard discovery, resource, and the device flow apply.
|
|
49
|
+
Fetcher.ensure_redirects_suppressed!(http_client) if http_client
|
|
50
|
+
@http_client = http_client
|
|
51
|
+
@timeout = Fetcher.normalize_timeout(timeout, default: DEFAULT_TIMEOUT)
|
|
15
52
|
end
|
|
16
53
|
|
|
17
54
|
# Exchanges an authorization code for access and refresh tokens.
|
|
@@ -80,14 +117,6 @@ module Basecamp
|
|
|
80
117
|
|
|
81
118
|
private
|
|
82
119
|
|
|
83
|
-
def build_default_client(timeout)
|
|
84
|
-
Faraday.new do |conn|
|
|
85
|
-
conn.options.timeout = timeout
|
|
86
|
-
conn.options.open_timeout = timeout
|
|
87
|
-
conn.adapter Faraday.default_adapter
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
120
|
def validate_exchange_request!(request)
|
|
92
121
|
raise OauthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
|
|
93
122
|
raise OauthError.new("validation", "Authorization code is required") if request.code.to_s.empty?
|
|
@@ -145,30 +174,128 @@ module Basecamp
|
|
|
145
174
|
def do_token_request(token_endpoint, params)
|
|
146
175
|
Basecamp::Security.require_https_unless_localhost!(token_endpoint, "token endpoint")
|
|
147
176
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
177
|
+
status, body = post_form(
|
|
178
|
+
token_endpoint, params,
|
|
179
|
+
skip_status: ->(s) { REDIRECT_STATUSES.include?(s) }
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# A refused redirect is a typed verdict classified by status alone —
|
|
183
|
+
# its body (skipped above) is never a token, and the credential POST
|
|
184
|
+
# is never re-issued toward Location (SPEC §16).
|
|
185
|
+
if REDIRECT_STATUSES.include?(status)
|
|
186
|
+
raise OauthError.new(
|
|
187
|
+
"api_error",
|
|
188
|
+
"redirect #{status} on the token endpoint is not followed",
|
|
189
|
+
http_status: status
|
|
190
|
+
)
|
|
152
191
|
end
|
|
153
192
|
|
|
154
|
-
parse_token_response(
|
|
193
|
+
parse_token_response(status, body)
|
|
155
194
|
rescue Faraday::TimeoutError
|
|
156
195
|
raise OauthError.new("network", "Token request timed out", retryable: true)
|
|
157
196
|
rescue Faraday::Error => e
|
|
158
197
|
raise OauthError.new("network", "Token request failed: #{e.message}", retryable: true)
|
|
159
198
|
end
|
|
160
199
|
|
|
161
|
-
|
|
162
|
-
|
|
200
|
+
# POSTs the token form and returns +[status, body]+, reading under the
|
|
201
|
+
# same bounded/streaming cap as discovery and the device flow.
|
|
202
|
+
#
|
|
203
|
+
# With no injected client the POST runs on the headers-first
|
|
204
|
+
# {Fetcher.stream_http} primitive: +skip_status+ classifies a redirect
|
|
205
|
+
# by status at HEADER time (its body is never read, even one that
|
|
206
|
+
# stalls forever), redirects are structurally never followed, and a
|
|
207
|
+
# watchdog bounds the whole request — a stalled or byte-dripped header
|
|
208
|
+
# phase included — at the timeout. An INJECTED Faraday connection keeps
|
|
209
|
+
# the Faraday path below.
|
|
210
|
+
def post_form(url, params, skip_status:)
|
|
211
|
+
if @http_client.nil?
|
|
212
|
+
Fetcher.stream_http(
|
|
213
|
+
:post, url,
|
|
214
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "application/json" },
|
|
215
|
+
form: params, timeout: @timeout, max_body_bytes: MAX_BODY_BYTES, skip_status: skip_status
|
|
216
|
+
)
|
|
217
|
+
else
|
|
218
|
+
post_form_injected(url, params, skip_status)
|
|
219
|
+
end
|
|
220
|
+
rescue Fetcher::SkipBody => e
|
|
221
|
+
# The body was intentionally not drained (a redirect's body is never a
|
|
222
|
+
# token) — classify by status upstream.
|
|
223
|
+
[ e.status, "" ]
|
|
224
|
+
rescue Fetcher::BodyTooLarge
|
|
225
|
+
raise OauthError.new("api_error", "Token response exceeds size cap")
|
|
226
|
+
rescue Fetcher::ReadDeadlineExceeded
|
|
227
|
+
# A slow-drip read is a transport timeout, not an api_error — surface
|
|
228
|
+
# as the Faraday timeout the caller's rescue classifies.
|
|
229
|
+
raise Faraday::TimeoutError, "Token request read exceeded the timeout deadline"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Injected-client (Faraday) lane — the injected-client fidelity tier
|
|
233
|
+
# (SPEC §16): the same invariants as the default transport (suppressed
|
|
234
|
+
# redirects, bounded body, whole-request wall clock), with buffered
|
|
235
|
+
# classification. +req.options.timeout+ below bounds only each socket
|
|
236
|
+
# read and resets on every +on_data+ chunk, so a slow-drip peer could
|
|
237
|
+
# otherwise hold the credential POST open past the timeout while
|
|
238
|
+
# staying under the cap — the monotonic deadline bounds the WHOLE
|
|
239
|
+
# request, and +Timeout.timeout+ enforces it through a stalled or
|
|
240
|
+
# dripped HEADER phase, where +on_data+ (a body callback) never runs.
|
|
241
|
+
def post_form_injected(url, params, skip_status)
|
|
242
|
+
deadline = Fetcher.monotonic_now + @timeout
|
|
243
|
+
chunks, on_data = Fetcher.bounded_reader(MAX_BODY_BYTES, deadline: deadline, skip_status: skip_status)
|
|
244
|
+
# The window is the REMAINING budget, not a fresh timeout: time spent
|
|
245
|
+
# before dispatch already counts against the deadline, so the request
|
|
246
|
+
# can never run past it.
|
|
247
|
+
remaining = deadline - Fetcher.monotonic_now
|
|
248
|
+
raise Faraday::TimeoutError, "request budget exhausted before dispatch" if remaining <= 0
|
|
249
|
+
|
|
250
|
+
response = Timeout.timeout(remaining, Faraday::TimeoutError) do
|
|
251
|
+
@http_client.post(url) do |req|
|
|
252
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
253
|
+
req.headers["Accept"] = "application/json"
|
|
254
|
+
req.body = URI.encode_www_form(params)
|
|
255
|
+
req.options.timeout = @timeout
|
|
256
|
+
req.options.open_timeout = @timeout
|
|
257
|
+
req.options.on_data = on_data
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Status-first backstop on the completed response: the +on_data+
|
|
262
|
+
# SkipBody fast-path only fires when the adapter streams AND passes
|
|
263
|
+
# +env+ (Faraday >= 2.5). A buffered adapter that ignores +on_data+,
|
|
264
|
+
# an older Faraday (2.0–2.4) that omits +env+, or a header-only
|
|
265
|
+
# response reaches here with the redirect body un-skipped — re-apply
|
|
266
|
+
# +skip_status+ to the final status so a redirect is classified by
|
|
267
|
+
# status for every client shape, never buffered into a size-cap error.
|
|
268
|
+
# A definitive completed status outranks the deadline re-check below;
|
|
269
|
+
# everything else completing past the deadline is refused as the same
|
|
270
|
+
# transport-shaped timeout (Timeout.timeout's interrupt can land late).
|
|
271
|
+
if skip_status.call(response.status)
|
|
272
|
+
[ response.status, "" ]
|
|
273
|
+
elsif Fetcher.monotonic_now > deadline
|
|
274
|
+
raise Faraday::TimeoutError, "response completed after the deadline"
|
|
275
|
+
else
|
|
276
|
+
body =
|
|
277
|
+
if chunks.empty?
|
|
278
|
+
raw = response.body.to_s
|
|
279
|
+
raise Fetcher::BodyTooLarge if raw.bytesize > MAX_BODY_BYTES
|
|
280
|
+
|
|
281
|
+
raw
|
|
282
|
+
else
|
|
283
|
+
chunks.join
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
[ response.status, body.dup.force_encoding(Encoding::UTF_8) ]
|
|
287
|
+
end
|
|
288
|
+
end
|
|
163
289
|
|
|
164
|
-
|
|
290
|
+
def parse_token_response(status, body)
|
|
291
|
+
data = JSON.parse(body)
|
|
165
292
|
|
|
166
|
-
handle_error_response(
|
|
293
|
+
handle_error_response(status, data) unless (200..299).cover?(status)
|
|
167
294
|
|
|
168
295
|
unless data["access_token"].is_a?(String) && !data["access_token"].empty?
|
|
169
296
|
raise OauthError.new(
|
|
170
297
|
"api_error", "Token response missing or non-string access_token",
|
|
171
|
-
http_status:
|
|
298
|
+
http_status: status
|
|
172
299
|
)
|
|
173
300
|
end
|
|
174
301
|
|
|
@@ -179,7 +306,7 @@ module Basecamp
|
|
|
179
306
|
raise OauthError.new(
|
|
180
307
|
"api_error",
|
|
181
308
|
"Token response resource must be a non-empty string when present",
|
|
182
|
-
http_status:
|
|
309
|
+
http_status: status
|
|
183
310
|
)
|
|
184
311
|
end
|
|
185
312
|
|
|
@@ -191,7 +318,7 @@ module Basecamp
|
|
|
191
318
|
raise OauthError.new(
|
|
192
319
|
"api_error",
|
|
193
320
|
"Token response token_type must be a non-empty string when present",
|
|
194
|
-
http_status:
|
|
321
|
+
http_status: status
|
|
195
322
|
)
|
|
196
323
|
end
|
|
197
324
|
|
|
@@ -213,7 +340,7 @@ module Basecamp
|
|
|
213
340
|
raise OauthError.new(
|
|
214
341
|
"api_error",
|
|
215
342
|
"Failed to parse token response",
|
|
216
|
-
http_status:
|
|
343
|
+
http_status: status
|
|
217
344
|
), cause: nil
|
|
218
345
|
end
|
|
219
346
|
|
|
@@ -239,7 +239,7 @@ module Basecamp
|
|
|
239
239
|
|
|
240
240
|
raise OauthError.new(
|
|
241
241
|
"validation",
|
|
242
|
-
"Injected OAuth
|
|
242
|
+
"Injected OAuth client must carry only an adapter (no middleware); " \
|
|
243
243
|
"found #{offending.klass.name}. Redirects are suppressed for SSRF safety, so a " \
|
|
244
244
|
"connection whose middleware stack cannot be verified redirect-free is refused"
|
|
245
245
|
)
|
|
@@ -16,6 +16,22 @@ module Basecamp
|
|
|
16
16
|
# Token endpoint for Basecamp OAuth
|
|
17
17
|
TOKEN_URL = "https://launchpad.37signals.com/authorization/token"
|
|
18
18
|
|
|
19
|
+
# The redirect statuses a token endpoint response is refused for
|
|
20
|
+
# (SPEC §16 "Token-Endpoint Transport Policy") — the refresh POST carries
|
|
21
|
+
# the refresh token and client secret, and a redirect must surface as a
|
|
22
|
+
# typed fault rather than re-issue those credentials toward Location.
|
|
23
|
+
# 304 stays on the generic non-success path (a cache validator, not a
|
|
24
|
+
# redirect-with-Location).
|
|
25
|
+
REDIRECT_STATUSES = [ 301, 302, 303, 307, 308 ].freeze
|
|
26
|
+
|
|
27
|
+
# Whole-request bound in seconds for the refresh POST — the shared
|
|
28
|
+
# credential-POST default (SPEC §16). Enforced as socket timeouts AND a
|
|
29
|
+
# monotonic wall-clock deadline by the transport below.
|
|
30
|
+
REFRESH_TIMEOUT = 30
|
|
31
|
+
|
|
32
|
+
# Cap on a refresh response body (1 MiB), matching the exchange path.
|
|
33
|
+
MAX_RESPONSE_BYTES = 1 * 1024 * 1024
|
|
34
|
+
|
|
19
35
|
# @return [String, nil] the current refresh token
|
|
20
36
|
attr_reader :refresh_token
|
|
21
37
|
|
|
@@ -77,30 +93,51 @@ module Basecamp
|
|
|
77
93
|
perform_refresh if expired? && refreshable?
|
|
78
94
|
end
|
|
79
95
|
|
|
96
|
+
# The refresh POST runs on the headers-first {Oauth::Fetcher.stream_http}
|
|
97
|
+
# primitive — the same transport as the exchange and device paths — so it
|
|
98
|
+
# gets the full SPEC §16 discipline rather than a bare Faraday.post:
|
|
99
|
+
# redirects structurally never followed and classified at header time,
|
|
100
|
+
# socket timeouts plus a monotonic whole-request watchdog (a slow-drip
|
|
101
|
+
# peer cannot hold the refresh open past REFRESH_TIMEOUT), and a bounded
|
|
102
|
+
# streaming body read.
|
|
80
103
|
def perform_refresh
|
|
81
104
|
require "faraday"
|
|
82
105
|
require "json"
|
|
83
|
-
require "uri"
|
|
84
|
-
|
|
85
|
-
response = Faraday.post(TOKEN_URL) do |req|
|
|
86
|
-
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
87
|
-
req.body = URI.encode_www_form(
|
|
88
|
-
type: "refresh",
|
|
89
|
-
refresh_token: @refresh_token,
|
|
90
|
-
client_id: @client_id,
|
|
91
|
-
client_secret: @client_secret
|
|
92
|
-
)
|
|
93
|
-
end
|
|
94
106
|
|
|
95
|
-
|
|
107
|
+
status, body = Oauth::Fetcher.stream_http(
|
|
108
|
+
:post, TOKEN_URL,
|
|
109
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
110
|
+
form: {
|
|
111
|
+
"type" => "refresh",
|
|
112
|
+
"refresh_token" => @refresh_token,
|
|
113
|
+
"client_id" => @client_id,
|
|
114
|
+
"client_secret" => @client_secret
|
|
115
|
+
},
|
|
116
|
+
timeout: REFRESH_TIMEOUT,
|
|
117
|
+
max_body_bytes: MAX_RESPONSE_BYTES,
|
|
118
|
+
skip_status: ->(s) { REDIRECT_STATUSES.include?(s) }
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# A refused redirect is a typed api fault carrying the real status —
|
|
122
|
+
# not the generic AuthError below, which would imply the credentials
|
|
123
|
+
# were judged and rejected when no such judgement happened.
|
|
124
|
+
if REDIRECT_STATUSES.include?(status)
|
|
125
|
+
raise ApiError.new("redirect #{status} on the token endpoint is not followed", http_status: status)
|
|
126
|
+
end
|
|
127
|
+
raise AuthError.new("Token refresh failed: #{status}") unless (200..299).cover?(status)
|
|
96
128
|
|
|
97
|
-
data = JSON.parse(
|
|
129
|
+
data = JSON.parse(body)
|
|
98
130
|
@access_token = data["access_token"]
|
|
99
131
|
@expires_at = Time.now + data["expires_in"].to_i if data["expires_in"]
|
|
100
132
|
|
|
101
133
|
@on_refresh&.call(@access_token, @refresh_token, @expires_at)
|
|
102
134
|
|
|
103
135
|
true
|
|
136
|
+
rescue Oauth::Fetcher::BodyTooLarge
|
|
137
|
+
raise ApiError.new("Token refresh response exceeds size cap")
|
|
138
|
+
rescue Oauth::Fetcher::ReadDeadlineExceeded => e
|
|
139
|
+
# A slow-drip read past the deadline is a transport timeout.
|
|
140
|
+
raise NetworkError.new("Token refresh network error", cause: e)
|
|
104
141
|
rescue Faraday::Error => e
|
|
105
142
|
raise NetworkError.new("Token refresh network error", cause: e)
|
|
106
143
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Raised when a template copy requires confirmation before granting project access.
|
|
5
|
+
class PeopleConfirmationRequiredError < ValidationError
|
|
6
|
+
# @return [Array<Basecamp::Types::TemplateLibraryConfirmationPerson>]
|
|
7
|
+
attr_reader :people
|
|
8
|
+
|
|
9
|
+
def initialize(message, people:, hint: nil, http_status: 422, field_errors: nil)
|
|
10
|
+
super(message, hint: hint, http_status: http_status, field_errors: field_errors)
|
|
11
|
+
@people = people
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module Basecamp
|
|
4
4
|
# Raised when rate limited (429).
|
|
5
5
|
class RateLimitError < Error
|
|
6
|
-
def initialize(retry_after: nil, cause: nil)
|
|
7
|
-
|
|
6
|
+
def initialize(retry_after: nil, hint: nil, cause: nil)
|
|
7
|
+
# A concrete Retry-After beats a body-derived hint; the class default
|
|
8
|
+
# fills in when neither is present.
|
|
9
|
+
hint = retry_after ? "Try again in #{retry_after} seconds" : (hint || "Please slow down requests")
|
|
8
10
|
super(
|
|
9
11
|
code: ErrorCode::RATE_LIMIT,
|
|
10
12
|
message: "Rate limit exceeded",
|
data/lib/basecamp/security.rb
CHANGED
|
@@ -32,6 +32,26 @@ module Basecamp
|
|
|
32
32
|
raise UsageError.new("Invalid #{label}: #{url}")
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Renders a URL for hooks as origin+path only (SPEC section 9): no
|
|
36
|
+
# userinfo, query or fragment. The download flow's hop-1 URL can carry a
|
|
37
|
+
# signed credential in its query; the wire request keeps the whole URL. A
|
|
38
|
+
# URL with no complete origin renders as the fixed token, never as any of
|
|
39
|
+
# its own text.
|
|
40
|
+
# @param url [String]
|
|
41
|
+
# @return [String]
|
|
42
|
+
def self.display_url(url)
|
|
43
|
+
uri = URI.parse(url)
|
|
44
|
+
return "unparsable" if uri.scheme.nil? || uri.host.nil? || uri.host.empty?
|
|
45
|
+
|
|
46
|
+
uri.password = nil
|
|
47
|
+
uri.user = nil
|
|
48
|
+
uri.query = nil
|
|
49
|
+
uri.fragment = nil
|
|
50
|
+
uri.to_s
|
|
51
|
+
rescue URI::Error
|
|
52
|
+
"unparsable"
|
|
53
|
+
end
|
|
54
|
+
|
|
35
55
|
def self.same_origin?(a, b)
|
|
36
56
|
ua = URI.parse(a)
|
|
37
57
|
ub = URI.parse(b)
|
|
@@ -39,13 +59,19 @@ module Basecamp
|
|
|
39
59
|
|
|
40
60
|
ua.scheme.downcase == ub.scheme.downcase &&
|
|
41
61
|
normalize_host(ua) == normalize_host(ub)
|
|
42
|
-
rescue URI::
|
|
62
|
+
rescue URI::Error
|
|
63
|
+
# URI::Error, not just InvalidURIError: URI.parse("mailto:") raises
|
|
64
|
+
# URI::InvalidComponentError (URI::MailTo demands an opaque part), and a
|
|
65
|
+
# scheme-only URL must be refused, not a crash.
|
|
43
66
|
false
|
|
44
67
|
end
|
|
45
68
|
|
|
46
69
|
def self.resolve_url(base, target)
|
|
47
70
|
URI.join(base, target).to_s
|
|
48
|
-
rescue URI::
|
|
71
|
+
rescue URI::Error
|
|
72
|
+
# URI::Error, not just InvalidURIError: URI.join raises
|
|
73
|
+
# URI::InvalidComponentError on a scheme-only target ("mailto:"),
|
|
74
|
+
# reachable from a poisoned Link header or Location redirect.
|
|
49
75
|
target
|
|
50
76
|
end
|
|
51
77
|
|
|
@@ -10,9 +10,11 @@ module Basecamp
|
|
|
10
10
|
# document (+app/views/api/authorizations/show.json.jbuilder+), which is not
|
|
11
11
|
# Launchpad's: it carries +identity.id+ and nothing else of the identity, no
|
|
12
12
|
# +product+ or +app_href+ on accounts, an RFC 8707 +resource+ indicator
|
|
13
|
-
# instead, a top-level +scope+ for BC3-issued tokens
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# instead, and a top-level +scope+ for BC3-issued tokens. Only +identity.id+,
|
|
14
|
+
# +accounts[].id+, +accounts[].name+, +accounts[].href+ and +expires_at+ are
|
|
15
|
+
# common to both; +expires_at+ is an ISO-8601 string from either issuer
|
|
16
|
+
# (integer epoch seconds from bc3 before bc3 #12646 — passed through
|
|
17
|
+
# verbatim either way).
|
|
16
18
|
#
|
|
17
19
|
# @example Get authorization info
|
|
18
20
|
# auth = client.authorization.get
|
data/lib/basecamp/version.rb
CHANGED
data/lib/basecamp.rb
CHANGED
|
@@ -117,31 +117,43 @@ module Basecamp
|
|
|
117
117
|
# @param retry_after [Integer, nil] Retry-After header value
|
|
118
118
|
# @return [Error]
|
|
119
119
|
def self.error_from_response(status, body = nil, retry_after: nil)
|
|
120
|
-
|
|
120
|
+
# SPEC §6 step 3: a body's error_description becomes the hint. Step 5:
|
|
121
|
+
# with no body message, the else arm falls back (via from_status) to the
|
|
122
|
+
# fixed code-bearing phrase, never a reason phrase.
|
|
123
|
+
hint = parse_error_hint(body)
|
|
124
|
+
server_message = parse_error_message(body)
|
|
125
|
+
message = server_message || "Request failed"
|
|
121
126
|
|
|
122
127
|
case status
|
|
123
128
|
when 400, 422
|
|
124
129
|
field_errors = parse_field_errors(body)
|
|
125
|
-
message = Security.truncate(compose_validation_message(
|
|
126
|
-
|
|
130
|
+
message = Security.truncate(compose_validation_message(server_message, field_errors) || "Request failed")
|
|
131
|
+
people = status == 422 ? parse_template_library_confirmation_people(body) : nil
|
|
132
|
+
if people
|
|
133
|
+
PeopleConfirmationRequiredError.new(
|
|
134
|
+
message, people: people, hint: hint, http_status: status, field_errors: field_errors
|
|
135
|
+
)
|
|
136
|
+
else
|
|
137
|
+
ValidationError.new(message, hint: hint, http_status: status, field_errors: field_errors)
|
|
138
|
+
end
|
|
127
139
|
when 401
|
|
128
|
-
AuthError.new(message)
|
|
140
|
+
AuthError.new(message, hint: hint)
|
|
129
141
|
when 403
|
|
130
|
-
ForbiddenError.new(message)
|
|
142
|
+
ForbiddenError.new(message, hint: hint)
|
|
131
143
|
when 404
|
|
132
|
-
NotFoundError.new(message: message)
|
|
144
|
+
NotFoundError.new(message: message, hint: hint)
|
|
133
145
|
when 429
|
|
134
|
-
RateLimitError.new(retry_after: retry_after)
|
|
146
|
+
RateLimitError.new(retry_after: retry_after, hint: hint)
|
|
135
147
|
when 507
|
|
136
148
|
# Decided before the 5xx arms: a 507 is an account limit, not a
|
|
137
149
|
# transient server failure, and no retry can satisfy it.
|
|
138
|
-
LimitExceededError.new(Security.truncate(message))
|
|
150
|
+
LimitExceededError.new(Security.truncate(message), hint: hint)
|
|
139
151
|
when 500
|
|
140
|
-
ApiError.new("Server error (500)", http_status: 500, retryable: true)
|
|
152
|
+
ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint)
|
|
141
153
|
when 502, 503, 504
|
|
142
|
-
ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
|
|
154
|
+
ApiError.new("Gateway error (#{status})", http_status: status, retryable: true, hint: hint)
|
|
143
155
|
else
|
|
144
|
-
ApiError.from_status(status,
|
|
156
|
+
ApiError.from_status(status, server_message, hint: hint)
|
|
145
157
|
end
|
|
146
158
|
end
|
|
147
159
|
|
|
@@ -180,6 +192,46 @@ module Basecamp
|
|
|
180
192
|
nil
|
|
181
193
|
end
|
|
182
194
|
|
|
195
|
+
# Parses the SPEC section 6 step-3 hint from a response body: the
|
|
196
|
+
# "error_description" key, used only when its value is a non-empty String,
|
|
197
|
+
# truncated like the message.
|
|
198
|
+
# @param body [String, nil]
|
|
199
|
+
# @return [String, nil]
|
|
200
|
+
def self.parse_error_hint(body)
|
|
201
|
+
return nil if body.nil? || body.empty?
|
|
202
|
+
|
|
203
|
+
Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
|
|
204
|
+
|
|
205
|
+
data = JSON.parse(body)
|
|
206
|
+
hint = data.is_a?(Hash) ? data["error_description"] : nil
|
|
207
|
+
hint.is_a?(String) && !hint.empty? ? Security.truncate(hint) : nil
|
|
208
|
+
rescue JSON::ParserError, ApiError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Extracts the people whose destination-project access requires confirmation.
|
|
213
|
+
# @param body [String, nil]
|
|
214
|
+
# @return [Array<Basecamp::Types::TemplateLibraryConfirmationPerson>, nil]
|
|
215
|
+
def self.parse_template_library_confirmation_people(body)
|
|
216
|
+
return nil if body.nil? || body.empty?
|
|
217
|
+
|
|
218
|
+
Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
|
|
219
|
+
data = JSON.parse(body)
|
|
220
|
+
people = data.is_a?(Hash) ? data["people"] : nil
|
|
221
|
+
return nil unless people.is_a?(Array) && !people.empty?
|
|
222
|
+
|
|
223
|
+
valid = people.all? do |person|
|
|
224
|
+
person.is_a?(Hash) && person["id"].is_a?(Integer) && person["id"].positive? &&
|
|
225
|
+
person["name"].is_a?(String) && !person["name"].empty? &&
|
|
226
|
+
person["avatar_url"].is_a?(String) && !person["avatar_url"].empty?
|
|
227
|
+
end
|
|
228
|
+
return nil unless valid
|
|
229
|
+
|
|
230
|
+
people.map { |person| Types::TemplateLibraryConfirmationPerson.new(person) }
|
|
231
|
+
rescue JSON::ParserError, ApiError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
183
235
|
# Extracts the field-keyed validation errors map from a response body — the
|
|
184
236
|
# Rails RecordInvalid rendering {"errors" => {"field" => ["msg", ...]}}.
|
|
185
237
|
# Entries whose value is not an array are skipped, non-string elements are
|
|
@@ -73,7 +73,7 @@ class ServiceGenerator
|
|
|
73
73
|
},
|
|
74
74
|
'Automation' => {
|
|
75
75
|
'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool],
|
|
76
|
-
'Recordings' => %w[ArchiveRecording UnarchiveRecording TrashRecording ListRecordings],
|
|
76
|
+
'Recordings' => %w[ArchiveRecording UnarchiveRecording TrashRecording ListRecordings SpotlightRecording UnspotlightRecording],
|
|
77
77
|
'Webhooks' => %w[ListWebhooks CreateWebhook GetWebhook UpdateWebhook DeleteWebhook],
|
|
78
78
|
'Events' => %w[ListEvents],
|
|
79
79
|
'Lineup' => %w[CreateLineupMarker UpdateLineupMarker DeleteLineupMarker],
|
|
@@ -81,6 +81,7 @@ class ServiceGenerator
|
|
|
81
81
|
'Templates' => %w[
|
|
82
82
|
ListTemplates CreateTemplate GetTemplate UpdateTemplate
|
|
83
83
|
DeleteTemplate CreateProjectFromTemplate GetProjectConstruction
|
|
84
|
+
GetTemplateLibrary CreateTemplateLibraryCopy GetTemplateLibraryCopy
|
|
84
85
|
],
|
|
85
86
|
'Checkins' => %w[
|
|
86
87
|
GetQuestionnaire ListQuestions CreateQuestion GetQuestion
|
|
@@ -139,6 +140,8 @@ class ServiceGenerator
|
|
|
139
140
|
|
|
140
141
|
# Method name overrides
|
|
141
142
|
METHOD_NAME_OVERRIDES = {
|
|
143
|
+
'SpotlightRecording' => 'spotlight',
|
|
144
|
+
'UnspotlightRecording' => 'unspotlight',
|
|
142
145
|
'GetMyProfile' => 'my_profile',
|
|
143
146
|
'GetTodolistOrGroup' => 'get',
|
|
144
147
|
# The plain `update` name belongs to the merge-safe composite; the raw
|
|
@@ -175,6 +178,9 @@ class ServiceGenerator
|
|
|
175
178
|
'Search' => 'search',
|
|
176
179
|
'CreateProjectFromTemplate' => 'create_project',
|
|
177
180
|
'GetProjectConstruction' => 'get_construction',
|
|
181
|
+
'GetTemplateLibrary' => 'get_library',
|
|
182
|
+
'CreateTemplateLibraryCopy' => 'create_library_copy',
|
|
183
|
+
'GetTemplateLibraryCopy' => 'get_library_copy',
|
|
178
184
|
'GetRecordingTimesheet' => 'for_recording',
|
|
179
185
|
'GetProjectTimesheet' => 'for_project',
|
|
180
186
|
'GetTimesheetReport' => 'report',
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: basecamp-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Basecamp
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -184,6 +184,7 @@ files:
|
|
|
184
184
|
- lib/basecamp/generated/services/base_service.rb
|
|
185
185
|
- lib/basecamp/generated/services/bookmarks_service.rb
|
|
186
186
|
- lib/basecamp/generated/services/boosts_service.rb
|
|
187
|
+
- lib/basecamp/generated/services/bubble_ups_service.rb
|
|
187
188
|
- lib/basecamp/generated/services/calendars_service.rb
|
|
188
189
|
- lib/basecamp/generated/services/campfires_service.rb
|
|
189
190
|
- lib/basecamp/generated/services/card_columns_service.rb
|
|
@@ -262,6 +263,7 @@ files:
|
|
|
262
263
|
- lib/basecamp/oauth_token_provider.rb
|
|
263
264
|
- lib/basecamp/operation_info.rb
|
|
264
265
|
- lib/basecamp/operation_result.rb
|
|
266
|
+
- lib/basecamp/people_confirmation_required_error.rb
|
|
265
267
|
- lib/basecamp/rate_limit_error.rb
|
|
266
268
|
- lib/basecamp/request_info.rb
|
|
267
269
|
- lib/basecamp/request_result.rb
|