basecamp-sdk 0.17.0 → 0.19.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 +5 -3
- data/lib/basecamp/bucket_mismatch_error.rb +41 -0
- data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
- data/lib/basecamp/campfire_index.rb +398 -0
- data/lib/basecamp/client.rb +47 -4
- data/lib/basecamp/generated/metadata.json +728 -1
- data/lib/basecamp/generated/services/account_service.rb +2 -2
- data/lib/basecamp/generated/services/attachments_service.rb +1 -1
- data/lib/basecamp/generated/services/automation_service.rb +1 -1
- data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
- data/lib/basecamp/generated/services/boosts_service.rb +3 -3
- data/lib/basecamp/generated/services/calendars_service.rb +2 -2
- data/lib/basecamp/generated/services/campfires_service.rb +7 -7
- data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
- data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
- data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
- data/lib/basecamp/generated/services/cards_service.rb +3 -3
- data/lib/basecamp/generated/services/checkins_service.rb +9 -9
- data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
- data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
- data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
- data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
- data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
- data/lib/basecamp/generated/services/comments_service.rb +3 -3
- data/lib/basecamp/generated/services/documents_service.rb +3 -3
- data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
- data/lib/basecamp/generated/services/everything_service.rb +2 -2
- data/lib/basecamp/generated/services/folders_service.rb +4 -4
- data/lib/basecamp/generated/services/forwards_service.rb +3 -3
- data/lib/basecamp/generated/services/gauges_service.rb +10 -6
- data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
- data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
- data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
- data/lib/basecamp/generated/services/message_types_service.rb +3 -3
- data/lib/basecamp/generated/services/messages_service.rb +3 -3
- data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
- data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
- data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
- data/lib/basecamp/generated/services/people_service.rb +11 -11
- data/lib/basecamp/generated/services/projects_service.rb +4 -4
- data/lib/basecamp/generated/services/recordings_service.rb +1 -1
- data/lib/basecamp/generated/services/reports_service.rb +3 -3
- data/lib/basecamp/generated/services/schedules_service.rb +6 -6
- data/lib/basecamp/generated/services/search_service.rb +1 -1
- data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
- data/lib/basecamp/generated/services/templates_service.rb +8 -8
- data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
- data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
- data/lib/basecamp/generated/services/todolists_service.rb +3 -3
- data/lib/basecamp/generated/services/todos_service.rb +4 -4
- data/lib/basecamp/generated/services/todosets_service.rb +1 -1
- data/lib/basecamp/generated/services/tools_service.rb +3 -3
- data/lib/basecamp/generated/services/uploads_service.rb +4 -4
- data/lib/basecamp/generated/services/vaults_service.rb +3 -3
- data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
- data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
- data/lib/basecamp/generated/types.rb +111 -7
- data/lib/basecamp/http.rb +383 -60
- data/lib/basecamp/ids.rb +277 -0
- data/lib/basecamp/mentions.rb +1119 -0
- data/lib/basecamp/person_id_sites.rb +184 -0
- data/lib/basecamp/recording_routing_error.rb +50 -0
- data/lib/basecamp/recording_summary_error.rb +33 -0
- data/lib/basecamp/services/comments_extensions.rb +121 -0
- data/lib/basecamp/services/merge_safe.rb +37 -13
- data/lib/basecamp/services/recordings_extensions.rb +1305 -0
- data/lib/basecamp/services/todolists_extensions.rb +4 -3
- data/lib/basecamp/text.rb +94 -0
- data/lib/basecamp/unresolved_recording_error.rb +68 -0
- data/lib/basecamp/version.rb +1 -1
- data/lib/basecamp/webhooks/event.rb +4 -1
- data/lib/basecamp.rb +22 -4
- data/scripts/generate-metadata.rb +75 -2
- data/scripts/generate-services.rb +35 -8
- data/scripts/generate-types.rb +19 -0
- metadata +15 -2
data/lib/basecamp/http.rb
CHANGED
|
@@ -12,26 +12,179 @@ module Basecamp
|
|
|
12
12
|
# Default User-Agent header
|
|
13
13
|
USER_AGENT = "basecamp-sdk-ruby/#{VERSION} (api:#{API_VERSION})".freeze
|
|
14
14
|
|
|
15
|
-
# Normalizes Person-shaped objects in parsed JSON
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# Normalizes Person-shaped objects in parsed JSON, finding them the TWO ways
|
|
16
|
+
# the reference finds them (go/pkg/basecamp/normalize.go):
|
|
17
|
+
#
|
|
18
|
+
# 1. by TYPE TAG — any object carrying "personable_type" (+normalizePersonIds+,
|
|
19
|
+
# normalize.go:16);
|
|
20
|
+
# 2. by STRUCTURAL POSITION — the "creator" object and each "participants"
|
|
21
|
+
# element, at any depth, WHETHER OR NOT they carry "personable_type"
|
|
22
|
+
# (+normalizeEmbeddedPersonIds+, normalize.go:83).
|
|
23
|
+
#
|
|
24
|
+
# The second one is not a refinement of the first, and this method had only
|
|
25
|
+
# the first. BC3's embedded creator and participants people frequently omit
|
|
26
|
+
# "personable_type" — the reference's own comment (normalize.go:78-82) says
|
|
27
|
+
# that is why the pass exists — so their string ids reached callers
|
|
28
|
+
# unconverted: 62 of the 74 measured rows of GoPersonIds::CORPUS differed
|
|
29
|
+
# from the reference for a bare {"creator":{"id":…}}, and the twelve that
|
|
30
|
+
# agreed did so only because "leave the string" is also what doing nothing
|
|
31
|
+
# looks like. The visible cost was on a WRITE: {Services::MergeSafe} then
|
|
32
|
+
# required an Integer id, so a merge-safe update of a schedule entry whose
|
|
33
|
+
# participants came back with string ids raised where the reference reads
|
|
34
|
+
# the number and proceeds. (It now reads a person id itself, through
|
|
35
|
+
# {Ids.person_from_wire}; this pass is still what the reference does here.)
|
|
36
|
+
#
|
|
37
|
+
# Whichever way a person is found, the id rule is the same one
|
|
38
|
+
# ({coerce_person_id}, the reference's shared coercePersonID):
|
|
39
|
+
# - Signed decimal strings: coerced to Integer, no system_label
|
|
18
40
|
# - Non-numeric sentinels (e.g. "basecamp"): id becomes 0, system_label preserves original
|
|
19
|
-
|
|
41
|
+
# - Numeric overflow: left as the string, for the reader to refuse
|
|
42
|
+
#
|
|
43
|
+
# ONE WALK, where the reference makes two passes over the same tree. That is
|
|
44
|
+
# sound only because {coerce_person_id} is idempotent — it returns untouched
|
|
45
|
+
# unless the id is a String, and it leaves a String behind only in the
|
|
46
|
+
# overflow case, where a second visit reaches the same refusal — so an
|
|
47
|
+
# object both passes find is coerced once and re-visited to no effect, and
|
|
48
|
+
# the order the two rules fire in cannot matter. Verified differentially
|
|
49
|
+
# against a literal two-walk transcription of normalize.go over the corpus
|
|
50
|
+
# in nineteen document shapes; if that idempotence is ever weakened, split
|
|
51
|
+
# this back into two walks.
|
|
52
|
+
def self.normalize_person_ids(obj, embedded_people: false)
|
|
20
53
|
case obj
|
|
21
54
|
when Hash
|
|
22
|
-
if obj.key?("personable_type")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if numeric
|
|
26
|
-
obj["id"] = numeric
|
|
27
|
-
else
|
|
28
|
-
obj["system_label"] = raw_id
|
|
29
|
-
obj["id"] = 0
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
obj.each_value { |v| normalize_person_ids(v) }
|
|
55
|
+
coerce_person_id(obj) if obj.key?("personable_type")
|
|
56
|
+
coerce_embedded_person_ids(obj) if embedded_people
|
|
57
|
+
obj.each_value { |v| normalize_person_ids(v, embedded_people: embedded_people) }
|
|
33
58
|
when Array
|
|
34
|
-
obj.each { |item| normalize_person_ids(item) }
|
|
59
|
+
obj.each { |item| normalize_person_ids(item, embedded_people: embedded_people) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The endpoints the reference runs the POSITIONAL pass over, and only those.
|
|
64
|
+
#
|
|
65
|
+
# +normalizeEmbeddedPeopleJSON+ is a function in the reference, not a layer.
|
|
66
|
+
# It is called from +decodeGaugePayload+ (gauges.go:170, reached by every
|
|
67
|
+
# gauge and needle body) and from the three notification decoders
|
|
68
|
+
# (my_notifications.go:171, 281, 296). Nothing else in Go calls it, so
|
|
69
|
+
# nothing else here runs it.
|
|
70
|
+
#
|
|
71
|
+
# WHY THIS GATE EXISTS AT ALL, which is a correction. The pass used to run
|
|
72
|
+
# on every response body. Its two keys are the reference's two, but "creator"
|
|
73
|
+
# and "participants" are not unique to the wrapper types: on
|
|
74
|
+
# +UpcomingScheduleEntry+ they hold +UpcomingSchedulePerson+, whose +Id+ is a
|
|
75
|
+
# plain int64 in the reference (client.gen.go:4194-4198), so a string id
|
|
76
|
+
# there is a decode error in Go and became person 0 with a +system_label+
|
|
77
|
+
# here — the SYSTEM ACTOR, on the field that says who acted, for a body the
|
|
78
|
+
# reference refuses outright. +MyAssignment.assignees+ and
|
|
79
|
+
# +DisableOutOfOfficeOutput.person+ are the same shape of mistake.
|
|
80
|
+
#
|
|
81
|
+
# Matched on the request path rather than an operation name because Go's own
|
|
82
|
+
# boundary is the call site, and because +update_gauge_needle+ reaches
|
|
83
|
+
# +decodeGaugePayload+ in the reference while passing no operation id here.
|
|
84
|
+
#
|
|
85
|
+
# Each pattern is anchored to the END of the URL's path, and only the path
|
|
86
|
+
# is matched — never the host, never the query. An unanchored pattern over
|
|
87
|
+
# the whole URL would let a base URL whose own path happened to contain
|
|
88
|
+
# "/gauge_needles/" switch the pass on for every request.
|
|
89
|
+
EMBEDDED_PEOPLE_PATHS = [
|
|
90
|
+
%r{/my/readings\.json\z}, # GetMyNotifications
|
|
91
|
+
%r{/my/readings/bubble_ups\.json\z}, # GetBubbleUps
|
|
92
|
+
%r{/gauge_needles/\d+\z}, # GetGaugeNeedle, UpdateGaugeNeedle
|
|
93
|
+
%r{/projects/\d+/gauge/needles\.json\z}, # ListGaugeNeedles, CreateGaugeNeedle
|
|
94
|
+
%r{/reports/gauges\.json\z} # ListGauges
|
|
95
|
+
].freeze
|
|
96
|
+
|
|
97
|
+
# Whether +url+ is one of the reference's two normalization surfaces.
|
|
98
|
+
def self.embedded_people_url?(url)
|
|
99
|
+
path = begin
|
|
100
|
+
URI.parse(url.to_s).path.to_s
|
|
101
|
+
rescue URI::InvalidURIError
|
|
102
|
+
return false
|
|
103
|
+
end
|
|
104
|
+
EMBEDDED_PEOPLE_PATHS.any? { |pattern| pattern.match?(path) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The people this object carries by POSITION rather than by type tag: its
|
|
108
|
+
# "creator", and each element of its "participants" (normalize.go:86-95,
|
|
109
|
+
# matched key for key).
|
|
110
|
+
#
|
|
111
|
+
# Only those two keys, and only their immediate shape — an object under
|
|
112
|
+
# "creator", the object elements under "participants" — because the
|
|
113
|
+
# reference names exactly these: they are the keys its wrapper types embed a
|
|
114
|
+
# *Person under (Notification, Gauge, GaugeNeedle).
|
|
115
|
+
#
|
|
116
|
+
# NOT because the reference leaves other person ids as strings. It does not.
|
|
117
|
+
# An earlier version of this comment said widening to "assignees" would
|
|
118
|
+
# "coerce ids the reference leaves alone", and that is false about the
|
|
119
|
+
# reference as a whole: Go spells the rule TWICE. This normalizer covers
|
|
120
|
+
# creator/participants for the wrapper types whose Person.ID is a plain
|
|
121
|
+
# int64, and Go's generated decoder covers every other person-valued field,
|
|
122
|
+
# because generated.Person.Id is a types.FlexibleInt64. So Go's OBSERVABLE
|
|
123
|
+
# answer at "assignees" is the number, reached by the other route.
|
|
124
|
+
#
|
|
125
|
+
# Ruby has no decoder on the generated path — +get+ returns a raw Hash — so
|
|
126
|
+
# that second route does not exist here, and an "assignees" id that arrives
|
|
127
|
+
# as a string stays one. That gap was measured on a write, and it is
|
|
128
|
+
# deliberately NOT closed by widening this list: the faithful site is the
|
|
129
|
+
# reader, field by field against the reference, because some person ids in
|
|
130
|
+
# the model really are plain int64 there (TemplateLibraryConfirmationPerson)
|
|
131
|
+
# and a blanket sweep would break them. It is closed at that reader:
|
|
132
|
+
# {Services::MergeSafe.writable_id_list} reads a string person id through
|
|
133
|
+
# {Ids.person_from_wire}.
|
|
134
|
+
def self.coerce_embedded_person_ids(obj)
|
|
135
|
+
creator = obj["creator"]
|
|
136
|
+
coerce_person_id(creator) if creator.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
participants = obj["participants"]
|
|
139
|
+
return unless participants.is_a?(Array)
|
|
140
|
+
|
|
141
|
+
participants.each { |person| coerce_person_id(person) if person.is_a?(Hash) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# One Person-shaped object's string id, by the reference's grammar.
|
|
145
|
+
#
|
|
146
|
+
# SIGNED DECIMAL, not Ruby's Integer(). That method detects a base from the
|
|
147
|
+
# literal and tolerates surrounding space and underscores, so it read
|
|
148
|
+
# "0x10" as 16, "0b11" as 3, "1_2" as 12 and " 7" as 7 where the reference
|
|
149
|
+
# reads every one of them as a non-numeric sentinel and zeroes it. Worst of
|
|
150
|
+
# the set is "010": the reference parses base 10 and gets 10, Ruby detected
|
|
151
|
+
# octal and got 8 — not a refusal against an acceptance but two different
|
|
152
|
+
# PEOPLE, either of which a caller could then mention.
|
|
153
|
+
#
|
|
154
|
+
# This runs before either composite sees the value, so the strict decoding
|
|
155
|
+
# they do could never have seen the original: the normalizer had already
|
|
156
|
+
# replaced it. Measured against strconv.ParseInt(s, 10, 64) over the shapes
|
|
157
|
+
# above; the two agree everywhere else.
|
|
158
|
+
#
|
|
159
|
+
# Overflow is left as a String on purpose, which is what the reference does:
|
|
160
|
+
# the id is out of range for the field, so the reader refuses it rather than
|
|
161
|
+
# this silently substituting a sentinel. WHICH strings are overflows is
|
|
162
|
+
# decided by Go's scan order and not by their shape — "18446744073709551616x"
|
|
163
|
+
# is one and "18446744073709551615x" is a sentinel — so this reads them with
|
|
164
|
+
# {Basecamp::Ids.parse_int}, which is that scan, and shares it with
|
|
165
|
+
# {Basecamp::Ids.person_from_wire}: one rule, both person-id sites.
|
|
166
|
+
def self.coerce_person_id(obj)
|
|
167
|
+
raw = obj["id"]
|
|
168
|
+
# An id that is already a number, or absent, is left alone — the guard the
|
|
169
|
+
# reference keeps inside this function rather than at its callers
|
|
170
|
+
# (normalize.go:41-44). Here it is also what makes the single walk above
|
|
171
|
+
# safe: both of the reference's passes can reach the same object, and the
|
|
172
|
+
# second visit has to be a no-op.
|
|
173
|
+
return unless raw.is_a?(String)
|
|
174
|
+
|
|
175
|
+
# Bounded by construction: this runs over EVERY decoded response, and a
|
|
176
|
+
# body may be 50 MB, so a long digit run built an arbitrarily large
|
|
177
|
+
# Integer here before anything decided to discard it. The scan refuses
|
|
178
|
+
# past 64 bits within 20 digits and never converts the rest.
|
|
179
|
+
parsed = Ids.parse_int(raw)
|
|
180
|
+
case parsed
|
|
181
|
+
when :syntax
|
|
182
|
+
obj["system_label"] = raw
|
|
183
|
+
obj["id"] = 0
|
|
184
|
+
when :range
|
|
185
|
+
nil # left as the string, for the reader to refuse
|
|
186
|
+
else
|
|
187
|
+
obj["id"] = parsed
|
|
35
188
|
end
|
|
36
189
|
end
|
|
37
190
|
|
|
@@ -225,11 +378,26 @@ module Basecamp
|
|
|
225
378
|
wrapper = nil
|
|
226
379
|
events = paginated_enumerator(path, key: key, params: params, operation: operation, \
|
|
227
380
|
max_items: max_items) do |first_data|
|
|
228
|
-
wrapper
|
|
381
|
+
# The wrapper is the same body the page items came out of, so it takes
|
|
382
|
+
# the same rule: a null body is an empty wrapper, not a crash. This is
|
|
383
|
+
# the site the previous null sweep did not reach — `nil.reject` raised
|
|
384
|
+
# NoMethodError out of a public method where the reference reads a zero
|
|
385
|
+
# value and reports no events.
|
|
386
|
+
wrapper = first_data.is_a?(Hash) ? first_data.reject { |k, _| k == key } : {}
|
|
229
387
|
end
|
|
230
388
|
wrapper.merge(key => events)
|
|
231
389
|
end
|
|
232
390
|
|
|
391
|
+
# The parsed Retry-After for a response some other path is mapping itself
|
|
392
|
+
# (the download hops in Client), so its error carries the same value the
|
|
393
|
+
# retry loop reads here — populated at every status (SPEC §6).
|
|
394
|
+
#
|
|
395
|
+
# @param value [String, nil] the raw Retry-After header value
|
|
396
|
+
# @return [Integer, nil] seconds, or nil when absent or malformed
|
|
397
|
+
def parse_retry_after_header(value)
|
|
398
|
+
parse_retry_after(value)
|
|
399
|
+
end
|
|
400
|
+
|
|
233
401
|
private
|
|
234
402
|
|
|
235
403
|
# Shared paginator core behind paginate/paginate_key/paginate_wrapped.
|
|
@@ -256,7 +424,7 @@ module Basecamp
|
|
|
256
424
|
|
|
257
425
|
@hooks.on_paginate(base_url, 1)
|
|
258
426
|
first_response = get(base_url, params: params, operation: operation)
|
|
259
|
-
first_data = parse_page(first_response, page: 1)
|
|
427
|
+
first_data = parse_page(first_response, page: 1, operation: operation)
|
|
260
428
|
yield first_data if block_given?
|
|
261
429
|
first_items = extract_page_items(first_data, key: key, page: 1)
|
|
262
430
|
|
|
@@ -271,7 +439,7 @@ module Basecamp
|
|
|
271
439
|
else
|
|
272
440
|
@hooks.on_paginate(base_url, 1)
|
|
273
441
|
response = get(base_url, params: params, operation: operation)
|
|
274
|
-
items = extract_page_items(parse_page(response, page: 1), key: key, page: 1)
|
|
442
|
+
items = extract_page_items(parse_page(response, page: 1, operation: operation), key: key, page: 1)
|
|
275
443
|
meta.restart!(total_count: parse_total_count(response.headers))
|
|
276
444
|
end
|
|
277
445
|
|
|
@@ -290,6 +458,11 @@ module Basecamp
|
|
|
290
458
|
|
|
291
459
|
capped = false
|
|
292
460
|
items.each_with_index do |item, index|
|
|
461
|
+
# A followed bare-array page is decoded item by item as each is
|
|
462
|
+
# KEPT: the reference trims a followed page to the cap before it
|
|
463
|
+
# decodes anything (client.go:604-631), so an item past the cap is
|
|
464
|
+
# never decoded there. Page 1 was decoded whole in parse_page.
|
|
465
|
+
PersonIdSites.decode_item!(item, operation) if page > 1 && key.nil?
|
|
293
466
|
yielded += 1
|
|
294
467
|
capped = max_items && yielded >= max_items
|
|
295
468
|
# Truncation is recorded before the capping yield: consumers like
|
|
@@ -318,7 +491,12 @@ module Basecamp
|
|
|
318
491
|
page += 1
|
|
319
492
|
@hooks.on_paginate(next_url, page)
|
|
320
493
|
response = get(next_url, operation: operation)
|
|
321
|
-
|
|
494
|
+
# No whole-page decode on a followed page (operation: nil): see
|
|
495
|
+
# PersonIdSites.decode_item!. A wrapped listing decodes every item
|
|
496
|
+
# under its key before the cap trims them, as timeline.go:444-465
|
|
497
|
+
# does; a bare array decodes each kept item in the yield loop above.
|
|
498
|
+
items = extract_page_items(parse_page(response, page: page, operation: nil), key: key, page: page)
|
|
499
|
+
items.each { |item| PersonIdSites.decode_item!(item, operation, key: key) } if key
|
|
322
500
|
url = next_url
|
|
323
501
|
end
|
|
324
502
|
end
|
|
@@ -338,13 +516,16 @@ module Basecamp
|
|
|
338
516
|
page.respond_to?(:to_i) && page.to_i.positive?
|
|
339
517
|
end
|
|
340
518
|
|
|
341
|
-
# Parses a pagination page body: size check, JSON parse,
|
|
342
|
-
# normalization,
|
|
343
|
-
|
|
519
|
+
# Parses a pagination page body: size check, JSON parse, person-ID
|
|
520
|
+
# normalization, then the operation's typed person-id decode of the whole
|
|
521
|
+
# page, with page-numbered error context. The whole-page decode is for
|
|
522
|
+
# page 1, which the reference decodes through Parse<Op>Response; a followed
|
|
523
|
+
# page passes operation: nil and is decoded per item by the caller.
|
|
524
|
+
def parse_page(response, page:, operation:)
|
|
344
525
|
Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
|
|
345
526
|
data = JSON.parse(response.body)
|
|
346
|
-
Http.normalize_person_ids(data)
|
|
347
|
-
data
|
|
527
|
+
Http.normalize_person_ids(data, embedded_people: response.embedded_people)
|
|
528
|
+
PersonIdSites.decode!(data, operation)
|
|
348
529
|
rescue JSON::ParserError => e
|
|
349
530
|
# +cause+ carries the parser's own error, not just its message (#750). The
|
|
350
531
|
# message says what happened; the slot is what a caller can act on, and it
|
|
@@ -362,20 +543,90 @@ module Basecamp
|
|
|
362
543
|
# bare-array pagination, or the named key's array otherwise.
|
|
363
544
|
def extract_page_items(data, key:, page:)
|
|
364
545
|
if key.nil?
|
|
546
|
+
# Bare-array pagination, so the body must BE the array. It was returned
|
|
547
|
+
# verbatim, and the caller's loop then did `items.each_with_index` on
|
|
548
|
+
# it: a scalar, a boolean or a null body raised NoMethodError out of a
|
|
549
|
+
# public list method, and an object returned its key/value pairs as
|
|
550
|
+
# though they were records. An empty object was the worst of the set,
|
|
551
|
+
# because it silently paginated to zero items — a composite that reads
|
|
552
|
+
# "no rows" as "nothing exists" then reports absent what it never
|
|
553
|
+
# managed to read.
|
|
554
|
+
# A null body is an EMPTY page, not a malformed one. The reference
|
|
555
|
+
# decodes JSON `null` into a slice as the nil slice with no error, so a
|
|
556
|
+
# listing that comes back null is "no rows" there. Rejecting it turned
|
|
557
|
+
# that into an ApiError and, for Campfire discovery, into a failed read
|
|
558
|
+
# where the contract has an empty one.
|
|
559
|
+
return [] if data.nil?
|
|
560
|
+
|
|
561
|
+
unless data.is_a?(Array)
|
|
562
|
+
raise Basecamp::ApiError.new(
|
|
563
|
+
# The CLASS only. MergeSafe.describe appends up to 500 bytes of the
|
|
564
|
+
# value, and here the value is a whole paginated page — customer
|
|
565
|
+
# data in an exception message, which travels into logs and bug
|
|
566
|
+
# reports. The shape is what a caller needs; the contents are not.
|
|
567
|
+
"Paginated response (page #{page}) is #{data.class}, not a list",
|
|
568
|
+
hint: "This operation paginates over a bare JSON array; a body of another shape cannot be read.",
|
|
569
|
+
retryable: false
|
|
570
|
+
)
|
|
571
|
+
end
|
|
572
|
+
|
|
365
573
|
data
|
|
366
574
|
else
|
|
575
|
+
# Same rule as the bare-array branch above: the reference decodes JSON
|
|
576
|
+
# `null` as the zero value, so a null body is an empty page rather than
|
|
577
|
+
# a malformed one — and anything that is not an object cannot be asked
|
|
578
|
+
# for a key at all. `data.key?` raised NoMethodError on nil and on a
|
|
579
|
+
# scalar, and TypeError on an array, straight out of a public method.
|
|
580
|
+
return [] if data.nil?
|
|
581
|
+
|
|
582
|
+
unless data.is_a?(Hash)
|
|
583
|
+
raise Basecamp::ApiError.new(
|
|
584
|
+
"Paginated response (page #{page}) is #{data.class}, not an object",
|
|
585
|
+
hint: "This operation paginates over the #{key.inspect} key of a JSON object; " \
|
|
586
|
+
"a body of another shape cannot be read.",
|
|
587
|
+
retryable: false
|
|
588
|
+
)
|
|
589
|
+
end
|
|
590
|
+
|
|
367
591
|
unless data.key?(key)
|
|
368
592
|
warn "[Basecamp SDK] paginate: expected key '#{key}' not found in response (page #{page})"
|
|
369
593
|
end
|
|
370
|
-
data[key]
|
|
594
|
+
items = data[key]
|
|
595
|
+
# The VALUE at the key takes the same rule as the envelope above, and
|
|
596
|
+
# the guard added with that one stopped a line short. A scalar or a
|
|
597
|
+
# boolean here reached the caller's `each_with_index` as a native
|
|
598
|
+
# exception out of a public method, and an OBJECT paginated over its
|
|
599
|
+
# key/value pairs as though they were records — which is the hazard this
|
|
600
|
+
# method's own comment writes down for the bare-array branch, live one
|
|
601
|
+
# `else` away from it.
|
|
602
|
+
return [] if items.nil?
|
|
603
|
+
|
|
604
|
+
unless items.is_a?(Array)
|
|
605
|
+
raise Basecamp::ApiError.new(
|
|
606
|
+
"Paginated response (page #{page}) has a #{items.class} at #{key.inspect}, not a list",
|
|
607
|
+
hint: "This operation paginates over the #{key.inspect} key of a JSON object; " \
|
|
608
|
+
"a value of another shape cannot be read.",
|
|
609
|
+
retryable: false
|
|
610
|
+
)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
items
|
|
371
614
|
end
|
|
372
615
|
end
|
|
373
616
|
|
|
374
617
|
# Parses the X-Total-Count header, returning 0 when absent or malformed.
|
|
375
618
|
def parse_total_count(headers)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
619
|
+
raw = headers["X-Total-Count"] || headers["x-total-count"]
|
|
620
|
+
return 0 unless raw.is_a?(String)
|
|
621
|
+
|
|
622
|
+
# The reference reads this with Atoi, which takes a signed decimal and
|
|
623
|
+
# returns 0 on anything else — so a negative, an overflow or a padded
|
|
624
|
+
# value is 0 there and was the number itself here. And nothing caps a
|
|
625
|
+
# HEADER: the body has a 50 MB ceiling, so this was the LESS defended of
|
|
626
|
+
# the sites the person-id sweep hardened, reached on the first response of
|
|
627
|
+
# every paginated call.
|
|
628
|
+
count = Ids.bounded_decimal(raw)
|
|
629
|
+
count.is_a?(Integer) && count.positive? ? count : 0
|
|
379
630
|
end
|
|
380
631
|
|
|
381
632
|
def build_faraday_client
|
|
@@ -391,7 +642,9 @@ module Basecamp
|
|
|
391
642
|
def request(method, path, params: {}, body: nil, allow_cross_origin: false, operation: nil)
|
|
392
643
|
url = build_url(path, allow_cross_origin: allow_cross_origin)
|
|
393
644
|
|
|
394
|
-
# Mutations don't retry on 429/5xx to avoid duplicating data
|
|
645
|
+
# Mutations don't retry on 429/5xx to avoid duplicating data. A mutation
|
|
646
|
+
# declared naturally idempotent (CreateStreamTicket) should retry per its
|
|
647
|
+
# operation metadata instead; routing it here is tracked in #901.
|
|
395
648
|
if method == :get
|
|
396
649
|
request_with_retry(method, url, params: params, allow_cross_origin: allow_cross_origin, operation: operation)
|
|
397
650
|
else
|
|
@@ -540,8 +793,13 @@ module Basecamp
|
|
|
540
793
|
Response.new(
|
|
541
794
|
body: response.body,
|
|
542
795
|
status: response.status,
|
|
543
|
-
headers: response.headers
|
|
796
|
+
headers: response.headers,
|
|
797
|
+
embedded_people: Http.embedded_people_url?(url)
|
|
544
798
|
)
|
|
799
|
+
rescue Faraday::TimeoutError => e
|
|
800
|
+
# Faraday::TimeoutError < Faraday::ServerError: named before the status
|
|
801
|
+
# clause, or a stalled read classifies as a status-less api_error.
|
|
802
|
+
severed = transport_failure(e, info: info, start_time: start_time, download: download)
|
|
545
803
|
rescue Faraday::ServerError, Faraday::ClientError => e
|
|
546
804
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
547
805
|
error = handle_error(e, refresh_on_401: refresh_replay)
|
|
@@ -571,21 +829,35 @@ module Basecamp
|
|
|
571
829
|
|
|
572
830
|
severed = error
|
|
573
831
|
rescue Faraday::Error => e
|
|
574
|
-
|
|
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
|
|
832
|
+
severed = transport_failure(e, info: info, start_time: start_time, download: download)
|
|
584
833
|
end
|
|
585
834
|
|
|
586
835
|
severed ? raise(severed, cause: nil) : result
|
|
587
836
|
end
|
|
588
837
|
|
|
838
|
+
# A transport failure — no HTTP response, so no status to map — as the
|
|
839
|
+
# network error the caller and on_request_end both see: a timeout by name,
|
|
840
|
+
# anything else as a connection failure, both retryable (SPEC §6).
|
|
841
|
+
# download: on hop 1 the Faraday error can render the URL it failed on, so
|
|
842
|
+
# it is neither the cause nor the hint (SPEC §9), and the error is returned
|
|
843
|
+
# for the caller to raise outside its rescue with cause: nil, so MRI's
|
|
844
|
+
# implicit cause is severed too. Every other request keeps its diagnostic
|
|
845
|
+
# and raises here.
|
|
846
|
+
def transport_failure(exception, info:, start_time:, download: false)
|
|
847
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
848
|
+
error = if download
|
|
849
|
+
Basecamp::NetworkError.new
|
|
850
|
+
else
|
|
851
|
+
Basecamp::NetworkError.new(transport_failure_message(exception), cause: exception)
|
|
852
|
+
end
|
|
853
|
+
@hooks.on_request_end(info, RequestResult.new(duration: duration, error: error))
|
|
854
|
+
download ? error : raise(error)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def transport_failure_message(exception)
|
|
858
|
+
exception.is_a?(Faraday::TimeoutError) ? "Request timed out" : "Connection failed"
|
|
859
|
+
end
|
|
860
|
+
|
|
589
861
|
# accept: nil is the binary-download carve-out (SPEC §14): hop 1 sends
|
|
590
862
|
# Authorization and User-Agent only, because it is not a JSON API call.
|
|
591
863
|
# Every other caller keeps the JSON Accept.
|
|
@@ -614,8 +886,11 @@ module Basecamp
|
|
|
614
886
|
Response.new(
|
|
615
887
|
body: response.body,
|
|
616
888
|
status: response.status,
|
|
617
|
-
headers: response.headers
|
|
889
|
+
headers: response.headers,
|
|
890
|
+
embedded_people: Http.embedded_people_url?(url)
|
|
618
891
|
)
|
|
892
|
+
rescue Faraday::TimeoutError => e
|
|
893
|
+
transport_failure(e, info: info, start_time: start_time)
|
|
619
894
|
rescue Faraday::ServerError, Faraday::ClientError => e
|
|
620
895
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
621
896
|
error = handle_error(e)
|
|
@@ -628,11 +903,7 @@ module Basecamp
|
|
|
628
903
|
@hooks.on_request_end(info, result)
|
|
629
904
|
raise error
|
|
630
905
|
rescue Faraday::Error => e
|
|
631
|
-
|
|
632
|
-
error = Basecamp::NetworkError.new("Connection failed", cause: e)
|
|
633
|
-
result = RequestResult.new(duration: duration, error: error)
|
|
634
|
-
@hooks.on_request_end(info, result)
|
|
635
|
-
raise error
|
|
906
|
+
transport_failure(e, info: info, start_time: start_time)
|
|
636
907
|
end
|
|
637
908
|
end
|
|
638
909
|
|
|
@@ -682,15 +953,24 @@ module Basecamp
|
|
|
682
953
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Account limit reached")
|
|
683
954
|
Basecamp::LimitExceededError.new(message, hint: hint)
|
|
684
955
|
when 500
|
|
685
|
-
Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint)
|
|
956
|
+
Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint, retry_after: retry_after)
|
|
686
957
|
when 502, 503, 504
|
|
687
|
-
|
|
958
|
+
# retry_after rides along here as it does on the 429 arm (SPEC §6
|
|
959
|
+
# "HTTP Status Mapping Algorithm"): request_with_retry reads the delay
|
|
960
|
+
# off the error, so this is also what makes a 503's Retry-After govern
|
|
961
|
+
# the sleep rather than the backoff curve.
|
|
962
|
+
Basecamp::ApiError.new(
|
|
963
|
+
"Gateway error (#{status})", http_status: status, retryable: true, hint: hint, retry_after: retry_after
|
|
964
|
+
)
|
|
688
965
|
else
|
|
689
966
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Request failed (HTTP #{status})")
|
|
690
|
-
Basecamp::ApiError.from_status(status || 0, message, hint: hint)
|
|
967
|
+
Basecamp::ApiError.from_status(status || 0, message, hint: hint, retry_after: retry_after)
|
|
691
968
|
end
|
|
692
969
|
|
|
693
970
|
err.instance_variable_set(:@request_id, request_id) if request_id
|
|
971
|
+
# Every status carries a parsed Retry-After (SPEC §6), including the arms
|
|
972
|
+
# whose error classes take no such argument.
|
|
973
|
+
err.instance_variable_set(:@retry_after, retry_after) if retry_after && err.retry_after.nil?
|
|
694
974
|
err
|
|
695
975
|
end
|
|
696
976
|
|
|
@@ -741,18 +1021,43 @@ module Basecamp
|
|
|
741
1021
|
base + jitter
|
|
742
1022
|
end
|
|
743
1023
|
|
|
744
|
-
|
|
1024
|
+
# SPEC §6 MAX_RETRY_AFTER_SECONDS: the value a parsed Retry-After saturates
|
|
1025
|
+
# at, in both wire forms. A representability bound pinned once for all six
|
|
1026
|
+
# SDKs (the narrowest retry_after integer any of them ships, and the ceiling
|
|
1027
|
+
# §16 already names), not a policy cap. Ruby's Integer is arbitrary-
|
|
1028
|
+
# precision, so without it the failure was one layer down: sleep raised
|
|
1029
|
+
# RangeError on the retry path.
|
|
1030
|
+
MAX_RETRY_AFTER_SECONDS = 2_147_483_647
|
|
1031
|
+
|
|
1032
|
+
def parse_retry_after(value, now: Time.now)
|
|
745
1033
|
return nil if value.nil? || value.empty?
|
|
746
1034
|
|
|
747
|
-
#
|
|
748
|
-
|
|
749
|
-
|
|
1035
|
+
# RFC 9110 spells delay-seconds as 1*DIGIT: no sign, which Integer()
|
|
1036
|
+
# would accept. A value over the ceiling saturates whatever its width,
|
|
1037
|
+
# since 1*DIGIT has no upper bound and no digit string is malformed for
|
|
1038
|
+
# its length.
|
|
1039
|
+
if value.match?(/\A\d+\z/)
|
|
1040
|
+
# Bounded before conversion, which is what the reference does here too
|
|
1041
|
+
# — its own comment says "the width test comes before ParseInt so no
|
|
1042
|
+
# conversion can overflow". So this is a contract divergence rather
|
|
1043
|
+
# than a hardening choice: the gate existed there and not here, and the
|
|
1044
|
+
# comment above fixed the consequence (sleep raising on a bignum)
|
|
1045
|
+
# rather than the conversion that produced it.
|
|
1046
|
+
seconds = Ids.bounded_decimal(value, signed: false)
|
|
1047
|
+
return MAX_RETRY_AFTER_SECONDS if seconds == :overflow
|
|
1048
|
+
return nil unless seconds.is_a?(Integer) && seconds.positive?
|
|
1049
|
+
|
|
1050
|
+
return [ seconds, MAX_RETRY_AFTER_SECONDS ].min
|
|
1051
|
+
end
|
|
750
1052
|
|
|
751
|
-
# Try parsing as HTTP-date
|
|
1053
|
+
# Try parsing as HTTP-date. Rounded UP (SPEC §6 step 2): truncating a
|
|
1054
|
+
# sub-second remainder toward zero turned a date 400ms out into 0, which
|
|
1055
|
+
# reads as "no usable value" and drops onto the backoff curve, and
|
|
1056
|
+
# retried up to a second before the moment the server named.
|
|
752
1057
|
begin
|
|
753
1058
|
date = Time.httpdate(value)
|
|
754
|
-
diff = (date -
|
|
755
|
-
return diff if diff.positive?
|
|
1059
|
+
diff = (date - now).ceil
|
|
1060
|
+
return [ diff, MAX_RETRY_AFTER_SECONDS ].min if diff.positive?
|
|
756
1061
|
rescue ArgumentError
|
|
757
1062
|
# Not a valid HTTP-date
|
|
758
1063
|
end
|
|
@@ -890,21 +1195,39 @@ module Basecamp
|
|
|
890
1195
|
# @return [Hash] response headers
|
|
891
1196
|
attr_reader :headers
|
|
892
1197
|
|
|
893
|
-
|
|
1198
|
+
# Whether this response came from one of the reference's two positional
|
|
1199
|
+
# normalization surfaces. See +Http::EMBEDDED_PEOPLE_PATHS+.
|
|
1200
|
+
# @return [Boolean]
|
|
1201
|
+
attr_reader :embedded_people
|
|
1202
|
+
|
|
1203
|
+
def initialize(body:, status:, headers:, embedded_people: false)
|
|
894
1204
|
@body = body
|
|
895
1205
|
@status = status
|
|
896
1206
|
@headers = headers
|
|
1207
|
+
@embedded_people = embedded_people
|
|
897
1208
|
end
|
|
898
1209
|
|
|
899
1210
|
# Parses the response body as JSON, normalizing Person-shaped objects.
|
|
1211
|
+
#
|
|
1212
|
+
# +operation+ is the channel the generated read path uses to name its
|
|
1213
|
+
# operation for the typed person-id decode ({PersonIdSites}). It is kept
|
|
1214
|
+
# apart from the +operation:+ a GET passes to {Http#get}, which selects the
|
|
1215
|
+
# declared retry policy: a mutation passes none there on purpose, and must
|
|
1216
|
+
# still have its response decoded.
|
|
1217
|
+
#
|
|
1218
|
+
# @param operation [String, nil] canonical operation id whose person-id
|
|
1219
|
+
# sites to decode; nil decodes none
|
|
900
1220
|
# @return [Hash, Array]
|
|
901
|
-
|
|
1221
|
+
# @raise [ApiError] when a person id at one of the operation's sites
|
|
1222
|
+
# cannot be decoded
|
|
1223
|
+
def json(operation: nil)
|
|
902
1224
|
@json ||= begin
|
|
903
1225
|
Security.check_body_size!(@body, Security::MAX_RESPONSE_BODY_BYTES)
|
|
904
1226
|
result = JSON.parse(@body)
|
|
905
|
-
Http.normalize_person_ids(result)
|
|
1227
|
+
Http.normalize_person_ids(result, embedded_people: @embedded_people)
|
|
906
1228
|
result
|
|
907
1229
|
end
|
|
1230
|
+
PersonIdSites.decode!(@json, operation)
|
|
908
1231
|
end
|
|
909
1232
|
|
|
910
1233
|
# Returns whether the response was successful (2xx).
|