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.
@@ -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-07T11:12:00Z
4
+ # Generated: 2026-09-03T02:27: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
@@ -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
@@ -4025,62 +4029,114 @@ module Basecamp
4025
4029
  # SearchResult
4026
4030
  class SearchResult
4027
4031
  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
4032
+ 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
4033
 
4030
4034
  # @return [Array<Symbol>]
4031
4035
  def self.required_fields
4032
- %i[app_url content description id title type url].freeze
4036
+ %i[content description].freeze
4033
4037
  end
4034
4038
 
4035
4039
  def initialize(data = {})
4036
- @app_url = data["app_url"]
4037
4040
  @content = data["content"]
4038
4041
  @description = data["description"]
4039
- @id = parse_integer(data["id"])
4040
- @title = data["title"]
4041
- @type = data["type"]
4042
- @url = data["url"]
4042
+ @app_download_url = data["app_download_url"]
4043
+ @app_url = data["app_url"]
4044
+ @attachments = parse_array(data["attachments"], "SearchResultAttachment")
4043
4045
  @bookmark_url = data["bookmark_url"]
4046
+ @boosts_count = parse_integer(data["boosts_count"])
4047
+ @boosts_url = data["boosts_url"]
4044
4048
  @bubble_up_url = data["bubble_up_url"]
4045
4049
  @bucket = parse_type(data["bucket"], "RecordingBucket")
4050
+ @byte_size = parse_integer(data["byte_size"])
4051
+ @cards_count = parse_integer(data["cards_count"])
4052
+ @cards_url = data["cards_url"]
4053
+ @color = data["color"]
4054
+ @comment_count = parse_integer(data["comment_count"])
4055
+ @comments_count = parse_integer(data["comments_count"])
4056
+ @comments_url = data["comments_url"]
4046
4057
  @content_attachments = parse_array(data["content_attachments"], "RichTextAttachment")
4058
+ @content_type = data["content_type"]
4047
4059
  @created_at = parse_datetime(data["created_at"])
4048
4060
  @creator = parse_type(data["creator"], "Person")
4049
4061
  @description_attachments = parse_array(data["description_attachments"], "RichTextAttachment")
4062
+ @download_url = data["download_url"]
4063
+ @filename = data["filename"]
4064
+ @height = parse_integer(data["height"])
4065
+ @id = parse_integer(data["id"])
4066
+ @image_url = data["image_url"]
4050
4067
  @inherits_status = parse_boolean(data["inherits_status"])
4068
+ @language = data["language"]
4069
+ @on_hold = parse_type(data["on_hold"], "CardColumnOnHold")
4051
4070
  @parent = parse_type(data["parent"], "RecordingParent")
4052
4071
  @plain_text_content = data["plain_text_content"]
4053
4072
  @plain_text_description = data["plain_text_description"]
4073
+ @position = parse_integer(data["position"])
4074
+ @preview_url = data["preview_url"]
4075
+ @previewable = parse_boolean(data["previewable"])
4076
+ @sound_url = data["sound_url"]
4054
4077
  @status = data["status"]
4055
4078
  @subject = data["subject"]
4079
+ @subscribers = parse_array(data["subscribers"], "Person")
4080
+ @subscription_url = data["subscription_url"]
4081
+ @thumbnail_url = data["thumbnail_url"]
4082
+ @title = data["title"]
4083
+ @type = data["type"]
4056
4084
  @updated_at = parse_datetime(data["updated_at"])
4085
+ @url = data["url"]
4057
4086
  @visible_to_clients = parse_boolean(data["visible_to_clients"])
4087
+ @width = parse_integer(data["width"])
4058
4088
  end
4059
4089
 
4060
4090
  def to_h
