basecamp-sdk 0.13.0 → 0.15.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: d41385ab7c36a096b4b54f56a0b60af4c6ab42e561e4bf147bdd850b77c8e230
4
- data.tar.gz: e93de2347867e1f40afcee749d1e49a7873fffd4f656200bf5305e0fef813b29
3
+ metadata.gz: f6c1f02712a441b2b7772199b8029904b0c254be6c9b687a94a8d957f7d8a066
4
+ data.tar.gz: f0750c8e75561aaa908630440f0b2c4276a54abb653418361cc4363883497d9a
5
5
  SHA512:
6
- metadata.gz: 8f33657f9328f7a786aa33925a96ab89786f655dd0f83db71f80b9fb9472c06bd2400a15b5637e964a00fc146a1dbae0f86b49157939f1df983f52838ec42380
7
- data.tar.gz: 2db82e6a78234d1f12f6e54a139d490df8f0f5205135a23b8946435f75f7120e863659d3c3fdeb6ae3e57a7cb94b55a32a7ba4914aa1ad0c7ebfba08f701d59e
6
+ metadata.gz: a7d6ecde9f18d17b9baadc7b827b6d396c0f6e63ab304d93800ee2e35f3803af9d668965b5d5885a408d926e780700f7fe0320d68375924a6647cce2462eee20
7
+ data.tar.gz: a445a4123c2ebf596026035d347dbe99883b207a650003d2651f0dbc77eed9c0f4634703555e17af674aca87e015fdbe17f38a40e9370deff1b73a42cec6d042
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Official Ruby SDK for the [Basecamp API](https://github.com/basecamp/bc3-api).
4
4
 
5
- **Upgrading to v0.13.0?** Read [MIGRATING.md](../MIGRATING.md#ruby) before you bump the version — Ruby carries eleven breaks nothing catches at load time ten with no signal at all, and one that raises only on a record where the field is populated.
5
+ **Upgrading to v0.14.0?** Read [MIGRATING.md](../MIGRATING.md) before you bump the version — nothing catches either break at load time. `uploads.list_versions` entries now carry the version event's keys, with the file nested under `"upload"` — code reading `version["filename"]` was getting nil and now has a real place to look — and every 507 reports `limit_exceeded` instead of a retryable `api_error`, so a `when` falling through to an else arm reroutes storage, project and webhook limits silently. Coming from v0.12.0 or earlier, read v0.13.0's section too.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -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
  |---------|-------------|
@@ -480,6 +480,7 @@ end
480
480
  | `ValidationError` | Invalid request data (400, 422) |
481
481
  | `RateLimitError` | Rate limit exceeded (429) |
482
482
  | `NetworkError` | Connection failures |
483
+ | `LimitExceededError` | Account limit reached (507) — file storage, projects, webhooks |
483
484
 
484
485
  ### Validation Errors
485
486
 
@@ -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
@@ -616,7 +618,26 @@ module Basecamp
616
618
  end
617
619
 
618
620
  def fetch_signed_download(url)
619
- uri = URI.parse(url)
621
+ # The Location target is server-supplied, and this unauthenticated hop
622
+ # has no same_origin? gate to refuse it (a signed URL is legitimately
623
+ # cross-origin), so illegible targets must be refused here — as
624
+ # ApiError, matching the legible refusals Go and TypeScript surface
625
+ # when their dial rejects the scheme. One predicate states the whole
626
+ # invariant — a dialable absolute HTTP(S) URL — because piecemeal
627
+ # guards kept leaking: "it parses" admitted "ftp://", and "scheme is
628
+ # http(s)" admitted hostless "http:foo", which Net::HTTP::Get rejects
629
+ # with a raw ArgumentError before the dial (nothing is ever sent).
630
+ # URI.parse types the scheme (URI::HTTPS < URI::HTTP), and a nil or
631
+ # empty host ("http:foo", "http:///x") is undialable.
632
+ uri = begin
633
+ URI.parse(url)
634
+ rescue URI::Error
635
+ nil
636
+ end
637
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
638
+ raise ApiError.new("redirect to undialable download URL: #{Security.truncate(url)}")
639
+ end
640
+
620
641
  http_client = Net::HTTP.new(uri.host, uri.port)
621
642
  http_client.use_ssl = (uri.scheme == "https")
622
643
  http_client.open_timeout = config.timeout
@@ -625,11 +646,22 @@ module Basecamp
625
646
  request = Net::HTTP::Get.new(uri)
626
647
 
627
648
  begin
649
+ # Net::HTTP#request never follows a redirect, which is the policy:
650
+ # the signed URL is the one destination the API host named, and a
651
+ # redirect from it is refused below, not dialled (SPEC §14 "Hop-2
652
+ # Redirect Policy"). Stated here so a move to a following client
653
+ # (Faraday, Net::HTTP.get_response's callers) has to argue with it.
628
654
  response = http_client.request(request)
629
655
  rescue StandardError => e
630
656
  raise NetworkError.new("Download failed: #{e.message}", cause: e)
631
657
  end
632
658
 
659
+ # The exact set hop 1 dispatches on, not Net::HTTPRedirection — that
660
+ # class also covers 304, which is a cache answer, not a redirect.
661
+ if [ 301, 302, 303, 307, 308 ].include?(response.code.to_i)
662
+ raise ApiError.new("redirect #{response.code} on the signed download hop is not followed", http_status: response.code.to_i)
663
+ end
664
+
633
665
  unless response.is_a?(Net::HTTPSuccess)
634
666
  raise ApiError.new("download failed with status #{response.code}", http_status: response.code.to_i)
635
667
  end
@@ -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)
@@ -79,6 +79,7 @@ module Basecamp
79
79
  when ErrorCode::API then ExitCode::API
80
80
  when ErrorCode::AMBIGUOUS then ExitCode::AMBIGUOUS
81
81
  when ErrorCode::VALIDATION then ExitCode::VALIDATION
82
+ when ErrorCode::LIMIT_EXCEEDED then ExitCode::LIMIT_EXCEEDED
82
83
  else ExitCode::API
83
84
  end
84
85
  end
@@ -12,5 +12,6 @@ module Basecamp
12
12
  API = "api_error"
13
13
  AMBIGUOUS = "ambiguous"
14
14
  VALIDATION = "validation"
15
+ LIMIT_EXCEEDED = "limit_exceeded"
15
16
  end
16
17
  end
@@ -13,5 +13,6 @@ module Basecamp
13
13
  API = 7
14
14
  AMBIGUOUS = 8
15
15
  VALIDATION = 9
16
+ LIMIT_EXCEEDED = 10
16
17
  end
17
18
  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-08-06T00:45:34Z",
4
+ "generated": "2026-08-12T19:58:36Z",
5
5
  "operations": {
6
6
  "GetAccount": {
7
7
  "retry": {
@@ -3148,6 +3148,17 @@
3148
3148
  "maxPageSize": 50
3149
3149
  }
3150
3150
  },
3151
+ "CreateUploadVersion": {
3152
+ "retry": {
3153
+ "maxAttempts": 2,
3154
+ "baseDelayMs": 1000,
3155
+ "backoff": "exponential",
3156
+ "retryOn": [
3157
+ 429,
3158
+ 503
3159
+ ]
3160
+ }
3161
+ },
3151
3162
  "GetVault": {
3152
3163
  "retry": {
3153
3164
  "maxAttempts": 3,
@@ -37,6 +37,24 @@ module Basecamp
37
37
  end
38
38
  end
39
39
 
40
+ # Replace an upload's file with a new version
41
+ # @param upload_id [Integer] upload id ID
42
+ # @param attachable_sgid [String] attachable sgid
43
+ # @param base_name [String, nil] Omit to keep the uploaded file's own name. Sending "" also keeps it.
44
+ # @param description [String, nil] Presence-aware: omit to carry the previous version's description forward,
45
+ # send "" to clear it, send a value to set it.
46
+ # @param notify [String, nil] Who to notify: "default", "everyone", or "custom" (the people in subscriptions).
47
+ #
48
+ # Omit both this and subscriptions to notify nobody. A subscriptions array sent
49
+ # without notify is read as "custom".
50
+ # @param subscriptions [Array, nil] People to notify about the replacement and subscribe to the upload.
51
+ # @return [Hash] response data
52
+ def create_version(upload_id:, attachable_sgid:, base_name: nil, description: nil, notify: nil, subscriptions: nil)
53
+ with_operation(service: "uploads", operation: "create_version", is_mutation: true, resource_id: upload_id) do
54
+ http_post("/uploads/#{upload_id}/versions.json", body: compact_params(attachable_sgid: attachable_sgid, base_name: base_name, description: description, notify: notify, subscriptions: subscriptions)).json
55
+ end
56
+ end
57
+
40
58
  # List uploads in a vault
41
59
  # @param vault_id [Integer] vault id ID
42
60
  # @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-08-06T00:45:34Z
4
+ # Generated: 2026-08-12T19:58:36Z
5
5
 
6
6
  require "json"
7
7
  require "time"
@@ -2672,23 +2672,23 @@ module Basecamp
2672
2672
  # MyAssignmentAssignee
2673
2673
  class MyAssignmentAssignee
2674
2674
  include TypeHelpers
2675
- attr_accessor :id, :avatar_url, :name
2675
+ attr_accessor :avatar_url, :id, :name
2676
2676
 
2677
2677
  # @return [Array<Symbol>]
2678
2678
  def self.required_fields
2679
- %i[id].freeze
2679
+ %i[avatar_url id name].freeze
2680
2680
  end
2681
2681
 
2682
2682
  def initialize(data = {})
2683
- @id = parse_integer(data["id"])
2684
2683
  @avatar_url = data["avatar_url"]
2684
+ @id = parse_integer(data["id"])
2685
2685
  @name = data["name"]
2686
2686
  end
2687
2687
 
2688
2688
  def to_h
2689
2689
  {
2690
- "id" => @id,
2691
2690
  "avatar_url" => @avatar_url,
2691
+ "id" => @id,
2692
2692
  "name" => @name,
2693
2693
  }.compact
2694
2694
  end
@@ -2955,23 +2955,23 @@ module Basecamp
2955
2955
  # OutOfOfficePerson
2956
2956
  class OutOfOfficePerson
2957
2957
  include TypeHelpers
2958
- attr_accessor :id, :avatar_url, :name
2958
+ attr_accessor :avatar_url, :id, :name
2959
2959
 
2960
2960
  # @return [Array<Symbol>]
2961
2961
  def self.required_fields
2962
- %i[id].freeze
2962
+ %i[avatar_url id name].freeze
2963
2963
  end
2964
2964
 
2965
2965
  def initialize(data = {})
2966
- @id = parse_integer(data["id"])
2967
2966
  @avatar_url = data["avatar_url"]
2967
+ @id = parse_integer(data["id"])
2968
2968
  @name = data["name"]
2969
2969
  end
2970
2970
 
2971
2971
  def to_h
2972
2972
  {
2973
- "id" => @id,
2974
2973
  "avatar_url" => @avatar_url,
2974
+ "id" => @id,
2975
2975
  "name" => @name,
2976
2976
  }.compact
2977
2977
  end
@@ -4025,62 +4025,114 @@ module Basecamp
4025
4025
  # SearchResult
4026
4026
  class SearchResult
4027
4027
  include TypeHelpers
4028
- attr_accessor :app_url, :content, :description, :id, :title, :type, :url, :bookmark_url, :bubble_up_url, :bucket, :content_attachments, :created_at, :creator, :description_attachments, :inherits_status, :parent, :plain_text_content, :plain_text_description, :status, :subject, :updated_at, :visible_to_clients
4028
+ attr_accessor :content, :description, :app_download_url, :app_url, :attachments, :bookmark_url, :boosts_count, :boosts_url, :bubble_up_url, :bucket, :byte_size, :cards_count, :cards_url, :color, :comment_count, :comments_count, :comments_url, :content_attachments, :content_type, :created_at, :creator, :description_attachments, :download_url, :filename, :height, :id, :image_url, :inherits_status, :language, :on_hold, :parent, :plain_text_content, :plain_text_description, :position, :preview_url, :previewable, :sound_url, :status, :subject, :subscribers, :subscription_url, :thumbnail_url, :title, :type, :updated_at, :url, :visible_to_clients, :width
4029
4029
 
4030
4030
  # @return [Array<Symbol>]
4031
4031
  def self.required_fields
4032
- %i[app_url content description id title type url].freeze
4032
+ %i[content description].freeze
4033
4033
  end
4034
4034
 
4035
4035
  def initialize(data = {})
4036
- @app_url = data["app_url"]
4037
4036
  @content = data["content"]
4038
4037
  @description = data["description"]
4039
- @id = parse_integer(data["id"])
4040
- @title = data["title"]
4041
- @type = data["type"]
4042
- @url = data["url"]
4038
+ @app_download_url = data["app_download_url"]
4039
+ @app_url = data["app_url"]
4040
+ @attachments = parse_array(data["attachments"], "SearchResultAttachment")
4043
4041
  @bookmark_url = data["bookmark_url"]
4042
+ @boosts_count = parse_integer(data["boosts_count"])
4043
+ @boosts_url = data["boosts_url"]
4044
4044
  @bubble_up_url = data["bubble_up_url"]
4045
4045
  @bucket = parse_type(data["bucket"], "RecordingBucket")
4046
+ @byte_size = parse_integer(data["byte_size"])
4047
+ @cards_count = parse_integer(data["cards_count"])
4048
+ @cards_url = data["cards_url"]
4049
+ @color = data["color"]
4050
+ @comment_count = parse_integer(data["comment_count"])
4051
+ @comments_count = parse_integer(data["comments_count"])
4052
+ @comments_url = data["comments_url"]
4046
4053
  @content_attachments = parse_array(data["content_attachments"], "RichTextAttachment")
4054
+ @content_type = data["content_type"]
4047
4055
  @created_at = parse_datetime(data["created_at"])
4048
4056
  @creator = parse_type(data["creator"], "Person")
4049
4057
  @description_attachments = parse_array(data["description_attachments"], "RichTextAttachment")
4058
+ @download_url = data["download_url"]
4059
+ @filename = data["filename"]
4060
+ @height = parse_integer(data["height"])
4061
+ @id = parse_integer(data["id"])
4062
+ @image_url = data["image_url"]
4050
4063
  @inherits_status = parse_boolean(data["inherits_status"])
4064
+ @language = data["language"]
4065
+ @on_hold = parse_type(data["on_hold"], "CardColumnOnHold")
4051
4066
  @parent = parse_type(data["parent"], "RecordingParent")
4052
4067
  @plain_text_content = data["plain_text_content"]
4053
4068
  @plain_text_description = data["plain_text_description"]
4069
+ @position = parse_integer(data["position"])
4070
+ @preview_url = data["preview_url"]
4071
+ @previewable = parse_boolean(data["previewable"])
4072
+ @sound_url = data["sound_url"]
4054
4073
  @status = data["status"]
4055
4074
  @subject = data["subject"]
4075
+ @subscribers = parse_array(data["subscribers"], "Person")
4076
+ @subscription_url = data["subscription_url"]
4077
+ @thumbnail_url = data["thumbnail_url"]
4078
+ @title = data["title"]
4079
+ @type = data["type"]
4056
4080
  @updated_at = parse_datetime(data["updated_at"])
4081
+ @url = data["url"]
4057
4082
  @visible_to_clients = parse_boolean(data["visible_to_clients"])
4083
+ @width = parse_integer(data["width"])
4058
4084
  end
4059
4085
 
4060
4086
  def to_h
4061
4087
  {
4062
- "app_url" => @app_url,
4063
4088
  "content" => @content,
4064
4089
  "description" => @description,
4065
- "id" => @id,
4066
- "title" => @title,
4067
- "type" => @type,
4068
- "url" => @url,
4090
+ "app_download_url" => @app_download_url,
4091
+ "app_url" => @app_url,
4092
+ "attachments" => @attachments,
4069
4093
  "bookmark_url" => @bookmark_url,
4094
+ "boosts_count" => @boosts_count,
4095
+ "boosts_url" => @boosts_url,
4070
4096
  "bubble_up_url" => @bubble_up_url,
4071
4097
  "bucket" => @bucket,
4098
+ "byte_size" => @byte_size,
4099
+ "cards_count" => @cards_count,
4100
+ "cards_url" => @cards_url,
4101
+ "color" => @color,
4102
+ "comment_count" => @comment_count,
4103
+ "comments_count" => @comments_count,
4104
+ "comments_url" => @comments_url,
4072
4105
  "content_attachments" => @content_attachments,
4106
+ "content_type" => @content_type,
4073
4107
  "created_at" => @created_at,
4074
4108
  "creator" => @creator,
4075
4109
  "description_attachments" => @description_attachments,
4110
+ "download_url" => @download_url,
4111
+ "filename" => @filename,
4112
+ "height" => @height,
4113
+ "id" => @id,
4114
+ "image_url" => @image_url,
4076
4115
  "inherits_status" => @inherits_status,
4116
+ "language" => @language,
4117
+ "on_hold" => @on_hold,
4077
4118
  "parent" => @parent,
4078
4119
  "plain_text_content" => @plain_text_content,
4079
4120
  "plain_text_description" => @plain_text_description,
4121
+ "position" => @position,
4122
+ "preview_url" => @preview_url,
4123
+ "previewable" => @previewable,
4124
+ "sound_url" => @sound_url,
4080
4125
  "status" => @status,
4081
4126
  "subject" => @subject,
4127
+ "subscribers" => @subscribers,
4128
+ "subscription_url" => @subscription_url,
4129
+ "thumbnail_url" => @thumbnail_url,
4130
+ "title" => @title,
4131
+ "type" => @type,
4082
4132
  "updated_at" => @updated_at,
4133
+ "url" => @url,
4083
4134
  "visible_to_clients" => @visible_to_clients,
4135
+ "width" => @width,
4084
4136
  }.reject { |k, v| v.nil? && !["content", "description"].include?(k) }
4085
4137
  end
4086
4138
 
@@ -4089,6 +4141,55 @@ module Basecamp
4089
4141
  end
4090
4142
  end
4091
4143
 
4144
+ # SearchResultAttachment
4145
+ class SearchResultAttachment
4146
+ include TypeHelpers
4147
+ attr_accessor :byte_size, :content_type, :download_url, :filename, :height, :id, :preview_url, :previewable, :sgid, :thumbnail_url, :title, :url, :width
4148
+
4149
+ # @return [Array<Symbol>]
4150
+ def self.required_fields
4151
+ %i[byte_size content_type download_url filename].freeze
4152
+ end
4153
+
4154
+ def initialize(data = {})
4155
+ @byte_size = parse_integer(data["byte_size"])
4156
+ @content_type = data["content_type"]
4157
+ @download_url = data["download_url"]
4158
+ @filename = data["filename"]
4159
+ @height = parse_integer(data["height"])
4160
+ @id = parse_integer(data["id"])
4161
+ @preview_url = data["preview_url"]
4162
+ @previewable = parse_boolean(data["previewable"])
4163
+ @sgid = data["sgid"]
4164
+ @thumbnail_url = data["thumbnail_url"]
4165
+ @title = data["title"]
4166
+ @url = data["url"]
4167
+ @width = parse_integer(data["width"])
4168
+ end
4169
+
4170
+ def to_h
4171
+ {
4172
+ "byte_size" => @byte_size,
4173
+ "content_type" => @content_type,
4174
+ "download_url" => @download_url,
4175
+ "filename" => @filename,
4176
+ "height" => @height,
4177
+ "id" => @id,
4178
+ "preview_url" => @preview_url,
4179
+ "previewable" => @previewable,
4180
+ "sgid" => @sgid,
4181
+ "thumbnail_url" => @thumbnail_url,
4182
+ "title" => @title,
4183
+ "url" => @url,
4184
+ "width" => @width,
4185
+ }.compact
4186
+ end
4187
+
4188
+ def to_json(*args)
4189
+ to_h.to_json(*args)
4190
+ end
4191
+ end
4192
+
4092
4193
  # SearchType
4093
4194
  class SearchType
4094
4195
  include TypeHelpers
@@ -4722,39 +4823,53 @@ module Basecamp
4722
4823
  # Tool
4723
4824
  class Tool
4724
4825
  include TypeHelpers
4725
- attr_accessor :created_at, :enabled, :id, :name, :title, :updated_at, :app_url, :bucket, :position, :status, :url
4826
+ attr_accessor :created_at, :creator, :id, :inherits_status, :title, :type, :updated_at, :visible_to_clients, :app_url, :bookmark_url, :bucket, :enabled, :name, :parent, :position, :status, :subscription_url, :url
4726
4827
 
4727
4828
  # @return [Array<Symbol>]
4728
4829
  def self.required_fields
4729
- %i[created_at enabled id name title updated_at].freeze
4830
+ %i[created_at creator id inherits_status title type updated_at visible_to_clients].freeze
4730
4831
  end
4731
4832
 
4732
4833
  def initialize(data = {})
4733
4834
  @created_at = parse_datetime(data["created_at"])
4734
- @enabled = parse_boolean(data["enabled"])
4835
+ @creator = parse_type(data["creator"], "Person")
4735
4836
  @id = parse_integer(data["id"])
4736
- @name = data["name"]
4837
+ @inherits_status = parse_boolean(data["inherits_status"])
4737
4838
  @title = data["title"]
4839
+ @type = data["type"]
4738
4840
  @updated_at = parse_datetime(data["updated_at"])
4841
+ @visible_to_clients = parse_boolean(data["visible_to_clients"])
4739
4842
  @app_url = data["app_url"]
4843
+ @bookmark_url = data["bookmark_url"]
4740
4844
  @bucket = parse_type(data["bucket"], "RecordingBucket")
4845
+ @enabled = parse_boolean(data["enabled"])
4846
+ @name = data["name"]
4847
+ @parent = parse_type(data["parent"], "RecordingParent")
4741
4848
  @position = parse_integer(data["position"])
4742
4849
  @status = data["status"]
4850
+ @subscription_url = data["subscription_url"]
4743
4851
  @url = data["url"]
4744
4852
  end
4745
4853
 
4746
4854
  def to_h
4747
4855
  {
4748
4856
  "created_at" => @created_at,
4749
- "enabled" => @enabled,
4857
+ "creator" => @creator,
4750
4858
  "id" => @id,
4751
- "name" => @name,
4859
+ "inherits_status" => @inherits_status,
4752
4860
  "title" => @title,
4861
+ "type" => @type,
4753
4862
  "updated_at" => @updated_at,
4863
+ "visible_to_clients" => @visible_to_clients,
4754
4864
  "app_url" => @app_url,
4865
+ "bookmark_url" => @bookmark_url,
4755
4866
  "bucket" => @bucket,
4867
+ "enabled" => @enabled,
4868
+ "name" => @name,
4869
+ "parent" => @parent,
4756
4870
  "position" => @position,
4757
4871
  "status" => @status,
4872
+ "subscription_url" => @subscription_url,
4758
4873
  "url" => @url,
4759
4874
  }.compact
4760
4875
  end
@@ -5063,6 +5178,82 @@ module Basecamp
5063
5178
  end
5064
5179
  end
5065
5180
 
5181
+ # UploadVersion
5182
+ class UploadVersion
5183
+ include TypeHelpers
5184
+ attr_accessor :action, :created_at, :creator, :id, :recording_id, :boosts_count, :boosts_url, :details, :upload
5185
+
5186
+ # @return [Array<Symbol>]
5187
+ def self.required_fields
5188
+ %i[action created_at creator id recording_id].freeze
5189
+ end
5190
+
5191
+ def initialize(data = {})
5192
+ @action = data["action"]
5193
+ @created_at = parse_datetime(data["created_at"])
5194
+ @creator = parse_type(data["creator"], "Person")
5195
+ @id = parse_integer(data["id"])
5196
+ @recording_id = parse_integer(data["recording_id"])
5197
+ @boosts_count = parse_integer(data["boosts_count"])
5198
+ @boosts_url = data["boosts_url"]
5199
+ @details = parse_type(data["details"], "EventDetails")
5200
+ @upload = parse_type(data["upload"], "UploadVersionFile")
5201
+ end
5202
+
5203
+ def to_h
5204
+ {
5205
+ "action" => @action,
5206
+ "created_at" => @created_at,
5207
+ "creator" => @creator,
5208
+ "id" => @id,
5209
+ "recording_id" => @recording_id,
5210
+ "boosts_count" => @boosts_count,
5211
+ "boosts_url" => @boosts_url,
5212
+ "details" => @details,
5213
+ "upload" => @upload,
5214
+ }.compact
5215
+ end
5216
+
5217
+ def to_json(*args)
5218
+ to_h.to_json(*args)
5219
+ end
5220
+ end
5221
+
5222
+ # UploadVersionFile
5223
+ class UploadVersionFile
5224
+ include TypeHelpers
5225
+ attr_accessor :app_download_url, :current, :download_url, :filename, :byte_size, :content_type
5226
+
5227
+ # @return [Array<Symbol>]
5228
+ def self.required_fields
5229
+ %i[app_download_url current download_url filename].freeze
5230
+ end
5231
+
5232
+ def initialize(data = {})
5233
+ @app_download_url = data["app_download_url"]
5234
+ @current = parse_boolean(data["current"])
5235
+ @download_url = data["download_url"]
5236
+ @filename = data["filename"]
5237
+ @byte_size = parse_integer(data["byte_size"])
5238
+ @content_type = data["content_type"]
5239
+ end
5240
+
5241
+ def to_h
5242
+ {
5243
+ "app_download_url" => @app_download_url,
5244
+ "current" => @current,
5245
+ "download_url" => @download_url,
5246
+ "filename" => @filename,
5247
+ "byte_size" => @byte_size,
5248
+ "content_type" => @content_type,
5249
+ }.compact
5250
+ end
5251
+
5252
+ def to_json(*args)
5253
+ to_h.to_json(*args)
5254
+ end
5255
+ end
5256
+
5066
5257
  # Vault
5067
5258
  class Vault
5068
5259
  include TypeHelpers
data/lib/basecamp/http.rb CHANGED
@@ -342,7 +342,16 @@ module Basecamp
342
342
  Http.normalize_person_ids(data)
343
343
  data
344
344
  rescue JSON::ParserError => e
345
- raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
345
+ # +cause+ carries the parser's own error, not just its message (#750). The
346
+ # message says what happened; the slot is what a caller can act on, and it
347
+ # is the same answer Go reaches through errors.As and Kotlin and Swift
348
+ # through their decodeFailure slot. Passed explicitly rather than left to
349
+ # Ruby's implicit +$!+ chaining because Basecamp::Error defines its own
350
+ # +cause+ reader.
351
+ raise Basecamp::ApiError.new(
352
+ "Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}",
353
+ cause: e
354
+ )
346
355
  end
347
356
 
348
357
  # Extracts the item array from a parsed page body: the body itself for
@@ -493,8 +502,10 @@ module Basecamp
493
502
  # Memoized per-operation retry metadata, keyed by canonical operation ID.
494
503
  # Benign-race memoization: concurrent first loads compute identical values.
495
504
  def self.operation_retry(operation)
505
+ # UTF-8 regardless of process locale — JSON is UTF-8 (RFC 8259), and
506
+ # LC_ALL=C would otherwise read as US-ASCII.
496
507
  @operation_metadata ||= JSON.parse(
497
- File.read(File.join(__dir__, "generated", "metadata.json"))
508
+ File.read(File.join(__dir__, "generated", "metadata.json"), encoding: "UTF-8")
498
509
  ).fetch("operations").freeze
499
510
  @operation_metadata.dig(operation, "retry")
500
511
  end
@@ -632,6 +643,12 @@ module Basecamp
632
643
  Basecamp.compose_validation_message(Basecamp.parse_error_message(body), field_errors) || "Validation failed"
633
644
  )
634
645
  Basecamp::ValidationError.new(message, http_status: status, field_errors: field_errors)
646
+ when 507
647
+ # A 5xx status carrying a client fact: the account is out of storage, or
648
+ # at its webhook ceiling. Retrying cannot satisfy it, so this is decided
649
+ # before the 5xx arms below.
650
+ message = Security.truncate(Basecamp.parse_error_message(body) || "Account limit reached")
651
+ Basecamp::LimitExceededError.new(message)
635
652
  when 500
636
653
  Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true)
637
654
  when 502, 503, 504
@@ -779,6 +796,32 @@ module Basecamp
779
796
  end
780
797
 
781
798
  def parse_next_link(link_header)
799
+ # A non-ASCII-compatible tag (UTF-16/32; only a stub or custom adapter
800
+ # produces one — Faraday's Net::HTTP adapter always yields ASCII-8BIT)
801
+ # defeats the byte scan below: "<" is 3C 00 in UTF-16LE, so the ASCII
802
+ # literals never match and a genuinely UTF-16LE header parsed to nil
803
+ # silently, where the pre-#678 character path at least crashed loudly.
804
+ # Transcoding to UTF-8 up front restores parsing for that case, and
805
+ # rebinding the local means the retag at the bottom hands back UTF-8.
806
+ #
807
+ # ASCII bytes MIStagged UTF-16LE split by bytesize parity, and both
808
+ # outcomes are deliberate:
809
+ # - odd (the natural case): invalid UTF-16LE, encode raises, and the
810
+ # fallthrough keeps today's refusal — the scan extracts the URL
811
+ # still mistagged, and Security.same_origin? refuses the follow
812
+ # (ApiError), so no origin-check bypass opens up.
813
+ # - even: valid UTF-16LE by construction, transcodes to CJK mojibake
814
+ # with no rel="next", parses to nil (was: the ApiError above).
815
+ # Garbage-tagged garbage; nil is as good a refusal as a raise.
816
+ unless link_header.nil? || link_header.encoding.ascii_compatible?
817
+ begin
818
+ link_header = link_header.encode(Encoding::UTF_8)
819
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError,
820
+ Encoding::ConverterNotFoundError
821
+ # Not decodable under its claimed encoding — scan the raw bytes.
822
+ end
823
+ end
824
+
782
825
  next_url = nil
783
826
 
784
827
  unless link_header.nil? || link_header.empty?
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when an account limit blocks the request (HTTP 507) — file storage
5
+ # exhausted, or a webhook ceiling reached.
6
+ #
7
+ # Never retryable: no amount of backoff frees storage or raises a plan limit.
8
+ # That is the whole reason this is not an ApiError, which a 507 would
9
+ # otherwise become through the 5xx catch-all.
10
+ class LimitExceededError < Error
11
+ def initialize(message = "Account limit reached", hint: nil, cause: nil)
12
+ super(
13
+ code: ErrorCode::LIMIT_EXCEEDED,
14
+ message: message,
15
+ hint: hint,
16
+ http_status: 507,
17
+ retryable: false,
18
+ cause: cause
19
+ )
20
+ end
21
+ end
22
+ end
@@ -39,13 +39,19 @@ module Basecamp
39
39
 
40
40
  ua.scheme.downcase == ub.scheme.downcase &&
41
41
  normalize_host(ua) == normalize_host(ub)
42
- rescue URI::InvalidURIError
42
+ rescue URI::Error
43
+ # URI::Error, not just InvalidURIError: URI.parse("mailto:") raises
44
+ # URI::InvalidComponentError (URI::MailTo demands an opaque part), and a
45
+ # scheme-only URL must be refused, not a crash.
43
46
  false
44
47
  end
45
48
 
46
49
  def self.resolve_url(base, target)
47
50
  URI.join(base, target).to_s
48
- rescue URI::InvalidURIError
51
+ rescue URI::Error
52
+ # URI::Error, not just InvalidURIError: URI.join raises
53
+ # URI::InvalidComponentError on a scheme-only target ("mailto:"),
54
+ # reachable from a poisoned Link header or Location redirect.
49
55
  target
50
56
  end
51
57
 
@@ -5,11 +5,22 @@ module Basecamp
5
5
  # Service for authorization operations.
6
6
  # This is the only service that doesn't require an account context.
7
7
  #
8
+ # The document's shape depends on which issuer served it. Discovery selects a
9
+ # BC5 issuer whenever one is advertised, and a BC5 issuer serves its *own*
10
+ # document (+app/views/api/authorizations/show.json.jbuilder+), which is not
11
+ # Launchpad's: it carries +identity.id+ and nothing else of the identity, no
12
+ # +product+ or +app_href+ on accounts, an RFC 8707 +resource+ indicator
13
+ # instead, and a top-level +scope+ for BC3-issued tokens. Only +identity.id+,
14
+ # +accounts[].id+, +accounts[].name+, +accounts[].href+ and +expires_at+ are
15
+ # common to both; +expires_at+ is an ISO-8601 string from either issuer
16
+ # (integer epoch seconds from bc3 before bc3 #12646 — passed through
17
+ # verbatim either way).
18
+ #
8
19
  # @example Get authorization info
9
20
  # auth = client.authorization.get
10
- # puts "Identity: #{auth["identity"]["email_address"]}"
21
+ # puts "Identity: #{auth["identity"]["id"]}"
11
22
  # auth["accounts"].each do |account|
12
- # puts "Account: #{account["name"]} (#{account["id"]})"
23
+ # puts "Account: #{account["name"]} (#{account["href"]})"
13
24
  # end
14
25
  class AuthorizationService < BaseService
15
26
  # Gets authorization information for the current user.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.13.0"
5
- API_VERSION = "2026-08-05"
4
+ VERSION = "0.15.0"
5
+ API_VERSION = "2026-08-11"
6
6
  end
data/lib/basecamp.rb CHANGED
@@ -132,6 +132,10 @@ module Basecamp
132
132
  NotFoundError.new(message: message)
133
133
  when 429
134
134
  RateLimitError.new(retry_after: retry_after)
135
+ when 507
136
+ # Decided before the 5xx arms: a 507 is an account limit, not a
137
+ # transient server failure, and no retry can satisfy it.
138
+ LimitExceededError.new(Security.truncate(message))
135
139
  when 500
136
140
  ApiError.new("Server error (500)", http_status: 500, retryable: true)
137
141
  when 502, 503, 504
@@ -14,7 +14,9 @@ class MetadataExtractor
14
14
  METHODS = %w[get post put patch delete].freeze
15
15
 
16
16
  def initialize(openapi_path)
17
- @openapi = JSON.parse(File.read(openapi_path))
17
+ # Read as UTF-8 regardless of process locale (LC_ALL=C would otherwise read
18
+ # as US-ASCII and JSON.parse dies on the spec's multibyte characters)
19
+ @openapi = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))
18
20
  end
