basecamp-sdk 0.14.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 +4 -4
- data/README.md +1 -1
- data/lib/basecamp/client.rb +34 -2
- data/lib/basecamp/config.rb +25 -3
- data/lib/basecamp/generated/metadata.json +1 -1
- data/lib/basecamp/generated/types.rb +142 -27
- data/lib/basecamp/http.rb +39 -2
- data/lib/basecamp/security.rb +8 -2
- data/lib/basecamp/services/authorization_service.rb +5 -3
- data/lib/basecamp/version.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6c1f02712a441b2b7772199b8029904b0c254be6c9b687a94a8d957f7d8a066
|
|
4
|
+
data.tar.gz: f0750c8e75561aaa908630440f0b2c4276a54abb653418361cc4363883497d9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a7d6ecde9f18d17b9baadc7b827b6d396c0f6e63ab304d93800ee2e35f3803af9d668965b5d5885a408d926e780700f7fe0320d68375924a6647cce2462eee20
|
|
7
|
+
data.tar.gz: a445a4123c2ebf596026035d347dbe99883b207a650003d2651f0dbc77eed9c0f4634703555e17af674aca87e015fdbe17f38a40e9370deff1b73a42cec6d042
|
data/README.md
CHANGED
|
@@ -316,7 +316,7 @@ aborts before an oversized body is buffered.
|
|
|
316
316
|
|
|
317
317
|
## Services
|
|
318
318
|
|
|
319
|
-
The SDK provides
|
|
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
|
|---------|-------------|
|
data/lib/basecamp/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/basecamp/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
+
validate_max_pages!(@max_pages)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Shared by validate! and the max_pages= writer. Integer-only, so a
|
|
222
|
+
# boolean or Float::INFINITY is refused along with zero and negatives.
|
|
223
|
+
def validate_max_pages!(value)
|
|
224
|
+
raise ArgumentError, "max_pages must be positive" unless value.is_a?(Integer) && value > 0
|
|
203
225
|
end
|
|
204
226
|
|
|
205
227
|
def normalize_url(url)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Auto-generated from OpenAPI spec. Do not edit manually.
|
|
4
|
-
# Generated: 2026-08-
|
|
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 :
|
|
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
|
|
@@ -4025,62 +4025,114 @@ module Basecamp
|
|
|
4025
4025
|
# SearchResult
|
|
4026
4026
|
class SearchResult
|
|
4027
4027
|
include TypeHelpers
|
|
4028
|
-
attr_accessor :
|
|
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[
|
|
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
|
-
@
|
|
4040
|
-
@
|
|
4041
|
-
@
|
|
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
|
-
"
|
|
4066
|
-
"
|
|
4067
|
-
"
|
|
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, :
|
|
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
|
|
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
|
-
@
|
|
4835
|
+
@creator = parse_type(data["creator"], "Person")
|
|
4735
4836
|
@id = parse_integer(data["id"])
|
|
4736
|
-
@
|
|
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
|
-
"
|
|
4857
|
+
"creator" => @creator,
|
|
4750
4858
|
"id" => @id,
|
|
4751
|
-
"
|
|
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
|
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
|
-
|
|
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
|
|
@@ -785,6 +796,32 @@ module Basecamp
|
|
|
785
796
|
end
|
|
786
797
|
|
|
787
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
|
+
|
|
788
825
|
next_url = nil
|
|
789
826
|
|
|
790
827
|
unless link_header.nil? || link_header.empty?
|
data/lib/basecamp/security.rb
CHANGED
|
@@ -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::
|
|
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::
|
|
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
|
|
|
@@ -10,9 +10,11 @@ module Basecamp
|
|
|
10
10
|
# document (+app/views/api/authorizations/show.json.jbuilder+), which is not
|
|
11
11
|
# Launchpad's: it carries +identity.id+ and nothing else of the identity, no
|
|
12
12
|
# +product+ or +app_href+ on accounts, an RFC 8707 +resource+ indicator
|
|
13
|
-
# instead, a top-level +scope+ for BC3-issued tokens
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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).
|
|
16
18
|
#
|
|
17
19
|
# @example Get authorization info
|
|
18
20
|
# auth = client.authorization.get
|
data/lib/basecamp/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-08-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|