4061
4091
  {
4062
- "app_url" => @app_url,
4063
4092
  "content" => @content,
4064
4093
  "description" => @description,
4065
- "id" => @id,
4066
- "title" => @title,
4067
- "type" => @type,
4068
- "url" => @url,
4094
+ "app_download_url" => @app_download_url,
4095
+ "app_url" => @app_url,
4096
+ "attachments" => @attachments,
4069
4097
  "bookmark_url" => @bookmark_url,
4098
+ "boosts_count" => @boosts_count,
4099
+ "boosts_url" => @boosts_url,
4070
4100
  "bubble_up_url" => @bubble_up_url,
4071
4101
  "bucket" => @bucket,
4102
+ "byte_size" => @byte_size,
4103
+ "cards_count" => @cards_count,
4104
+ "cards_url" => @cards_url,
4105
+ "color" => @color,
4106
+ "comment_count" => @comment_count,
4107
+ "comments_count" => @comments_count,
4108
+ "comments_url" => @comments_url,
4072
4109
  "content_attachments" => @content_attachments,
4110
+ "content_type" => @content_type,
4073
4111
  "created_at" => @created_at,
4074
4112
  "creator" => @creator,
4075
4113
  "description_attachments" => @description_attachments,
4114
+ "download_url" => @download_url,
4115
+ "filename" => @filename,
4116
+ "height" => @height,
4117
+ "id" => @id,
4118
+ "image_url" => @image_url,
4076
4119
  "inherits_status" => @inherits_status,
4120
+ "language" => @language,
4121
+ "on_hold" => @on_hold,
4077
4122
  "parent" => @parent,
4078
4123
  "plain_text_content" => @plain_text_content,
4079
4124
  "plain_text_description" => @plain_text_description,
4125
+ "position" => @position,
4126
+ "preview_url" => @preview_url,
4127
+ "previewable" => @previewable,
4128
+ "sound_url" => @sound_url,
4080
4129
  "status" => @status,
4081
4130
  "subject" => @subject,
4131
+ "subscribers" => @subscribers,
4132
+ "subscription_url" => @subscription_url,
4133
+ "thumbnail_url" => @thumbnail_url,
4134
+ "title" => @title,
4135
+ "type" => @type,
4082
4136
  "updated_at" => @updated_at,
4137
+ "url" => @url,
4083
4138
  "visible_to_clients" => @visible_to_clients,
4139
+ "width" => @width,
4084
4140
  }.reject { |k, v| v.nil? && !["content", "description"].include?(k) }
4085
4141
  end
4086
4142
 
@@ -4089,6 +4145,55 @@ module Basecamp
4089
4145
  end
4090
4146
  end
4091
4147
 
4148
+ # SearchResultAttachment
4149
+ class SearchResultAttachment
4150
+ include TypeHelpers
4151
+ attr_accessor :byte_size, :content_type, :download_url, :filename, :height, :id, :preview_url, :previewable, :sgid, :thumbnail_url, :title, :url, :width
4152
+
4153
+ # @return [Array<Symbol>]
4154
+ def self.required_fields
4155
+ %i[byte_size content_type download_url filename].freeze
4156
+ end
4157
+
4158
+ def initialize(data = {})
4159
+ @byte_size = parse_integer(data["byte_size"])
4160
+ @content_type = data["content_type"]
4161
+ @download_url = data["download_url"]
4162
+ @filename = data["filename"]
4163
+ @height = parse_integer(data["height"])
4164
+ @id = parse_integer(data["id"])
4165
+ @preview_url = data["preview_url"]
4166
+ @previewable = parse_boolean(data["previewable"])
4167
+ @sgid = data["sgid"]
4168
+ @thumbnail_url = data["thumbnail_url"]
4169
+ @title = data["title"]
4170
+ @url = data["url"]
4171
+ @width = parse_integer(data["width"])
4172
+ end
4173
+
4174
+ def to_h
4175
+ {
4176
+ "byte_size" => @byte_size,
4177
+ "content_type" => @content_type,
4178
+ "download_url" => @download_url,
4179
+ "filename" => @filename,
4180
+ "height" => @height,
4181
+ "id" => @id,
4182
+ "preview_url" => @preview_url,
4183
+ "previewable" => @previewable,
4184
+ "sgid" => @sgid,
4185
+ "thumbnail_url" => @thumbnail_url,
4186
+ "title" => @title,
4187
+ "url" => @url,
4188
+ "width" => @width,
4189
+ }.compact
4190
+ end
4191
+
4192
+ def to_json(*args)
4193
+ to_h.to_json(*args)
4194
+ end
4195
+ end
4196
+
4092
4197
  # SearchType
