basecamp-sdk 0.18.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 +108 -6
- 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/ids.rb
ADDED
|
@@ -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
|