basecamp-sdk 0.7.3 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d07bf1af7eccbc28f132ef388bf5beb2c2571662e0cfc60b030705c1b8184b15
4
- data.tar.gz: ab40ddc799b5aea73915f596ba38ba556e0c69b67795809d63927aec9c88ce77
3
+ metadata.gz: 50a6f193dfed4631d675333812b23a9d586b5f78d2a060bbd15222855c93f62c
4
+ data.tar.gz: 2a9ce91aff3ae13f3262701feaa2d7706f100c1d217714896bfad94006563daf
5
5
  SHA512:
6
- metadata.gz: 7ab64d75d93daba3f7d514531a08b688f8442f444a2baa1f265a59d9968f90c4b0d2dc956f0b7a5adf3e1d4c25c0b3d8678664648617b0605164c22919d935db
7
- data.tar.gz: feb7f7b9df02aa100f0a63e3b302d045a38c62577b0a121ce28e0db4f0ea7e9e21a11ab89e5cb3a9e10d0f0ac6c05f3cbd8aba5192c9b3990aa621563c1e3ee5
6
+ metadata.gz: a594aada84cb34e591e3ec6747095d799626fb37763531c14c3d7a0fdc05360e61572e6e3e9748c405c73e4c603ca30c503b22b73c24273cfc13de84891a8f9b
7
+ data.tar.gz: 60a1d05afbe172cd92b4ee5c75871e57df33cb52f3f664e4c0dfa2823259f7ef5593cb32e022e41cc73ee30b255b214c16c4e2d1901ceef3f48eed6e11075f3b
data/README.md CHANGED
@@ -133,6 +133,50 @@ if token.expired?
133
133
  end
134
134
  ```
135
135
 
136
+ ### Resource-First Discovery (RFC 9728 + RFC 8414)
137
+
138
+ BC5's Authorization Server (AS) metadata lives only at the canonical issuer (the
139
+ web host), so discovery starts from the **resource** (the API host) rather than
140
+ probing the API host for AS metadata. Three composable operations are provided:
141
+
142
+ ```ruby
143
+ # RFC 8414 — AS metadata for a known issuer, bound to the requested issuer by
144
+ # code-point. token_endpoint is required; authorization_endpoint is OPTIONAL
145
+ # (device-only servers omit it) — authorization-code consumers must assert it.
146
+ config = Basecamp::Oauth.discover("https://launchpad.37signals.com")
147
+
148
+ # RFC 9728 — protected-resource metadata for a resource origin. resource is
149
+ # bound by code-point; authorization_servers preserves absent (nil) vs [].
150
+ resource = Basecamp::Oauth.discover_protected_resource("https://3.basecampapi.com")
151
+
152
+ # Orchestrator — resource-first selection + stage-sensitive fallback.
153
+ result = Basecamp::Oauth.discover_from_resource(
154
+ "https://3.basecampapi.com",
155
+ expected_issuer: nil # optional: authoritative issuer selection
156
+ )
157
+
158
+ if result.selected?
159
+ config = result.config # bound AS config for the selected issuer
160
+ else
161
+ # Only two SOFT reasons ever yield a fallback (→ Launchpad):
162
+ # "resource_discovery_failed" | "no_as_advertised"
163
+ result.reason
164
+ end
165
+ ```
166
+
167
+ `discover_from_resource` returns a `DiscoveryResult` that is either **selected**
168
+ or a **soft fallback**. Every *hard* failure — an ambiguous advertised set, an
169
+ unavailable `expected_issuer`, an invalid advertised issuer origin, an AS-metadata
170
+ fetch failure, or an issuer-binding mismatch after a BC5 issuer was selected —
171
+ raises `Basecamp::Oauth::DiscoverySelectionError` (carrying a `reason`). A hard
172
+ failure is **never** silently converted into a Launchpad request.
173
+
174
+ All discovery fetches are SSRF-hardened: origins are validated against the
175
+ origin-root profile (HTTPS-only, localhost exempt) with Ruby's `URI` parser before
176
+ any socket opens, redirects are not followed, timeouts are bounded, non-2xx maps
177
+ to `api_error`, and the response body is read under a genuine streaming cap that
178
+ aborts before an oversized body is buffered.
179
+
136
180
  ## Services
137
181
 
138
182
  The SDK provides 37 services covering the complete Basecamp API:
@@ -194,6 +238,25 @@ first_10 = account.todos.list(todolist_id: 456).take(10)
194
238
  all_projects = account.projects.list.to_a
195
239
  ```
196
240
 