4093
4198
  class SearchType
4094
4199
  include TypeHelpers
@@ -4188,6 +4293,99 @@ module Basecamp
4188
4293
  end
4189
4294
  end
4190
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
+
4191
4389
  # TimelineAttachment
4192
4390
  class TimelineAttachment
4193
4391
  include TypeHelpers
@@ -4722,39 +4920,53 @@ module Basecamp
4722
4920
  # Tool
4723
4921
  class Tool
4724
4922
  include TypeHelpers
4725
- attr_accessor :created_at, :enabled, :id, :name, :title, :updated_at, :app_url, :bucket, :position, :status, :url
4923
+ 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
4924
 
4727
4925
  # @return [Array<Symbol>]
4728
4926
  def self.required_fields
4729
- %i[created_at enabled id name title updated_at].freeze
4927
+ %i[created_at creator id inherits_status title type updated_at visible_to_clients].freeze
4730
4928
  end
4731
4929
 
4732
4930
  def initialize(data = {})
4733
4931
  @created_at = parse_datetime(data["created_at"])
4734
- @enabled = parse_boolean(data["enabled"])
4932
+ @creator = parse_type(data["creator"], "Person")
4735
4933
  @id = parse_integer(data["id"])
4736
- @name = data["name"]
4934
+ @inherits_status = parse_boolean(data["inherits_status"])
4737
4935
  @title = data["title"]
4936
+ @type = data["type"]
4738
4937
  @updated_at = parse_datetime(data["updated_at"])
4938
+ @visible_to_clients = parse_boolean(data["visible_to_clients"])
4739
4939
  @app_url = data["app_url"]
4940
+ @bookmark_url = data["bookmark_url"]
4740
4941
  @bucket = parse_type(data["bucket"], "RecordingBucket")
4942
+ @enabled = parse_boolean(data["enabled"])
4943
+ @name = data["name"]
4944
+ @parent = parse_type(data["parent"], "RecordingParent")
4741
4945
  @position = parse_integer(data["position"])
4742
4946
  @status = data["status"]
4947
+ @subscription_url = data["subscription_url"]
4743
4948
  @url = data["url"]
4744
4949
  end
4745
4950
 
4746
4951
  def to_h
4747
4952
  {
4748
4953
  "created_at" => @created_at,
4749
- "enabled" => @enabled,
4954
+ "creator" => @creator,
4750
4955
  "id" => @id,
4751
- "name" => @name,
4956
+ "inherits_status" => @inherits_status,
4752
4957
  "title" => @title,
4958
+ "type" => @type,
4753
4959
  "updated_at" => @updated_at,
4960
+ "visible_to_clients" => @visible_to_clients,
4754
4961
  "app_url" => @app_url,
4962
+ "bookmark_url" => @bookmark_url,
4755
4963
  "bucket" => @bucket,
4964
+ "enabled" => @enabled,
4965
+ "name" => @name,
4966
+ "parent" => @parent,
4756
4967
  "position" => @position,
4757
4968
  "status" => @status,
4969
+ "subscription_url" => @subscription_url,
4758
4970
  "url" => @url,
4759
4971
  }.compact
4760
4972
  end
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
- request_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, accept: nil)
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.
@@ -342,7 +346,16 @@ module Basecamp
342
346
  Http.normalize_person_ids(data)
343
347
  data
344
348
  rescue JSON::ParserError => e