19
21
 
20
22
  def extract
@@ -65,7 +65,7 @@ class ServiceGenerator
65
65
  },
66
66
  'Files' => {
67
67
  'Attachments' => %w[CreateAttachment],
68
- 'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions],
68
+ 'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions CreateUploadVersion],
69
69
  'Vaults' => %w[GetVault UpdateVault ListVaults CreateVault],
70
70
  'Documents' => %w[GetDocument ReplaceDocument ListDocuments CreateDocument],
71
71
  'CloudFiles' => %w[GetCloudFile CreateCloudFile UpdateCloudFile],
@@ -217,6 +217,7 @@ class ServiceGenerator
217
217
  'ListUploads' => 'list',
218
218
  'CreateUpload' => 'create',
219
219
  'ListUploadVersions' => 'list_versions',
220
+ 'CreateUploadVersion' => 'create_version',
220
221
  'GetMessage' => 'get',
221
222
  'UpdateMessage' => 'update',
222
223
  'CreateMessage' => 'create',
@@ -320,7 +321,8 @@ class ServiceGenerator
320
321
  ].freeze
321
322
 
322
323
  def initialize(openapi_path)
323
- @openapi = JSON.parse(File.read(openapi_path))
324
+ # UTF-8 regardless of process locale — see generate-metadata.rb
325
+ @openapi = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))
324
326
  @schemas = @openapi.dig('components', 'schemas') || {}
325
327
  end
326
328
 
@@ -131,7 +131,8 @@ if __FILE__ == $PROGRAM_NAME
131
131
  puts ' module Types'
132
132
  puts ' include TypeHelpers'
133
133
 
134
- schemas = JSON.parse(File.read(openapi_path))['components']['schemas'] || {}
134
+ # UTF-8 regardless of process locale — see generate-metadata.rb
135
+ schemas = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))['components']['schemas'] || {}
135
136
  sorted = schemas.keys.sort
136
137
 
137
138
  sorted.each do |name|
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.13.0
4
+ version: 0.15.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-08-07 00:00:00.000000000 Z
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -235,6 +235,7 @@ files:
235
235
  - lib/basecamp/generated/types.rb
236
236
  - lib/basecamp/hooks.rb
237
237
  - lib/basecamp/http.rb
238
+ - lib/basecamp/limit_exceeded_error.rb
238
239
  - lib/basecamp/list_enumerator.rb
239
240
  - lib/basecamp/list_meta.rb
240
241
  - lib/basecamp/logger_hooks.rb