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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b770f1606323f83fb028ebb44d51e0d2500664a6d88ec9209dc227807ace197
4
- data.tar.gz: 418cf25fbdc65f25db5e9d816565cd20a7f2e3d0c06c5a147dcf896c38d2ec9e
3
+ metadata.gz: 67cf91504a5aadff973463d83b4a4de98aa4ad35e2dd7d0d4e5f4f26021e8423
4
+ data.tar.gz: 88eacbb6628831a01e6a9cb56503618377ffec4a85ad85d5a514b025ff53dd2a
5
5
  SHA512:
6
- metadata.gz: 82caf3dad37f93e3b9ce65ad2da90b798efa466d0a2fc8e25d63ea84778357a436fe0da8615bbde3f03c3836eb9077dee20b35775ab283900437e575a64aa99e
7
- data.tar.gz: c17f87a5bac84d66e89edf3725fb3321fa768b31d868d8214f1dbf8326a6361f73dd5acf355c4a22712a9434fe5514d0b750edd2eb8d6ce15468a0867f44419d
6
+ metadata.gz: 8cede9f49e112c50b65d7c7c4901f442cba400211bfe4676a9af95a95ac4ca5da1a1477c529f72456745a0f8423b766162c151135b89a2c52ce1abbacb9eb17f
7
+ data.tar.gz: 695198c7697799719f0d66657463ed8ad76739bcd3bbe11877ad0a16d1fdd6b20602e2d6efa623c9d6e05a2b78ef5528e346ebec5975dd1697e5379e96b1e640
data/README.md CHANGED
@@ -316,7 +316,7 @@ aborts before an oversized body is buffered.
316
316
 
317
317
  ## Services
318
318
 
319
- The SDK provides 46 account-scoped services. The table below covers the common ones; see `lib/basecamp/generated/services/` for the authoritative, complete set:
319
+ The SDK provides the full account-scoped service set documented in SPEC §5. The table below covers the common ones; see `lib/basecamp/generated/services/` for the authoritative, complete set:
320
320
 
321
321
  | Service | Description |
322
322
  |---------|-------------|
@@ -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
@@ -240,7 +240,9 @@ module Basecamp
240
240
  #
241
241
  # Handles the full download flow: URL rewriting to the configured API host,
242
242
  # authenticated first hop (which typically 302s to a signed download URL),
243
- # and unauthenticated second hop to fetch the actual file content.
243
+ # and unauthenticated second hop to fetch the actual file content. Neither
244
+ # hop follows a redirect on its own: hop 1's is the dispatch to hop 2, and
245
+ # a redirect on hop 2 is an error (SPEC §14 "Hop-2 Redirect Policy").
244
246
  #
245
247
  # @param raw_url [String] absolute download URL (e.g., from bc-attachment elements)
246
248
  # @return [DownloadResult] the download result with body, content_type, content_length, filename
@@ -505,6 +507,11 @@ module Basecamp
505
507
  service(:bookmarks) { Services::BookmarksService.new(self) }
506
508
  end
507
509
 
510
+ # @return [Services::BubbleUpsService]
511
+ def bubble_ups
512
+ service(:bubble_ups) { Services::BubbleUpsService.new(self) }
513
+ end
514
+
508
515
  # @return [Services::FoldersService]
509
516
  def folders
510
517
  service(:folders) { Services::FoldersService.new(self) }
@@ -616,7 +623,35 @@ module Basecamp
616
623
  end
617
624
 
618
625
  def fetch_signed_download(url)
619
- uri = URI.parse(url)
626
+ # The Location target is server-supplied, and this unauthenticated hop
627
+ # has no same_origin? gate to refuse it (a signed URL is legitimately
628
+ # cross-origin), so illegible targets must be refused here — as
629
+ # ApiError, matching the legible refusals Go and TypeScript surface
630
+ # when their dial rejects the scheme. One predicate states the whole
631
+ # invariant — a dialable absolute HTTP(S) URL — because piecemeal
632
+ # guards kept leaking: "it parses" admitted "ftp://", and "scheme is
633
+ # http(s)" admitted hostless "http:foo", which Net::HTTP::Get rejects
634
+ # with a raw ArgumentError before the dial (nothing is ever sent).
635
+ # URI.parse types the scheme (URI::HTTPS < URI::HTTP), and a nil or
636
+ # empty host ("http:foo", "http:///x") is undialable.
637
+ uri = begin
638
+ URI.parse(url)
639
+ rescue URI::Error
640
+ nil
641
+ end
642
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
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}")
653
+ end
654
+
620
655
  http_client = Net::HTTP.new(uri.host, uri.port)