345
- raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
349
+ # +cause+ carries the parser's own error, not just its message (#750). The
350
+ # message says what happened; the slot is what a caller can act on, and it
351
+ # is the same answer Go reaches through errors.As and Kotlin and Swift
352
+ # through their decodeFailure slot. Passed explicitly rather than left to
353
+ # Ruby's implicit +$!+ chaining because Basecamp::Error defines its own
354
+ # +cause+ reader.
355
+ raise Basecamp::ApiError.new(
356
+ "Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}",
357
+ cause: e
358
+ )
346
359
  end
347
360
 
348
361
  # Extracts the item array from a parsed page body: the body itself for
@@ -387,7 +400,7 @@ module Basecamp
387
400
  end
388
401
 
389
402
  def request_with_retry(method, url, params: {}, allow_cross_origin: false, operation: nil, retry_on: nil,
390
- accept: "application/json")
403
+ accept: "application/json", download: false)
391
404
  op_retry = operation && Http.operation_retry(operation)
392
405
  # The cap is floored at one attempt on every path: whether a request
393
406
  # reaches the wire at all must not depend on whether the operation
@@ -413,7 +426,7 @@ module Basecamp
413
426
 
414
427
  begin
415
428
  return single_request(method, url, params: params, body: nil, attempt: attempt,
416
- 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)
417
430
  rescue Basecamp::AuthError => e
418
431
  # SPEC §4: the refresh replay is a request on the wire, so it spends
419
432
  # an attempt from THIS budget rather than an uncounted one inside
@@ -465,8 +478,9 @@ module Basecamp
465
478
 
466
479
  delay = calculate_delay(attempt, error.retry_after)
467
480
 
468
- @hooks.on_retry(RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt), attempt + 1, error,
469
- delay)
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)
470
484
  sleep(delay)
471
485
  end
472
486
  end
@@ -493,21 +507,28 @@ module Basecamp
493
507
  # Memoized per-operation retry metadata, keyed by canonical operation ID.
494
508
  # Benign-race memoization: concurrent first loads compute identical values.
495
509
  def self.operation_retry(operation)
510
+ # UTF-8 regardless of process locale — JSON is UTF-8 (RFC 8259), and
511
+ # LC_ALL=C would otherwise read as US-ASCII.
496
512
  @operation_metadata ||= JSON.parse(
497
- File.read(File.join(__dir__, "generated", "metadata.json"))
513
+ File.read(File.join(__dir__, "generated", "metadata.json"), encoding: "UTF-8")
498
514
  ).fetch("operations").freeze
499
515
  @operation_metadata.dig(operation, "retry")
500
516
  end
501
517
 
502
518
  def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false,
503
- accept: "application/json", refresh_replay: true)
519
+ accept: "application/json", refresh_replay: true, download: false)
504
520
  assert_credential_origin!(url, allow_cross_origin)
505
- info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
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)
506
526
  @hooks.on_request_start(info)
507
527
 
508
528
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
509
529
 
510
- begin
530
+ severed = nil
531
+ result = begin
511
532
  response = @faraday.run_request(method, url, body, request_headers(accept: accept)) do |req|
512
533
  req.params.merge!(params) if params.any?
513
534
  end
@@ -540,17 +561,29 @@ module Basecamp
540
561
  && @token_refreshed
541
562
  @token_refreshed = false
542
563
  return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1,
543
- 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)
544
565
  end
545
566
 
546
- raise error
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
547
573
  rescue Faraday::Error => e
548
574
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
549
- error = Basecamp::NetworkError.new("Connection failed", cause: e)
550
- result = RequestResult.new(duration: duration, error: error)
551
- @hooks.on_request_end(info, result)
552
- raise error
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
553
584
  end
585
+
586
+ severed ? raise(severed, cause: nil) : result
554
587
  end
555
588
 
556
589
  # accept: nil is the binary-download carve-out (SPEC §14): hop 1 sends
@@ -613,38 +646,48 @@ module Basecamp
613
646
 
614
647
  retry_after = parse_retry_after(headers["Retry-After"] || headers["retry-after"])
615
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)
616
652
 
617
653
  err = case status
618
654
  when 401
619
655
  # Try token refresh; flag for caller to retry
