basecamp-sdk 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/basecamp/api_error.rb +5 -3
- data/lib/basecamp/bucket_mismatch_error.rb +41 -0
- data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
- data/lib/basecamp/campfire_index.rb +398 -0
- data/lib/basecamp/client.rb +47 -4
- data/lib/basecamp/generated/metadata.json +728 -1
- data/lib/basecamp/generated/services/account_service.rb +2 -2
- data/lib/basecamp/generated/services/attachments_service.rb +1 -1
- data/lib/basecamp/generated/services/automation_service.rb +1 -1
- data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
- data/lib/basecamp/generated/services/boosts_service.rb +3 -3
- data/lib/basecamp/generated/services/calendars_service.rb +2 -2
- data/lib/basecamp/generated/services/campfires_service.rb +7 -7
- data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
- data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
- data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
- data/lib/basecamp/generated/services/cards_service.rb +3 -3
- data/lib/basecamp/generated/services/checkins_service.rb +9 -9
- data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
- data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
- data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
- data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
- data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
- data/lib/basecamp/generated/services/comments_service.rb +3 -3
- data/lib/basecamp/generated/services/documents_service.rb +3 -3
- data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
- data/lib/basecamp/generated/services/everything_service.rb +2 -2
- data/lib/basecamp/generated/services/folders_service.rb +4 -4
- data/lib/basecamp/generated/services/forwards_service.rb +3 -3
- data/lib/basecamp/generated/services/gauges_service.rb +10 -6
- data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
- data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
- data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
- data/lib/basecamp/generated/services/message_types_service.rb +3 -3
- data/lib/basecamp/generated/services/messages_service.rb +3 -3
- data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
- data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
- data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
- data/lib/basecamp/generated/services/people_service.rb +11 -11
- data/lib/basecamp/generated/services/projects_service.rb +4 -4
- data/lib/basecamp/generated/services/recordings_service.rb +1 -1
- data/lib/basecamp/generated/services/reports_service.rb +3 -3
- data/lib/basecamp/generated/services/schedules_service.rb +6 -6
- data/lib/basecamp/generated/services/search_service.rb +1 -1
- data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
- data/lib/basecamp/generated/services/templates_service.rb +8 -8
- data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
- data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
- data/lib/basecamp/generated/services/todolists_service.rb +3 -3
- data/lib/basecamp/generated/services/todos_service.rb +4 -4
- data/lib/basecamp/generated/services/todosets_service.rb +1 -1
- data/lib/basecamp/generated/services/tools_service.rb +3 -3
- data/lib/basecamp/generated/services/uploads_service.rb +4 -4
- data/lib/basecamp/generated/services/vaults_service.rb +3 -3
- data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
- data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
- data/lib/basecamp/generated/types.rb +111 -7
- data/lib/basecamp/http.rb +383 -60
- data/lib/basecamp/ids.rb +277 -0
- data/lib/basecamp/mentions.rb +1119 -0
- data/lib/basecamp/person_id_sites.rb +184 -0
- data/lib/basecamp/recording_routing_error.rb +50 -0
- data/lib/basecamp/recording_summary_error.rb +33 -0
- data/lib/basecamp/services/comments_extensions.rb +121 -0
- data/lib/basecamp/services/merge_safe.rb +37 -13
- data/lib/basecamp/services/recordings_extensions.rb +1305 -0
- data/lib/basecamp/services/todolists_extensions.rb +4 -3
- data/lib/basecamp/text.rb +94 -0
- data/lib/basecamp/unresolved_recording_error.rb +68 -0
- data/lib/basecamp/version.rb +1 -1
- data/lib/basecamp/webhooks/event.rb +4 -1
- data/lib/basecamp.rb +22 -4
- data/scripts/generate-metadata.rb +75 -2
- data/scripts/generate-services.rb +35 -8
- data/scripts/generate-types.rb +19 -0
- metadata +15 -2
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Basecamp
|
|
6
|
+
# Decodes a person's id at the response paths the reference reads it as
|
|
7
|
+
# +types.FlexibleInt64+, keyed by operation id.
|
|
8
|
+
#
|
|
9
|
+
# Go decodes every generated read through +Parse<Op>Response+, and
|
|
10
|
+
# +generated.Person.Id+ is +types.FlexibleInt64+
|
|
11
|
+
# (go/pkg/types/flexible_int64.go:27): an untagged +{"creator":{"id":"7"}}+
|
|
12
|
+
# is 7 there. Ruby returns the parsed Hash, and the normalizer converts only
|
|
13
|
+
# a person carrying +personable_type+ (plus creator/participants on the gauge
|
|
14
|
+
# and notification surfaces), so that id reached the caller as the string
|
|
15
|
+
# "7" (SPEC §10 "Person Ids Off the Wire").
|
|
16
|
+
#
|
|
17
|
+
# The fix is NOT a wider normalizer walk. The same key names hold plain int64
|
|
18
|
+
# ids in the reference (+UpcomingSchedulePerson+, +MyAssignmentAssignee+,
|
|
19
|
+
# +OutOfOfficePerson+, +TemplateLibraryConfirmationPerson+), where a string
|
|
20
|
+
# is a decode error. So the sites come from the generated table in
|
|
21
|
+
# +generated/metadata.json+ ("personIdSites"), which +generate-metadata.rb+
|
|
22
|
+
# selects on the +x-go-type+ marker of +Person.id+ and never on a key name:
|
|
23
|
+
# an operation not in the table, or a path not under it, is never touched.
|
|
24
|
+
#
|
|
25
|
+
# Runs AFTER {Http.normalize_person_ids} on the same body. Idempotent: a
|
|
26
|
+
# second pass finds only integers and leaves them.
|
|
27
|
+
module PersonIdSites
|
|
28
|
+
HINT = "A person id in this response is neither a 64-bit integer nor a string, " \
|
|
29
|
+
"or is a string out of int64 range; the response cannot be read."
|
|
30
|
+
|
|
31
|
+
# Operations whose FOLLOWED pages the reference does not decode as the
|
|
32
|
+
# generated types. Go runs page 1 through Parse<Op>Response (Person.Id is
|
|
33
|
+
# FlexibleInt64, so a null id fails), but decodes each kept item of a
|
|
34
|
+
# followed page into a hand-written type whose Person.ID is a plain int64
|
|
35
|
+
# after the positional normalizer — gauges.go:236-241 (Gauge),
|
|
36
|
+
# gauges.go:307-312 (GaugeNeedle), my_notifications.go:285-305
|
|
37
|
+
# (Notification) — and encoding/json leaves a plain int64 at zero for null.
|
|
38
|
+
# So there a null id reads rather than fails. Go-SDK behaviour, not schema,
|
|
39
|
+
# hence a hand-written list rather than a generated one.
|
|
40
|
+
NULL_ID_READS_ON_FOLLOWED_PAGES = %w[ListGauges ListGaugeNeedles GetBubbleUps].freeze
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Decodes the person ids at +operation+'s sites in +body+, in place.
|
|
44
|
+
#
|
|
45
|
+
# +body+ is a whole response: a single read, or the FIRST page of a
|
|
46
|
+
# paginated one (the array for a bare-array list, the wrapper object for
|
|
47
|
+
# a wrapped one), which the reference decodes whole through
|
|
48
|
+
# Parse<Op>Response before any cap applies. A followed page goes through
|
|
49
|
+
# {decode_item!} instead.
|
|
50
|
+
#
|
|
51
|
+
# @param body [Object] parsed, normalized JSON
|
|
52
|
+
# @param operation [String, nil] canonical operation id
|
|
53
|
+
# @return [Object] +body+
|
|
54
|
+
# @raise [ApiError] non-retryable, when a site's id cannot be decoded
|
|
55
|
+
def decode!(body, operation)
|
|
56
|
+
paths = operation && table[operation]
|
|
57
|
+
return body unless paths
|
|
58
|
+
|
|
59
|
+
paths.each { |path, array_site| visit(body, path, 0, array_site, operation) }
|
|
60
|
+
body
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Decodes ONE item of a FOLLOWED page, at the sites under that item only.
|
|
64
|
+
#
|
|
65
|
+
# The reference never decodes a followed page whole. A bare-array list
|
|
66
|
+
# collects the page as raw items, trims them to the cap and decodes only
|
|
67
|
+
# what it kept (client.go:604-631, followPagination), so the caller
|
|
68
|
+
# decodes each item as it is kept. A wrapped listing decodes only the
|
|
69
|
+
# items under its key — GetPersonProgress unmarshals struct{ Events }
|
|
70
|
+
# and never reads the page's person (timeline.go:444-457) — so the
|
|
71
|
+
# wrapper's other keys are not touched here either.
|
|
72
|
+
#
|
|
73
|
+
# @param item [Object] one parsed, normalized item
|
|
74
|
+
# @param operation [String, nil] canonical operation id
|
|
75
|
+
# @param key [String, nil] the wrapped listing's key; nil for a bare array
|
|
76
|
+
# @return [Object] +item+
|
|
77
|
+
# @raise [ApiError] non-retryable, when a site's id cannot be decoded
|
|
78
|
+
def decode_item!(item, operation, key: nil)
|
|
79
|
+
paths = operation && item_table(operation, key)
|
|
80
|
+
return item unless paths
|
|
81
|
+
|
|
82
|
+
null_reads = NULL_ID_READS_ON_FOLLOWED_PAGES.include?(operation)
|
|
83
|
+
paths.each { |path, array_site| visit(item, path, 0, array_site, operation, null_reads: null_reads) }
|
|
84
|
+
item
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# {operationId => [[components, array_site], ...]} relative to the whole
|
|
88
|
+
# body, memoized. Benign race: concurrent first loads compute identical
|
|
89
|
+
# values.
|
|
90
|
+
def table
|
|
91
|
+
@table ||= raw_table.transform_values do |paths|
|
|
92
|
+
paths.map { |components| split_site(components) }.freeze
|
|
93
|
+
end.freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# {operationId => [components, ...]}, each the full "."-split path.
|
|
99
|
+
def raw_table
|
|
100
|
+
@raw_table ||= JSON.parse(
|
|
101
|
+
File.read(File.join(__dir__, "generated", "metadata.json"), encoding: "UTF-8")
|
|
102
|
+
).fetch("personIdSites").transform_values do |paths|
|
|
103
|
+
paths.map { |path| (path == "$" ? [] : path.split(".")).freeze }.freeze
|
|
104
|
+
end.freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The operation's sites relative to one item: those under "[]" for a
|
|
108
|
+
# bare array, or under "<key>.[]" for a wrapped listing, prefix removed.
|
|
109
|
+
# An item that is itself the person ("[]", "<key>.[]") is the empty path.
|
|
110
|
+
def item_table(operation, key)
|
|
111
|
+
(@item_tables ||= {})[[ operation, key ]] ||= begin
|
|
112
|
+
prefix = key ? [ key, "[]" ] : [ "[]" ]
|
|
113
|
+
(raw_table[operation] || []).filter_map do |components|
|
|
114
|
+
next unless components.first(prefix.length) == prefix
|
|
115
|
+
|
|
116
|
+
split_site(components.drop(prefix.length))
|
|
117
|
+
end.freeze
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A path ending in "[]" names an array of people.
|
|
122
|
+
def split_site(components)
|
|
123
|
+
array_site = components.last == "[]"
|
|
124
|
+
[ (array_site ? components[0...-1] : components).freeze, array_site ].freeze
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Follows the components; a value of another shape on the way ends the
|
|
128
|
+
# walk with nothing done. Go zero-fills or refuses those as part of its
|
|
129
|
+
# whole-body decode, which this lenient tier does for no field — that
|
|
130
|
+
# residual is not the person id's to close.
|
|
131
|
+
def visit(value, components, index, array_site, operation, null_reads: false)
|
|
132
|
+
if index == components.length
|
|
133
|
+
if array_site
|
|
134
|
+
value.each { |person| decode_person(person, components, operation, null_reads) } if value.is_a?(Array)
|
|
135
|
+
else
|
|
136
|
+
decode_person(value, components, operation, null_reads)
|
|
137
|
+
end
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
component = components[index]
|
|
142
|
+
if component == "[]"
|
|
143
|
+
value.each { |element| visit(element, components, index + 1, array_site, operation, null_reads: null_reads) } if value.is_a?(Array)
|
|
144
|
+
elsif value.is_a?(Hash) && value.key?(component)
|
|
145
|
+
visit(value[component], components, index + 1, array_site, operation, null_reads: null_reads)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# FlexibleInt64.UnmarshalJSON on one person's "id", present only: an
|
|
150
|
+
# absent key is the zero value with no call in Go, so it stays absent.
|
|
151
|
+
def decode_person(person, components, operation, null_reads)
|
|
152
|
+
return unless person.is_a?(Hash) && person.key?("id")
|
|
153
|
+
|
|
154
|
+
raw = person["id"]
|
|
155
|
+
# A plain int64 reads null as zero (see NULL_ID_READS_ON_FOLLOWED_PAGES);
|
|
156
|
+
# left as null, the same representation residual as an absent id.
|
|
157
|
+
return if null_reads && raw.nil?
|
|
158
|
+
|
|
159
|
+
# One rule for a person id off the wire: {Ids.person_from_wire} is the
|
|
160
|
+
# flexible decoder — an int64 passes, a string goes through ParseInt
|
|
161
|
+
# (ErrSyntax reads 0, flexible_int64.go:46; ErrRange fails,
|
|
162
|
+
# flexible_int64.go:43), and a float, null, boolean, array, object or
|
|
163
|
+
# out-of-range number fails (json.Number.Int64, flexible_int64.go:57).
|
|
164
|
+
# No system_label: the decoder writes none.
|
|
165
|
+
id = Ids.person_from_wire(raw)
|
|
166
|
+
raise refusal(raw, components, operation) if id.nil?
|
|
167
|
+
|
|
168
|
+
person["id"] = id unless raw.is_a?(Integer)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Names the operation, the site and the value's kind — not the value,
|
|
172
|
+
# which is the response's and travels into logs from here.
|
|
173
|
+
def refusal(raw, components, operation)
|
|
174
|
+
site = components.empty? ? "the response body" : components.join(".")
|
|
175
|
+
shape = case raw
|
|
176
|
+
when String, Integer then "an out-of-range #{raw.class}"
|
|
177
|
+
when nil then "null"
|
|
178
|
+
else "a #{raw.class}"
|
|
179
|
+
end
|
|
180
|
+
ApiError.new("#{operation} returned a person id at #{site} that is #{shape}", hint: HINT, retryable: false)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Raised for a recording pointer +summarize+ cannot route, before any request
|
|
5
|
+
# is made. Two kinds:
|
|
6
|
+
#
|
|
7
|
+
# * +no_recording_type+ — an event type that names no recording type, which is
|
|
8
|
+
# +boost.*+: the row points at the boost's target and does not carry that
|
|
9
|
+
# target's type. A consumer resolves those from its own record of what it
|
|
10
|
+
# posted, not through +summarize+.
|
|
11
|
+
# * +unknown_recording_type+ — neither the event type nor the recording type
|
|
12
|
+
# names a type in the routing table. The table is a DELIBERATE set, not an
|
|
13
|
+
# exhaustive one (see +RecordingsExtensions::RECORDING_TYPES+), so a type
|
|
14
|
+
# outside it is this error by design, whether or not the SDK has an id-only
|
|
15
|
+
# read for it.
|
|
16
|
+
#
|
|
17
|
+
# +code+ is +usage+: nothing was asked of the API, and the caller's own
|
|
18
|
+
# pointer is what cannot be routed.
|
|
19
|
+
class RecordingRoutingError < RecordingSummaryError
|
|
20
|
+
NO_RECORDING_TYPE = "no_recording_type"
|
|
21
|
+
UNKNOWN_RECORDING_TYPE = "unknown_recording_type"
|
|
22
|
+
|
|
23
|
+
# @return [String] the recording type or event type that could not be routed
|
|
24
|
+
attr_reader :routing_key
|
|
25
|
+
|
|
26
|
+
# @param kind [String] {NO_RECORDING_TYPE} or {UNKNOWN_RECORDING_TYPE}
|
|
27
|
+
# @param routing_key [String, nil] the type string that could not be routed
|
|
28
|
+
def initialize(kind:, routing_key:)
|
|
29
|
+
reason = kind == NO_RECORDING_TYPE ? "event type names no recording type" : "no typed read for recording type"
|
|
30
|
+
super(
|
|
31
|
+
kind: kind,
|
|
32
|
+
code: ErrorCode::USAGE,
|
|
33
|
+
message: "#{reason}: #{routing_key.to_s.inspect}"
|
|
34
|
+
)
|
|
35
|
+
@routing_key = routing_key.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param routing_key [String, nil]
|
|
39
|
+
# @return [RecordingRoutingError]
|
|
40
|
+
def self.no_recording_type(routing_key)
|
|
41
|
+
new(kind: NO_RECORDING_TYPE, routing_key: routing_key)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param routing_key [String, nil]
|
|
45
|
+
# @return [RecordingRoutingError]
|
|
46
|
+
def self.unknown_recording_type(routing_key)
|
|
47
|
+
new(kind: UNKNOWN_RECORDING_TYPE, routing_key: routing_key)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Base class for the identities +RecordingsExtensions#summarize+ raises.
|
|
5
|
+
#
|
|
6
|
+
# These are the composite's OWN outcomes, not HTTP answers: "this pointer
|
|
7
|
+
# names no recording type", "this chat line is under no Campfire you can
|
|
8
|
+
# currently see", "discovery could not be carried to a conclusion", "the read
|
|
9
|
+
# came back from another bucket". A consumer matches the CLASS — or {#kind},
|
|
10
|
+
# the same vocabulary the conformance fixture pins — rather than parsing the
|
|
11
|
+
# message or reading a status off it.
|
|
12
|
+
#
|
|
13
|
+
# +code+ stays inside SPEC section 6's closed taxonomy, which is the
|
|
14
|
+
# HTTP-status mapping and has no room for composite identities. Each subclass
|
|
15
|
+
# documents the canonical code it chose and why; the code is the coarse
|
|
16
|
+
# category a CLI exits on, the class is the identity.
|
|
17
|
+
class RecordingSummaryError < Error
|
|
18
|
+
# The composite identity, in the conformance fixture's vocabulary
|
|
19
|
+
# (+no_recording_type+, +unknown_recording_type+, +recording_unresolved+,
|
|
20
|
+
# +campfire_discovery_incomplete+, +bucket_mismatch+).
|
|
21
|
+
# @return [String]
|
|
22
|
+
attr_reader :kind
|
|
23
|
+
|
|
24
|
+
# @param kind [String] the composite identity
|
|
25
|
+
# @param message [String]
|
|
26
|
+
# @param code [String] a canonical {ErrorCode}
|
|
27
|
+
# @param hint [String, nil]
|
|
28
|
+
def initialize(kind:, message:, code:, hint: nil)
|
|
29
|
+
super(code: code, message: message, hint: hint)
|
|
30
|
+
@kind = kind
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Mention-expanding comment writes, prepended onto the generated
|
|
6
|
+
# {CommentsService} (see the +on_load+ hook in +basecamp.rb+).
|
|
7
|
+
#
|
|
8
|
+
# Both methods compose public generated operations — +people.get+ and
|
|
9
|
+
# +comments.create+ — so hooks observe those two wire operations under their
|
|
10
|
+
# own identities, never a synthetic composite (SPEC.md section 18 rule 3).
|
|
11
|
+
module CommentsExtensions
|
|
12
|
+
# Returns content that mentions each of the given people, for posting as a
|
|
13
|
+
# comment — or, since the markup is the same, as a rich-text Campfire line.
|
|
14
|
+
#
|
|
15
|
+
# Every requested id is read through +people.get+ for its
|
|
16
|
+
# +attachable_sgid+ — one read per distinct id, ALWAYS: an sgid already in
|
|
17
|
+
# the content is unsigned and cannot prove the person is mentioned, so it
|
|
18
|
+
# never stands in for the read — and the mentions are placed as
|
|
19
|
+
# {Basecamp::Mentions.with_mentions} places them, which adds nothing for a
|
|
20
|
+
# person whose exact +attachable_sgid+ the content already carries.
|
|
21
|
+
#
|
|
22
|
+
# A person read that fails — an id that is not a person in this account, a
|
|
23
|
+
# 403 — fails the expansion; nothing is posted on a partial mention list.
|
|
24
|
+
# That error is raised unchanged rather than wrapped: in Ruby the error's
|
|
25
|
+
# CLASS and +code+ are the identity a caller matches on, and re-raising a
|
|
26
|
+
# wrapper to carry "which person" in the message would cost both.
|
|
27
|
+
#
|
|
28
|
+
# The rendered mentions round-trip:
|
|
29
|
+
# {Basecamp::Mentions.mentioned_person_ids} on the returned content reports
|
|
30
|
+
# every id passed here, and +recordings.summarize+ reports them on the
|
|
31
|
+
# comment once posted.
|
|
32
|
+
#
|
|
33
|
+
# @param content [String] the comment's rich text
|
|
34
|
+
# @param person_ids [Array<Integer>, nil] people to mention
|
|
35
|
+
# @return [String] the content with the mentions placed
|
|
36
|
+
# @raise [Basecamp::UsageError] on a non-positive person id
|
|
37
|
+
def expand_mentions(content:, person_ids: nil)
|
|
38
|
+
content = require_content(content)
|
|
39
|
+
person_ids = Array(person_ids)
|
|
40
|
+
return content if person_ids.empty?
|
|
41
|
+
|
|
42
|
+
seen = {}
|
|
43
|
+
people = []
|
|
44
|
+
person_ids.each do |person_id|
|
|
45
|
+
id = Ids.integer(person_id, "mention person id")
|
|
46
|
+
raise UsageError.new("invalid mention person id #{person_id.inspect}") unless id.positive?
|
|
47
|
+
next if seen.key?(id)
|
|
48
|
+
|
|
49
|
+
seen[id] = true
|
|
50
|
+
# Collected with an explicit push rather than by filter_map. That
|
|
51
|
+
# dropped every FALSY return as well as the duplicates it was meant
|
|
52
|
+
# to skip, so a people read answering JSON null or false removed the
|
|
53
|
+
# mention and let the comment post without it — the exact opposite of
|
|
54
|
+
# what this method's own doc promises, and a defect a comment saying
|
|
55
|
+
# "nothing is posted on a partial mention list" made harder to see.
|
|
56
|
+
people << read_person(id, @client.people.get(person_id: id))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Mentions.with_mentions(content, people)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Creates a comment on a recording whose content mentions the given people:
|
|
63
|
+
# {#expand_mentions}, then the generated +create+. The mention reads happen
|
|
64
|
+
# before the write, so a failed lookup posts nothing.
|
|
65
|
+
#
|
|
66
|
+
# @param recording_id [Integer] the recording to comment on
|
|
67
|
+
# @param content [String] the comment's rich text
|
|
68
|
+
# @param person_ids [Array<Integer>, nil] people to mention
|
|
69
|
+
# @return [Hash] the created comment
|
|
70
|
+
# @raise [Basecamp::UsageError] when the content is empty, or on a
|
|
71
|
+
# non-positive person id
|
|
72
|
+
def create_with_mentions(recording_id:, content:, person_ids: nil)
|
|
73
|
+
# Checked as a STRING, not as `content.to_s.empty?`. That validated a
|
|
74
|
+
# coerced copy and then handed the ORIGINAL to create, so a Hash was
|
|
75
|
+
# posted as a JSON object when no mentions were requested and was
|
|
76
|
+
# rendered into Ruby text by with_mentions when they were — two
|
|
77
|
+
# different wire bodies for one argument, neither of them the documented
|
|
78
|
+
# String. The reference takes `content string`, so a non-string cannot
|
|
79
|
+
# reach it at all; this is the equivalent refusal in a tier that has to
|
|
80
|
+
# make it explicitly.
|
|
81
|
+
content = require_content(content)
|
|
82
|
+
raise UsageError.new("comment content is required") if content.empty?
|
|
83
|
+
|
|
84
|
+
# Typed, not bounded. Ruby has no int64 to receive this in, so a value
|
|
85
|
+
# that is not an id at all is refused here — but the reference validates
|
|
86
|
+
# the CONTENT only and sends whatever id it is given, so a zero or a
|
|
87
|
+
# negative goes to the wire and comes back a 404, as it does there.
|
|
88
|
+
recording_id = Ids.integer(recording_id, "recording id")
|
|
89
|
+
|
|
90
|
+
create(recording_id: recording_id, content: expand_mentions(content: content, person_ids: person_ids))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# The comment's rich text, which has to be a String before anything is
|
|
96
|
+
# measured, written or posted.
|
|
97
|
+
def require_content(content)
|
|
98
|
+
return content if content.is_a?(String)
|
|
99
|
+
|
|
100
|
+
raise UsageError.new("comment content must be a string, got #{content.class}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The people read's body, which has to be an object before a mention is
|
|
104
|
+
# built from it.
|
|
105
|
+
#
|
|
106
|
+
# The reference decodes into a typed Person, so a scalar, an array or a
|
|
107
|
+
# null body fails the read there and never reaches the write. Here they
|
|
108
|
+
# reached {Basecamp::Mentions.with_mentions}, which would have gone on to
|
|
109
|
+
# index them.
|
|
110
|
+
def read_person(id, person)
|
|
111
|
+
return person if person.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
raise MergeSafe.malformed(
|
|
114
|
+
"the read for person #{id} returned #{MergeSafe.describe(person)}, not a person object",
|
|
115
|
+
"Every requested mention is resolved before the comment is written, so a person that " \
|
|
116
|
+
"cannot be read fails the whole write rather than posting without that mention."
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -202,12 +202,25 @@ module Basecamp
|
|
|
202
202
|
# replaces (<tt>(body[key] || []).map { |p| p["id"] }</tt>) has three ways
|
|
203
203
|
# to go wrong on malformed data: a non-Array has no +map+ (or, for a Hash,
|
|
204
204
|
# maps over its pairs), a non-Hash element raises TypeError on +[]+, and a
|
|
205
|
-
#
|
|
205
|
+
# wrong-typed +id+ rides through verbatim into the full-replace PUT — the
|
|
206
206
|
# same corruption as a wrong-typed string, one level down.
|
|
207
207
|
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
208
|
+
# What it must NOT do is refuse an id the reference ACCEPTS, which is the
|
|
209
|
+
# other half of the same defect and the one this guard had (#913). A
|
|
210
|
+
# person's id is the single field in the generated model the reference
|
|
211
|
+
# decodes flexibly, and +fieldsFromTodo+ appends what that produced with
|
|
212
|
+
# no filter of any kind — 0 included.
|
|
213
|
+
#
|
|
214
|
+
# It must not lean on {Basecamp::Http}'s pre-decode normalizer having
|
|
215
|
+
# reached the person first. Which people that walk finds is the walk's
|
|
216
|
+
# rule — the reference's positional pass covers +creator+ and
|
|
217
|
+
# +participants+ and nothing else — and a person it did not find arrives
|
|
218
|
+
# here exactly as BC3 sent it. How often that is a String the fixtures
|
|
219
|
+
# cannot say (none of the person objects in +spec/fixtures+ carries one);
|
|
220
|
+
# what they show is that the marker the +personable_type+ pass keys on is
|
|
221
|
+
# often missing, on 3 of 7 +assignees+ people. The rule itself lives in
|
|
222
|
+
# {Basecamp::Ids.person_from_wire}, where the read path already reads one,
|
|
223
|
+
# so the two cannot drift.
|
|
211
224
|
def writable_id_list(body, key, record:, escape:)
|
|
212
225
|
value = body[key]
|
|
213
226
|
return [] if value.nil?
|
|
@@ -225,7 +238,23 @@ module Basecamp
|
|
|
225
238
|
end
|
|
226
239
|
|
|
227
240
|
# Validates one element of an id-list field and returns its id.
|
|
241
|
+
#
|
|
242
|
+
# Three element shapes are accepted that a strict reading would refuse,
|
|
243
|
+
# each because the reference accepts it:
|
|
244
|
+
#
|
|
245
|
+
# * a +nil+ element is 0 — a JSON null in the array decodes to the
|
|
246
|
+
# reference's zero Person, whose id is 0 and is appended like any other;
|
|
247
|
+
# * an element with NO "id" is 0, the zero value, with no error;
|
|
248
|
+
# * an element whose "id" is a String is whatever ParseInt makes of it,
|
|
249
|
+
# and a String that is not a number at all is the system actor 0.
|
|
250
|
+
#
|
|
251
|
+
# An explicit <tt>"id" => nil</tt> is NOT the same as an absent one and
|
|
252
|
+
# still fails the read, which is why the absent case is tested with
|
|
253
|
+
# +key?+ rather than by asking for the value: see
|
|
254
|
+
# {Basecamp::Ids.person_from_wire} for the decoder line that splits them.
|
|
228
255
|
def person_id(element, index, key, record:, escape:)
|
|
256
|
+
return 0 if element.nil?
|
|
257
|
+
|
|
229
258
|
unless element.is_a?(Hash)
|
|
230
259
|
raise malformed(
|
|
231
260
|
"#{record} field #{key.inspect}[#{index}] is not an object: #{describe(element)}",
|
|
@@ -233,17 +262,12 @@ module Basecamp
|
|
|
233
262
|
)
|
|
234
263
|
end
|
|
235
264
|
|
|
236
|
-
|
|
237
|
-
if id.nil?
|
|
238
|
-
raise malformed(
|
|
239
|
-
"#{record} field #{key.inspect}[#{index}] has no \"id\"",
|
|
240
|
-
format(RESEND_HINT, escape: escape)
|
|
241
|
-
)
|
|
242
|
-
end
|
|
265
|
+
return 0 unless element.key?("id")
|
|
243
266
|
|
|
244
|
-
|
|
267
|
+
id = Ids.person_from_wire(element["id"])
|
|
268
|
+
if id.nil?
|
|
245
269
|
raise malformed(
|
|
246
|
-
"#{record} field #{key.inspect}[#{index}].id is not
|
|
270
|
+
"#{record} field #{key.inspect}[#{index}].id is not a person id: #{describe(element["id"])}",
|
|
247
271
|
format(RESEND_HINT, escape: escape)
|
|
248
272
|
)
|
|
249
273
|
end
|