basecamp-sdk 0.15.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/lib/basecamp/api_error.rb +3 -2
- data/lib/basecamp/client.rb +21 -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 +99 -2
- data/lib/basecamp/http.rb +55 -23
- 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 +20 -0
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +63 -11
- data/scripts/generate-services.rb +7 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67cf91504a5aadff973463d83b4a4de98aa4ad35e2dd7d0d4e5f4f26021e8423
|
|
4
|
+
data.tar.gz: 88eacbb6628831a01e6a9cb56503618377ffec4a85ad85d5a514b025ff53dd2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8cede9f49e112c50b65d7c7c4901f442cba400211bfe4676a9af95a95ac4ca5da1a1477c529f72456745a0f8423b766162c151135b89a2c52ce1abbacb9eb17f
|
|
7
|
+
data.tar.gz: 695198c7697799719f0d66657463ed8ad76739bcd3bbe11877ad0a16d1fdd6b20602e2d6efa623c9d6e05a2b78ef5528e346ebec5975dd1697e5379e96b1e640
|
data/lib/basecamp/api_error.rb
CHANGED
|
@@ -17,11 +17,12 @@ module Basecamp
|
|
|
17
17
|
# Creates an ApiError from an HTTP status code.
|
|
18
18
|
# @param status [Integer] HTTP status code
|
|
19
19
|
# @param message [String, nil] optional error message
|
|
20
|
+
# @param hint [String, nil] optional hint (SPEC section 6 step 3)
|
|
20
21
|
# @return [ApiError]
|
|
21
|
-
def self.from_status(status, message = nil)
|
|
22
|
+
def self.from_status(status, message = nil, hint: nil)
|
|
22
23
|
message ||= "Request failed (HTTP #{status})"
|
|
23
24
|
retryable = status >= 500 && status < 600
|
|
24
|
-
new(message, http_status: status, retryable: retryable)
|
|
25
|
+
new(message, http_status: status, hint: hint, retryable: retryable)
|
|
25
26
|
end
|
|
26
27
|
end
|
|
27
28
|
end
|
data/lib/basecamp/client.rb
CHANGED
|
@@ -507,6 +507,11 @@ module Basecamp
|
|
|
507
507
|
service(:bookmarks) { Services::BookmarksService.new(self) }
|
|
508
508
|
end
|
|
509
509
|
|
|
510
|
+
# @return [Services::BubbleUpsService]
|
|
511
|
+
def bubble_ups
|
|
512
|
+
service(:bubble_ups) { Services::BubbleUpsService.new(self) }
|
|
513
|
+
end
|
|
514
|
+
|
|
510
515
|
# @return [Services::FoldersService]
|
|
511
516
|
def folders
|
|
512
517
|
service(:folders) { Services::FoldersService.new(self) }
|
|
@@ -635,7 +640,16 @@ module Basecamp
|
|
|
635
640
|
nil
|
|
636
641
|
end
|
|
637
642
|
unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
|
|
638
|
-
|
|
643
|
+
# SPEC §9: the signed URL is a credential — render its origin alone,
|
|
644
|
+
# projected from the parse, or the fixed token when no complete origin
|
|
645
|
+
# exists. Truncating the whole URL kept a short query intact.
|
|
646
|
+
origin = if uri&.scheme && uri&.host && !uri.host.empty?
|
|
647
|
+
port = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
|
|
648
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
649
|
+
else
|
|
650
|
+
"unparsable"
|
|
651
|
+
end
|
|
652
|
+
raise ApiError.new("redirect to undialable download URL: #{origin}")
|
|
639
653
|
end
|
|
640
654
|
|
|
641
655
|
http_client = Net::HTTP.new(uri.host, uri.port)
|
|
@@ -652,8 +666,12 @@ module Basecamp
|
|
|
652
666
|
# Redirect Policy"). Stated here so a move to a following client
|
|
653
667
|
# (Faraday, Net::HTTP.get_response's callers) has to argue with it.
|
|
654
668
|
response = http_client.request(request)
|
|
655
|
-
rescue StandardError
|
|
656
|
-
|
|
669
|
+
rescue StandardError
|
|
670
|
+
# SPEC §9: the transport error renders the signed URL, so neither its
|
|
671
|
+
# message nor the exception itself survives. cause: nil at the raise
|
|
672
|
+
# site — MRI sets the built-in cause at raise time past the class's
|
|
673
|
+
# stored cause: keyword (same pattern as oauth/exchange.rb).
|
|
674
|
+
raise NetworkError.new("Download failed"), cause: nil
|
|
657
675
|
end
|
|
658
676
|
|
|
659
677
|
# The exact set hop 1 dispatches on, not Net::HTTPRedirection — that
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://basecamp.com/schemas/sdk-metadata.json",
|
|
3
3
|
"version": "1.0.0",
|
|
4
|
-
"generated": "2026-
|
|
4
|
+
"generated": "2026-09-03T02:27:35Z",
|
|
5
5
|
"operations": {
|
|
6
6
|
"GetAccount": {
|
|
7
7
|
"retry": {
|
|
@@ -1576,6 +1576,17 @@
|
|
|
1576
1576
|
"maxPageSize": 50
|
|
1577
1577
|
}
|
|
1578
1578
|
},
|
|
1579
|
+
"ListRecentProjects": {
|
|
1580
|
+
"retry": {
|
|
1581
|
+
"maxAttempts": 3,
|
|
1582
|
+
"baseDelayMs": 1000,
|
|
1583
|
+
"backoff": "exponential",
|
|
1584
|
+
"retryOn": [
|
|
1585
|
+
429,
|
|
1586
|
+
503
|
|
1587
|
+
]
|
|
1588
|
+
}
|
|
1589
|
+
},
|
|
1579
1590
|
"MarkAsRead": {
|
|
1580
1591
|
"retry": {
|
|
1581
1592
|
"maxAttempts": 2,
|
|
@@ -1806,6 +1817,20 @@
|
|
|
1806
1817
|
"natural": true
|
|
1807
1818
|
}
|
|
1808
1819
|
},
|
|
1820
|
+
"RecordProjectVisit": {
|
|
1821
|
+
"retry": {
|
|
1822
|
+
"maxAttempts": 3,
|
|
1823
|
+
"baseDelayMs": 1000,
|
|
1824
|
+
"backoff": "exponential",
|
|
1825
|
+
"retryOn": [
|
|
1826
|
+
429,
|
|
1827
|
+
503
|
|
1828
|
+
]
|
|
1829
|
+
},
|
|
1830
|
+
"idempotent": {
|
|
1831
|
+
"natural": true
|
|
1832
|
+
}
|
|
1833
|
+
},
|
|
1809
1834
|
"UnarchiveProject": {
|
|
1810
1835
|
"retry": {
|
|
1811
1836
|
"maxAttempts": 3,
|
|
@@ -2144,6 +2169,34 @@
|
|
|
2144
2169
|
]
|
|
2145
2170
|
}
|
|
2146
2171
|
},
|
|
2172
|
+
"CreateBubbleUp": {
|
|
2173
|
+
"retry": {
|
|
2174
|
+
"maxAttempts": 3,
|
|
2175
|
+
"baseDelayMs": 1000,
|
|
2176
|
+
"backoff": "exponential",
|
|
2177
|
+
"retryOn": [
|
|
2178
|
+
429,
|
|
2179
|
+
503
|
|
2180
|
+
]
|
|
2181
|
+
},
|
|
2182
|
+
"idempotent": {
|
|
2183
|
+
"natural": true
|
|
2184
|
+
}
|
|
2185
|
+
},
|
|
2186
|
+
"DeleteBubbleUp": {
|
|
2187
|
+
"retry": {
|
|
2188
|
+
"maxAttempts": 3,
|
|
2189
|
+
"baseDelayMs": 1000,
|
|
2190
|
+
"backoff": "exponential",
|
|
2191
|
+
"retryOn": [
|
|
2192
|
+
429,
|
|
2193
|
+
503
|
|
2194
|
+
]
|
|
2195
|
+
},
|
|
2196
|
+
"idempotent": {
|
|
2197
|
+
"natural": true
|
|
2198
|
+
}
|
|
2199
|
+
},
|
|
2147
2200
|
"SetClientVisibility": {
|
|
2148
2201
|
"retry": {
|
|
2149
2202
|
"maxAttempts": 3,
|
|
@@ -2228,6 +2281,34 @@
|
|
|
2228
2281
|
]
|
|
2229
2282
|
}
|
|
2230
2283
|
},
|
|
2284
|
+
"SpotlightRecording": {
|
|
2285
|
+
"retry": {
|
|
2286
|
+
"maxAttempts": 3,
|
|
2287
|
+
"baseDelayMs": 1000,
|
|
2288
|
+
"backoff": "exponential",
|
|
2289
|
+
"retryOn": [
|
|
2290
|
+
429,
|
|
2291
|
+
503
|
|
2292
|
+
]
|
|
2293
|
+
},
|
|
2294
|
+
"idempotent": {
|
|
2295
|
+
"natural": true
|
|
2296
|
+
}
|
|
2297
|
+
},
|
|
2298
|
+
"UnspotlightRecording": {
|
|
2299
|
+
"retry": {
|
|
2300
|
+
"maxAttempts": 3,
|
|
2301
|
+
"baseDelayMs": 1000,
|
|
2302
|
+
"backoff": "exponential",
|
|
2303
|
+
"retryOn": [
|
|
2304
|
+
429,
|
|
2305
|
+
503
|
|
2306
|
+
]
|
|
2307
|
+
},
|
|
2308
|
+
"idempotent": {
|
|
2309
|
+
"natural": true
|
|
2310
|
+
}
|
|
2311
|
+
},
|
|
2231
2312
|
"UnarchiveRecording": {
|
|
2232
2313
|
"retry": {
|
|
2233
2314
|
"maxAttempts": 3,
|
|
@@ -2668,6 +2749,39 @@
|
|
|
2668
2749
|
"natural": true
|
|
2669
2750
|
}
|
|
2670
2751
|
},
|
|
2752
|
+
"GetTemplateLibrary": {
|
|
2753
|
+
"retry": {
|
|
2754
|
+
"maxAttempts": 3,
|
|
2755
|
+
"baseDelayMs": 1000,
|
|
2756
|
+
"backoff": "exponential",
|
|
2757
|
+
"retryOn": [
|
|
2758
|
+
429,
|
|
2759
|
+
503
|
|
2760
|
+
]
|
|
2761
|
+
}
|
|
2762
|
+
},
|
|
2763
|
+
"CreateTemplateLibraryCopy": {
|
|
2764
|
+
"retry": {
|
|
2765
|
+
"maxAttempts": 2,
|
|
2766
|
+
"baseDelayMs": 1000,
|
|
2767
|
+
"backoff": "exponential",
|
|
2768
|
+
"retryOn": [
|
|
2769
|
+
429,
|
|
2770
|
+
503
|
|
2771
|
+
]
|
|
2772
|
+
}
|
|
2773
|
+
},
|
|
2774
|
+
"GetTemplateLibraryCopy": {
|
|
2775
|
+
"retry": {
|
|
2776
|
+
"maxAttempts": 3,
|
|
2777
|
+
"baseDelayMs": 1000,
|
|
2778
|
+
"backoff": "exponential",
|
|
2779
|
+
"retryOn": [
|
|
2780
|
+
429,
|
|
2781
|
+
503
|
|
2782
|
+
]
|
|
2783
|
+
}
|
|
2784
|
+
},
|
|
2671
2785
|
"ListTemplates": {
|
|
2672
2786
|
"retry": {
|
|
2673
2787
|
"maxAttempts": 3,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Service for BubbleUps operations
|
|
6
|
+
#
|
|
7
|
+
# @generated from OpenAPI spec
|
|
8
|
+
class BubbleUpsService < BaseService
|
|
9
|
+
|
|
10
|
+
# Bubble up a recording for the current user, resurfacing it in the current
|
|
11
|
+
# @param recording_id [Integer] recording id ID
|
|
12
|
+
# @param at [String, nil] Timing for the bubble-up. `"now"` bubbles up immediately; a scheduling
|
|
13
|
+
# keyword (`"today"`, `"tomorrow"`, `"weekend"`, `"next_week"`) or an ISO8601
|
|
14
|
+
# date (e.g. `"2026-09-10"`) schedules it to resurface later. bc3 requires a
|
|
15
|
+
# value — omitting `at` errors server-side (`Date.iso8601(nil)`) — so send
|
|
16
|
+
# `"now"` for the immediate case.
|
|
17
|
+
# @return [void]
|
|
18
|
+
def create_bubble_up(recording_id:, at: nil)
|
|
19
|
+
with_operation(service: "bubbleups", operation: "create_bubble_up", is_mutation: true, resource_id: recording_id) do
|
|
20
|
+
http_post("/recordings/#{recording_id}/bubble_up.json", body: compact_params(at: at))
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Remove the current user's bubble-up from a recording (returns 204 No Content).
|
|
26
|
+
# @param recording_id [Integer] recording id ID
|
|
27
|
+
# @return [void]
|
|
28
|
+
def delete_bubble_up(recording_id:)
|
|
29
|
+
with_operation(service: "bubbleups", operation: "delete_bubble_up", is_mutation: true, resource_id: recording_id) do
|
|
30
|
+
http_delete("/recordings/#{recording_id}/bubble_up.json")
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -7,6 +7,14 @@ module Basecamp
|
|
|
7
7
|
# @generated from OpenAPI spec
|
|
8
8
|
class ProjectsService < BaseService
|
|
9
9
|
|
|
10
|
+
# List the projects the current user has most recently visited, most recent visit first.
|
|
11
|
+
# @return [Array<Hash>] response data
|
|
12
|
+
def list_recent_projects()
|
|
13
|
+
with_operation(service: "projects", operation: "list_recent_projects", is_mutation: false) do
|
|
14
|
+
http_get("/my/recent_projects.json", operation: "ListRecentProjects").json
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
10
18
|
# List projects (active by default; optionally archived/trashed)
|
|
11
19
|
# @param status [String, nil] active|archived|trashed
|
|
12
20
|
# @param page [Integer, nil] Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.
|
|
@@ -61,6 +69,16 @@ module Basecamp
|
|
|
61
69
|
end
|
|
62
70
|
end
|
|
63
71
|
|
|
72
|
+
# Record that the current user visited a project, moving it to the front of ListRecentProjects (returns 204 No Content).
|
|
73
|
+
# @param project_id [Integer] project id ID
|
|
74
|
+
# @return [void]
|
|
75
|
+
def record_project_visit(project_id:)
|
|
76
|
+
with_operation(service: "projects", operation: "record_project_visit", is_mutation: true, project_id: project_id) do
|
|
77
|
+
http_post("/projects/#{project_id}/recent_visit.json")
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
64
82
|
# Restore a project to active status from trash as well as from the archive (returns 204 No Content).
|
|
65
83
|
# @param project_id [Integer] project id ID
|
|
66
84
|
# @return [void]
|
|
@@ -23,6 +23,25 @@ module Basecamp
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Put a recording's card in the spotlight area on its project or template home page.
|
|
27
|
+
# @param recording_id [Integer] recording id ID
|
|
28
|
+
# @return [Hash] response data
|
|
29
|
+
def spotlight(recording_id:)
|
|
30
|
+
with_operation(service: "recordings", operation: "spotlight", is_mutation: true, resource_id: recording_id) do
|
|
31
|
+
http_post("/recordings/#{recording_id}/spotlight.json").json
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Remove a recording from the spotlight area.
|
|
36
|
+
# @param recording_id [Integer] recording id ID
|
|
37
|
+
# @return [void]
|
|
38
|
+
def unspotlight(recording_id:)
|
|
39
|
+
with_operation(service: "recordings", operation: "unspotlight", is_mutation: true, resource_id: recording_id) do
|
|
40
|
+
http_delete("/recordings/#{recording_id}/spotlight.json")
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
26
45
|
# Unarchive a recording (restore to active status)
|
|
27
46
|
# @param recording_id [Integer] recording id ID
|
|
28
47
|
# @return [void]
|
|
@@ -7,6 +7,34 @@ module Basecamp
|
|
|
7
7
|
# @generated from OpenAPI spec
|
|
8
8
|
class TemplatesService < BaseService
|
|
9
9
|
|
|
10
|
+
# Get the account's to-do list template library
|
|
11
|
+
# @return [Hash] response data
|
|
12
|
+
def get_library()
|
|
13
|
+
with_operation(service: "templates", operation: "get_library", is_mutation: false) do
|
|
14
|
+
http_get("/template_library.json", operation: "GetTemplateLibrary").json
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Start copying a to-do list template into a project
|
|
19
|
+
# @param template_recording_id [Integer] template recording id
|
|
20
|
+
# @param destination_parent_id [Integer] destination parent id
|
|
21
|
+
# @param adding_people_confirmed [Boolean, nil] Confirm granting destination-project access to people referenced by the template.
|
|
22
|
+
# @return [Hash] response data
|
|
23
|
+
def create_library_copy(template_recording_id:, destination_parent_id:, adding_people_confirmed: nil)
|
|
24
|
+
with_operation(service: "templates", operation: "create_library_copy", is_mutation: true) do
|
|
25
|
+
http_post("/template_library/copies.json", body: compact_params(template_recording_id: template_recording_id, destination_parent_id: destination_parent_id, adding_people_confirmed: adding_people_confirmed)).json
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Get the current status of a to-do list template copy
|
|
30
|
+
# @param copy_id [Integer] copy id ID
|
|
31
|
+
# @return [Hash] response data
|
|
32
|
+
def get_library_copy(copy_id:)
|
|
33
|
+
with_operation(service: "templates", operation: "get_library_copy", is_mutation: false, resource_id: copy_id) do
|
|
34
|
+
http_get("/template_library/copies/#{copy_id}", operation: "GetTemplateLibraryCopy").json
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
10
38
|
# List all templates visible to the current user
|
|
11
39
|
# @param status [String, nil] active|archived|trashed
|
|
12
40
|
# @param page [Integer, nil] Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Auto-generated from OpenAPI spec. Do not edit manually.
|
|
4
|
-
# Generated: 2026-
|
|
4
|
+
# Generated: 2026-09-03T02:27:36Z
|
|
5
5
|
|
|
6
6
|
require "json"
|
|
7
7
|
require "time"
|
|
@@ -3169,7 +3169,7 @@ module Basecamp
|
|
|
3169
3169
|
include TypeHelpers
|
|
3170
3170
|
# @!attribute [rw] clientside
|
|
3171
3171
|
# @deprecated This shape is deprecated since 2024-01: Use Client Visibility feature instead
|
|
3172
|
-
attr_accessor :app_url, :created_at, :id, :name, :status, :updated_at, :url, :bookmark_url, :bookmarked, :client_company, :clients_enabled, :clientside, :description, :dock, :end_date, :purpose, :start_date
|
|
3172
|
+
attr_accessor :app_url, :created_at, :id, :name, :status, :updated_at, :url, :bookmark_url, :bookmarked, :client_company, :clients_enabled, :clientside, :description, :dock, :end_date, :purpose, :star_url, :starred, :start_date
|
|
3173
3173
|
|
|
3174
3174
|
# @return [Array<Symbol>]
|
|
3175
3175
|
def self.required_fields
|
|
@@ -3193,6 +3193,8 @@ module Basecamp
|
|
|
3193
3193
|
@dock = parse_array(data["dock"], "DockItem")
|
|
3194
3194
|
@end_date = data["end_date"]
|
|
3195
3195
|
@purpose = data["purpose"]
|
|
3196
|
+
@star_url = data["star_url"]
|
|
3197
|
+
@starred = parse_boolean(data["starred"])
|
|
3196
3198
|
@start_date = data["start_date"]
|
|
3197
3199
|
end
|
|
3198
3200
|
|
|
@@ -3214,6 +3216,8 @@ module Basecamp
|
|
|
3214
3216
|
"dock" => @dock,
|
|
3215
3217
|
"end_date" => @end_date,
|
|
3216
3218
|
"purpose" => @purpose,
|
|
3219
|
+
"star_url" => @star_url,
|
|
3220
|
+
"starred" => @starred,
|
|
3217
3221
|
"start_date" => @start_date,
|
|
3218
3222
|
}.compact
|
|
3219
3223
|
end
|
|
@@ -4289,6 +4293,99 @@ module Basecamp
|
|
|
4289
4293
|
end
|
|
4290
4294
|
end
|
|
4291
4295
|
|
|
4296
|
+
# TemplateLibrary
|
|
4297
|
+
class TemplateLibrary
|
|
4298
|
+
include TypeHelpers
|
|
4299
|
+
attr_accessor :bucket, :todolists, :todoset
|
|
4300
|
+
|
|
4301
|
+
# @return [Array<Symbol>]
|
|
4302
|
+
def self.required_fields
|
|
4303
|
+
%i[bucket todolists todoset].freeze
|
|
4304
|
+
end
|
|
4305
|
+
|
|
4306
|
+
def initialize(data = {})
|
|
4307
|
+
@bucket = parse_type(data["bucket"], "RecordingBucket")
|
|
4308
|
+
@todolists = parse_array(data["todolists"], "Todolist")
|
|
4309
|
+
@todoset = parse_type(data["todoset"], "RecordingParent")
|
|
4310
|
+
end
|
|
4311
|
+
|
|
4312
|
+
def to_h
|
|
4313
|
+
{
|
|
4314
|
+
"bucket" => @bucket,
|
|
4315
|
+
"todolists" => @todolists,
|
|
4316
|
+
"todoset" => @todoset,
|
|
4317
|
+
}.compact
|
|
4318
|
+
end
|
|
4319
|
+
|
|
4320
|
+
def to_json(*args)
|
|
4321
|
+
to_h.to_json(*args)
|
|
4322
|
+
end
|
|
4323
|
+
end
|
|
4324
|
+
|
|
4325
|
+
# TemplateLibraryConfirmationPerson
|
|
4326
|
+
class TemplateLibraryConfirmationPerson
|
|
4327
|
+
include TypeHelpers
|
|
4328
|
+
attr_accessor :avatar_url, :id, :name
|
|
4329
|
+
|
|
4330
|
+
# @return [Array<Symbol>]
|
|
4331
|
+
def self.required_fields
|
|
4332
|
+
%i[avatar_url id name].freeze
|
|
4333
|
+
end
|
|
4334
|
+
|
|
4335
|
+
def initialize(data = {})
|
|
4336
|
+
@avatar_url = data["avatar_url"]
|
|
4337
|
+
@id = parse_integer(data["id"])
|
|
4338
|
+
@name = data["name"]
|
|
4339
|
+
end
|
|
4340
|
+
|
|
4341
|
+
def to_h
|
|
4342
|
+
{
|
|
4343
|
+
"avatar_url" => @avatar_url,
|
|
4344
|
+
"id" => @id,
|
|
4345
|
+
"name" => @name,
|
|
4346
|
+
}.compact
|
|
4347
|
+
end
|
|
4348
|
+
|
|
4349
|
+
def to_json(*args)
|
|
4350
|
+
to_h.to_json(*args)
|
|
4351
|
+
end
|
|
4352
|
+
end
|
|
4353
|
+
|
|
4354
|
+
# TemplateLibraryCopy
|
|
4355
|
+
class TemplateLibraryCopy
|
|
4356
|
+
include TypeHelpers
|
|
4357
|
+
attr_accessor :destination_parent_id, :id, :source_recording_id, :status, :url, :destination_todolist
|
|
4358
|
+
|
|
4359
|
+
# @return [Array<Symbol>]
|
|
4360
|
+
def self.required_fields
|
|
4361
|
+
%i[destination_parent_id id source_recording_id status url].freeze
|
|
4362
|
+
end
|
|
4363
|
+
|
|
4364
|
+
def initialize(data = {})
|
|
4365
|
+
@destination_parent_id = parse_integer(data["destination_parent_id"])
|
|
4366
|
+
@id = parse_integer(data["id"])
|
|
4367
|
+
@source_recording_id = parse_integer(data["source_recording_id"])
|
|
4368
|
+
@status = data["status"]
|
|
4369
|
+
@url = data["url"]
|
|
4370
|
+
@destination_todolist = parse_type(data["destination_todolist"], "Todolist")
|
|
4371
|
+
end
|
|
4372
|
+
|
|
4373
|
+
def to_h
|
|
4374
|
+
{
|
|
4375
|
+
"destination_parent_id" => @destination_parent_id,
|
|
4376
|
+
"id" => @id,
|
|
4377
|
+
"source_recording_id" => @source_recording_id,
|
|
4378
|
+
"status" => @status,
|
|
4379
|
+
"url" => @url,
|
|
4380
|
+
"destination_todolist" => @destination_todolist,
|
|
4381
|
+
}.compact
|
|
4382
|
+
end
|
|
4383
|
+
|
|
4384
|
+
def to_json(*args)
|
|
4385
|
+
to_h.to_json(*args)
|
|
4386
|
+
end
|
|
4387
|
+
end
|
|
4388
|
+
|
|
4292
4389
|
# TimelineAttachment
|
|
4293
4390
|
class TimelineAttachment
|
|
4294
4391
|
include TypeHelpers
|
data/lib/basecamp/http.rb
CHANGED
|
@@ -176,7 +176,11 @@ module Basecamp
|
|
|
176
176
|
# @param url [String] absolute URL
|
|
177
177
|
# @return [Response]
|
|
178
178
|
def get_download(url)
|
|
179
|
-
|
|
179
|
+
# download: true projects this flow's hooks and transport errors (SPEC
|
|
180
|
+
# §9): the caller's URL can smuggle a signed query through the rewrite
|
|
181
|
+
# into hop 1, and a transport error renders the URL it failed on. The
|
|
182
|
+
# wire request keeps the query; only the renderings are projected.
|
|
183
|
+
request_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, accept: nil, download: true)
|
|
180
184
|
end
|
|
181
185
|
|
|
182
186
|
# Fetches all pages of a paginated resource.
|
|
@@ -396,7 +400,7 @@ module Basecamp
|
|
|
396
400
|
end
|
|
397
401
|
|
|
398
402
|
def request_with_retry(method, url, params: {}, allow_cross_origin: false, operation: nil, retry_on: nil,
|
|
399
|
-
accept: "application/json")
|
|
403
|
+
accept: "application/json", download: false)
|
|
400
404
|
op_retry = operation && Http.operation_retry(operation)
|
|
401
405
|
# The cap is floored at one attempt on every path: whether a request
|
|
402
406
|
# reaches the wire at all must not depend on whether the operation
|
|
@@ -422,7 +426,7 @@ module Basecamp
|
|
|
422
426
|
|
|
423
427
|
begin
|
|
424
428
|
return single_request(method, url, params: params, body: nil, attempt: attempt,
|
|
425
|
-
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: false)
|
|
429
|
+
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: false, download: download)
|
|
426
430
|
rescue Basecamp::AuthError => e
|
|
427
431
|
# SPEC §4: the refresh replay is a request on the wire, so it spends
|
|
428
432
|
# an attempt from THIS budget rather than an uncounted one inside
|
|
@@ -474,8 +478,9 @@ module Basecamp
|
|
|
474
478
|
|
|
475
479
|
delay = calculate_delay(attempt, error.retry_after)
|
|
476
480
|
|
|
477
|
-
|
|
478
|
-
|
|
481
|
+
hook_url = download ? Security.display_url(url) : url
|
|
482
|
+
@hooks.on_retry(RequestInfo.new(method: method.to_s.upcase, url: hook_url, attempt: attempt),
|
|
483
|
+
attempt + 1, error, delay)
|
|
479
484
|
sleep(delay)
|
|
480
485
|
end
|
|
481
486
|
end
|
|
@@ -511,14 +516,19 @@ module Basecamp
|
|
|
511
516
|
end
|
|
512
517
|
|
|
513
518
|
def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false,
|
|
514
|
-
accept: "application/json", refresh_replay: true)
|
|
519
|
+
accept: "application/json", refresh_replay: true, download: false)
|
|
515
520
|
assert_credential_origin!(url, allow_cross_origin)
|
|
516
|
-
|
|
521
|
+
# download: the SPEC §9 projection for a URL whose query can carry a
|
|
522
|
+
# credential (download hop 1) — hooks see origin+path, and a transport
|
|
523
|
+
# error is severed below; the wire request keeps url.
|
|
524
|
+
info = RequestInfo.new(method: method.to_s.upcase, url: download ? Security.display_url(url) : url,
|
|
525
|
+
attempt: attempt)
|
|
517
526
|
@hooks.on_request_start(info)
|
|
518
527
|
|
|
519
528
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
520
529
|
|
|
521
|
-
|
|
530
|
+
severed = nil
|
|
531
|
+
result = begin
|
|
522
532
|
response = @faraday.run_request(method, url, body, request_headers(accept: accept)) do |req|
|
|
523
533
|
req.params.merge!(params) if params.any?
|
|
524
534
|
end
|
|
@@ -551,17 +561,29 @@ module Basecamp
|
|
|
551
561
|
&& @token_refreshed
|
|
552
562
|
@token_refreshed = false
|
|
553
563
|
return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1,
|
|
554
|
-
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: refresh_replay)
|
|
564
|
+
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: refresh_replay, download: download)
|
|
555
565
|
end
|
|
556
566
|
|
|
557
|
-
|
|
567
|
+
# On a download, a status error is raised below the rescue too: the
|
|
568
|
+
# Faraday exception retains the request (SPEC §9), and raising here
|
|
569
|
+
# would make it MRI's implicit cause.
|
|
570
|
+
raise error unless download
|
|
571
|
+
|
|
572
|
+
severed = error
|
|
558
573
|
rescue Faraday::Error => e
|
|
559
574
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
560
|
-
error
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
575
|
+
# SPEC §9: on a download hop 1 the Faraday error can render the URL it
|
|
576
|
+
# failed on, so it is neither the cause nor the hint, and it is raised
|
|
577
|
+
# below — outside this rescue, with cause: nil — so MRI's implicit
|
|
578
|
+
# cause is severed too. Every other request keeps its diagnostic.
|
|
579
|
+
error = download ? Basecamp::NetworkError.new : Basecamp::NetworkError.new("Connection failed", cause: e)
|
|
580
|
+
@hooks.on_request_end(info, RequestResult.new(duration: duration, error: error))
|
|
581
|
+
raise error unless download
|
|
582
|
+
|
|
583
|
+
severed = error
|
|
564
584
|
end
|
|
585
|
+
|
|
586
|
+
severed ? raise(severed, cause: nil) : result
|
|
565
587
|
end
|
|
566
588
|
|
|
567
589
|
# accept: nil is the binary-download carve-out (SPEC §14): hop 1 sends
|
|
@@ -624,38 +646,48 @@ module Basecamp
|
|
|
624
646
|
|
|
625
647
|
retry_after = parse_retry_after(headers["Retry-After"] || headers["retry-after"])
|
|
626
648
|
request_id = headers["X-Request-Id"] || headers["x-request-id"]
|
|
649
|
+
# SPEC §6 step 3: a body's error_description becomes the hint. Class
|
|
650
|
+
# defaults (AuthError, ForbiddenError) still apply when the body has none.
|
|
651
|
+
hint = Basecamp.parse_error_hint(body)
|
|
627
652
|
|
|
628
653
|
err = case status
|
|
629
654
|
when 401
|
|
630
655
|
# Try token refresh; flag for caller to retry
|
|
631
656
|
@token_refreshed = refresh_on_401 && @token_provider&.refreshable? && @token_provider.refresh
|
|
632
|
-
Basecamp::AuthError.new("Authentication failed")
|
|
657
|
+
Basecamp::AuthError.new("Authentication failed", hint: hint)
|
|
633
658
|
when 403
|
|
634
|
-
Basecamp::ForbiddenError.new("Access denied")
|
|
659
|
+
Basecamp::ForbiddenError.new("Access denied", hint: hint)
|
|
635
660
|
when 404
|
|
636
661
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Not found")
|
|
637
|
-
Basecamp::NotFoundError.new(message: message)
|
|
662
|
+
Basecamp::NotFoundError.new(message: message, hint: hint)
|
|
638
663
|
when 429
|
|
639
|
-
Basecamp::RateLimitError.new(retry_after: retry_after)
|
|
664
|
+
Basecamp::RateLimitError.new(retry_after: retry_after, hint: hint)
|
|
640
665
|
when 400, 422
|
|
641
666
|
field_errors = Basecamp.parse_field_errors(body)
|
|
642
667
|
message = Security.truncate(
|
|
643
668
|
Basecamp.compose_validation_message(Basecamp.parse_error_message(body), field_errors) || "Validation failed"
|
|
644
669
|
)
|
|
645
|
-
Basecamp
|
|
670
|
+
people = status == 422 ? Basecamp.parse_template_library_confirmation_people(body) : nil
|
|
671
|
+
if people
|
|
672
|
+
Basecamp::PeopleConfirmationRequiredError.new(
|
|
673
|
+
message, people: people, hint: hint, http_status: status, field_errors: field_errors
|
|
674
|
+
)
|
|
675
|
+
else
|
|
676
|
+
Basecamp::ValidationError.new(message, hint: hint, http_status: status, field_errors: field_errors)
|
|
677
|
+
end
|
|
646
678
|
when 507
|
|
647
679
|
# A 5xx status carrying a client fact: the account is out of storage, or
|
|
648
680
|
# at its webhook ceiling. Retrying cannot satisfy it, so this is decided
|
|
649
681
|
# before the 5xx arms below.
|
|
650
682
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Account limit reached")
|
|
651
|
-
Basecamp::LimitExceededError.new(message)
|
|
683
|
+
Basecamp::LimitExceededError.new(message, hint: hint)
|
|
652
684
|
when 500
|
|
653
|
-
Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true)
|
|
685
|
+
Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint)
|
|
654
686
|
when 502, 503, 504
|
|
655
|
-
Basecamp::ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
|
|
687
|
+
Basecamp::ApiError.new("Gateway error (#{status})", http_status: status, retryable: true, hint: hint)
|
|
656
688
|
else
|
|
657
689
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Request failed (HTTP #{status})")
|
|
658
|
-
Basecamp::ApiError.from_status(status || 0, message)
|
|
690
|
+
Basecamp::ApiError.from_status(status || 0, message, hint: hint)
|
|
659
691
|
end
|
|
660
692
|
|
|
661
693
|
err.instance_variable_set(:@request_id, request_id) if request_id
|
|
@@ -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)
|
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
|