basecamp-sdk 0.12.0 → 0.14.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 +128 -8
- data/lib/basecamp/client.rb +35 -11
- data/lib/basecamp/config.rb +69 -0
- data/lib/basecamp/error.rb +1 -0
- data/lib/basecamp/error_code.rb +1 -0
- data/lib/basecamp/exit_code.rb +1 -0
- data/lib/basecamp/generated/metadata.json +291 -141
- data/lib/basecamp/generated/services/base_service.rb +37 -16
- data/lib/basecamp/generated/services/bookmarks_service.rb +5 -4
- data/lib/basecamp/generated/services/boosts_service.rb +12 -6
- data/lib/basecamp/generated/services/campfires_service.rb +54 -41
- data/lib/basecamp/generated/services/cards_service.rb +6 -3
- data/lib/basecamp/generated/services/checkins_service.rb +28 -15
- data/lib/basecamp/generated/services/client_approvals_service.rb +8 -5
- data/lib/basecamp/generated/services/client_correspondences_service.rb +8 -5
- data/lib/basecamp/generated/services/client_replies_service.rb +12 -7
- data/lib/basecamp/generated/services/cloud_files_service.rb +57 -0
- data/lib/basecamp/generated/services/comments_service.rb +6 -3
- data/lib/basecamp/generated/services/documents_service.rb +9 -6
- data/lib/basecamp/generated/services/drafts_service.rb +5 -4
- data/lib/basecamp/generated/services/events_service.rb +6 -3
- data/lib/basecamp/generated/services/everything_service.rb +70 -56
- data/lib/basecamp/generated/services/folders_service.rb +62 -0
- data/lib/basecamp/generated/services/forwards_service.rb +12 -17
- data/lib/basecamp/generated/services/gauges_service.rb +12 -7
- data/lib/basecamp/generated/services/google_documents_service.rb +61 -0
- data/lib/basecamp/generated/services/message_types_service.rb +4 -3
- data/lib/basecamp/generated/services/messages_service.rb +6 -4
- data/lib/basecamp/generated/services/my_notes_service.rb +1 -1
- data/lib/basecamp/generated/services/my_notifications_service.rb +8 -5
- data/lib/basecamp/generated/services/people_service.rb +17 -10
- data/lib/basecamp/generated/services/projects_service.rb +26 -4
- data/lib/basecamp/generated/services/recordings_service.rb +6 -13
- data/lib/basecamp/generated/services/reports_service.rb +15 -9
- data/lib/basecamp/generated/services/schedules_service.rb +90 -16
- data/lib/basecamp/generated/services/search_service.rb +6 -4
- data/lib/basecamp/generated/services/templates_service.rb +6 -4
- data/lib/basecamp/generated/services/timeline_service.rb +6 -3
- data/lib/basecamp/generated/services/timesheets_service.rb +22 -8
- data/lib/basecamp/generated/services/todolist_groups_service.rb +7 -4
- data/lib/basecamp/generated/services/todolists_service.rb +11 -9
- data/lib/basecamp/generated/services/todos_service.rb +6 -14
- data/lib/basecamp/generated/services/uploads_service.rb +28 -6
- data/lib/basecamp/generated/services/vaults_service.rb +6 -3
- data/lib/basecamp/generated/services/webhooks_service.rb +4 -3
- data/lib/basecamp/generated/types.rb +603 -139
- data/lib/basecamp/http.rb +352 -163
- data/lib/basecamp/limit_exceeded_error.rb +22 -0
- data/lib/basecamp/list_enumerator.rb +29 -0
- data/lib/basecamp/list_meta.rb +44 -0
- data/lib/basecamp/services/authorization_service.rb +11 -2
- data/lib/basecamp/services/cards_extensions.rb +35 -27
- data/lib/basecamp/services/documents_extensions.rb +136 -0
- data/lib/basecamp/services/merge_safe.rb +255 -0
- data/lib/basecamp/services/schedules_extensions.rb +354 -0
- data/lib/basecamp/services/todolists_extensions.rb +274 -0
- data/lib/basecamp/services/todos_extensions.rb +22 -6
- data/lib/basecamp/validation_error.rb +11 -1
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +98 -4
- data/scripts/generate-metadata.rb +3 -1
- data/scripts/generate-services.rb +78 -27
- data/scripts/generate-types.rb +4 -2
- data/scripts/go_type_spellings.rb +26 -0
- metadata +13 -2
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Merge-safe +update_entry+ and read-modify-write +edit_entry+ for schedule
|
|
6
|
+
# entries, prepended onto the generated {SchedulesService} (see the
|
|
7
|
+
# +on_load+ hook in +basecamp.rb+).
|
|
8
|
+
#
|
|
9
|
+
# BC3's +Schedules::EntriesController#update+ rebuilds the recordable from
|
|
10
|
+
# the submitted params, so <tt>PUT /schedule_entries/{id}</tt> is a full
|
|
11
|
+
# replace: a body that omits +description+ ERASES it, and one that omits
|
|
12
|
+
# +summary+ erases that too — the entry then reads back as
|
|
13
|
+
# <tt>"Untitled"</tt>, because +Schedule::Entry#summary+ is
|
|
14
|
+
# <tt>super.presence || "Untitled"</tt>. The sparse PUT — the natural thing
|
|
15
|
+
# to write — is therefore destructive on the raw endpoint, which stays
|
|
16
|
+
# available as +replace_entry+.
|
|
17
|
+
#
|
|
18
|
+
# == Two classes of writable field
|
|
19
|
+
#
|
|
20
|
+
# Unlike documents and todolists, this record's writable set does not have
|
|
21
|
+
# one uniform rule. It splits in two:
|
|
22
|
+
#
|
|
23
|
+
# [full state] +summary+, +starts_at+, +ends_at+, +description+, +all_day+.
|
|
24
|
+
# Always resent, empties included: <tt>""</tt> is how a clear is
|
|
25
|
+
# expressed on a full-replace endpoint, never JSON null and
|
|
26
|
+
# never an omission.
|
|
27
|
+
# [addressed-only] +participant_ids+, +url+, +highlighted+, +notify+. Sent
|
|
28
|
+
# *only* when the caller addressed them, and never seeded
|
|
29
|
+
# onto the wire from the read-back.
|
|
30
|
+
#
|
|
31
|
+
# The first three of the addressed-only set are the operation's
|
|
32
|
+
# +preservedOnOmission+ carve-out: BC3 seeds them from the existing
|
|
33
|
+
# recordable when the request does not address them, so resending them is
|
|
34
|
+
# redundant at best and wrong if the GET raced a concurrent change. Echoing
|
|
35
|
+
# the response's +url+ would be worse than redundant — that key is the
|
|
36
|
+
# entry's own Basecamp API URL, written by +recordings/_recording+ before
|
|
37
|
+
# the entry partial renders, so BC3 emits the join link under the
|
|
38
|
+
# non-colliding +join_url+. Writing +url+ back would store the API URL as
|
|
39
|
+
# the join link. +notify+ is addressed-only for a different reason: it is a
|
|
40
|
+
# directive, not state — sending it makes BC3 recompute a drafted entry's
|
|
41
|
+
# subscriber list — and the read-back carries nothing to seed it from.
|
|
42
|
+
#
|
|
43
|
+
# An explicitly *empty* value in that second class is an address, not an
|
|
44
|
+
# absence: <tt>participant_ids: []</tt> clears participants,
|
|
45
|
+
# <tt>url: ""</tt> clears the join link, <tt>highlighted: false</tt> removes
|
|
46
|
+
# the highlight. All three survive body compaction, which strips only nil
|
|
47
|
+
# (SPEC section 18).
|
|
48
|
+
#
|
|
49
|
+
# == Recurring entries
|
|
50
|
+
#
|
|
51
|
+
# +ensure_non_recurring_event+ 302-redirects both +show+ and +update+ for a
|
|
52
|
+
# recurring entry, so this route serves non-recurring entries only. The SDK
|
|
53
|
+
# does not follow redirects on a PUT, and the GET's redirect lands on a body
|
|
54
|
+
# this composite refuses rather than reads. Recurrence itself
|
|
55
|
+
# (+recurrence_schedule+, +recurs_until+, +time_zone_name+) is unmodelled
|
|
56
|
+
# here and stays unmodelled: BC3 forces all three to nil for a non-recurring
|
|
57
|
+
# entry.
|
|
58
|
+
#
|
|
59
|
+
# Both methods compose the public +get_entry+ and +replace_entry+, so hooks
|
|
60
|
+
# observe the two wire operations (+get_entry+ then +replace_entry+), not a
|
|
61
|
+
# synthetic composite.
|
|
62
|
+
#
|
|
63
|
+
# Neither is atomic: there is no conditional-update signal on this endpoint,
|
|
64
|
+
# so a concurrent write between the GET and PUT is overwritten — last write
|
|
65
|
+
# wins for the whole representation. The window is one round-trip. Use
|
|
66
|
+
# +replace_entry+ to overwrite deliberately.
|
|
67
|
+
module SchedulesExtensions
|
|
68
|
+
# The deliberate-overwrite escape hatch named in every malformed-response
|
|
69
|
+
# hint raised out of this composite.
|
|
70
|
+
ESCAPE_HATCH = "replace_entry"
|
|
71
|
+
|
|
72
|
+
# The record name interpolated into {MergeSafe}'s messages.
|
|
73
|
+
RECORD = "Schedule entry"
|
|
74
|
+
|
|
75
|
+
# The writable members BC3 preserves when the request does not address
|
|
76
|
+
# them, plus +notify+, which is a directive rather than state. Sent only
|
|
77
|
+
# on an explicit address; never seeded from the read-back.
|
|
78
|
+
CARVE_OUTS = %i[participant_ids url highlighted notify].freeze
|
|
79
|
+
|
|
80
|
+
# How each writable member spells "cleared" on the wire.
|
|
81
|
+
#
|
|
82
|
+
# +compact_params+ strips nil (SPEC section 18), so a nil that reached the
|
|
83
|
+
# request would silently become an omission — an address turned back into
|
|
84
|
+
# an absence, which on a full-replace endpoint is exactly the defect this
|
|
85
|
+
# composite exists to prevent. Every member whose type has an empty value
|
|
86
|
+
# is normalised to it here. The three booleans (+all_day+, +highlighted+,
|
|
87
|
+
# +notify+) have none: a boolean is true or false, so a nil assigned to
|
|
88
|
+
# one is caller error and is refused rather than dropped.
|
|
89
|
+
CLEARED = {
|
|
90
|
+
summary: "",
|
|
91
|
+
starts_at: "",
|
|
92
|
+
ends_at: "",
|
|
93
|
+
description: "",
|
|
94
|
+
participant_ids: [].freeze,
|
|
95
|
+
url: ""
|
|
96
|
+
}.freeze
|
|
97
|
+
|
|
98
|
+
# A schedule entry's writable state, yielded to the {#edit_entry} block.
|
|
99
|
+
#
|
|
100
|
+
# The full-state members are plain accessors: they are resent whether or
|
|
101
|
+
# not the block touches them, so nothing has to be recorded. The
|
|
102
|
+
# carve-outs are readable — seeded from the read-back so a block can
|
|
103
|
+
# inspect the current join link, highlight and participants before
|
|
104
|
+
# deciding — but each writer *records the address*, and only an addressed
|
|
105
|
+
# carve-out reaches the wire.
|
|
106
|
+
#
|
|
107
|
+
# Dirty tracking is by setter invocation, deliberately, and not by
|
|
108
|
+
# comparing the block's result against the read-back: assigning a
|
|
109
|
+
# carve-out the value the GET just returned is an address like any other.
|
|
110
|
+
# A diff would drop it, and the server would then hold whatever a
|
|
111
|
+
# concurrent writer left there instead of the value the caller stated.
|
|
112
|
+
class ScheduleEntryFields
|
|
113
|
+
attr_accessor :summary, :starts_at, :ends_at, :description, :all_day
|
|
114
|
+
attr_reader :participant_ids, :url, :highlighted, :notify
|
|
115
|
+
|
|
116
|
+
def initialize(summary:, starts_at:, ends_at:, description:, all_day:,
|
|
117
|
+
participant_ids:, url:, highlighted:)
|
|
118
|
+
@summary = summary
|
|
119
|
+
@starts_at = starts_at
|
|
120
|
+
@ends_at = ends_at
|
|
121
|
+
@description = description
|
|
122
|
+
@all_day = all_day
|
|
123
|
+
@participant_ids = participant_ids
|
|
124
|
+
@url = url
|
|
125
|
+
@highlighted = highlighted
|
|
126
|
+
# Nothing in the response seeds a directive.
|
|
127
|
+
@notify = nil
|
|
128
|
+
@addressed = {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def participant_ids=(value)
|
|
132
|
+
@addressed[:participant_ids] = true
|
|
133
|
+
@participant_ids = value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def url=(value)
|
|
137
|
+
@addressed[:url] = true
|
|
138
|
+
@url = value
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def highlighted=(value)
|
|
142
|
+
@addressed[:highlighted] = true
|
|
143
|
+
@highlighted = value
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def notify=(value)
|
|
147
|
+
@addressed[:notify] = true
|
|
148
|
+
@notify = value
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Whether the caller addressed this carve-out, by assignment.
|
|
152
|
+
def addressed?(name)
|
|
153
|
+
@addressed.key?(name)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Sets the given fields on a schedule entry and preserves everything else:
|
|
158
|
+
# GETs the current entry, overlays the explicitly-passed keyword
|
|
159
|
+
# arguments, and PUTs the full representation back.
|
|
160
|
+
#
|
|
161
|
+
# An omitted (+nil+) argument is untouched, guaranteed. For the full-state
|
|
162
|
+
# fields that means the read-back value is resent; for the addressed-only
|
|
163
|
+
# fields it means the key never reaches the wire, leaving BC3 to seed it
|
|
164
|
+
# from the record it already holds. An explicitly-passed <tt>""</tt>,
|
|
165
|
+
# <tt>[]</tt> or +false+ is an address and is sent.
|
|
166
|
+
#
|
|
167
|
+
# +nil+ is an unambiguous "not addressed" for every one of these
|
|
168
|
+
# arguments: none of them has a JSON null wire spelling — a clear is
|
|
169
|
+
# <tt>""</tt>, <tt>[]</tt> or +false+ — and +compact_params+ strips nil
|
|
170
|
+
# before serialization anyway, so no sentinel is needed to tell "passed
|
|
171
|
+
# nil" from "not passed".
|
|
172
|
+
#
|
|
173
|
+
# Not atomic — see the module docs for the GET→PUT race, and for the
|
|
174
|
+
# recurring-entry redirect. Use +replace_entry+ to overwrite deliberately.
|
|
175
|
+
#
|
|
176
|
+
# @param entry_id [Integer] entry id
|
|
177
|
+
# @param summary [String, nil] new summary (nil = keep current)
|
|
178
|
+
# @param starts_at [String, nil] new start, a date or timestamp (nil = keep current)
|
|
179
|
+
# @param ends_at [String, nil] new end, a date or timestamp (nil = keep current)
|
|
180
|
+
# @param description [String, nil] new description (nil = keep current, "" clears)
|
|
181
|
+
# @param all_day [Boolean, nil] new all-day flag (nil = keep current)
|
|
182
|
+
# @param participant_ids [Array<Integer>, nil] replaces participants (nil = leave to BC3, [] clears)
|
|
183
|
+
# @param url [String, nil] new join link (nil = leave to BC3, "" clears)
|
|
184
|
+
# @param highlighted [Boolean, nil] new highlight (nil = leave to BC3, false removes)
|
|
185
|
+
# @param notify [Boolean, nil] notify participants (nil = do not address)
|
|
186
|
+
# @return [Hash] the updated schedule entry
|
|
187
|
+
def update_entry(entry_id:, summary: nil, starts_at: nil, ends_at: nil, description: nil,
|
|
188
|
+
all_day: nil, participant_ids: nil, url: nil, highlighted: nil, notify: nil)
|
|
189
|
+
# Delegating to edit_entry is not a shortcut: it makes "the caller
|
|
190
|
+
# addressed this" one rule with one implementation. A non-nil argument
|
|
191
|
+
# invokes the same writer a block would, so the carve-outs are recorded
|
|
192
|
+
# by exactly the mechanism edit_entry documents.
|
|
193
|
+
edit_entry(entry_id: entry_id) do |entry|
|
|
194
|
+
entry.summary = summary unless summary.nil?
|
|
195
|
+
entry.starts_at = starts_at unless starts_at.nil?
|
|
196
|
+
entry.ends_at = ends_at unless ends_at.nil?
|
|
197
|
+
entry.description = description unless description.nil?
|
|
198
|
+
entry.all_day = all_day unless all_day.nil?
|
|
199
|
+
entry.participant_ids = participant_ids unless participant_ids.nil?
|
|
200
|
+
entry.url = url unless url.nil?
|
|
201
|
+
entry.highlighted = highlighted unless highlighted.nil?
|
|
202
|
+
entry.notify = notify unless notify.nil?
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Applies a read-modify-write block to a schedule entry: GETs the current
|
|
207
|
+
# entry, yields its writable state ({ScheduleEntryFields}), and PUTs it
|
|
208
|
+
# back. The full-state fields are resent whether or not the block touches
|
|
209
|
+
# them; a carve-out is sent only if the block assigns it, even when it
|
|
210
|
+
# assigns the value the read already returned. If the block raises, the
|
|
211
|
+
# edit aborts and nothing is written.
|
|
212
|
+
#
|
|
213
|
+
# Not atomic — see the module docs for the GET→PUT race, and for the
|
|
214
|
+
# recurring-entry redirect.
|
|
215
|
+
#
|
|
216
|
+
# @example Clear the description, leave the join link and highlight alone
|
|
217
|
+
# account.schedules.edit_entry(entry_id: 123) do |entry|
|
|
218
|
+
# entry.summary = "🚨 #{entry.summary}"
|
|
219
|
+
# entry.description = "" # clearing = setting empty on a full object
|
|
220
|
+
# end
|
|
221
|
+
#
|
|
222
|
+
# @example Address a carve-out
|
|
223
|
+
# account.schedules.edit_entry(entry_id: 123) do |entry|
|
|
224
|
+
# entry.url = "" if entry.url.start_with?("https://meet.example.com/")
|
|
225
|
+
# end
|
|
226
|
+
#
|
|
227
|
+
# @param entry_id [Integer] entry id
|
|
228
|
+
# @yieldparam fields [ScheduleEntryFields] the entry's writable state, to mutate in place
|
|
229
|
+
# @return [Hash] the updated schedule entry
|
|
230
|
+
# @raise [ArgumentError] if no block is given
|
|
231
|
+
def edit_entry(entry_id:)
|
|
232
|
+
raise ArgumentError, "edit_entry requires a block" unless block_given?
|
|
233
|
+
|
|
234
|
+
fields = fields_from_entry(get_entry(entry_id: entry_id))
|
|
235
|
+
yield fields
|
|
236
|
+
put_entry_fields(entry_id, fields)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
# Derives the writable state from a GET response.
|
|
242
|
+
#
|
|
243
|
+
# Every full-state value here is resent in the full-replace PUT, so every
|
|
244
|
+
# one is validated before it is read. Ruby has no typed decoder between
|
|
245
|
+
# the GET and this read (+get_entry+ returns a raw Hash), so the check is
|
|
246
|
+
# explicit work here rather than something the layer below already did.
|
|
247
|
+
# See {MergeSafe} and #576.
|
|
248
|
+
#
|
|
249
|
+
# The guards differ per field because the spec models the fields
|
|
250
|
+
# differently:
|
|
251
|
+
#
|
|
252
|
+
# * +summary+, +starts_at+ and +ends_at+ are <tt>@required</tt> on the
|
|
253
|
+
# response and BC3 can never render them absent, null or blank —
|
|
254
|
+
# +Schedule::Entry#summary+ falls back to <tt>"Untitled"</tt>, and
|
|
255
|
+
# +starts_at+/+ends_at+ are NOT NULL columns every partial emits — so
|
|
256
|
+
# any of those shapes is a malformed response, not an empty value.
|
|
257
|
+
# * +starts_at+/+ends_at+ are read as strings and round-tripped verbatim,
|
|
258
|
+
# never parsed: the wire value is a bare date (<tt>"2026-06-05"</tt>)
|
|
259
|
+
# for an all-day entry and a timestamp otherwise, and reformatting it
|
|
260
|
+
# would rewrite a value the caller never mentioned.
|
|
261
|
+
# * +all_day+ is <tt>@required</tt> and NOT NULL DEFAULT false. It cannot
|
|
262
|
+
# be read with a truthiness test, because +false+ is the value the read
|
|
263
|
+
# most needs to admit; and defaulting a missing one to +false+ would
|
|
264
|
+
# silently convert an all-day event into a midnight-to-midnight timed
|
|
265
|
+
# one on a call that only changed the summary.
|
|
266
|
+
# * +description+ is optional and nullable — the rich-text partial always
|
|
267
|
+
# sets the key but the value may be null — so absent or null is
|
|
268
|
+
# genuinely empty.
|
|
269
|
+
#
|
|
270
|
+
# The carve-outs are seeded for *reading*, so a block can inspect the
|
|
271
|
+
# current join link, highlight and participants before deciding; seeding
|
|
272
|
+
# alone puts nothing on the wire. They are guarded all the same, because
|
|
273
|
+
# "reaches the wire only when addressed" is not "never reaches the wire":
|
|
274
|
+
# dirty tracking is by setter invocation, so <tt>entry.url = entry.url</tt>
|
|
275
|
+
# is an address like any other and sends whatever the seed held — and a
|
|
276
|
+
# block that merely inspects a corrupt seed decides on garbage. +url+ is
|
|
277
|
+
# seeded from +join_url+, never from +url+ (see the module docs).
|
|
278
|
+
# +highlighted+ takes the *optional* boolean guard: the member is absent
|
|
279
|
+
# from the reduced calendar partial +GetUpcomingSchedule+ renders, so a
|
|
280
|
+
# missing one is genuinely "not highlighted" rather than malformed — but a
|
|
281
|
+
# <tt>"yes"</tt> or a +1+ is refused, not coerced.
|
|
282
|
+
def fields_from_entry(entry)
|
|
283
|
+
body = MergeSafe.require_hash(
|
|
284
|
+
entry, record: RECORD, operation: "GetScheduleEntry", escape: ESCAPE_HATCH
|
|
285
|
+
)
|
|
286
|
+
ScheduleEntryFields.new(
|
|
287
|
+
summary: required_string(body, "summary"),
|
|
288
|
+
starts_at: required_string(body, "starts_at"),
|
|
289
|
+
ends_at: required_string(body, "ends_at"),
|
|
290
|
+
description: MergeSafe.writable_string(body, "description", record: RECORD, escape: ESCAPE_HATCH),
|
|
291
|
+
all_day: MergeSafe.required_writable_boolean(body, "all_day", record: RECORD, escape: ESCAPE_HATCH),
|
|
292
|
+
participant_ids: MergeSafe.writable_id_list(body, "participants", record: RECORD, escape: ESCAPE_HATCH),
|
|
293
|
+
url: MergeSafe.writable_string(body, "join_url", record: RECORD, escape: ESCAPE_HATCH),
|
|
294
|
+
highlighted: MergeSafe.writable_boolean(body, "highlighted", record: RECORD, escape: ESCAPE_HATCH)
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def required_string(body, key)
|
|
299
|
+
MergeSafe.required_writable_string(body, key, record: RECORD, escape: ESCAPE_HATCH)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# PUTs the writable state via +replace_entry+.
|
|
303
|
+
#
|
|
304
|
+
# The five full-state members are always sent, empties included: a cleared
|
|
305
|
+
# field travels as <tt>""</tt> rather than JSON null, and omitting it
|
|
306
|
+
# would hand the clear back to the server's rebuild instead of stating it.
|
|
307
|
+
# The carve-outs are splatted in only when addressed, so an untouched one
|
|
308
|
+
# leaves no key at all and BC3 seeds it from the record it holds.
|
|
309
|
+
#
|
|
310
|
+
# As in Documents (#576), validating the *type* of what the caller assigns
|
|
311
|
+
# is out of scope: a value the caller chose is not a value silently
|
|
312
|
+
# substituted for one they asked to preserve. Normalising nil is not that
|
|
313
|
+
# check — it is the addressedness contract, since a nil would be stripped
|
|
314
|
+
# by +compact_params+ and turn a stated clear into an omission.
|
|
315
|
+
def put_entry_fields(entry_id, fields)
|
|
316
|
+
replace_entry(
|
|
317
|
+
entry_id: entry_id,
|
|
318
|
+
summary: caller_value(fields.summary, :summary),
|
|
319
|
+
starts_at: caller_value(fields.starts_at, :starts_at),
|
|
320
|
+
ends_at: caller_value(fields.ends_at, :ends_at),
|
|
321
|
+
description: caller_value(fields.description, :description),
|
|
322
|
+
all_day: caller_value(fields.all_day, :all_day),
|
|
323
|
+
**addressed_carve_outs(fields)
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# The addressed carve-outs, as replace_entry keyword arguments. An
|
|
328
|
+
# unaddressed one is absent from the Hash, so it is absent from the body.
|
|
329
|
+
def addressed_carve_outs(fields)
|
|
330
|
+
CARVE_OUTS.select { |name| fields.addressed?(name) } \
|
|
331
|
+
.to_h { |name| [ name, caller_value(fields.public_send(name), name) ] }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Normalises a nil the caller left or assigned into that member's empty
|
|
335
|
+
# spelling, and refuses it where the member has none.
|
|
336
|
+
def caller_value(value, key)
|
|
337
|
+
if value.nil?
|
|
338
|
+
CLEARED.fetch(key) { raise nil_boolean(key) }
|
|
339
|
+
else
|
|
340
|
+
value
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def nil_boolean(key)
|
|
345
|
+
UsageError.new(
|
|
346
|
+
"schedule entry #{key} must be true or false, not nil",
|
|
347
|
+
hint: "#{key} is a boolean with no empty value, and body compaction drops nil — " \
|
|
348
|
+
"sending it would omit the field rather than state it, letting the server " \
|
|
349
|
+
"decide. Assign true or false, or leave the member alone."
|
|
350
|
+
)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Merge-safe +update+ and read-modify-write +edit+ for todolists (and
|
|
6
|
+
# todolist groups), prepended onto the generated {TodolistsService} (see
|
|
7
|
+
# the +on_load+ hook in +basecamp.rb+).
|
|
8
|
+
#
|
|
9
|
+
# BC3's +TodolistsController#update+ rebuilds the recordable from only the
|
|
10
|
+
# permitted params, so <tt>PUT /todolists/{id}</tt> is a full replace:
|
|
11
|
+
# a body that omits +description+ ERASES it. The sparse PUT — the natural
|
|
12
|
+
# thing to write — is therefore destructive on the raw endpoint, which
|
|
13
|
+
# stays available as {#replace}.
|
|
14
|
+
#
|
|
15
|
+
# Both compose the public +get+ and +replace+ methods, so hooks observe
|
|
16
|
+
# the two wire operations (+get+ then +replace+), not a synthetic
|
|
17
|
+
# composite.
|
|
18
|
+
#
|
|
19
|
+
# Neither is atomic: there is no conditional-update signal on this
|
|
20
|
+
# endpoint, so a concurrent write between the GET and PUT is
|
|
21
|
+
# overwritten — last write wins for the whole representation. The
|
|
22
|
+
# window is one round-trip. Use +replace+ to overwrite deliberately.
|
|
23
|
+
module TodolistsExtensions
|
|
24
|
+
# A todolist's full writable state, yielded to the +edit+ block. The
|
|
25
|
+
# whole struct is PUT back to the server, so clearing a field means
|
|
26
|
+
# setting it empty (<tt>""</tt>) — there is no third state. The writable
|
|
27
|
+
# set is exactly what BC3 permits: +name+ and +description+.
|
|
28
|
+
TodolistFields = Struct.new(:name, :description, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
# Sets the given fields on a todolist and preserves everything else:
|
|
31
|
+
# GETs the current todolist, overlays the explicitly-passed keyword
|
|
32
|
+
# arguments, and PUTs the full representation back. An omitted (+nil+)
|
|
33
|
+
# field is untouched, guaranteed; an explicitly-passed <tt>""</tt>
|
|
34
|
+
# clears.
|
|
35
|
+
#
|
|
36
|
+
# Not atomic — see the module docs for the GET→PUT race. Use {#replace}
|
|
37
|
+
# to overwrite deliberately, or {#edit} to clear fields.
|
|
38
|
+
#
|
|
39
|
+
# @param id [Integer] todolist id
|
|
40
|
+
# @param name [String, nil] new name (nil = keep current)
|
|
41
|
+
# @param description [String, nil] new description (nil = keep current, "" clears)
|
|
42
|
+
# @return [Hash] the updated todolist
|
|
43
|
+
# @raise [Basecamp::UsageError] if the resulting name would be empty
|
|
44
|
+
def update(id:, name: nil, description: nil)
|
|
45
|
+
fields = fields_from_todolist(get(id: id))
|
|
46
|
+
fields.name = name unless name.nil?
|
|
47
|
+
fields.description = description unless description.nil?
|
|
48
|
+
put_fields(id, fields)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Applies a read-modify-write block to a todolist: GETs the current
|
|
52
|
+
# todolist, yields its full writable state ({TodolistFields}), and PUTs
|
|
53
|
+
# the whole thing back. Clearing a field means setting it empty
|
|
54
|
+
# (<tt>""</tt>) — an untouched field keeps its current value. If the
|
|
55
|
+
# block raises, the edit aborts and nothing is written.
|
|
56
|
+
#
|
|
57
|
+
# Not atomic — see the module docs for the GET→PUT race.
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# account.todolists.edit(id: 123) do |list|
|
|
61
|
+
# list.name = "🚨 #{list.name}"
|
|
62
|
+
# list.description = "" # clearing = setting empty on a full object
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# @param id [Integer] todolist id
|
|
66
|
+
# @yieldparam fields [TodolistFields] the todolist's writable state, to mutate in place
|
|
67
|
+
# @return [Hash] the updated todolist
|
|
68
|
+
# @raise [ArgumentError] if no block is given
|
|
69
|
+
# @raise [Basecamp::UsageError] if the block leaves the name empty
|
|
70
|
+
def edit(id:)
|
|
71
|
+
raise ArgumentError, "edit requires a block" unless block_given?
|
|
72
|
+
|
|
73
|
+
fields = fields_from_todolist(get(id: id))
|
|
74
|
+
yield fields
|
|
75
|
+
put_fields(id, fields)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Derives the full writable state from a GET response.
|
|
81
|
+
#
|
|
82
|
+
# BC3 answers this route with the recordable's flat JSON, and since #544
|
|
83
|
+
# the Smithy model says the same: one flat +Todolist+ structure, no
|
|
84
|
+
# +todolist+/+group+ envelope and no union. A group is a Todolist —
|
|
85
|
+
# +todolists/groups/{index,show}.json.jbuilder+ render
|
|
86
|
+
# +todolists/_todolist.json.jbuilder+ — so both projections arrive here
|
|
87
|
+
# with +name+ and +description+ at the top level and are read the same
|
|
88
|
+
# way. Nothing branches on the +type+ string; the structural
|
|
89
|
+
# discriminator (+groups_url+ for a list, +group_position_url+ for a
|
|
90
|
+
# group) is not writable state and is none of this method's business.
|
|
91
|
+
#
|
|
92
|
+
# The former arm lookup is gone with the union that motivated it: an
|
|
93
|
+
# unmodelled +todolist+/+group+ wrapper is now a malformed response, and
|
|
94
|
+
# unwrapping one would write the wrapper's contents over the record.
|
|
95
|
+
def fields_from_todolist(todolist)
|
|
96
|
+
body = require_hash(todolist)
|
|
97
|
+
TodolistFields.new(
|
|
98
|
+
name: writable_string(body, "name", non_empty: true),
|
|
99
|
+
description: writable_string(body, "description")
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Renders a value for an error message without ever throwing.
|
|
104
|
+
#
|
|
105
|
+
# The guard's own error path must not fail while explaining a failure:
|
|
106
|
+
# +inspect+ is arbitrary user code and can raise. The class name is always
|
|
107
|
+
# available; the rendering is a bonus, capped per SPEC section 9 and
|
|
108
|
+
# dropped if it fails.
|
|
109
|
+
def describe(value)
|
|
110
|
+
kind = value.class.to_s
|
|
111
|
+
begin
|
|
112
|
+
Security.truncate("#{kind} #{value.inspect}")
|
|
113
|
+
rescue StandardError
|
|
114
|
+
kind
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The response must be a Hash before any field is read.
|
|
119
|
+
#
|
|
120
|
+
# Level 1 of the wire-to-written-value path, one level up from the
|
|
121
|
+
# malformed-field guards. Since #544 flattened the shape the path is
|
|
122
|
+
# object -> scalar and has exactly two levels — the body and each writable
|
|
123
|
+
# field — where it used to have three. Two, not none: a flat wire shape
|
|
124
|
+
# says what the API returns, not that anything validates it. The generated
|
|
125
|
+
# +get+ returns <tt>http_get(...).json</tt>, a raw Hash with no decoder
|
|
126
|
+
# behind it, so a successful GET can still hand this method a scalar, an
|
|
127
|
+
# Array or nil.
|
|
128
|
+
#
|
|
129
|
+
# +body["name"]+ raises TypeError on an Integer or Array, and on a String
|
|
130
|
+
# it does not raise at all: it is a substring search. A body of
|
|
131
|
+
# <tt>"no name here"</tt> answers <tt>"name"</tt> for +name+ and +nil+ for
|
|
132
|
+
# +description+, so without this guard the composite would PUT the literal
|
|
133
|
+
# string "name" over the record's real name and clear its description —
|
|
134
|
+
# failing silently, which is why that defect outlived eight review passes
|
|
135
|
+
# on #574. A String has no interior, so there is no third level.
|
|
136
|
+
def require_hash(body)
|
|
137
|
+
unless body.is_a?(Hash)
|
|
138
|
+
raise ApiError.new(
|
|
139
|
+
Security.truncate("GetTodolistOrGroup returned #{describe(body)} where a todolist object was expected"),
|
|
140
|
+
hint: "The merge-safe update/edit read this record's fields before rewriting them, " \
|
|
141
|
+
"so a non-object body cannot be used. Use replace to write the record deliberately."
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
body
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Reads a writable string field, refusing to coerce a malformed one.
|
|
149
|
+
#
|
|
150
|
+
# *Classification is by origin, not by value.* The same empty string is a
|
|
151
|
+
# caller error when the caller passed it and malformed response data when
|
|
152
|
+
# it came off the wire, so each provenance is checked where it is
|
|
153
|
+
# unambiguous: this read step owns the response, and +put_fields+ owns the
|
|
154
|
+
# caller. That is why an empty +name+ here raises ApiError while an empty
|
|
155
|
+
# +name+ the caller supplied raises UsageError — same value, different
|
|
156
|
+
# origin, different fault.
|
|
157
|
+
#
|
|
158
|
+
# *Presence and non-emptiness are two different claims, and only one of
|
|
159
|
+
# them is per-field.* Since #544 +name+ and +description+ are both
|
|
160
|
+
# +@required+ and never null on this shape — +format_api_content+ funnels
|
|
161
|
+
# a blank rich text through +call_pipeline+, which returns <tt>""</tt>
|
|
162
|
+
# rather than nil — so for BOTH a missing key and an explicit +nil+ are
|
|
163
|
+
# malformed and are refused here, before any PUT. Reading either as
|
|
164
|
+
# <tt>""</tt> would put that <tt>""</tt> in the full-replace body and
|
|
165
|
+
# erase the record's real value on a call that never mentioned the field.
|
|
166
|
+
#
|
|
167
|
+
# +non_empty+ is the OTHER claim and holds for +name+ alone: BC3
|
|
168
|
+
# presence-validates the attribute, so no real todolist carries an empty
|
|
169
|
+
# one and <tt>""</tt> off the wire is malformed too. +description+ has no
|
|
170
|
+
# such validation — a description-less list carries <tt>""</tt>, which is
|
|
171
|
+
# the ordinary case, and the canonical group fixture ships one — so an
|
|
172
|
+
# empty description is a real value, preserved and resent verbatim.
|
|
173
|
+
# Conflating the two flags would refuse every description-less record.
|
|
174
|
+
#
|
|
175
|
+
# A wrong type is malformed either way and must NOT be coerced: a plain
|
|
176
|
+
# <tt>|| ""</tt> turns +false+ into <tt>""</tt> and passes arrays, hashes
|
|
177
|
+
# and numbers straight through. This endpoint is full-replace, so either
|
|
178
|
+
# outcome is written back over the real value.
|
|
179
|
+
#
|
|
180
|
+
# Ruby has no typed decoder between the GET and this read, unlike the Go,
|
|
181
|
+
# Swift and Kotlin composites where a wrong-typed field fails at decode,
|
|
182
|
+
# and flattening the shape did not add one: the generated method still
|
|
183
|
+
# returns <tt>http_get(...).json</tt> verbatim. The same shape is live in
|
|
184
|
+
# the shipped Todos composite; tracked in #576, with the generated
|
|
185
|
+
# validating layer that would retire this guard tracked in #578.
|
|
186
|
+
def writable_string(body, key, non_empty: false)
|
|
187
|
+
raise_missing_field(key) unless body.key?(key)
|
|
188
|
+
|
|
189
|
+
value = body[key]
|
|
190
|
+
|
|
191
|
+
if value.nil?
|
|
192
|
+
raise_null_field(key)
|
|
193
|
+
elsif !value.is_a?(String)
|
|
194
|
+
raise ApiError.new(
|
|
195
|
+
Security.truncate("Todolist field #{key.inspect} is not a string: #{describe(value)}"),
|
|
196
|
+
hint: "The merge-safe update/edit resend this field verbatim, so a coerced or " \
|
|
197
|
+
"empty value would overwrite the current one. Use replace to write the record " \
|
|
198
|
+
"deliberately."
|
|
199
|
+
)
|
|
200
|
+
elsif non_empty && value.empty?
|
|
201
|
+
raise ApiError.new(
|
|
202
|
+
"Todolist field #{key.inspect} is empty in the response",
|
|
203
|
+
hint: "#{key} is presence-validated server-side, so an empty one is a malformed " \
|
|
204
|
+
"response. The caller did not ask to clear it."
|
|
205
|
+
)
|
|
206
|
+
else
|
|
207
|
+
value
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def raise_missing_field(key)
|
|
212
|
+
raise ApiError.new(
|
|
213
|
+
"Todolist field #{key.inspect} is missing from the response",
|
|
214
|
+
hint: "#{key} is required on every todolist, so a body without one is a malformed " \
|
|
215
|
+
"response, not an empty value to preserve. The merge-safe update/edit PUT the full " \
|
|
216
|
+
"writable state back, so reading it as empty would erase the real value."
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def raise_null_field(key)
|
|
221
|
+
raise ApiError.new(
|
|
222
|
+
"Todolist field #{key.inspect} is null in the response",
|
|
223
|
+
hint: "#{key} is required and never null, so a null one is a malformed response, not " \
|
|
224
|
+
"an empty value to preserve. The merge-safe update/edit PUT the full writable state " \
|
|
225
|
+
"back, so reading it as empty would erase the real value."
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# PUTs the full writable state via +replace+. Both fields are always
|
|
230
|
+
# sent, description included when empty: the generated layer's
|
|
231
|
+
# +compact_params+ strips nils, so a cleared description travels as
|
|
232
|
+
# <tt>""</tt> rather than JSON null (SPEC section 18 body compaction) —
|
|
233
|
+
# and omitting it would hand the clear back to the server's rebuild
|
|
234
|
+
# instead of stating it.
|
|
235
|
+
#
|
|
236
|
+
# An empty name is refused rather than sent: BC3 presence-validates it,
|
|
237
|
+
# so a blank name is a 422 and never a preserve.
|
|
238
|
+
def put_fields(id, fields)
|
|
239
|
+
name = caller_string(fields.name, "name")
|
|
240
|
+
description = caller_string(fields.description, "description")
|
|
241
|
+
|
|
242
|
+
if name.empty?
|
|
243
|
+
raise UsageError, "name must be present; a full write has no nil state and BC3 rejects a blank name with 422"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
replace(id: id, name: name, description: description)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Validates a caller-supplied writable value, the mirror of the read step.
|
|
250
|
+
#
|
|
251
|
+
# +writable_string+ owns *response* provenance; this owns *caller*
|
|
252
|
+
# provenance, and the two are one rule seen from opposite ends. +edit+
|
|
253
|
+
# yields a mutable view of the full writable state and Ruby enforces
|
|
254
|
+
# nothing about what comes back — a block assigning +42+ or +[]+ would
|
|
255
|
+
# otherwise walk straight into the full-replace PUT and write it. That is
|
|
256
|
+
# caller misuse, hence UsageError, where the same wrong type arriving from
|
|
257
|
+
# the server is an ApiError. +nil+ is accepted as the empty string: the
|
|
258
|
+
# struct starts nil-valued and clearing by assigning nil is idiomatic.
|
|
259
|
+
def caller_string(value, key)
|
|
260
|
+
if value.nil?
|
|
261
|
+
""
|
|
262
|
+
elsif value.is_a?(String)
|
|
263
|
+
value
|
|
264
|
+
else
|
|
265
|
+
raise UsageError.new(
|
|
266
|
+
Security.truncate("todolist #{key} must be a String, got #{describe(value)}"),
|
|
267
|
+
hint: "The full writable state is PUT back verbatim, so a non-String would be " \
|
|
268
|
+
"written to the record. Assign a String; use \"\" to clear."
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -26,6 +26,10 @@ module Basecamp
|
|
|
26
26
|
keyword_init: true
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
+
# The deliberate-overwrite escape hatch named in every malformed-response
|
|
30
|
+
# hint raised out of this composite.
|
|
31
|
+
ESCAPE_HATCH = "replace"
|
|
32
|
+
|
|
29
33
|
# Sets the given fields on a todo and preserves everything else:
|
|
30
34
|
# GETs the current todo, overlays the explicitly-passed keyword
|
|
31
35
|
# arguments, and PUTs the full representation back. An omitted
|
|
@@ -85,14 +89,26 @@ module Basecamp
|
|
|
85
89
|
private
|
|
86
90
|
|
|
87
91
|
# Derives the full writable state from a GET response.
|
|
92
|
+
#
|
|
93
|
+
# Every value here is resent in the full-replace PUT, so every value is
|
|
94
|
+
# validated before it is read. The plain <tt>|| ""</tt> this replaced
|
|
95
|
+
# turned +false+ into <tt>""</tt> — erasing the field on a call that never
|
|
96
|
+
# mentioned it — and passed arrays, hashes, numbers and +true+ straight
|
|
97
|
+
# through to be written verbatim. Ruby has no typed decoder between the
|
|
98
|
+
# GET and this read (+get+ returns a raw Hash), so the check is explicit
|
|
99
|
+
# work here rather than something the layer below already did. See
|
|
100
|
+
# {MergeSafe} and #576.
|
|
88
101
|
def fields_from_todo(todo)
|
|
102
|
+
body = MergeSafe.require_hash(todo, record: "Todo", operation: "GetTodo", escape: ESCAPE_HATCH)
|
|
89
103
|
TodoFields.new(
|
|
90
|
-
content:
|
|
91
|
-
description:
|
|
92
|
-
assignee_ids: (
|
|
93
|
-
completion_subscriber_ids: (
|
|
94
|
-
|
|
95
|
-
|
|
104
|
+
content: MergeSafe.writable_string(body, "content", record: "Todo", escape: ESCAPE_HATCH),
|
|
105
|
+
description: MergeSafe.writable_string(body, "description", record: "Todo", escape: ESCAPE_HATCH),
|
|
106
|
+
assignee_ids: MergeSafe.writable_id_list(body, "assignees", record: "Todo", escape: ESCAPE_HATCH),
|
|
107
|
+
completion_subscriber_ids: MergeSafe.writable_id_list(
|
|
108
|
+
body, "completion_subscribers", record: "Todo", escape: ESCAPE_HATCH
|
|
109
|
+
),
|
|
110
|
+
due_on: MergeSafe.writable_string(body, "due_on", record: "Todo", escape: ESCAPE_HATCH),
|
|
111
|
+
starts_on: MergeSafe.writable_string(body, "starts_on", record: "Todo", escape: ESCAPE_HATCH),
|
|
96
112
|
notify: false
|
|
97
113
|
)
|
|
98
114
|
end
|