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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/basecamp/api_error.rb +5 -3
  4. data/lib/basecamp/bucket_mismatch_error.rb +41 -0
  5. data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
  6. data/lib/basecamp/campfire_index.rb +398 -0
  7. data/lib/basecamp/client.rb +47 -4
  8. data/lib/basecamp/generated/metadata.json +728 -1
  9. data/lib/basecamp/generated/services/account_service.rb +2 -2
  10. data/lib/basecamp/generated/services/attachments_service.rb +1 -1
  11. data/lib/basecamp/generated/services/automation_service.rb +1 -1
  12. data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
  13. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  14. data/lib/basecamp/generated/services/calendars_service.rb +2 -2
  15. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  16. data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
  17. data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
  18. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  19. data/lib/basecamp/generated/services/cards_service.rb +3 -3
  20. data/lib/basecamp/generated/services/checkins_service.rb +9 -9
  21. data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
  22. data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
  23. data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
  24. data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
  25. data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
  26. data/lib/basecamp/generated/services/comments_service.rb +3 -3
  27. data/lib/basecamp/generated/services/documents_service.rb +3 -3
  28. data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
  29. data/lib/basecamp/generated/services/everything_service.rb +2 -2
  30. data/lib/basecamp/generated/services/folders_service.rb +4 -4
  31. data/lib/basecamp/generated/services/forwards_service.rb +3 -3
  32. data/lib/basecamp/generated/services/gauges_service.rb +10 -6
  33. data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
  34. data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
  35. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  36. data/lib/basecamp/generated/services/message_types_service.rb +3 -3
  37. data/lib/basecamp/generated/services/messages_service.rb +3 -3
  38. data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
  39. data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
  40. data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
  41. data/lib/basecamp/generated/services/people_service.rb +11 -11
  42. data/lib/basecamp/generated/services/projects_service.rb +4 -4
  43. data/lib/basecamp/generated/services/recordings_service.rb +1 -1
  44. data/lib/basecamp/generated/services/reports_service.rb +3 -3
  45. data/lib/basecamp/generated/services/schedules_service.rb +6 -6
  46. data/lib/basecamp/generated/services/search_service.rb +1 -1
  47. data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
  48. data/lib/basecamp/generated/services/templates_service.rb +8 -8
  49. data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
  50. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  51. data/lib/basecamp/generated/services/todolists_service.rb +3 -3
  52. data/lib/basecamp/generated/services/todos_service.rb +4 -4
  53. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  54. data/lib/basecamp/generated/services/tools_service.rb +3 -3
  55. data/lib/basecamp/generated/services/uploads_service.rb +4 -4
  56. data/lib/basecamp/generated/services/vaults_service.rb +3 -3
  57. data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
  58. data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
  59. data/lib/basecamp/generated/types.rb +111 -7
  60. data/lib/basecamp/http.rb +383 -60
  61. data/lib/basecamp/ids.rb +277 -0
  62. data/lib/basecamp/mentions.rb +1119 -0
  63. data/lib/basecamp/person_id_sites.rb +184 -0
  64. data/lib/basecamp/recording_routing_error.rb +50 -0
  65. data/lib/basecamp/recording_summary_error.rb +33 -0
  66. data/lib/basecamp/services/comments_extensions.rb +121 -0
  67. data/lib/basecamp/services/merge_safe.rb +37 -13
  68. data/lib/basecamp/services/recordings_extensions.rb +1305 -0
  69. data/lib/basecamp/services/todolists_extensions.rb +4 -3
  70. data/lib/basecamp/text.rb +94 -0
  71. data/lib/basecamp/unresolved_recording_error.rb +68 -0
  72. data/lib/basecamp/version.rb +1 -1
  73. data/lib/basecamp/webhooks/event.rb +4 -1
  74. data/lib/basecamp.rb +22 -4
  75. data/scripts/generate-metadata.rb +75 -2
  76. data/scripts/generate-services.rb +35 -8
  77. data/scripts/generate-types.rb +19 -0
  78. 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
- # For objects with personable_type and a string id:
17
- # - Numeric strings: coerced to Integer, no system_label
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
- def self.normalize_person_ids(obj)
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") && obj["id"].is_a?(String)
23
- raw_id = obj["id"]
24
- numeric = Integer(raw_id, exception: false)
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 = first_data.reject { |k, _| k == key }
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
- items = extract_page_items(parse_page(response, page: page), key: key, page: page)
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, and person-ID
342
- # normalization, with page-numbered error context.
343
- def parse_page(response, page:)
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
- Integer(headers["X-Total-Count"] || headers["x-total-count"], 10)
377
- rescue ArgumentError, TypeError
378
- 0
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
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
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
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
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
- Basecamp::ApiError.new("Gateway error (#{status})", http_status: status, retryable: true, hint: hint)
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
- def parse_retry_after(value)
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
- # Try parsing as seconds (integer)
748
- seconds = Integer(value, exception: false)
749
- return seconds if seconds&.positive?
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 - Time.now).to_i
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
- def initialize(body:, status:, headers:)
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
- def json
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).