621
656
  http_client.use_ssl = (uri.scheme == "https")
622
657
  http_client.open_timeout = config.timeout
@@ -625,9 +660,24 @@ module Basecamp
625
660
  request = Net::HTTP::Get.new(uri)
626
661
 
627
662
  begin
663
+ # Net::HTTP#request never follows a redirect, which is the policy:
664
+ # the signed URL is the one destination the API host named, and a
665
+ # redirect from it is refused below, not dialled (SPEC §14 "Hop-2
666
+ # Redirect Policy"). Stated here so a move to a following client
667
+ # (Faraday, Net::HTTP.get_response's callers) has to argue with it.
628
668
  response = http_client.request(request)
629
- rescue StandardError => e
630
- raise NetworkError.new("Download failed: #{e.message}", cause: e)
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
675
+ end
676
+
677
+ # The exact set hop 1 dispatches on, not Net::HTTPRedirection — that
678
+ # class also covers 304, which is a cache answer, not a redirect.
679
+ if [ 301, 302, 303, 307, 308 ].include?(response.code.to_i)
680
+ raise ApiError.new("redirect #{response.code} on the signed download hop is not followed", http_status: response.code.to_i)
631
681
  end
632
682
 
633
683
  unless response.is_a?(Net::HTTPSuccess)
@@ -35,7 +35,21 @@ module Basecamp
35
35
  attr_accessor :max_jitter
36
36
 
37
37
  # @return [Integer] maximum pages to fetch in paginated requests
38
- attr_accessor :max_pages
38
+ attr_reader :max_pages
39
+
40
+ # A validating writer, not attr_accessor: Http reads the config live at
41
+ # every page boundary, so `config.max_pages = -1` after construction
42
+ # replaced the validated cap with a value validate! would have refused —
43
+ # the same second door TypeScript's compile-time-only `readonly` left
44
+ # open. The config deliberately stays mutable (from_file → load_from_env
45
+ # re-validation is builder-style by design); only the cap's invariant
46
+ # must hold at assignment.
47
+ #
48
+ # @param value [Integer] maximum pages to fetch in paginated requests
49
+ def max_pages=(value)
50
+ validate_max_pages!(value)
51
+ @max_pages = value
52
+ end
39
53
 
40
54
  # Default values
41
55
  DEFAULT_BASE_URL = "https://3.basecampapi.com"
@@ -164,7 +178,9 @@ module Basecamp
164
178
  # @param path [String] path to JSON config file
165
179
  # @return [Config]
166
180
  def self.from_file(path)
167
- data = JSON.parse(File.read(path))
181
+ # UTF-8 regardless of process locale — JSON is UTF-8 (RFC 8259), and
182
+ # LC_ALL=C would otherwise read as US-ASCII.
183
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
168
184
  config = new(
169
185
  base_url: data["base_url"] || DEFAULT_BASE_URL,
170
186
  timeout: data["timeout"] || DEFAULT_TIMEOUT,
@@ -199,7 +215,13 @@ module Basecamp
199
215
  def validate!
200
216
  raise ArgumentError, "timeout must be positive" unless @timeout.is_a?(Numeric) && @timeout > 0
201
217
  raise ArgumentError, "max_retries must be non-negative" unless @max_retries.is_a?(Integer) && @max_retries >= 0
202
- raise ArgumentError, "max_pages must be positive" unless @max_pages.is_a?(Integer) && @max_pages > 0
218
+ validate_max_pages!(@max_pages)
219
+ end
220
+
221
+ # Shared by validate! and the max_pages= writer. Integer-only, so a
222
+ # boolean or Float::INFINITY is refused along with zero and negatives.
223
+ def validate_max_pages!(value)
224
+ raise ArgumentError, "max_pages must be positive" unless value.is_a?(Integer) && value > 0
203
225
  end
204
226
 
205
227
  def normalize_url(url)
@@ -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-08-07T11:12:00Z",
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.