241
+ ## Downloading Files
242
+
243
+ Fetch an upload's file content in one call. The SDK fetches the upload
244
+ metadata, then follows the authenticated-hop + 302 flow against the
245
+ signed storage URL.
246
+
247
+ ```ruby
248
+ result = account.uploads.download(upload_id: 1069479400)
249
+ File.binwrite("uploaded.bin", result.body)
250
+ # result.content_type, result.content_length, result.filename are also available
251
+ ```
252
+
253
+ For any authenticated download URL (e.g. a `download_url` you already
254
+ have in hand), use `AccountClient#download_url`:
255
+
256
+ ```ruby
257
+ result = account.download_url(url)
258
+ ```
259
+
197
260
  ## Retry Behavior
198
261
 
199
262
  GET requests automatically retry on transient failures with exponential backoff:
data/basecamp-sdk.gemspec CHANGED
@@ -37,10 +37,9 @@ Gem::Specification.new do |spec|
37
37
  spec.add_development_dependency 'minitest', '~> 6.0'
38
38
  spec.add_development_dependency 'rake', '~> 13.0'
39
39
  spec.add_development_dependency 'rubocop-37signals'
40
- spec.add_development_dependency 'simplecov', '~> 0.22'
40
+ spec.add_development_dependency 'simplecov', '~> 1.0'
41
41
  spec.add_development_dependency 'webmock', '~> 3.24'
42
42
  spec.add_development_dependency 'irb', '~> 1.15'
43
- spec.add_development_dependency 'rdoc', '~> 7.1'
44
- spec.add_development_dependency 'webrick', '~> 1.9'
43
+ spec.add_development_dependency 'rdoc', '~> 8.0'
45
44
  spec.add_development_dependency 'yard', '~> 0.9'
46
45
  end