620
656
  @token_refreshed = refresh_on_401 && @token_provider&.refreshable? && @token_provider.refresh
621
- Basecamp::AuthError.new("Authentication failed")
657
+ Basecamp::AuthError.new("Authentication failed", hint: hint)
622
658
  when 403
623
- Basecamp::ForbiddenError.new("Access denied")
659
+ Basecamp::ForbiddenError.new("Access denied", hint: hint)
624
660
  when 404
625
661
  message = Security.truncate(Basecamp.parse_error_message(body) || "Not found")
626
- Basecamp::NotFoundError.new(message: message)
662
+ Basecamp::NotFoundError.new(message: message, hint: hint)
627
663
  when 429
628
- Basecamp::RateLimitError.new(retry_after: retry_after)
664
+ Basecamp::RateLimitError.new(retry_after: retry_after, hint: hint)
629
665
  when 400, 422
630
666
  field_errors = Basecamp.parse_field_errors(body)
631
667
  message = Security.truncate(
632
668
  Basecamp.compose_validation_message(Basecamp.parse_error_message(body), field_errors) || "Validation failed"
633
669
  )
634
- Basecamp::ValidationError.new(message, http_status: status, field_errors: field_errors)
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
635
678
  when 507
636
679
  # A 5xx status carrying a client fact: the account is out of storage, or
637
680
  # at its webhook ceiling. Retrying cannot satisfy it, so this is decided
638
681
  # before the 5xx arms below.
639
682
  message = Security.truncate(Basecamp.parse_error_message(body) || "Account limit reached")
640
- Basecamp::LimitExceededError.new(message)
683
+ Basecamp::LimitExceededError.new(message, hint: hint)
641
684
  when 500
642
- 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)
643
686
  when 502, 503, 504
644
- 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)
645
688
  else
646
689
  message = Security.truncate(Basecamp.parse_error_message(body) || "Request failed (HTTP #{status})")
647
- Basecamp::ApiError.from_status(status || 0, message)
690
+ Basecamp::ApiError.from_status(status || 0, message, hint: hint)
648
691
  end
649
692
 
650
693
  err.instance_variable_set(:@request_id, request_id) if request_id
@@ -785,6 +828,32 @@ module Basecamp
785
828
  end
786
829
 
787
830
  def parse_next_link(link_header)
831
+ # A non-ASCII-compatible tag (UTF-16/32; only a stub or custom adapter
832
+ # produces one — Faraday's Net::HTTP adapter always yields ASCII-8BIT)
833
+ # defeats the byte scan below: "<" is 3C 00 in UTF-16LE, so the ASCII
834
+ # literals never match and a genuinely UTF-16LE header parsed to nil
835
+ # silently, where the pre-#678 character path at least crashed loudly.
836
+ # Transcoding to UTF-8 up front restores parsing for that case, and
837
+ # rebinding the local means the retag at the bottom hands back UTF-8.
838
+ #
839
+ # ASCII bytes MIStagged UTF-16LE split by bytesize parity, and both
840
+ # outcomes are deliberate:
841
+ # - odd (the natural case): invalid UTF-16LE, encode raises, and the
842
+ # fallthrough keeps today's refusal — the scan extracts the URL
843
+ # still mistagged, and Security.same_origin? refuses the follow
844
+ # (ApiError), so no origin-check bypass opens up.
845
+ # - even: valid UTF-16LE by construction, transcodes to CJK mojibake
846
+ # with no rel="next", parses to nil (was: the ApiError above).
847
+ # Garbage-tagged garbage; nil is as good a refusal as a raise.
848
+ unless link_header.nil? || link_header.encoding.ascii_compatible?
849
+ begin
850
+ link_header = link_header.encode(Encoding::UTF_8)
851
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError,
852
+ Encoding::ConverterNotFoundError
853
+ # Not decodable under its claimed encoding — scan the raw bytes.
854
+ end
855
+ end
856
+
788
857
  next_url = nil
789
858
 
790
859
  unless link_header.nil? || link_header.empty?