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
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Reads a documented integer id argument.
5
+ #
6
+ # The composites take ids the caller holds — a bucket, a recording, the people
7
+ # to mention — and put them straight into a read. Coercing with +to_i+ would
8
+ # turn "12oops" and 12.9 into 12 and go and fetch THAT record, which is a
9
+ # wrong answer wearing the shape of a right one. The generated services do not
10
+ # coerce either: they interpolate what they are given, so a malformed id fails
11
+ # as a bad path rather than as a different record.
12
+ #
13
+ # An Integer passes through. A string of DIGITS is accepted, since an id read
14
+ # out of JSON or off a command line is a reasonable thing to hold. Anything
15
+ # else — a float, a partly numeric string, nil — is a usage error, named so
16
+ # the caller can see which argument it was.
17
+ #
18
+ # "A string of digits" is matched as such, not handed to +Integer()+, whose
19
+ # grammar is Ruby's integer literal: it reads "1_2" as 12, accepts a sign and
20
+ # surrounding whitespace, and would put this method back to fetching a record
21
+ # the caller did not ask for. The value is bounded like the API's own ids, so
22
+ # a number too large to be one is refused rather than sent.
23
+ module Ids
24
+ # The range a signed 64-bit id can carry, which is what the API's own ids
25
+ # are. Shared with {Basecamp::Mentions}, which bounds a person id decoded
26
+ # out of an sgid the same way.
27
+ MAX = (2**63) - 1
28
+ MIN = -(2**63)
29
+
30
+ # The most digits a signed 64-bit value can carry, so a longer run is out of
31
+ # range without converting it.
32
+ MAX_DIGITS = MAX.to_s.length
33
+
34
+ # The magnitude ParseUint accumulates into before ParseInt applies its own
35
+ # bound: the scan refuses at UNSIGNED 64-bit, not at MAX (strconv/atoi.go,
36
+ # ParseUint's loop). See {parse_int}, where that one boundary is the whole
37
+ # difference between a syntax refusal and a range refusal.
38
+ U64_MAX = (2**64) - 1
39
+
40
+ PLUS = "+".ord
41
+ MINUS = "-".ord
42
+ ZERO = "0".ord
43
+ private_constant :PLUS, :MINUS, :ZERO
44
+
45
+ module_function
46
+
47
+ # A decimal string as an Integer, :overflow when it is out of range, or
48
+ # :not_decimal when it is not one.
49
+ #
50
+ # BOUNDED LEXICALLY BEFORE CONVERTING, which is the whole point. Every
51
+ # caller used to run to_i and then range-check the result, so a digit run
52
+ # from a response body or from rich text built an arbitrarily large Integer
53
+ # first — 5,000,000 digits measured at 2.5 seconds, and a body may be 50 MB.
54
+ # An int64 is at most #{MAX_DIGITS} digits, so anything longer is out of
55
+ # range and can be rejected by LENGTH. Leading zeros are stripped before
56
+ # that test, since they carry no magnitude.
57
+ #
58
+ # Five call sites had this shape and a review found three; the other two
59
+ # were the caller-argument reader and the sgid decoder, which is the one fed
60
+ # by rich text other people wrote.
61
+ #
62
+ # LEXICAL FIRST, THEN BOUNDED — which is a different rule from the one
63
+ # {parse_int} implements, and deliberately so: this one asks "is the whole
64
+ # string a decimal?" before it asks "does it fit?", so junk anywhere makes
65
+ # it :not_decimal however large the digits are. The sites that read a
66
+ # caller's argument, a header count, a Retry-After and an sgid's id want
67
+ # exactly that. The two sites that read a PERSON id off the wire do not,
68
+ # because Go's scan decides in the other order; they call {parse_int}.
69
+ #
70
+ # @param value [String]
71
+ # @param signed [Boolean] whether a leading "+" or "-" is allowed
72
+ def bounded_decimal(value, signed: true)
73
+ digits = value.b
74
+ return :not_decimal unless digits.match?(signed ? /\A[-+]?\d+\z/n : /\A\d+\z/n)
75
+
76
+ magnitude = digits.sub(/\A[-+]/, "").sub(/\A0+(?=\d)/, "")
77
+ return :overflow if magnitude.length > MAX_DIGITS
78
+
79
+ parsed = digits.to_i
80
+ parsed.between?(MIN, MAX) ? parsed : :overflow
81
+ end
82
+
83
+ # <tt>strconv.ParseInt(s, 10, 64)</tt>, scan order included: the Integer the
84
+ # string spells, :syntax when Go reports ErrSyntax, or :range when it
85
+ # reports ErrRange.
86
+ #
87
+ # The two refusals are kept APART because the reference does two different
88
+ # things with them, and no single "is this a number?" predicate can tell
89
+ # them apart: ErrSyntax is the "basecamp" sentinel and reads 0
90
+ # (go/pkg/types/flexible_int64.go:46), ErrRange fails the read
91
+ # (flexible_int64.go:43). Which one a string earns depends on where in it
92
+ # the first disqualifying byte sits, so the answer is a property of the
93
+ # SCAN, not of the string's shape.
94
+ #
95
+ # The grammar: one optional ASCII "+" or "-", then one or more ASCII
96
+ # digits, and nothing else. No surrounding whitespace (Go trims none, so
97
+ # " 7" is ErrSyntax and reads 0), no "_" separator (only base 0 allows one),
98
+ # and ASCII digits alone — a fullwidth "7" or an Arabic-Indic "٧" is not a
99
+ # digit. Ruby happens to agree on that last one, since both /\d/ and
100
+ # Integer() are ASCII-only here, but the rule is Go's rather than Ruby's:
101
+ # a \p{Nd}-aware rewrite would accept ids the reference calls sentinels,
102
+ # which is how the other ports of this scan drifted.
103
+ #
104
+ # THE SUBTLETY THAT COSTS THE HAND-ROLLED LOOP: ParseInt delegates the
105
+ # magnitude to ParseUint, which checks it INSIDE the scan and returns
106
+ # ErrRange the instant the accumulator would overflow uint64 — before it
107
+ # ever reaches the rest of the string. The first disqualifying byte wins,
108
+ # and the boundary it wins at is u64, not int64. So one digit decides which
109
+ # refusal a malformed id earns:
110
+ #
111
+ # "18446744073709551615x" digits still fit u64, the scan reaches the
112
+ # "x" -> :syntax, reads 0
113
+ # "18446744073709551616x" the overflow fires first -> :range, fails
114
+ #
115
+ # Testing the whole string for well-formedness first — which is what
116
+ # {bounded_decimal} does, correctly, for its own callers — gets that pair
117
+ # backwards and hands a malformed oversized id to the reader as the
118
+ # "basecamp" system actor. Measured against the reference on both rows.
119
+ #
120
+ # NOT the rule the sgid's person id gets. That one walks the bytes and
121
+ # refuses anything outside 0..9 BEFORE parsing (go/pkg/basecamp/mentions.go:
122
+ # 252-256), so it rejects the leading "+" this one accepts, and
123
+ # {Basecamp::Mentions.parse_global_id} carries that walk because the
124
+ # reference has that shape at THAT site. Two co-resident rules, deliberately
125
+ # different: do not hoist either into the other, in either direction —
126
+ # unifying them here would accept "+77" as a mentioned person again, and
127
+ # unifying them there would refuse a "+7" the people read accepts.
128
+ #
129
+ # Bounded by construction, so it needs no length gate: the accumulator
130
+ # passes u64 within 20 digits and every other byte ends the scan, so no
131
+ # input builds a large Integer however long it is. Read by BYTES for the
132
+ # same reason {bounded_decimal} is — a String carrying invalid UTF-8 makes
133
+ # the regexp engine raise, and +getbyte+ neither raises nor copies the
134
+ # string, which matters on a body that may be 50 MB.
135
+ #
136
+ # @param value [String]
137
+ # @return [Integer, Symbol] the value, :syntax, or :range
138
+ def parse_int(value)
139
+ index = 0
140
+ negative = false
141
+ case value.getbyte(0)
142
+ when PLUS then index = 1
143
+ when MINUS then index, negative = 1, true
144
+ end
145
+ # An empty digit run, after a sign or without one, is ErrSyntax.
146
+ return :syntax if index == value.bytesize
147
+
148
+ # ParseUint's loop, byte for byte.
149
+ magnitude = 0
150
+ while index < value.bytesize
151
+ digit = value.getbyte(index) - ZERO
152
+ return :syntax unless digit.between?(0, 9)
153
+
154
+ magnitude = (magnitude * 10) + digit
155
+ return :range if magnitude > U64_MAX
156
+
157
+ index += 1
158
+ end
159
+
160
+ # ParseInt's own bound, applied to what ParseUint returned: a negative may
161
+ # carry 2**63, which is MIN, and a positive may carry MAX.
162
+ if negative
163
+ magnitude > -MIN ? :range : -magnitude
164
+ else
165
+ magnitude > MAX ? :range : magnitude
166
+ end
167
+ end
168
+
169
+ # @param value [Object] the id as the caller supplied it
170
+ # @param name [String] the argument's name, for the error message
171
+ # @return [Integer]
172
+ # @raise [Basecamp::UsageError] when the value is not an integer id
173
+ def integer(value, name)
174
+ id = value if value.is_a?(Integer)
175
+ # Matched on BYTES: a String carrying invalid UTF-8 makes the regexp
176
+ # engine raise ArgumentError, and an id that is not a number is a usage
177
+ # error naming the argument, not an exception out of a public method.
178
+ if id.nil? && value.is_a?(String)
179
+ parsed = bounded_decimal(value, signed: false)
180
+ raise UsageError.new("#{name} is out of range: #{value.inspect}") if parsed == :overflow
181
+
182
+ id = parsed unless parsed == :not_decimal
183
+ end
184
+ raise UsageError.new("#{name} must be an integer, got #{value.inspect}") if id.nil?
185
+ raise UsageError.new("#{name} is out of range: #{value.inspect}") unless id.between?(MIN, MAX)
186
+
187
+ id
188
+ end
189
+
190
+ # Reads an id off a value the API sent: the Integer itself, 0 when the value
191
+ # is absent, and nil when it is anything else.
192
+ #
193
+ # Nothing is coerced, and a string of digits is NOT an id. At every field
194
+ # this reads — a bucket id, a dock item's id, a listed Campfire's id and its
195
+ # bucket's id — the reference holds a plain 64-bit integer, so a JSON
196
+ # string, float, boolean, array or object there is a DECODE error that fails
197
+ # the read. This tier has no decoder, which is precisely why the check has
198
+ # to be explicit (the same reason {Basecamp::Services::MergeSafe} exists).
199
+ # An earlier version accepted digit strings on an argument about
200
+ # deduplicating ids across two sources; that argument was written in a
201
+ # comment and was never true of these fields.
202
+ #
203
+ # NOT "every id in the API", which an earlier version of this paragraph
204
+ # claimed. A PERSON's id is the one exception in the whole generated model:
205
+ # it is decoded flexibly, so the reference takes <tt>"7"</tt> as 7 and
206
+ # <tt>"basecamp"</tt> — the sentinel it serves for system-generated
207
+ # entities — as 0, neither of them an error. Nothing routed through here
208
+ # reads a person id today. Anything that starts to must not reach for this
209
+ # method, because it would refuse a body the reference accepts.
210
+ #
211
+ # The caller turns nil into a malformed-response error. It is not raised
212
+ # here because the message belongs to the field, not to this reader.
213
+ #
214
+ # @param value [Object] as it arrived on the wire
215
+ # @return [Integer, nil] the id, 0 when absent, nil when malformed
216
+ def from_wire(value)
217
+ # BOUNDED, like the argument reader above. The fields this serves are
218
+ # plain 64-bit integers in the reference, so a number outside that range
219
+ # is a decode failure there — 2**63 failed the read and was returned
220
+ # verbatim here, which the doc above already claimed was impossible.
221
+ return value if value.is_a?(Integer) && value.between?(MIN, MAX)
222
+ return 0 if value.nil?
223
+
224
+ nil
225
+ end
226
+
227
+ # A PERSON's id, which the reference decodes flexibly rather than strictly.
228
+ #
229
+ # +Person.Id+ is the single field in the generated model typed as the
230
+ # flexible decoder, so the rules differ from {from_wire} in exactly the way
231
+ # that method's own doc warns about:
232
+ #
233
+ # * an Integer in range, or an absent value, behaves as everywhere else;
234
+ # * a STRING of digits is the integer it spells, since the decoder hands it
235
+ # to ParseInt — which takes a leading sign;
236
+ # * any OTHER string is 0 and not an error, because that is the sentinel the
237
+ # API serves for system-generated entities ("basecamp");
238
+ # * a string whose digits overflow is a range error, so it fails the read,
239
+ # and so is one whose digits overflow BEFORE the junk that follows them
240
+ # ("18446744073709551616x"), because ParseUint refuses the magnitude
241
+ # inside the scan — the same junk one digit earlier reads 0;
242
+ # * anything else — a float, a boolean, an array, an object — is a decode
243
+ # failure, as it is for every id.
244
+ #
245
+ # @param value [Object] as it arrived on the wire
246
+ # @return [Integer, nil] the id, 0 for absent or a non-numeric sentinel,
247
+ # nil when the reference could not have decoded it
248
+ def person_from_wire(value)
249
+ return value if value.is_a?(Integer) && value.between?(MIN, MAX)
250
+ # NULL IS NOT ABSENT here, and that is the one place these two readers
251
+ # differ on nil. encoding/json calls the flexible decoder's own
252
+ # UnmarshalJSON for a null, its number path leaves the buffer empty, and
253
+ # ParseInt("") fails — so {"id": null} FAILS THE READ while a missing
254
+ # "id" is the zero value with no error. A plain int64 field has no
255
+ # UnmarshalJSON, so json handles its null itself and both are 0, which is
256
+ # why {from_wire} may treat them alike and this may not. Measured through
257
+ # the real decode path; an earlier version of this method returned 0 here
258
+ # and a test pinned that, which made the wrong rule harder to see rather
259
+ # than easier.
260
+ #
261
+ # The caller distinguishes them: an absent key never reaches this.
262
+ return nil if value.nil?
263
+ return nil unless value.is_a?(String)
264
+
265
+ # Go's scan rather than a lexical test, because the reference's two
266
+ # refusals do not partition the string the way a regexp does: an
267
+ # oversized digit run followed by junk is a RANGE error there and fails
268
+ # the read, while the same junk one digit earlier is a syntax error and
269
+ # reads 0. See {parse_int}.
270
+ parsed = parse_int(value)
271
+ return 0 if parsed == :syntax
272
+ return nil if parsed == :range
273
+
274
+ parsed
275
+ end
276
+ end
277
+ end