@@ -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-03-26T04:17:28Z",
4
+ "generated": "2026-07-22T08:33:01Z",
5
5
  "operations": {
6
6
  "GetAccount": {
7
7
  "retry": {
@@ -92,7 +92,7 @@
92
92
  "natural": true
93
93
  }
94
94
  },
95
- "ListWebhooks": {
95
+ "SetCardColumnColor": {
96
96
  "retry": {
97
97
  "maxAttempts": 3,
98
98
  "baseDelayMs": 1000,
@@ -102,13 +102,11 @@
102
102
  503
103
103
  ]
104
104
  },
105
- "pagination": {
106
- "style": "link",
107
- "totalCountHeader": "X-Total-Count",
108
- "maxPageSize": 50
105
+ "idempotent": {
106
+ "natural": true
109
107
  }
110
108
  },
111
- "CreateWebhook": {
109
+ "EnableCardColumnOnHold": {
112
110
  "retry": {
113
111
  "maxAttempts": 2,
114
112
  "baseDelayMs": 1000,
@@ -119,7 +117,7 @@
119
117
  ]
120
118
  }
121
119
  },
122
- "GetCard": {
120
+ "DisableCardColumnOnHold": {
123
121
  "retry": {
124
122
  "maxAttempts": 3,
125
123
  "baseDelayMs": 1000,
@@ -128,34 +126,39 @@
128
126
  429,
129
127
  503
130
128
  ]
129
+ },
130
+ "idempotent": {
131
+ "natural": true
131
132
  }
132
133
  },
133
- "UpdateCard": {
134
+ "CreateTool": {
134
135
  "retry": {
135
- "maxAttempts": 3,
136
+ "maxAttempts": 2,
136
137
  "baseDelayMs": 1000,
137
138
  "backoff": "exponential",
138
139
  "retryOn": [
139
140
  429,
140
141
  503
141
142
  ]
142
- },
143
- "idempotent": {
144
- "natural": true
145
143
  }
146
144
  },
147
- "MoveCard": {
145
+ "ListWebhooks": {
148
146
  "retry": {
149
- "maxAttempts": 2,
147
+ "maxAttempts": 3,
150
148
  "baseDelayMs": 1000,
151
149
  "backoff": "exponential",
152
150
  "retryOn": [
153
151
  429,
154
152
  503
155
153
  ]
154
+ },
155
+ "pagination": {
156
+ "style": "link",
157
+ "totalCountHeader": "X-Total-Count",
158
+ "maxPageSize": 50
156
159
  }
157
160
  },
158
- "RepositionCardStep": {
161
+ "CreateWebhook": {
159
162
  "retry": {
160
163
  "maxAttempts": 2,
161
164
  "baseDelayMs": 1000,
@@ -166,9 +169,9 @@
166
169
  ]
167
170
  }
168
171
  },
169
- "CreateCardStep": {
172
+ "GetCard": {
170
173
  "retry": {
171
- "maxAttempts": 2,
174
+ "maxAttempts": 3,
172
175
  "baseDelayMs": 1000,
173
176
  "backoff": "exponential",
174
177
  "retryOn": [
@@ -177,7 +180,7 @@
177
180
  ]
178
181
  }
179
182
  },
180
- "GetCardColumn": {
183
+ "UpdateCard": {
181
184
  "retry": {
182
185
  "maxAttempts": 3,
183
186
  "baseDelayMs": 1000,
@@ -186,37 +189,34 @@
186
189
  429,
187
190
  503
188
191
  ]
192
+ },
193
+ "idempotent": {
194
+ "natural": true
189
195
  }
190
196
  },
191
- "UpdateCardColumn": {
197
+ "MoveCard": {
192
198
  "retry": {
193
- "maxAttempts": 3,
199
+ "maxAttempts": 2,
194
200
  "baseDelayMs": 1000,
195
201
  "backoff": "exponential",
196
202
  "retryOn": [
197
203
  429,
198
204
  503
199
205
  ]
200
- },
201
- "idempotent": {
202
- "natural": true
203
206
  }
204
207
  },
205
- "SetCardColumnColor": {
208
+ "RepositionCardStep": {
206
209
  "retry": {
207
- "maxAttempts": 3,
210
+ "maxAttempts": 2,
208
211
  "baseDelayMs": 1000,
209
212
  "backoff": "exponential",
210
213
  "retryOn": [
211
214
  429,
212
215
  503
213
216
  ]
214
- },
215
- "idempotent": {
216
- "natural": true
217
217
  }
218
218
  },
219
- "EnableCardColumnOnHold": {
219
+ "CreateCardStep": {
220
220
  "retry": {
221
221
  "maxAttempts": 2,
222
222
  "baseDelayMs": 1000,
@@ -227,7 +227,18 @@
227
227
  ]
228
228
  }
229
229
  },
230
- "DisableCardColumnOnHold": {
230
+ "GetCardColumn": {
231
+ "retry": {
232
+ "maxAttempts": 3,
233
+ "baseDelayMs": 1000,
234
+ "backoff": "exponential",
235
+ "retryOn": [
236
+ 429,
237
+ 503
238
+ ]
239
+ }
240
+ },
241
+ "UpdateCardColumn": {
231
242
  "retry": {
232
243
  "maxAttempts": 3,
233
244
  "baseDelayMs": 1000,
@@ -565,6 +576,20 @@
565
576
  ]
566
577
  }
567
578
  },
579
+ "UpdateCampfireLine": {
580
+ "retry": {
581
+ "maxAttempts": 3,
582
+ "baseDelayMs": 1000,
583
+ "backoff": "exponential",
584
+ "retryOn": [
585
+ 429,
586
+ 503
587
+ ]
588
+ },
589
+ "idempotent": {
590
+ "natural": true
591
+ }
592
+ },
568
593
  "DeleteCampfireLine": {
569
594
  "retry": {
570
595
  "maxAttempts": 3,
@@ -728,17 +753,6 @@
728
753
  "natural": true
729
754
  }
730
755
  },
731
- "CloneTool": {
732
- "retry": {
733
- "maxAttempts": 2,
734
- "baseDelayMs": 1000,
735
- "backoff": "exponential",
736
- "retryOn": [
737
- 429,
738
- 503
739
- ]
740
- }
741
- },
742
756
  "GetTool": {
743
757
  "retry": {
744
758
  "maxAttempts": 3,
@@ -2329,7 +2343,7 @@
2329
2343
  ]
2330
2344
  }
2331
2345
  },
2332
- "UpdateTodo": {
2346
+ "ReplaceTodo": {
2333
2347
  "retry": {
2334
2348
  "maxAttempts": 3,
2335
2349
  "baseDelayMs": 1000,
@@ -112,7 +112,19 @@ module Basecamp
112
112
  end
113
113
  end
114
114
 
115
- # Delete a campfire line
115
+ # Update an existing campfire line; the content is always treated as rich text (HTML).
116
+ # @param campfire_id [Integer] campfire id ID
117
+ # @param line_id [Integer] line id ID
118
+ # @param content [String] The new line content, interpreted as rich text (HTML)
119
+ # @return [void]
120
+ def update_line(campfire_id:, line_id:, content:)
121
+ with_operation(service: "campfires", operation: "update_line", is_mutation: true, resource_id: line_id) do
122
+ http_put("/chats/#{campfire_id}/lines/#{line_id}", body: compact_params(content: content))
123
+ nil
124
+ end
125
+ end
126
+
127
+ # Delete a campfire line; allowed for the line's creator or an admin.
116
128
  # @param campfire_id [Integer] campfire id ID
117
129
  # @param line_id [Integer] line id ID
118
130
  # @return [void]
@@ -7,51 +7,54 @@ module Basecamp
7
7
  # @generated from OpenAPI spec
8
8
  class CardColumnsService < BaseService
9
9
 
10
- # Get a card column by ID
10
+ # Set the color of a column
11
+ # @param bucket_id [Integer] bucket id ID
11
12
  # @param column_id [Integer] column id ID
13
+ # @param color [String] Valid colors: white, red, orange, yellow, green, blue, aqua, purple, gray, pink, brown
12
14
  # @return [Hash] response data
13
- def get(column_id:)
14
- with_operation(service: "cardcolumns", operation: "get", is_mutation: false, resource_id: column_id) do
15
- http_get("/card_tables/columns/#{column_id}").json
15
+ def set_color(bucket_id:, column_id:, color:)
16
+ with_operation(service: "cardcolumns", operation: "set_color", is_mutation: true, resource_id: column_id) do
17
+ http_put("/buckets/#{bucket_id}/card_tables/columns/#{column_id}/color.json", body: compact_params(color: color)).json
16
18
  end
17
19
  end
18
20
 
19
- # Update an existing column
21
+ # Enable on-hold section in a column
22
+ # @param bucket_id [Integer] bucket id ID
20
23
  # @param column_id [Integer] column id ID
21
- # @param title [String, nil] title
22
- # @param description [String, nil] description
23
24
  # @return [Hash] response data
24
- def update(column_id:, title: nil, description: nil)
25
- with_operation(service: "cardcolumns", operation: "update", is_mutation: true, resource_id: column_id) do
26
- http_put("/card_tables/columns/#{column_id}", body: compact_params(title: title, description: description)).json
25
+ def enable_on_hold(bucket_id:, column_id:)
26
+ with_operation(service: "cardcolumns", operation: "enable_on_hold", is_mutation: true, resource_id: column_id) do
27
+ http_post("/buckets/#{bucket_id}/card_tables/columns/#{column_id}/on_hold.json").json
27
28
  end
28
29
  end
29
30
 
30
- # Set the color of a column
31
+ # Disable on-hold section in a column
32
+ # @param bucket_id [Integer] bucket id ID
31
33
  # @param column_id [Integer] column id ID
32
- # @param color [String] Valid colors: white, red, orange, yellow, green, blue, aqua, purple, gray, pink, brown
33
34
  # @return [Hash] response data
34
- def set_color(column_id:, color:)
35
- with_operation(service: "cardcolumns", operation: "set_color", is_mutation: true, resource_id: column_id) do
36
- http_put("/card_tables/columns/#{column_id}/color.json", body: compact_params(color: color)).json
35
+ def disable_on_hold(bucket_id:, column_id:)
36
+ with_operation(service: "cardcolumns", operation: "disable_on_hold", is_mutation: true, resource_id: column_id) do
37
+ http_delete("/buckets/#{bucket_id}/card_tables/columns/#{column_id}/on_hold.json").json
37
38
  end
38
39
  end
39
40
 
40
- # Enable on-hold section in a column
41
+ # Get a card column by ID
41
42
  # @param column_id [Integer] column id ID
42
43
  # @return [Hash] response data
43
- def enable_on_hold(column_id:)
44
- with_operation(service: "cardcolumns", operation: "enable_on_hold", is_mutation: true, resource_id: column_id) do
45
- http_post("/card_tables/columns/#{column_id}/on_hold.json").json
44
+ def get(column_id:)
45
+ with_operation(service: "cardcolumns", operation: "get", is_mutation: false, resource_id: column_id) do
46
+ http_get("/card_tables/columns/#{column_id}").json
46
47
  end
47
48
  end
48
49
 
49
- # Disable on-hold section in a column
50
+ # Update an existing column
50
51
  # @param column_id [Integer] column id ID
52
+ # @param title [String, nil] title
53
+ # @param description [String, nil] description
51
54
  # @return [Hash] response data
52
- def disable_on_hold(column_id:)
53
- with_operation(service: "cardcolumns", operation: "disable_on_hold", is_mutation: true, resource_id: column_id) do
54
- http_delete("/card_tables/columns/#{column_id}/on_hold.json").json
55
+ def update(column_id:, title: nil, description: nil)
56
+ with_operation(service: "cardcolumns", operation: "update", is_mutation: true, resource_id: column_id) do
57
+ http_put("/card_tables/columns/#{column_id}", body: compact_params(title: title, description: description)).json
55
58
  end
56
59
  end
57
60
 
@@ -15,7 +15,7 @@ module Basecamp
15
15
  end
16
16
  end
17
17
 
18
- # Get upcoming schedule entries within a date window
18
+ # Get upcoming schedule entries and assignable items within a date window.
19
19
  # @param window_starts_on [String, nil] window starts on
20
20
  # @param window_ends_on [String, nil] window ends on
21
21
  # @return [Hash] response data
@@ -44,9 +44,9 @@ module Basecamp
44
44
  end
45
45
  end
46
46
 
47
- # Update an existing todo
47
+ # Replace a todo with a new complete representation.
48
48
  # @param todo_id [Integer] todo id ID
49
- # @param content [String, nil] content
49
+ # @param content [String] content
50
50
  # @param description [String, nil] description
51
51
  # @param assignee_ids [Array, nil] assignee ids
52
52
  # @param completion_subscriber_ids [Array, nil] completion subscriber ids
@@ -54,8 +54,8 @@ module Basecamp
54
54
  # @param due_on [String, nil] due on (YYYY-MM-DD)
55
55
  # @param starts_on [String, nil] starts on (YYYY-MM-DD)
56
56
  # @return [Hash] response data
57
- def update(todo_id:, content: nil, description: nil, assignee_ids: nil, completion_subscriber_ids: nil, notify: nil, due_on: nil, starts_on: nil)
58
- with_operation(service: "todos", operation: "update", is_mutation: true, resource_id: todo_id) do
57
+ def replace(todo_id:, content:, description: nil, assignee_ids: nil, completion_subscriber_ids: nil, notify: nil, due_on: nil, starts_on: nil)
58
+ with_operation(service: "todos", operation: "replace", is_mutation: true, resource_id: todo_id) do
59
59
  http_put("/todos/#{todo_id}", body: compact_params(content: content, description: description, assignee_ids: assignee_ids, completion_subscriber_ids: completion_subscriber_ids, notify: notify, due_on: due_on, starts_on: starts_on)).json
60
60
  end
61
61
  end
@@ -7,13 +7,14 @@ module Basecamp
7
7
  # @generated from OpenAPI spec
8
8
  class ToolsService < BaseService
9
9
 
10
- # Clone an existing tool to create a new one
11
- # @param source_recording_id [Integer] source recording id
12
- # @param title [String, nil] title
10
+ # Create a tool in a project dock
11
+ # @param bucket_id [Integer] bucket id ID
12
+ # @param tool_type [String] Tool type to add to the project dock. Values: Chat::Transcript|Inbox|Kanban::Board|Message::Board|Questionnaire|Schedule|Todoset|Vault.
13
+ # @param title [String, nil] Title for the new tool. When omitted, Basecamp assigns the next available default title for the tool type.
13
14
  # @return [Hash] response data
14
- def clone(source_recording_id:, title: nil)
15
- with_operation(service: "tools", operation: "clone", is_mutation: true) do
16
- http_post("/dock/tools.json", body: compact_params(source_recording_id: source_recording_id, title: title)).json
15
+ def create(bucket_id:, tool_type:, title: nil)
16
+ with_operation(service: "tools", operation: "create", is_mutation: true, resource_id: bucket_id) do
17
+ http_post("/buckets/#{bucket_id}/dock/tools.json", body: compact_params(tool_type: tool_type, title: title)).json
17
18
  end
18
19
  end
19
20
 
@@ -57,6 +57,23 @@ module Basecamp
57
57
  http_post("/vaults/#{vault_id}/uploads.json", body: compact_params(attachable_sgid: attachable_sgid, description: description, base_name: base_name, subscriptions: subscriptions)).json
58
58
  end
59
59
  end
60
+
61
+ # Download an upload's file content in one call.
62
+ # Fetches upload metadata, then delegates to the AccountClient download
63
+ # primitive so the auth'd-hop + 302-follow flow lives in one place.
64
+ # @param upload_id [Integer] upload id ID
65
+ # @return [Basecamp::DownloadResult]
66
+ def download(upload_id:)
67
+ with_operation(service: "uploads", operation: "download", is_mutation: false, resource_id: upload_id) do
68
+ upload = get(upload_id: upload_id)
69
+ url = upload["download_url"]
70
+ raise UsageError.new("upload #{upload_id} has no download_url") if url.nil? || url.empty?
71
+
72
+ result = @client.download_url(url)
73
+ filename = upload["filename"]
74
+ filename.to_s.empty? ? result : result.with(filename: filename)
75
+ end
76
+ end
60
77
  end
61
78
  end
62
79
  end