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 +4 -4
- data/README.md +1 -1
- data/lib/basecamp/api_error.rb +3 -2
- data/lib/basecamp/client.rb +54 -4
- data/lib/basecamp/config.rb +25 -3
- data/lib/basecamp/generated/metadata.json +115 -1
- data/lib/basecamp/generated/services/bubble_ups_service.rb +36 -0
- data/lib/basecamp/generated/services/projects_service.rb +18 -0
- data/lib/basecamp/generated/services/recordings_service.rb +19 -0
- data/lib/basecamp/generated/services/templates_service.rb +28 -0
- data/lib/basecamp/generated/types.rb +240 -28
- data/lib/basecamp/http.rb +94 -25
- data/lib/basecamp/oauth/exchange.rb +152 -25
- data/lib/basecamp/oauth/fetcher.rb +1 -1
- data/lib/basecamp/oauth_token_provider.rb +50 -13
- data/lib/basecamp/people_confirmation_required_error.rb +14 -0
- data/lib/basecamp/rate_limit_error.rb +4 -2
- data/lib/basecamp/security.rb +28 -2
- data/lib/basecamp/services/authorization_service.rb +5 -3
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +63 -11
- data/scripts/generate-services.rb +7 -1
- metadata +4 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Auto-generated from OpenAPI spec. Do not edit manually.
|
|
4
|
-
# Generated: 2026-
|
|
4
|
+
# Generated: 2026-09-03T02:27:36Z
|
|
5
5
|
|
|
6
6
|
require "json"
|
|
7
7
|
require "time"
|
|
@@ -2672,23 +2672,23 @@ module Basecamp
|
|
|
2672
2672
|
# MyAssignmentAssignee
|
|
2673
2673
|
class MyAssignmentAssignee
|
|
2674
2674
|
include TypeHelpers
|
|
2675
|
-
attr_accessor :
|
|
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 :
|
|
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 :
|
|
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[
|
|
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
|
-
@
|
|
4040
|
-
@
|
|
4041
|
-
@
|
|
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
|
-
"
|
|
4066
|
-
"
|
|
4067
|
-
"
|
|
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, :
|
|
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
|
|
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
|
-
@
|
|
4932
|
+
@creator = parse_type(data["creator"], "Person")
|
|
4735
4933
|
@id = parse_integer(data["id"])
|
|
4736
|
-
@
|
|
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
|
-
"
|
|
4954
|
+
"creator" => @creator,
|
|
4750
4955
|
"id" => @id,
|
|
4751
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
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?
|