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
|
@@ -0,0 +1,1305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# A compact projection of one recording, resolved from the pointer an
|
|
6
|
+
# account event feed row or a webhook carries — bucket id, recording id, and
|
|
7
|
+
# the event type or recording type — through the typed read that type names.
|
|
8
|
+
# Prepended onto the generated {RecordingsService} (see the +on_load+ hook in
|
|
9
|
+
# +basecamp.rb+).
|
|
10
|
+
#
|
|
11
|
+
# It exists for consumers that must decide something about a recording
|
|
12
|
+
# without paying for its full payload: an agent connector's admission step,
|
|
13
|
+
# an MCP tool answering "what is this?".
|
|
14
|
+
#
|
|
15
|
+
# The SDK has no untyped recording read (BC3 has no such route), so the type
|
|
16
|
+
# is the routing key: +comment.created+ reads a comment, +card.created+ reads
|
|
17
|
+
# a card, and so on — one typed read per type. Chat lines are the exception,
|
|
18
|
+
# because their read needs the Campfire id and the pointer does not carry it;
|
|
19
|
+
# {#summarize} discovers the Campfire first (see the Campfire discovery
|
|
20
|
+
# section below).
|
|
21
|
+
#
|
|
22
|
+
# This is hand-written composition over the generated services (AGENTS.md;
|
|
23
|
+
# SPEC.md section 18, Appendix F). It makes no wire request of its own, and
|
|
24
|
+
# it mints no operation identity: hooks see the constituent reads under their
|
|
25
|
+
# own names (SPEC.md section 18 rule 3), so a +summarize+ of a comment shows
|
|
26
|
+
# up as +comments.get+ and nothing else.
|
|
27
|
+
module RecordingsExtensions
|
|
28
|
+
# Maps the subject of an account event feed type — everything before its
|
|
29
|
+
# final "." — to a read. This is the feed's catalog (bc3
|
|
30
|
+
# +Event::EventType+) minus +boost+, which names no recording type and is
|
|
31
|
+
# refused explicitly rather than left to fall through as unknown.
|
|
32
|
+
EVENT_SUBJECTS = {
|
|
33
|
+
"comment" => :comment,
|
|
34
|
+
"message" => :message,
|
|
35
|
+
"todo" => :todo,
|
|
36
|
+
"card" => :card,
|
|
37
|
+
"chat.line" => :chat_line
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Chat line subtypes (+Chat::Lines::Text+, +::RichText+, +::Code+,
|
|
41
|
+
# +::Upload+, +::Integration+) all read through the same route, so they are
|
|
42
|
+
# matched by prefix rather than listed.
|
|
43
|
+
CHAT_LINE_TYPE_PREFIX = "Chat::Lines::"
|
|
44
|
+
|
|
45
|
+
# What a malformed field was being read FOR. Two sites, two hints: a hint
|
|
46
|
+
# that sends the reader to the wrong half of the composite is worse than a
|
|
47
|
+
# shorter one.
|
|
48
|
+
RECORDING_HINT =
|
|
49
|
+
"The recording summary reads this field to decide what the recording is and which " \
|
|
50
|
+
"project it belongs to, so a value of the wrong type cannot be used."
|
|
51
|
+
|
|
52
|
+
DISCOVERY_HINT =
|
|
53
|
+
"The recording summary reads this field to decide which Campfires a chat line could " \
|
|
54
|
+
"be in, so a value of the wrong type cannot be used."
|
|
55
|
+
|
|
56
|
+
# The fields of a nested member the reference types, by the model it
|
|
57
|
+
# decodes into. See #read_member_fields for why these are transcribed from
|
|
58
|
+
# +generated+ rather than from the hand-written wrapper types, and for
|
|
59
|
+
# what deliberately stays out of them.
|
|
60
|
+
#
|
|
61
|
+
# +generated.Person+: every one of these is a +*string+ but +name+, which
|
|
62
|
+
# is a plain +string+ — null is a no-op for both, so they take one rule.
|
|
63
|
+
# +created_at+ and +updated_at+ are +*time.Time+ there; a non-string fails
|
|
64
|
+
# on both sides and an unparseable string fails only there.
|
|
65
|
+
# +system_label+ is deliberately ABSENT: the generated model has no such
|
|
66
|
+
# field, so the reference ignores it as an unknown key.
|
|
67
|
+
PERSON_STRING_FIELDS = %w[
|
|
68
|
+
attachable_sgid avatar_url bio created_at email_address location
|
|
69
|
+
name personable_type tagline time_zone title updated_at
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
# +generated.Person+'s +*bool+ fields.
|
|
73
|
+
PERSON_BOOL_FIELDS = %w[
|
|
74
|
+
admin can_access_hill_charts can_access_timesheet can_manage_people
|
|
75
|
+
can_manage_projects can_ping client employee owner
|
|
76
|
+
].freeze
|
|
77
|
+
|
|
78
|
+
# +generated.RecordingParent+ / +generated.TodoParent+. Their +bucket+ is
|
|
79
|
+
# the difference between the two and is handled separately.
|
|
80
|
+
PARENT_STRING_FIELDS = %w[title type url app_url].freeze
|
|
81
|
+
|
|
82
|
+
# +generated.TodoBucket+ / +generated.RecordingBucket+.
|
|
83
|
+
BUCKET_STRING_FIELDS = %w[name type].freeze
|
|
84
|
+
|
|
85
|
+
# Maps BC3's recording type strings to a read. It is the routing contract,
|
|
86
|
+
# and it is a DELIBERATE set, not an exhaustive one: the recording types
|
|
87
|
+
# the account event feed's trigger matrix names (comment, message, to-do,
|
|
88
|
+
# card, chat line), plus the content and tool recordings a consumer
|
|
89
|
+
# reasoning about those is likely to hold an id for.
|
|
90
|
+
#
|
|
91
|
+
# A type outside this set is +unknown_recording_type+ by design, whether or
|
|
92
|
+
# not the SDK has an id-only read for it — timesheet entries and gauge
|
|
93
|
+
# needles do, and are not routed; +Client::Reply+ and +Forward::Reply+
|
|
94
|
+
# cannot be, since their reads need a parent id the pointer does not carry.
|
|
95
|
+
# Widening the set is a product decision, not a gap: add the type here, its
|
|
96
|
+
# projection in +read_summary+, a routing row in the native test, and a
|
|
97
|
+
# case in +conformance/tests/recording_summary.json+, the fixture this
|
|
98
|
+
# implements.
|
|
99
|
+
RECORDING_TYPES = {
|
|
100
|
+
"Comment" => :comment,
|
|
101
|
+
"Message" => :message,
|
|
102
|
+
"Todo" => :todo,
|
|
103
|
+
"Kanban::Card" => :card,
|
|
104
|
+
"Document" => :document,
|
|
105
|
+
"Upload" => :upload,
|
|
106
|
+
"Schedule::Entry" => :schedule_entry,
|
|
107
|
+
"Question" => :question,
|
|
108
|
+
"Question::Answer" => :question_answer,
|
|
109
|
+
"Todolist" => :todolist,
|
|
110
|
+
"Vault" => :vault,
|
|
111
|
+
"Inbox::Forward" => :forward,
|
|
112
|
+
"Client::Approval" => :client_approval,
|
|
113
|
+
"Client::Correspondence" => :client_correspondence,
|
|
114
|
+
"GoogleDocument" => :google_document,
|
|
115
|
+
"CloudFile" => :cloud_file,
|
|
116
|
+
"Kanban::Step" => :card_step,
|
|
117
|
+
"Questionnaire" => :questionnaire,
|
|
118
|
+
"Schedule" => :schedule,
|
|
119
|
+
"Todoset" => :todoset,
|
|
120
|
+
"Message::Board" => :message_board,
|
|
121
|
+
"Kanban::Board" => :card_table,
|
|
122
|
+
"Kanban::Column" => :card_column,
|
|
123
|
+
"Inbox" => :inbox,
|
|
124
|
+
"Chat::Transcript" => :campfire
|
|
125
|
+
}.freeze
|
|
126
|
+
|
|
127
|
+
# The chat line subtypes that carry rich text — the two that declare
|
|
128
|
+
# +rich_text_attribute :content+ in BC3, and so the only two whose content
|
|
129
|
+
# can hold a mention. A Text line's content is HTML-escaped on the way out
|
|
130
|
+
# (+content_helper.rb+, +format_chat_line_with+), a Code line's is served
|
|
131
|
+
# verbatim — a snippet that happens to contain a bc-attachment tag — and an
|
|
132
|
+
# Upload line has no content.
|
|
133
|
+
RICH_TEXT_CHAT_LINE_TYPES = [ "Chat::Lines::RichText", "Chat::Lines::Integration" ].freeze
|
|
134
|
+
|
|
135
|
+
# Bounds how many Campfires one {#summarize} call tries, across both
|
|
136
|
+
# discovery sources and the refresh. A project has one Campfire and a
|
|
137
|
+
# handful of pings; a bucket past this bound is not a shape BC3 produces,
|
|
138
|
+
# and the call reports {Basecamp::CampfireDiscoveryIncompleteError} rather
|
|
139
|
+
# than calling the rest absent.
|
|
140
|
+
MAX_CAMPFIRE_CANDIDATES = 50
|
|
141
|
+
|
|
142
|
+
# Where #project leaves the validated bucket id for the cross-bucket
|
|
143
|
+
# check, removed before the summary is returned. A symbol, so it cannot
|
|
144
|
+
# collide with any string key the projection carries.
|
|
145
|
+
BUCKET_ID_MEMO = :__validated_bucket_id
|
|
146
|
+
|
|
147
|
+
# Resolves a recording pointer into a compact projection through the typed
|
|
148
|
+
# read its type names.
|
|
149
|
+
#
|
|
150
|
+
# The projection is a Hash with string keys: +id+, +status+, +type+,
|
|
151
|
+
# +title+, +app_url+, +content+, +updated_at+ and +mentioned_person_ids+
|
|
152
|
+
# always; +parent+, +bucket+, +creator+ and +assignees+ when the type has
|
|
153
|
+
# them; +campfire_id+ for a chat line — the Campfire it was found under,
|
|
154
|
+
# which is the reply destination for a chat trigger.
|
|
155
|
+
#
|
|
156
|
+
# @param bucket_id [Integer] the project the recording lives in. Required:
|
|
157
|
+
# it scopes the Campfire discovery for chat lines, and the read is
|
|
158
|
+
# checked against it so a pointer from one project can never resolve to a
|
|
159
|
+
# recording in another.
|
|
160
|
+
# @param recording_id [Integer] the recording's id
|
|
161
|
+
# @param event_type [String, nil] the account event feed type that named
|
|
162
|
+
# the recording — "comment.created", "card.assignment_changed",
|
|
163
|
+
# "chat.line.created". The segment before the action names the recording
|
|
164
|
+
# type. Used when +recording_type+ is empty.
|
|
165
|
+
# @param recording_type [String, nil] the recording's own type as BC3
|
|
166
|
+
# spells it — "Comment", "Kanban::Card", "Chat::Lines::Text". When set it
|
|
167
|
+
# takes precedence over +event_type+, being the more exact of the two.
|
|
168
|
+
# @return [Hash] the projection
|
|
169
|
+
# @raise [Basecamp::UsageError] when the pointer is incomplete
|
|
170
|
+
# @raise [Basecamp::RecordingRoutingError] before any request, when the
|
|
171
|
+
# pointer names no routable type
|
|
172
|
+
# @raise [Basecamp::UnresolvedRecordingError] when every visible Campfire
|
|
173
|
+
# answered 404 for a chat line
|
|
174
|
+
# @raise [Basecamp::CampfireDiscoveryIncompleteError] when candidates were
|
|
175
|
+
# left unsearched
|
|
176
|
+
# @raise [Basecamp::BucketMismatchError] when the read returned a recording
|
|
177
|
+
# from another bucket
|
|
178
|
+
# @raise [Basecamp::ApiError] non-retryable, when a successful response
|
|
179
|
+
# carries a value the reference's decoder would have refused — a body
|
|
180
|
+
# that is not an object, a bucket that is not one, an id that is not an
|
|
181
|
+
# integer. Minted by this composite, not by the read.
|
|
182
|
+
# @raise [Basecamp::Error] the read's own error otherwise — a 404 is a
|
|
183
|
+
# {Basecamp::NotFoundError}, as from the typed read itself
|
|
184
|
+
def summarize(bucket_id:, recording_id:, event_type: nil, recording_type: nil)
|
|
185
|
+
bucket_id = Ids.integer(bucket_id, "bucket id")
|
|
186
|
+
recording_id = Ids.integer(recording_id, "recording id")
|
|
187
|
+
unless bucket_id.positive? && recording_id.positive?
|
|
188
|
+
raise UsageError.new("bucket id and recording id are required")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
kind = route_recording(event_type: event_type, recording_type: recording_type)
|
|
192
|
+
summary = read_summary(kind, bucket_id: bucket_id, recording_id: recording_id)
|
|
193
|
+
|
|
194
|
+
# UNREACHABLE TODAY, and deliberately kept. All 26 arms of #read_summary
|
|
195
|
+
# go through #project exactly once, so the memo is always present and
|
|
196
|
+
# this block never runs — a reviewer confirmed that by mutating it to
|
|
197
|
+
# `{ 0 }` and finding both suites green. It is here because the failure
|
|
198
|
+
# it guards is silent: a future route that builds a summary without
|
|
199
|
+
# #project would otherwise read "no bucket" and skip the comparison
|
|
200
|
+
# rather than fail. Stated as unreachable rather than tested, because a
|
|
201
|
+
# test for it could only be written by reaching around the design.
|
|
202
|
+
read_bucket_id = summary.delete(BUCKET_ID_MEMO) { raise unvalidated_summary }
|
|
203
|
+
# NON-ZERO, not positive. The reference compares whenever its bucket id
|
|
204
|
+
# is not the zero value, so a negative one is a mismatch there and was
|
|
205
|
+
# returned as a match here — this is the one check standing between a
|
|
206
|
+
# pointer and a recording in another project, and it was the only place
|
|
207
|
+
# the earlier sweep of this same defect did not reach.
|
|
208
|
+
if !read_bucket_id.zero? && read_bucket_id != bucket_id
|
|
209
|
+
raise BucketMismatchError.new(
|
|
210
|
+
bucket_id: bucket_id, actual_bucket_id: read_bucket_id, recording_id: recording_id
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
summary
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The recording types {#summarize} routes by +recording_type+, sorted, with
|
|
218
|
+
# the +Chat::Lines+ subtypes represented by their shared prefix. The set is
|
|
219
|
+
# deliberate rather than exhaustive — see {RECORDING_TYPES} — and any other
|
|
220
|
+
# type is +unknown_recording_type+ by design.
|
|
221
|
+
#
|
|
222
|
+
# @return [Array<String>]
|
|
223
|
+
def summarizable_recording_types
|
|
224
|
+
(RECORDING_TYPES.keys + [ "#{CHAT_LINE_TYPE_PREFIX}*" ]).sort
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The account event feed subjects {#summarize} routes by +event_type+ — an
|
|
228
|
+
# event type is "<subject>.<action>", and any action on a listed subject
|
|
229
|
+
# routes to that subject's read — sorted. "boost" is absent on purpose.
|
|
230
|
+
#
|
|
231
|
+
# @return [Array<String>]
|
|
232
|
+
def summarizable_event_types
|
|
233
|
+
EVENT_SUBJECTS.keys.map { |subject| "#{subject}.*" }.sort
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
# The read's body, which has to be an object before a single field is read
|
|
239
|
+
# off it.
|
|
240
|
+
#
|
|
241
|
+
# This is the guard one level up from the field checks, and it is the one
|
|
242
|
+
# that matters most, because a String body does not fail — it PASSES. In
|
|
243
|
+
# Ruby <tt>"scalar"["bucket"]</tt> is a substring search that quietly
|
|
244
|
+
# answers nil, so the bucket reads as absent, the cross-bucket comparison
|
|
245
|
+
# never runs, and a recording from another project is returned. That is
|
|
246
|
+
# the same fail-open this composite has now produced from three
|
|
247
|
+
# directions; the other two were a negative bucket id and a non-object
|
|
248
|
+
# bucket. An Array or a number raises TypeError instead, and nil raises
|
|
249
|
+
# NoMethodError — native exceptions out of a public method, where the
|
|
250
|
+
# reference has a decode failure.
|
|
251
|
+
#
|
|
252
|
+
# {Basecamp::Services::MergeSafe#require_hash} describes this same hazard
|
|
253
|
+
# in the same words; the message differs only because the escape it names
|
|
254
|
+
# belongs to the merge-safe writes.
|
|
255
|
+
#
|
|
256
|
+
# NULL IS NOT MALFORMED, and the first version of this guard had that
|
|
257
|
+
# wrong — it said the reference has a decode failure for nil, and the
|
|
258
|
+
# reference has no such thing. Measured: json.Unmarshal of `null` into a
|
|
259
|
+
# struct returns no error and leaves it zero, everywhere and at every
|
|
260
|
+
# depth, so a null body is a zero-valued summary there. An empty object
|
|
261
|
+
# is the same zero: every field reads as absent, and the bucket
|
|
262
|
+
# comparison is skipped exactly as it is for a zero bucket id.
|
|
263
|
+
def read_record(record)
|
|
264
|
+
return record if record.is_a?(Hash)
|
|
265
|
+
return {} if record.nil?
|
|
266
|
+
|
|
267
|
+
raise malformed_response("the read returned #{MergeSafe.describe(record)}, not a recording object")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The bucket the read came back in, or 0 when it carries none.
|
|
271
|
+
#
|
|
272
|
+
# An absent or null "bucket" is genuinely none: the reference holds a
|
|
273
|
+
# pointer there and reads its zero value. ANYTHING ELSE that is not an
|
|
274
|
+
# object — a number, a string, an array — is a decode failure in the
|
|
275
|
+
# reference, which fails the read rather than reaching the comparison, and
|
|
276
|
+
# so is a malformed response here. Reading it as "none" would skip the
|
|
277
|
+
# comparison, which is how a projection from another project would have
|
|
278
|
+
# been returned.
|
|
279
|
+
def read_bucket_id(record)
|
|
280
|
+
bucket = record["bucket"]
|
|
281
|
+
return 0 if bucket.nil?
|
|
282
|
+
|
|
283
|
+
unless bucket.is_a?(Hash)
|
|
284
|
+
raise malformed_response("the recording's \"bucket\" is #{MergeSafe.describe(bucket)}, not an object")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
id = Ids.from_wire(bucket["id"])
|
|
288
|
+
return id unless id.nil?
|
|
289
|
+
|
|
290
|
+
raise malformed_response("the recording's bucket id is #{MergeSafe.describe(bucket["id"])}, not an integer")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Raised when a summary reaches the cross-bucket check without having
|
|
294
|
+
# been through #project, which is the only place the bucket is validated.
|
|
295
|
+
def unvalidated_summary
|
|
296
|
+
malformed_response("the recording summary was built without validating its bucket")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The error for a body this composite cannot read.
|
|
300
|
+
#
|
|
301
|
+
# ApiError and not UsageError, non-retryable, for the reason
|
|
302
|
+
# {Basecamp::Services::MergeSafe} gives: the value arrived in a successful
|
|
303
|
+
# response, nothing the caller passed is at fault, and re-requesting
|
|
304
|
+
# cannot repair it. The reference gets this refusal from its decoder; this
|
|
305
|
+
# tier has no decoder, so it is explicit.
|
|
306
|
+
# The hint names what the field is actually read FOR, so the two sites get
|
|
307
|
+
# two hints. Attaching the recording one to a dock item said the value
|
|
308
|
+
# decided "which project it belongs to" when it decides which Campfire to
|
|
309
|
+
# search — a hint that sends the reader to the wrong half of the composite
|
|
310
|
+
# is worse than a shorter one.
|
|
311
|
+
def malformed_response(message, hint: RECORDING_HINT)
|
|
312
|
+
MergeSafe.malformed(message, hint)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Picks the read for a pointer. +recording_type+ wins when set.
|
|
316
|
+
#
|
|
317
|
+
# Matched on BYTES. A Go string carries arbitrary bytes, so the reference
|
|
318
|
+
# routes "comment.\xFF" on its "comment" subject like any other; Ruby's
|
|
319
|
+
# String#strip validates the encoding and raised
|
|
320
|
+
# Encoding::CompatibilityError straight out of the public method for both
|
|
321
|
+
# arguments. The tables are ASCII, and an ASCII-only binary string is
|
|
322
|
+
# eql? to its text twin, so the lookups are unaffected.
|
|
323
|
+
#
|
|
324
|
+
# Trimmed by {Basecamp::Text}, not by String#strip, and the difference is
|
|
325
|
+
# a routing decision rather than a nicety. strip removes a leading or
|
|
326
|
+
# trailing NUL where the reference's TrimSpace does not, so "Comment\0"
|
|
327
|
+
# SELECTED A REAL TYPE here and was unknown_recording_type there — a
|
|
328
|
+
# malformed key reaching a live read, in the accepting direction. strip on
|
|
329
|
+
# a byte string also leaves the nineteen multi-byte spaces the reference
|
|
330
|
+
# trims. Measured: one byte of 256 diverges one way and nineteen
|
|
331
|
+
# characters the other.
|
|
332
|
+
#
|
|
333
|
+
# Only the RECORDING TYPE, because only it is compared whole. An event
|
|
334
|
+
# type is split on its last "." and only the subject is looked up, so a
|
|
335
|
+
# NUL in the action segment is never examined and "comment.created\0"
|
|
336
|
+
# routes on BOTH sides. An earlier version of this paragraph claimed
|
|
337
|
+
# otherwise and the test three files away already said so — the review
|
|
338
|
+
# that found the NUL made the same claim, and measuring it is what
|
|
339
|
+
# separated the half that was true from the half that was not.
|
|
340
|
+
def route_recording(event_type:, recording_type:)
|
|
341
|
+
type = Text.trim_space(recording_type.to_s)
|
|
342
|
+
unless type.empty?
|
|
343
|
+
return :chat_line if type.start_with?(CHAT_LINE_TYPE_PREFIX)
|
|
344
|
+
|
|
345
|
+
kind = RECORDING_TYPES[type]
|
|
346
|
+
return kind if kind
|
|
347
|
+
|
|
348
|
+
raise RecordingRoutingError.unknown_recording_type(type)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
subject_type = Text.trim_space(event_type.to_s)
|
|
352
|
+
raise RecordingRoutingError.unknown_recording_type(subject_type) if subject_type.empty?
|
|
353
|
+
|
|
354
|
+
# A feed type is "<subject>.<action>"; the subject names the recording
|
|
355
|
+
# type. A string with no action is not a feed type and is not routed.
|
|
356
|
+
separator = subject_type.rindex(".")
|
|
357
|
+
if separator.nil? || !separator.positive? || separator == subject_type.length - 1
|
|
358
|
+
raise RecordingRoutingError.unknown_recording_type(subject_type)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
subject = subject_type[0, separator]
|
|
362
|
+
raise RecordingRoutingError.no_recording_type(subject_type) if subject == "boost"
|
|
363
|
+
|
|
364
|
+
EVENT_SUBJECTS[subject] || raise(RecordingRoutingError.unknown_recording_type(subject_type))
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Performs the one typed read a kind names and projects it. Every branch is
|
|
368
|
+
# a single public generated method: no path is built here and no verb is
|
|
369
|
+
# chosen here.
|
|
370
|
+
def read_summary(kind, bucket_id:, recording_id:)
|
|
371
|
+
id = recording_id
|
|
372
|
+
|
|
373
|
+
case kind
|
|
374
|
+
when :comment
|
|
375
|
+
project(@client.comments.get(comment_id: id))
|
|
376
|
+
when :message
|
|
377
|
+
record = read_record(@client.messages.get(message_id: id))
|
|
378
|
+
project(record, title: first_non_empty(record["title"], record["subject"]))
|
|
379
|
+
when :todo
|
|
380
|
+
# A to-do's content is its plain title; the rich text — where mentions
|
|
381
|
+
# live — is the description.
|
|
382
|
+
record = read_record(@client.todos.get(todo_id: id))
|
|
383
|
+
project(
|
|
384
|
+
record,
|
|
385
|
+
title: first_non_empty(record["title"], record["content"]),
|
|
386
|
+
content: record["description"],
|
|
387
|
+
assignees: record["assignees"],
|
|
388
|
+
# A todo carries a TodoParent, which has no bucket field at all.
|
|
389
|
+
parent_has_bucket: false
|
|
390
|
+
)
|
|
391
|
+
when :card
|
|
392
|
+
record = read_record(@client.cards.get(card_id: id))
|
|
393
|
+
project(
|
|
394
|
+
record,
|
|
395
|
+
content: first_non_empty(record["content"], record["description"]),
|
|
396
|
+
assignees: record["assignees"]
|
|
397
|
+
)
|
|
398
|
+
when :chat_line
|
|
399
|
+
summarize_chat_line(bucket_id: bucket_id, line_id: id)
|
|
400
|
+
when :document
|
|
401
|
+
project(@client.documents.get(document_id: id))
|
|
402
|
+
when :upload
|
|
403
|
+
record = read_record(@client.uploads.get(upload_id: id))
|
|
404
|
+
project(
|
|
405
|
+
record,
|
|
406
|
+
title: first_non_empty(record["title"], record["filename"]),
|
|
407
|
+
content: record["description"]
|
|
408
|
+
)
|
|
409
|
+
when :schedule_entry
|
|
410
|
+
record = read_record(@client.schedules.get_entry(entry_id: id))
|
|
411
|
+
project(
|
|
412
|
+
record,
|
|
413
|
+
title: first_non_empty(record["title"], record["summary"]),
|
|
414
|
+
content: record["description"]
|
|
415
|
+
)
|
|
416
|
+
when :question
|
|
417
|
+
project(@client.checkins.get_question(question_id: id), content: "")
|
|
418
|
+
when :question_answer
|
|
419
|
+
project(@client.checkins.get_answer(answer_id: id))
|
|
420
|
+
when :todolist
|
|
421
|
+
record = read_record(@client.todolists.get(id: id))
|
|
422
|
+
project(
|
|
423
|
+
record,
|
|
424
|
+
title: first_non_empty(record["title"], record["name"]),
|
|
425
|
+
content: record["description"],
|
|
426
|
+
# A todolist carries a TodoParent too — same shape, same absence.
|
|
427
|
+
parent_has_bucket: false
|
|
428
|
+
)
|
|
429
|
+
when :vault
|
|
430
|
+
project(@client.vaults.get(vault_id: id), content: "")
|
|
431
|
+
when :forward
|
|
432
|
+
record = read_record(@client.forwards.get(forward_id: id))
|
|
433
|
+
project(record, title: first_non_empty(record["title"], record["subject"]))
|
|
434
|
+
when :client_approval
|
|
435
|
+
record = read_record(@client.client_approvals.get(approval_id: id))
|
|
436
|
+
project(record, title: first_non_empty(record["title"], record["subject"]))
|
|
437
|
+
when :client_correspondence
|
|
438
|
+
record = read_record(@client.client_correspondences.get(correspondence_id: id))
|
|
439
|
+
project(record, title: first_non_empty(record["title"], record["subject"]))
|
|
440
|
+
when :google_document
|
|
441
|
+
record = read_record(@client.google_documents.get_google_document(google_document_id: id))
|
|
442
|
+
project(record, content: record["description"])
|
|
443
|
+
when :cloud_file
|
|
444
|
+
record = read_record(@client.cloud_files.get_cloud_file(cloud_file_id: id))
|
|
445
|
+
project(record, content: record["description"])
|
|
446
|
+
when :card_step
|
|
447
|
+
record = read_record(@client.card_steps.get(step_id: id))
|
|
448
|
+
project(record, content: "", assignees: record["assignees"])
|
|
449
|
+
when :questionnaire
|
|
450
|
+
record = read_record(@client.checkins.get_questionnaire(questionnaire_id: id))
|
|
451
|
+
project(
|
|
452
|
+
record,
|
|
453
|
+
title: first_non_empty(record["title"], record["name"]),
|
|
454
|
+
content: "",
|
|
455
|
+
parent: nil
|
|
456
|
+
)
|
|
457
|
+
when :schedule
|
|
458
|
+
project(@client.schedules.get(schedule_id: id), content: "", parent: nil)
|
|
459
|
+
when :todoset
|
|
460
|
+
record = read_record(@client.todosets.get(todoset_id: id))
|
|
461
|
+
project(
|
|
462
|
+
record,
|
|
463
|
+
title: first_non_empty(record["title"], record["name"]),
|
|
464
|
+
content: "",
|
|
465
|
+
parent: nil
|
|
466
|
+
)
|
|
467
|
+
when :message_board
|
|
468
|
+
project(@client.message_boards.get(board_id: id), content: "", parent: nil)
|
|
469
|
+
when :card_table
|
|
470
|
+
project(@client.card_tables.get(card_table_id: id), content: "", parent: nil)
|
|
471
|
+
when :card_column
|
|
472
|
+
record = read_record(@client.card_columns.get(column_id: id))
|
|
473
|
+
project(record, content: record["description"])
|
|
474
|
+
when :inbox
|
|
475
|
+
project(@client.forwards.get_inbox(inbox_id: id), content: "", parent: nil)
|
|
476
|
+
when :campfire
|
|
477
|
+
project(@client.campfires.get(campfire_id: id), content: "", parent: nil)
|
|
478
|
+
else
|
|
479
|
+
# Unreachable while every value in RECORDING_TYPES and EVENT_SUBJECTS
|
|
480
|
+
# has a branch. It is here so that adding a routing row and forgetting
|
|
481
|
+
# the projection refuses the pointer by name rather than returning nil
|
|
482
|
+
# and failing somewhere else.
|
|
483
|
+
raise RecordingRoutingError.unknown_recording_type(kind)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Projects one read recording into the summary shape.
|
|
488
|
+
#
|
|
489
|
+
# Absent members are omitted rather than carried as nil, so a consumer
|
|
490
|
+
# reads the same shape whatever the type: a comment has no assignees, a
|
|
491
|
+
# vault no content. +mentioned_person_ids+ is the exception — always
|
|
492
|
+
# present, so a consumer reads [] rather than a missing key.
|
|
493
|
+
# Builds the projection.
|
|
494
|
+
#
|
|
495
|
+
# WHERE THIS PORT'S TYPE CHECKING STOPS, stated here because the edge is a
|
|
496
|
+
# decision rather than an oversight. The reference decodes the whole body
|
|
497
|
+
# into a struct, so any field of the wrong type fails its read. This tier
|
|
498
|
+
# has no decoder, and reproducing one field by field would be writing a
|
|
499
|
+
# second decoder by hand against a spec that moves.
|
|
500
|
+
#
|
|
501
|
+
# So the rule is: this composite refuses what would make it ACT wrongly,
|
|
502
|
+
# and passes through what it merely REPORTS — but that line moved three
|
|
503
|
+
# times, each time because a fix made this composite read a field it had
|
|
504
|
+
# only been reporting, so it is now drawn where the REFERENCE draws it
|
|
505
|
+
# instead of where this port happened to need it.
|
|
506
|
+
#
|
|
507
|
+
# TYPED, because the reference holds a plain string and a value of another
|
|
508
|
+
# type is a decode failure there: "status", "type", "title", "content",
|
|
509
|
+
# "app_url". Null normalizes to "" at every one of them, which is what its
|
|
510
|
+
# decoder does. Also refused, for reasons of their own: the body envelope,
|
|
511
|
+
# the bucket and its id (they decide whether the recording is in the
|
|
512
|
+
# caller's project), the dock and listing entries with their ids and names
|
|
513
|
+
# (they decide which Campfires get searched), and "assignees" (it decides
|
|
514
|
+
# whether the key appears at all).
|
|
515
|
+
#
|
|
516
|
+
# Also typed: "id", which the reference holds as a plain 64-bit integer.
|
|
517
|
+
# An earlier version of this paragraph listed it as passed through and
|
|
518
|
+
# called it the place where reproducing the decoder begins. That was
|
|
519
|
+
# wrong twice over — ten shapes reached the projection verbatim that the
|
|
520
|
+
# reference refuses, and the check was already written, four times, on the
|
|
521
|
+
# sibling id fields in this same file.
|
|
522
|
+
#
|
|
523
|
+
# ALSO TYPED, as of card 39 — "parent", "bucket" and "creator", the
|
|
524
|
+
# nested objects, FIELD BY FIELD against the model the reference decodes
|
|
525
|
+
# into. #read_member_fields carries the table and the reasoning; the
|
|
526
|
+
# short version is that these used to be passed through whole, and this
|
|
527
|
+
# paragraph used to argue that widening meant writing a decoder. The
|
|
528
|
+
# survey that settled the card showed the argument was scoped wrong:
|
|
529
|
+
# a nested identity is a bounded, enumerable set — Person's 12 strings,
|
|
530
|
+
# 9 bools and a company, Parent's 4 strings and its bucket, Bucket's 2 —
|
|
531
|
+
# and it was only the WHOLE-RECORDING decode that was out of reach.
|
|
532
|
+
# Four of the seven SDKs get the nested half for free through a typed
|
|
533
|
+
# decoder, so stopping short of it made this port the minority rather
|
|
534
|
+
# than the careful one.
|
|
535
|
+
#
|
|
536
|
+
# Their ID is decoded rather than merely checked, because this composite
|
|
537
|
+
# reads it to decide whether the member appears at all, and reading a
|
|
538
|
+
# value one way while emitting it another is what made a caller see
|
|
539
|
+
# "007" where the contract gives 7. An ABSENT id is still not
|
|
540
|
+
# synthesised: the reference emits a whole typed struct with every field
|
|
541
|
+
# present, and inventing one of them while passing the rest through would
|
|
542
|
+
# be half a decode. What is also honoured is the reference's emptiness
|
|
543
|
+
# rule — it builds each member only when it has an id or a name, so an
|
|
544
|
+
# empty object leaves the key out rather than appearing as "{}".
|
|
545
|
+
#
|
|
546
|
+
# "bucket" appears in both lists above for a reason worth stating rather
|
|
547
|
+
# than tidying: its OBJECT is reported to the caller, and its ID is read
|
|
548
|
+
# by the cross-bucket check. It is the one member that is both reported
|
|
549
|
+
# and interpreted, which is exactly why it has been the source of four
|
|
550
|
+
# separate defects on this branch.
|
|
551
|
+
#
|
|
552
|
+
# The rule that produced three rounds of findings, stated so the next
|
|
553
|
+
# person does not rediscover it: when a change makes this composite READ
|
|
554
|
+
# a field it used to only report, the field moves into the typed set and
|
|
555
|
+
# this paragraph has to move with it.
|
|
556
|
+
#
|
|
557
|
+
# THE RESIDUE — the one place it is enumerated in this file. The other
|
|
558
|
+
# comments that mention part of it point here rather than repeating it,
|
|
559
|
+
# because three spellings of this list in one file had already drifted
|
|
560
|
+
# into three different lists, under a heading calling one of them whole.
|
|
561
|
+
# * the recording's own fields this projection never reads — "url",
|
|
562
|
+
# "visible_to_clients", "bookmark_url", "created_at",
|
|
563
|
+
# "content_attachments". The reference decodes the entire typed model,
|
|
564
|
+
# so a wrong-typed one of those fails ITS read and not this one.
|
|
565
|
+
# Closing that is the whole-recording decoder across every routed type,
|
|
566
|
+
# which is a different change and deliberately not this one. Python and
|
|
567
|
+
# TypeScript carry the identical residue and their SPEC rows say so.
|
|
568
|
+
# * "updated_at", a deliberate divergence rather than a gap: the
|
|
569
|
+
# reference parses an instant and every port here keeps the API's own
|
|
570
|
+
# string, which Appendix F records. The TYPE is checked; the VALUE is
|
|
571
|
+
# not.
|
|
572
|
+
# * the same for a PERSON's "created_at" / "updated_at", one level down
|
|
573
|
+
# (#read_member_fields): <tt>*time.Time</tt> in the model, so a string
|
|
574
|
+
# that is not RFC 3339 fails only in the reference.
|
|
575
|
+
# * an assignee's null "name", emitted as nil where the reference emits
|
|
576
|
+
# "" — the label write-back #read_member does for a creator, a parent
|
|
577
|
+
# and a bucket does not reach #read_assignees. TypeScript matches this
|
|
578
|
+
# port there rather than moving alone.
|
|
579
|
+
# * a parent's nested "bucket", which is TYPED (a wrong-typed one fails
|
|
580
|
+
# the read, as the generated decode does) but then passed through
|
|
581
|
+
# whole, id undecoded, where the reference's conversion builds a
|
|
582
|
+
# <tt>Parent</tt> without a bucket and emits no such key at all.
|
|
583
|
+
def project(record, title: :default, content: :default, assignees: nil, parent: :default,
|
|
584
|
+
parent_has_bucket: true)
|
|
585
|
+
record = read_record(record)
|
|
586
|
+
title = record["title"] if title == :default
|
|
587
|
+
content = record["content"] if content == :default
|
|
588
|
+
parent = record["parent"] if parent == :default
|
|
589
|
+
# CONTENT is refused rather than coerced, because it is the one member
|
|
590
|
+
# of the projection this composite INTERPRETS: mentioned_person_ids is
|
|
591
|
+
# derived from it. to_s turned an array or a hash into its Ruby
|
|
592
|
+
# rendering and then scanned that for mentions, which is reading a body
|
|
593
|
+
# the reference would have failed to decode. Title goes through the same
|
|
594
|
+
# check because first_non_empty reaches both.
|
|
595
|
+
content = read_text(content, "content")
|
|
596
|
+
title = read_text(title, "title")
|
|
597
|
+
|
|
598
|
+
summary = {
|
|
599
|
+
"id" => read_id(record["id"]),
|
|
600
|
+
"status" => read_text(record["status"], "status"),
|
|
601
|
+
# Typed, not passed through, because the chat-line route READS this to
|
|
602
|
+
# decide whether a line's content can carry a mention. The reference
|
|
603
|
+
# holds a plain string, so an array or an object there is a decode
|
|
604
|
+
# failure — and without this a line whose type was an object came back
|
|
605
|
+
# successfully with its mentions silently cleared.
|
|
606
|
+
"type" => read_text(record["type"], "type"),
|
|
607
|
+
"title" => title.to_s,
|
|
608
|
+
"app_url" => read_text(record["app_url"], "app_url"),
|
|
609
|
+
"parent" => parent,
|
|
610
|
+
"bucket" => record["bucket"],
|
|
611
|
+
"creator" => record["creator"],
|
|
612
|
+
"assignees" => assignees,
|
|
613
|
+
"mentioned_person_ids" => Mentions.mentioned_person_ids(content),
|
|
614
|
+
"content" => content,
|
|
615
|
+
"updated_at" => record["updated_at"]
|
|
616
|
+
}
|
|
617
|
+
# EMPTY, not merely absent. The reference builds each of these only when
|
|
618
|
+
# it has something in it — `if Id != 0 || Name != ""` for a bucket, the
|
|
619
|
+
# same shape for a parent and a creator — so a `{}` in the response
|
|
620
|
+
# leaves the field nil there and `omitempty` drops the key. Ruby emitted
|
|
621
|
+
# the empty object, so a caller testing `summary.key?("bucket")` got a
|
|
622
|
+
# different answer from the contract's.
|
|
623
|
+
# VALIDATED HERE, before any key is dropped, and memoized for the
|
|
624
|
+
# cross-bucket check. Reading it out of the summary afterwards made
|
|
625
|
+
# keep_member? — a predicate whose job is "should this key appear" —
|
|
626
|
+
# the thing guaranteeing that check runs at all, through two clauses
|
|
627
|
+
# written purely for that purpose. It failed open twice, both times
|
|
628
|
+
# because someone editing an output-shape predicate had no reason to
|
|
629
|
+
# suspect they were disabling a safety check. The reference validates
|
|
630
|
+
# the raw member and drops the key on its own emptiness rule, which is
|
|
631
|
+
# what this now does in the same order.
|
|
632
|
+
summary[BUCKET_ID_MEMO] = read_bucket_id(record)
|
|
633
|
+
|
|
634
|
+
parent_member = read_member(parent, "title", "parent", kind: :parent, parent_has_bucket: parent_has_bucket)
|
|
635
|
+
parent_member.nil? ? summary.delete("parent") : summary["parent"] = parent_member
|
|
636
|
+
|
|
637
|
+
bucket_member = read_member(summary["bucket"], "name", "bucket", kind: :bucket)
|
|
638
|
+
bucket_member.nil? ? summary.delete("bucket") : summary["bucket"] = bucket_member
|
|
639
|
+
# A creator's id is decoded FLEXIBLY, because a creator is a Person and
|
|
640
|
+
# that is the one id in the generated model typed that way. Reading it
|
|
641
|
+
# with the strict decoder made {"id" => "basecamp"} — the sentinel the
|
|
642
|
+
# API serves for system-generated entities — look malformed, so the
|
|
643
|
+
# member was kept where the reference reads 0 and drops it.
|
|
644
|
+
creator_member = read_member(summary["creator"], "name", "creator", kind: :person)
|
|
645
|
+
creator_member.nil? ? summary.delete("creator") : summary["creator"] = creator_member
|
|
646
|
+
# Absent or empty is genuinely nothing to report — the reference's
|
|
647
|
+
# +omitempty+ leaves an empty slice out of its summary too, so the key
|
|
648
|
+
# goes. Anything else that is not an array of objects is a decode
|
|
649
|
+
# failure there and fails the read here.
|
|
650
|
+
#
|
|
651
|
+
# This comment used to say a malformed "assignees" was read as absent
|
|
652
|
+
# "for the same reason a malformed bucket is". That reason stopped being
|
|
653
|
+
# true when the bucket started raising, and the sentence survived the
|
|
654
|
+
# change it contradicted — which is the whole hazard of writing an
|
|
655
|
+
# invariant down next to the code instead of into a test.
|
|
656
|
+
if assignees.nil? || (assignees.is_a?(Array) && assignees.empty?)
|
|
657
|
+
summary.delete("assignees")
|
|
658
|
+
else
|
|
659
|
+
summary["assignees"] = read_assignees(assignees)
|
|
660
|
+
end
|
|
661
|
+
summary
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# The assignees, which the reference decodes as a slice of people.
|
|
665
|
+
#
|
|
666
|
+
# Each member is checked for being an object, its ID is decoded FLEXIBLY
|
|
667
|
+
# and re-emitted, and EVERY OTHER FIELD the reference types is typed here
|
|
668
|
+
# too — the same Person table #read_member_fields applies to the creator,
|
|
669
|
+
# applied one level down and to EVERY element rather than only the first.
|
|
670
|
+
# What this does NOT do — including emitting a null name as nil where the
|
|
671
|
+
# reference emits "" — is enumerated once, on #project.
|
|
672
|
+
#
|
|
673
|
+
# This paragraph used to end "every other field passes through whole" and
|
|
674
|
+
# called that the deliberate edge of this port's rule. Card 39 moved the
|
|
675
|
+
# edge — the reference decodes <tt>[]Person</tt>, so a wrong-typed
|
|
676
|
+
# <tt>avatar_url</tt> on the SECOND assignee fails its read there — and
|
|
677
|
+
# the sentence survived the change that contradicted it. That is twice now
|
|
678
|
+
# for this one comment.
|
|
679
|
+
#
|
|
680
|
+
# Before that it ended "a person id is the one place the two genuinely
|
|
681
|
+
# differ", and by the time it was written that was false three ways: the
|
|
682
|
+
# id is where the two now AGREE, the NAME is where they differ, and the
|
|
683
|
+
# reader it cited is the strict one while this path uses the flexible one.
|
|
684
|
+
# It sat directly above the gap it denied.
|
|
685
|
+
def read_assignees(assignees)
|
|
686
|
+
unless assignees.is_a?(Array)
|
|
687
|
+
raise malformed_response("the recording's \"assignees\" is #{MergeSafe.describe(assignees)}, not an array")
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
assignees = assignees.map do |assignee|
|
|
691
|
+
# A null member is the ZERO PERSON there, and the reference emits it
|
|
692
|
+
# as an object — so passing nil through handed a consumer something
|
|
693
|
+
# that crashes on `assignee["id"]` where the contract gives 0.
|
|
694
|
+
# An empty object, which is what this tier passes through for a member
|
|
695
|
+
# carrying nothing.
|
|
696
|
+
#
|
|
697
|
+
# The previous version emitted {"id" => 0, "name" => ""} here, which
|
|
698
|
+
# is what the reference marshals for a null member — and left {},
|
|
699
|
+
# {"id" => 5} and {"name" => "N"} alone, which the reference marshals
|
|
700
|
+
# the SAME way. So it synthesised the zero Person for one of the four
|
|
701
|
+
# inputs that produce it and passed the other three through, under a
|
|
702
|
+
# comment naming as fixed the very input it left. One inconsistency
|
|
703
|
+
# replaced by a smaller one is not a fix.
|
|
704
|
+
#
|
|
705
|
+
# Passing through is the boundary #project states and the one every
|
|
706
|
+
# other nested member follows: the reference emits a whole typed
|
|
707
|
+
# struct and this tier emits the object it was given. An empty hash is
|
|
708
|
+
# still indexable, which was the actual complaint — a nil member
|
|
709
|
+
# crashed on assignee["id"]. Whether to close this uniformly across
|
|
710
|
+
# all four inputs, and across parent, bucket and creator, is still
|
|
711
|
+
# open: card 39 settled how far a nested member is TYPED, not whether
|
|
712
|
+
# this tier synthesises the zero Person the reference emits, and this
|
|
713
|
+
# line should not be read as if it had.
|
|
714
|
+
next {} if assignee.nil?
|
|
715
|
+
|
|
716
|
+
unless assignee.is_a?(Hash)
|
|
717
|
+
raise malformed_response("an assignee is #{MergeSafe.describe(assignee)}, not an object")
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Every assignee is a Person, so its id takes the flexible decoder and
|
|
721
|
+
# is emitted decoded — the same rule as the creator, one level down,
|
|
722
|
+
# and the level the first version of that fix did not reach.
|
|
723
|
+
#
|
|
724
|
+
# Its OTHER FIELDS take the same Person table the creator takes. They
|
|
725
|
+
# used to pass through untyped, which was the stopping point
|
|
726
|
+
# #read_member declared and card 39 closed: the reference decodes
|
|
727
|
+
# <tt>[]Person</tt>, so a wrong-typed <tt>avatar_url</tt> on the
|
|
728
|
+
# SECOND assignee fails its read exactly as it does on the first, and
|
|
729
|
+
# a rule applied to the creator but not to the assignees would have
|
|
730
|
+
# been the same inconsistency one level out.
|
|
731
|
+
read_member_fields(assignee, "assignee", kind: :person)
|
|
732
|
+
|
|
733
|
+
id = read_member_id(assignee, person: true)
|
|
734
|
+
if id.nil?
|
|
735
|
+
raise malformed_response("an assignee's id is #{MergeSafe.describe(assignee["id"])}, not a person id")
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
assignee.key?("id") ? assignee.merge("id" => id) : assignee
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# Returned explicitly rather than leaning on #each handing back its
|
|
742
|
+
# receiver. The projection assigns from this call, so a later edit that
|
|
743
|
+
# ends the method on anything else — an each_with_index, a guard clause,
|
|
744
|
+
# one more line — would put nil into the summary silently.
|
|
745
|
+
assignees
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# The first of these that is a non-empty string.
|
|
749
|
+
#
|
|
750
|
+
# Each candidate is type-checked rather than coerced, for the reason
|
|
751
|
+
# read_text gives: these feed the title and the content, and a to_s here
|
|
752
|
+
# would have hidden exactly what that check exists to catch.
|
|
753
|
+
# The recording's own id.
|
|
754
|
+
#
|
|
755
|
+
# The reference types this as a plain 64-bit integer, so a string, a
|
|
756
|
+
# float, a boolean, an array or an object is a decode failure there — ten
|
|
757
|
+
# shapes that reached the projection verbatim while the boundary comment
|
|
758
|
+
# claimed this field was where reproducing the decoder would begin. It is
|
|
759
|
+
# not: the check is the same one already applied to four sibling id fields
|
|
760
|
+
# in this file. Absent is 0, as its zero value is.
|
|
761
|
+
def read_id(value)
|
|
762
|
+
id = Ids.from_wire(value)
|
|
763
|
+
return id unless id.nil?
|
|
764
|
+
|
|
765
|
+
raise malformed_response("the recording's id is #{MergeSafe.describe(value)}, not an integer")
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# A nested member as the projection should carry it, or nil to drop it.
|
|
769
|
+
#
|
|
770
|
+
# The name and the shape both changed when this stopped being a predicate,
|
|
771
|
+
# and this paragraph described the predicate for two rounds after — down
|
|
772
|
+
# to citing a mutation result that no longer reproduces. What it does now:
|
|
773
|
+
# refuse a member the reference could not decode, decode its id, and
|
|
774
|
+
# return it or nil by the emptiness rule.
|
|
775
|
+
#
|
|
776
|
+
# EMPTINESS IS TWO NAMED FIELDS, and the label differs by member: the
|
|
777
|
+
# reference tests <tt>Id != 0 || Name != ""</tt> for a bucket and a
|
|
778
|
+
# creator and <tt>Id != 0 || Title != ""</tt> for a parent, uniformly
|
|
779
|
+
# across all 17 conversions. A predicate over every value instead kept
|
|
780
|
+
# <tt>{"type" => "Project"}</tt>, which the reference drops.
|
|
781
|
+
#
|
|
782
|
+
# WHAT IS AND IS NOT VALIDATED, stated exactly, because a previous
|
|
783
|
+
# version of this claimed the line was drawn where the reference draws it
|
|
784
|
+
# and that was measurably false. Validated: the member is an object, its
|
|
785
|
+
# ID decodes, its LABEL is a string, and EVERY OTHER FIELD the reference
|
|
786
|
+
# types is typed here too — see #read_member_fields for the table and for
|
|
787
|
+
# why it is transcribed from the generated model rather than the
|
|
788
|
+
# hand-written one. What is NOT validated, or is emitted differently, is
|
|
789
|
+
# enumerated once, on #project, and deliberately not restated here: a
|
|
790
|
+
# partial copy of that list under the words "stated exactly" is what this
|
|
791
|
+
# paragraph was until a review counted it.
|
|
792
|
+
#
|
|
793
|
+
# This used to stop at the three values the composite reads, and said so
|
|
794
|
+
# as a deliberate limit. Card 39 settled it the other way: four of the
|
|
795
|
+
# seven SDKs reproduce the whole nested decode for free through a typed
|
|
796
|
+
# decoder and cannot stop short of it, so the contract is the reference's
|
|
797
|
+
# line rather than this port's, and a port that stopped earlier was the
|
|
798
|
+
# minority rather than the rule.
|
|
799
|
+
def read_member(member, label, member_name, kind:, parent_has_bucket: true)
|
|
800
|
+
return nil if member.nil?
|
|
801
|
+
|
|
802
|
+
unless member.is_a?(Hash)
|
|
803
|
+
raise malformed_response("the recording's #{member_name} is " \
|
|
804
|
+
"#{MergeSafe.describe(member)}, not an object")
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
person = kind == :person
|
|
808
|
+
read_member_fields(member, member_name, kind: kind, parent_has_bucket: parent_has_bucket)
|
|
809
|
+
|
|
810
|
+
# REFUSED, not kept. The reference holds a plain string for a name or a
|
|
811
|
+
# title and a typed integer for an id, so a value of another type is a
|
|
812
|
+
# whole-body decode failure there — and both earlier versions of this
|
|
813
|
+
# code kept the member instead, on a comment claiming that the reader
|
|
814
|
+
# which reports a malformed body had to see it. Only the BUCKET has such
|
|
815
|
+
# a reader; for a parent and a creator nothing else ever looked, so
|
|
816
|
+
# "kept for the reader" was under-refusal with a justification that did
|
|
817
|
+
# not apply at two of its three call sites.
|
|
818
|
+
# Normalized to "" for a null, like every other string field — the
|
|
819
|
+
# boundary paragraph on #project already claims that for the top-level
|
|
820
|
+
# ones and the nested labels were silently exempt, so a null title came
|
|
821
|
+
# back as nil where the contract gives "" and `creator["name"].empty?`
|
|
822
|
+
# raised for a caller.
|
|
823
|
+
name = read_text(member[label], "#{member_name}'s #{label}")
|
|
824
|
+
|
|
825
|
+
id = read_member_id(member, person: person)
|
|
826
|
+
if id.nil?
|
|
827
|
+
raise malformed_response("the #{member_name}'s id is #{MergeSafe.describe(member["id"])}, not an id")
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# The DECODED id is what travels, not the raw wire value. The reference
|
|
831
|
+
# decodes a person id flexibly and re-emits the integer — "007" comes
|
|
832
|
+
# back as 7 and the "basecamp" sentinel as 0 — so reading it only to
|
|
833
|
+
# decide whether to keep the member, while emitting the string, applied
|
|
834
|
+
# half of the rule and left a caller reading a different type from the
|
|
835
|
+
# contract's.
|
|
836
|
+
# The normalized LABEL is written back too. It used to be computed into a
|
|
837
|
+
# local, used for the emptiness test, and discarded — so a null title
|
|
838
|
+
# still reached the caller as nil while the comment above claimed it
|
|
839
|
+
# came back as "". A reviewer proved it a pure no-op: reverting the
|
|
840
|
+
# whole change altered not one assertion in the suite.
|
|
841
|
+
decoded = member.key?("id") ? member.merge("id" => id) : member
|
|
842
|
+
decoded = decoded.merge(label => name) if member.key?(label)
|
|
843
|
+
|
|
844
|
+
# Present only when it carries something, which is the emptiness rule
|
|
845
|
+
# the reference builds these under: `Id != 0 || Name != ""` for a bucket
|
|
846
|
+
# and a creator, `Id != 0 || Title != ""` for a parent.
|
|
847
|
+
(!id.zero? || !name.to_s.empty?) ? decoded : nil
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
# Every field of a nested member that the reference types, typed here too.
|
|
851
|
+
#
|
|
852
|
+
# THE TABLE IS TRANSCRIBED FROM <tt>generated</tt>, NOT FROM
|
|
853
|
+
# <tt>pkg/basecamp</tt>, and the distinction is not academic. The service
|
|
854
|
+
# wrapper the composite calls hands the body to the GENERATED client and
|
|
855
|
+
# converts the result afterwards (<tt>commentFromGenerated</tt>), so the
|
|
856
|
+
# decode — the thing that fails a read — happens against the generated
|
|
857
|
+
# model. The two disagree in exactly the places that would have bitten:
|
|
858
|
+
# <tt>system_label</tt> is a field of the hand-written Person and NOT of
|
|
859
|
+
# the generated one, so it is an unknown key the reference IGNORES and
|
|
860
|
+
# refusing it here would reject a body the reference accepts; and a
|
|
861
|
+
# person's id is <tt>FlexibleInt64</tt> there against a plain
|
|
862
|
+
# <tt>int64</tt> here, which is why #read_member_id keeps two readers.
|
|
863
|
+
#
|
|
864
|
+
# NULL IS NOT A WRONG TYPE. Every field below is a pointer or a plain
|
|
865
|
+
# string in the model, and <tt>json.Unmarshal</tt> of <tt>null</tt> is a
|
|
866
|
+
# no-op at any depth, so only a PRESENT value of the wrong type fails.
|
|
867
|
+
# The same asymmetry #read_record applies to the body applies to each
|
|
868
|
+
# field of each member.
|
|
869
|
+
#
|
|
870
|
+
# NOT VALIDATED here: the VALUE of a person's <tt>created_at</tt> /
|
|
871
|
+
# <tt>updated_at</tt>, which the reference holds as a
|
|
872
|
+
# <tt>*time.Time</tt> and therefore parses. A non-string fails on both
|
|
873
|
+
# sides; a string that is not RFC 3339 fails only there. That and the
|
|
874
|
+
# rest of what this port declares rather than closes are enumerated once,
|
|
875
|
+
# on #project.
|
|
876
|
+
def read_member_fields(member, member_name, kind:, parent_has_bucket: true)
|
|
877
|
+
case kind
|
|
878
|
+
when :person
|
|
879
|
+
PERSON_STRING_FIELDS.each { |field| read_member_string(member, field, member_name) }
|
|
880
|
+
PERSON_BOOL_FIELDS.each { |field| read_member_bool(member, field, member_name) }
|
|
881
|
+
read_person_company(member, member_name)
|
|
882
|
+
when :parent
|
|
883
|
+
PARENT_STRING_FIELDS.each { |field| read_member_string(member, field, member_name) }
|
|
884
|
+
# A parent's bucket is a typed <tt>*RecordingBucket</tt> under
|
|
885
|
+
# <tt>RecordingParent</tt> and does not exist at all under
|
|
886
|
+
# <tt>TodoParent</tt>, which is what a todo and a todolist carry. So
|
|
887
|
+
# the same key is a typed struct under one containing type and an
|
|
888
|
+
# ignored unknown under another, and only the reference says which —
|
|
889
|
+
# hence the flag, threaded from the routing arm rather than guessed
|
|
890
|
+
# from the payload. Applying one shape to both would refuse
|
|
891
|
+
# <tt>{"parent" => {"bucket" => 7}}</tt> on a todo, which the
|
|
892
|
+
# reference accepts.
|
|
893
|
+
read_nested_bucket(member["bucket"], "#{member_name}'s bucket") if parent_has_bucket
|
|
894
|
+
when :bucket
|
|
895
|
+
BUCKET_STRING_FIELDS.each { |field| read_member_string(member, field, member_name) }
|
|
896
|
+
end
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
# A nested bucket — the parent's, whose shape is the top-level one's.
|
|
900
|
+
def read_nested_bucket(value, member_name)
|
|
901
|
+
return if value.nil?
|
|
902
|
+
|
|
903
|
+
unless value.is_a?(Hash)
|
|
904
|
+
raise malformed_response("the recording's #{member_name} is " \
|
|
905
|
+
"#{MergeSafe.describe(value)}, not an object")
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
id = Ids.from_wire(value["id"])
|
|
909
|
+
if id.nil?
|
|
910
|
+
raise malformed_response("the #{member_name} id is #{MergeSafe.describe(value["id"])}, not an integer")
|
|
911
|
+
end
|
|
912
|
+
BUCKET_STRING_FIELDS.each { |field| read_member_string(value, field, member_name) }
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# A person's company: an object, with a plain integer id and a string name.
|
|
916
|
+
def read_person_company(member, member_name)
|
|
917
|
+
company = member["company"]
|
|
918
|
+
return if company.nil?
|
|
919
|
+
|
|
920
|
+
unless company.is_a?(Hash)
|
|
921
|
+
raise malformed_response("the #{member_name}'s company is " \
|
|
922
|
+
"#{MergeSafe.describe(company)}, not an object")
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# Plain <tt>int64</tt> on <tt>PersonCompany</tt>, NOT the flexible
|
|
926
|
+
# reader the person's own id takes — a company id of "1" fails the read
|
|
927
|
+
# there where the person's resolves.
|
|
928
|
+
id = Ids.from_wire(company["id"])
|
|
929
|
+
if id.nil?
|
|
930
|
+
raise malformed_response("the #{member_name}'s company id is " \
|
|
931
|
+
"#{MergeSafe.describe(company["id"])}, not an integer")
|
|
932
|
+
end
|
|
933
|
+
read_member_string(company, "name", "#{member_name}'s company")
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def read_member_string(member, field, member_name)
|
|
937
|
+
value = member[field]
|
|
938
|
+
return if value.nil? || value.is_a?(String)
|
|
939
|
+
|
|
940
|
+
raise malformed_response("the #{member_name}'s #{field} is " \
|
|
941
|
+
"#{MergeSafe.describe(value)}, not a string")
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def read_member_bool(member, field, member_name)
|
|
945
|
+
value = member[field]
|
|
946
|
+
return if value.nil? || value == true || value == false
|
|
947
|
+
|
|
948
|
+
raise malformed_response("the #{member_name}'s #{field} is " \
|
|
949
|
+
"#{MergeSafe.describe(value)}, not a boolean")
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
# A nested member's id, by the decoder the reference types that field with.
|
|
953
|
+
def read_member_id(member, person: false)
|
|
954
|
+
return Ids.from_wire(member["id"]) unless person
|
|
955
|
+
# Absent is the zero value; a present null is a decode failure, which is
|
|
956
|
+
# the one place these two readers differ on nil.
|
|
957
|
+
return 0 unless member.key?("id")
|
|
958
|
+
|
|
959
|
+
Ids.person_from_wire(member["id"])
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
def first_non_empty(*values)
|
|
963
|
+
# EVERY candidate is type-checked, not just the ones reached before the
|
|
964
|
+
# first non-empty one. Returning early left a malformed SECOND candidate
|
|
965
|
+
# unread — a card with a good "content" and a numeric "description"
|
|
966
|
+
# succeeded here and was a decode failure there, because the reference
|
|
967
|
+
# decodes the whole struct before choosing between its fields.
|
|
968
|
+
texts = values.map { |value| read_text(value, "title or content") }
|
|
969
|
+
texts.find { |text| !text.empty? } || ""
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# A text member of a read, or "" when it carries none.
|
|
973
|
+
#
|
|
974
|
+
# Absent or null is genuinely empty. A String passes through. Anything
|
|
975
|
+
# else is a decode failure in the reference, which holds a plain string
|
|
976
|
+
# for every one of these.
|
|
977
|
+
def read_text(value, name)
|
|
978
|
+
return "" if value.nil?
|
|
979
|
+
return value if value.is_a?(String)
|
|
980
|
+
|
|
981
|
+
raise malformed_response("the recording's \"#{name}\" is #{MergeSafe.describe(value)}, not a string")
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# Resolves the line, then projects it with the Campfire it was found under.
|
|
985
|
+
def summarize_chat_line(bucket_id:, line_id:)
|
|
986
|
+
line, campfire_id = resolve_chat_line(bucket_id: bucket_id, line_id: line_id)
|
|
987
|
+
summary = project(line)
|
|
988
|
+
# Read off the PROJECTION, not off the original. project normalizes a
|
|
989
|
+
# null body to a zero-valued summary — the reference decodes `null` as
|
|
990
|
+
# the zero value with no error — and this line went on indexing the raw
|
|
991
|
+
# response, so a chat line that came back null raised NoMethodError out
|
|
992
|
+
# of a public method while every other summary route handled it. The
|
|
993
|
+
# null rule was applied where it was found and not at the one site that
|
|
994
|
+
# reads around it.
|
|
995
|
+
unless RICH_TEXT_CHAT_LINE_TYPES.include?(summary["type"])
|
|
996
|
+
# A plain-text or code line's content is text BC3 never read as markup,
|
|
997
|
+
# so a literal "<bc-attachment>" in it mentions nobody.
|
|
998
|
+
summary["mentioned_person_ids"] = []
|
|
999
|
+
end
|
|
1000
|
+
# Omitted when zero, as the reference's `omitempty` omits it. A listed
|
|
1001
|
+
# Campfire with a zero id is a real candidate on both sides — neither
|
|
1002
|
+
# filters ids there — so this is reachable rather than theoretical.
|
|
1003
|
+
summary["campfire_id"] = campfire_id unless campfire_id.zero?
|
|
1004
|
+
summary
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# Campfire discovery for chat lines.
|
|
1008
|
+
#
|
|
1009
|
+
# The loop tries the line under each candidate until one answers, within
|
|
1010
|
+
# one total budget of {MAX_CAMPFIRE_CANDIDATES} per call.
|
|
1011
|
+
#
|
|
1012
|
+
# Two failure shapes are kept apart on purpose. A candidate that answers
|
|
1013
|
+
# anything but 404 — 401, 403, 5xx, a network error — stops the loop and is
|
|
1014
|
+
# raised as that error: the read failed, and trying the next Campfire would
|
|
1015
|
+
# only hide it. A 404 means "not here", so the loop moves on. Only when
|
|
1016
|
+
# every candidate said "not here" is the line unresolved
|
|
1017
|
+
# ({Basecamp::UnresolvedRecordingError}) — and before concluding that, the
|
|
1018
|
+
# cached sources are refreshed (subject to the floor) so a Campfire created
|
|
1019
|
+
# after the cache filled is tried too. Discovery that could not be
|
|
1020
|
+
# completed — a listing cut off at its cap, a bucket with more candidates
|
|
1021
|
+
# than the budget — is {Basecamp::CampfireDiscoveryIncompleteError}, never
|
|
1022
|
+
# "unresolved": nothing unsearched is ever reported absent.
|
|
1023
|
+
#
|
|
1024
|
+
# What HTTP cannot tell apart: BC3 answers 404 both for a line that is not
|
|
1025
|
+
# in a Campfire and for a Campfire the caller may no longer see. See
|
|
1026
|
+
# {Basecamp::UnresolvedRecordingError} for what that means for a consumer.
|
|
1027
|
+
def resolve_chat_line(bucket_id:, line_id:)
|
|
1028
|
+
index = @client.campfire_index
|
|
1029
|
+
account_id = @client.account_id
|
|
1030
|
+
search = ChatLineSearch.new(campfires: @client.campfires, line_id: line_id)
|
|
1031
|
+
|
|
1032
|
+
# Pass 1: what the sources already hold — the dock (read if it must be),
|
|
1033
|
+
# then the listing only if it is cached. A listing fetch is the
|
|
1034
|
+
# expensive, slow request, and it is not made until the dock — including
|
|
1035
|
+
# its refresh — has had its say, so a listing that is down, over its cap,
|
|
1036
|
+
# or stalled never stands between a project's line and the one project
|
|
1037
|
+
# read that finds it.
|
|
1038
|
+
dock = index.dock_campfires(account_id: account_id, bucket_id: bucket_id) do
|
|
1039
|
+
dock_campfire_ids(bucket_id)
|
|
1040
|
+
end
|
|
1041
|
+
found = search.try(dock.ids)
|
|
1042
|
+
return found if found
|
|
1043
|
+
|
|
1044
|
+
listed = index.cached_listed_campfires(account_id: account_id, bucket_id: bucket_id)
|
|
1045
|
+
list_cached = !listed.nil?
|
|
1046
|
+
if list_cached
|
|
1047
|
+
found = search.try(listed.ids)
|
|
1048
|
+
return found if found
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
# Pass 2: re-read the dock if it was served from cache (the floor may
|
|
1052
|
+
# decline), then fetch or refresh the listing. Whatever comes back is the
|
|
1053
|
+
# current snapshot of that source, whoever loaded it — another caller may
|
|
1054
|
+
# have populated or refreshed it in the meantime — so it always replaces
|
|
1055
|
+
# the pass-1 one; "refreshed" is whether a source the conclusion had
|
|
1056
|
+
# consulted is now newer than when it was consulted.
|
|
1057
|
+
#
|
|
1058
|
+
# What a spent budget does here has three cases, and they are three
|
|
1059
|
+
# different answers rather than one:
|
|
1060
|
+
#
|
|
1061
|
+
# 1. A source ALREADY CONSULTED is not re-read. It could hand this call
|
|
1062
|
+
# no candidate it may try, so the request cannot help — and a failure
|
|
1063
|
+
# on it would replace a settled verdict with a transient error a
|
|
1064
|
+
# consumer retries forever.
|
|
1065
|
+
# 2. A source NEVER CONSULTED is incomplete, and says which one. There
|
|
1066
|
+
# may be candidates there, unsearched, and nothing unsearched is ever
|
|
1067
|
+
# reported absent.
|
|
1068
|
+
# 3. A spent budget with BOTH sources consulted is unresolved, not
|
|
1069
|
+
# incomplete. Everything was searched; "look again" would be wrong.
|
|
1070
|
+
refreshed = false
|
|
1071
|
+
|
|
1072
|
+
if search.budget_left? && dock.cached?
|
|
1073
|
+
again = index.dock_campfires(account_id: account_id, bucket_id: bucket_id, refresh: true) do
|
|
1074
|
+
dock_campfire_ids(bucket_id)
|
|
1075
|
+
end
|
|
1076
|
+
refreshed = true if again.fetched > dock.fetched || !again.cached?
|
|
1077
|
+
dock = again
|
|
1078
|
+
found = search.try(dock.ids)
|
|
1079
|
+
return found if found
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
if !search.budget_left?
|
|
1083
|
+
unless list_cached
|
|
1084
|
+
raise CampfireDiscoveryIncompleteError.new(
|
|
1085
|
+
bucket_id: bucket_id, recording_id: line_id,
|
|
1086
|
+
reason: "the candidate budget of #{MAX_CAMPFIRE_CANDIDATES} was spent before " \
|
|
1087
|
+
"the account listing was consulted"
|
|
1088
|
+
)
|
|
1089
|
+
end
|
|
1090
|
+
else
|
|
1091
|
+
begin
|
|
1092
|
+
again = index.listed_campfires(
|
|
1093
|
+
account_id: account_id, bucket_id: bucket_id, refresh: list_cached
|
|
1094
|
+
) { listed_campfire_ids_by_bucket }
|
|
1095
|
+
rescue CampfireIndex::ListingOverflow => e
|
|
1096
|
+
raise CampfireDiscoveryIncompleteError.new(
|
|
1097
|
+
bucket_id: bucket_id, recording_id: line_id, reason: e.message
|
|
1098
|
+
)
|
|
1099
|
+
end
|
|
1100
|
+
refreshed = true if list_cached && (again.fetched > listed.fetched || !again.cached?)
|
|
1101
|
+
listed = again
|
|
1102
|
+
found = search.try(listed.ids)
|
|
1103
|
+
return found if found
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
raise budget_exhausted(bucket_id, line_id) if search.skipped?
|
|
1107
|
+
|
|
1108
|
+
stale = if refreshed
|
|
1109
|
+
search.tried.reject { |id| dock.ids.include?(id) || listed.ids.include?(id) }
|
|
1110
|
+
else
|
|
1111
|
+
[]
|
|
1112
|
+
end
|
|
1113
|
+
raise UnresolvedRecordingError.new(
|
|
1114
|
+
bucket_id: bucket_id,
|
|
1115
|
+
recording_id: line_id,
|
|
1116
|
+
campfire_ids: search.tried,
|
|
1117
|
+
refreshed: refreshed,
|
|
1118
|
+
stale_campfire_ids: stale
|
|
1119
|
+
)
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
# The Campfire ids a bucket's project dock names. A bucket that is not a
|
|
1123
|
+
# project (a 404 on the project read) has none; any other failure of the
|
|
1124
|
+
# read is raised.
|
|
1125
|
+
def dock_campfire_ids(bucket_id)
|
|
1126
|
+
project = begin
|
|
1127
|
+
@client.projects.get(project_id: bucket_id)
|
|
1128
|
+
rescue NotFoundError
|
|
1129
|
+
return []
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
project = read_record(project)
|
|
1133
|
+
dock = project["dock"]
|
|
1134
|
+
# An absent dock is genuinely none — a project need not have one, and the
|
|
1135
|
+
# reference reads its zero value. Anything else that is not an array is a
|
|
1136
|
+
# decode failure there, and reading it as "no Campfires" would report a
|
|
1137
|
+
# line absent from a project whose dock was never legible.
|
|
1138
|
+
return [] if dock.nil?
|
|
1139
|
+
|
|
1140
|
+
unless dock.is_a?(Array)
|
|
1141
|
+
raise malformed_response("the project's \"dock\" is #{MergeSafe.describe(dock)}, not an array",
|
|
1142
|
+
hint: DISCOVERY_HINT)
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
dock.filter_map do |item|
|
|
1146
|
+
# A null element decodes to a ZERO item there, not to an error, and a
|
|
1147
|
+
# zero item's name is "" — so it is skipped for the same reason any
|
|
1148
|
+
# non-chat item is, rather than failing the read.
|
|
1149
|
+
next if item.nil?
|
|
1150
|
+
|
|
1151
|
+
unless item.is_a?(Hash)
|
|
1152
|
+
raise malformed_response("a dock item is #{MergeSafe.describe(item)}, not an object",
|
|
1153
|
+
hint: DISCOVERY_HINT)
|
|
1154
|
+
end
|
|
1155
|
+
# Read BEFORE the name filter, because the reference's dock item holds
|
|
1156
|
+
# a plain int64 id and a plain string name for EVERY item, whatever it
|
|
1157
|
+
# docks — so a malformed id on the schedule fails the read there while
|
|
1158
|
+
# a check placed after the filter would never see it. A rule that only
|
|
1159
|
+
# runs on the entries that survive an earlier filter is a rule about
|
|
1160
|
+
# this port's control flow rather than about the response.
|
|
1161
|
+
name = item["name"]
|
|
1162
|
+
unless name.nil? || name.is_a?(String)
|
|
1163
|
+
raise malformed_response("a dock item's name is #{MergeSafe.describe(name)}, not a string",
|
|
1164
|
+
hint: DISCOVERY_HINT)
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
# Any NON-ZERO id, as the reference keeps, rather than any positive
|
|
1168
|
+
# one — a negative id is a candidate there and dropping it here would
|
|
1169
|
+
# search one Campfire fewer. An id of the wrong type is a decode
|
|
1170
|
+
# failure there, so it fails this read rather than skipping an entry.
|
|
1171
|
+
id = Ids.from_wire(item["id"])
|
|
1172
|
+
if id.nil?
|
|
1173
|
+
raise malformed_response("a dock item's id is #{MergeSafe.describe(item["id"])}, not an integer",
|
|
1174
|
+
hint: DISCOVERY_HINT)
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
next unless name == "chat"
|
|
1178
|
+
|
|
1179
|
+
id.zero? ? nil : id
|
|
1180
|
+
end
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
# The account-wide Campfire listing, grouped by bucket. BC3 has no
|
|
1184
|
+
# per-bucket listing, so the whole account is read and filtered. A listing
|
|
1185
|
+
# that overflows the cap is not cached and is reported as incomplete.
|
|
1186
|
+
def listed_campfire_ids_by_bucket
|
|
1187
|
+
campfires = @client.campfires.list(max_items: CampfireIndex::MAX_LISTING)
|
|
1188
|
+
listed = campfires.to_a
|
|
1189
|
+
if campfires.meta.truncated?
|
|
1190
|
+
# The reason states what was OBSERVED and not why. meta.truncated? is
|
|
1191
|
+
# set both by the max_items cap this call passes and by the client's
|
|
1192
|
+
# max_pages limit leaving a next page unfetched, and the reference
|
|
1193
|
+
# conflates the two in exactly the same way (client.go sets hasMore
|
|
1194
|
+
# for the page cap). Naming one cause reported a small two-page
|
|
1195
|
+
# listing under max_pages: 1 as "exceeds 1000".
|
|
1196
|
+
#
|
|
1197
|
+
# The VERDICT is the same either way, which is why this stays one
|
|
1198
|
+
# error: candidates were left unsearched, so the call is incomplete
|
|
1199
|
+
# rather than absent, and re-requesting repairs neither bound.
|
|
1200
|
+
raise CampfireIndex::ListingOverflow,
|
|
1201
|
+
"the account campfire listing was truncated before it was complete — either past the " \
|
|
1202
|
+
"#{CampfireIndex::MAX_LISTING}-item cap or past the client's max_pages limit"
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
listed.each_with_object({}) do |campfire, by_bucket|
|
|
1206
|
+
campfire = read_record(campfire)
|
|
1207
|
+
|
|
1208
|
+
# The id is read BEFORE the bucket filter, for the reason the dock's is
|
|
1209
|
+
# read before its name filter: the reference decodes every listed
|
|
1210
|
+
# Campfire, so an id it cannot decode fails the listing whether or not
|
|
1211
|
+
# this call would have gone on to want that entry.
|
|
1212
|
+
#
|
|
1213
|
+
# No id FILTER at all, which is what the reference applies here — its
|
|
1214
|
+
# only guard on a listed Campfire is the BUCKET id below, so an id of
|
|
1215
|
+
# zero is a candidate too.
|
|
1216
|
+
campfire_id = Ids.from_wire(campfire["id"])
|
|
1217
|
+
if campfire_id.nil?
|
|
1218
|
+
raise malformed_response("a campfire's id is #{MergeSafe.describe(campfire["id"])}, not an integer",
|
|
1219
|
+
hint: DISCOVERY_HINT)
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
bucket = campfire["bucket"]
|
|
1223
|
+
# An absent bucket is genuinely none and cannot be grouped; anything
|
|
1224
|
+
# else that is not an object is a decode failure there, and skipping it
|
|
1225
|
+
# would drop a candidate rather than report the body.
|
|
1226
|
+
next if bucket.nil?
|
|
1227
|
+
|
|
1228
|
+
unless bucket.is_a?(Hash)
|
|
1229
|
+
raise malformed_response("a campfire's \"bucket\" is #{MergeSafe.describe(bucket)}, not an object",
|
|
1230
|
+
hint: DISCOVERY_HINT)
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
bucket_id = Ids.from_wire(bucket["id"])
|
|
1234
|
+
if bucket_id.nil?
|
|
1235
|
+
raise malformed_response("a campfire's bucket id is #{MergeSafe.describe(bucket["id"])}, not an integer",
|
|
1236
|
+
hint: DISCOVERY_HINT)
|
|
1237
|
+
end
|
|
1238
|
+
next if bucket_id.zero?
|
|
1239
|
+
|
|
1240
|
+
(by_bucket[bucket_id] ||= []) << campfire_id
|
|
1241
|
+
end
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
def budget_exhausted(bucket_id, line_id)
|
|
1245
|
+
CampfireDiscoveryIncompleteError.new(
|
|
1246
|
+
bucket_id: bucket_id,
|
|
1247
|
+
recording_id: line_id,
|
|
1248
|
+
reason: "more than #{MAX_CAMPFIRE_CANDIDATES} visible campfires in the bucket"
|
|
1249
|
+
)
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
# One {#summarize} call's discovery state: the candidates already tried,
|
|
1253
|
+
# what is left of the budget, and whether a candidate was left untried for
|
|
1254
|
+
# want of it.
|
|
1255
|
+
class ChatLineSearch
|
|
1256
|
+
# @return [Array<Integer>] candidates that answered 404, in order
|
|
1257
|
+
attr_reader :tried
|
|
1258
|
+
|
|
1259
|
+
def initialize(campfires:, line_id:, budget: MAX_CAMPFIRE_CANDIDATES)
|
|
1260
|
+
@campfires = campfires
|
|
1261
|
+
@line_id = line_id
|
|
1262
|
+
@budget = budget
|
|
1263
|
+
@tried = []
|
|
1264
|
+
@skipped = false
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
# Reads the line under each candidate not yet tried.
|
|
1268
|
+
#
|
|
1269
|
+
# @param candidates [Array<Integer>]
|
|
1270
|
+
# @return [Array(Hash, Integer), nil] the line and its Campfire on a hit;
|
|
1271
|
+
# nil on a miss, with the candidates recorded in {#tried}
|
|
1272
|
+
# @raise [Basecamp::Error] any answer but 404, as that read's own error
|
|
1273
|
+
def try(candidates)
|
|
1274
|
+
Array(candidates).each do |campfire_id|
|
|
1275
|
+
next if @tried.include?(campfire_id)
|
|
1276
|
+
|
|
1277
|
+
if @budget <= 0
|
|
1278
|
+
@skipped = true
|
|
1279
|
+
return nil
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
@budget -= 1
|
|
1283
|
+
begin
|
|
1284
|
+
line = @campfires.get_line(campfire_id: campfire_id, line_id: @line_id)
|
|
1285
|
+
return [ line, campfire_id ]
|
|
1286
|
+
rescue NotFoundError
|
|
1287
|
+
@tried << campfire_id
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
nil
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
# @return [Boolean] whether a candidate was left untried for want of budget
|
|
1294
|
+
def skipped?
|
|
1295
|
+
@skipped
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
# @return [Boolean] whether this call may still try another candidate
|
|
1299
|
+
def budget_left?
|
|
1300
|
+
@budget.positive?
|
|
1301
|
+
end
|
|
1302
|
+
end
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
1305
|
+
end
|