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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -8
  3. data/lib/basecamp/client.rb +35 -11
  4. data/lib/basecamp/config.rb +69 -0
  5. data/lib/basecamp/error.rb +1 -0
  6. data/lib/basecamp/error_code.rb +1 -0
  7. data/lib/basecamp/exit_code.rb +1 -0
  8. data/lib/basecamp/generated/metadata.json +291 -141
  9. data/lib/basecamp/generated/services/base_service.rb +37 -16
  10. data/lib/basecamp/generated/services/bookmarks_service.rb +5 -4
  11. data/lib/basecamp/generated/services/boosts_service.rb +12 -6
  12. data/lib/basecamp/generated/services/campfires_service.rb +54 -41
  13. data/lib/basecamp/generated/services/cards_service.rb +6 -3
  14. data/lib/basecamp/generated/services/checkins_service.rb +28 -15
  15. data/lib/basecamp/generated/services/client_approvals_service.rb +8 -5
  16. data/lib/basecamp/generated/services/client_correspondences_service.rb +8 -5
  17. data/lib/basecamp/generated/services/client_replies_service.rb +12 -7
  18. data/lib/basecamp/generated/services/cloud_files_service.rb +57 -0
  19. data/lib/basecamp/generated/services/comments_service.rb +6 -3
  20. data/lib/basecamp/generated/services/documents_service.rb +9 -6
  21. data/lib/basecamp/generated/services/drafts_service.rb +5 -4
  22. data/lib/basecamp/generated/services/events_service.rb +6 -3
  23. data/lib/basecamp/generated/services/everything_service.rb +70 -56
  24. data/lib/basecamp/generated/services/folders_service.rb +62 -0
  25. data/lib/basecamp/generated/services/forwards_service.rb +12 -17
  26. data/lib/basecamp/generated/services/gauges_service.rb +12 -7
  27. data/lib/basecamp/generated/services/google_documents_service.rb +61 -0
  28. data/lib/basecamp/generated/services/message_types_service.rb +4 -3
  29. data/lib/basecamp/generated/services/messages_service.rb +6 -4
  30. data/lib/basecamp/generated/services/my_notes_service.rb +1 -1
  31. data/lib/basecamp/generated/services/my_notifications_service.rb +8 -5
  32. data/lib/basecamp/generated/services/people_service.rb +17 -10
  33. data/lib/basecamp/generated/services/projects_service.rb +26 -4
  34. data/lib/basecamp/generated/services/recordings_service.rb +6 -13
  35. data/lib/basecamp/generated/services/reports_service.rb +15 -9
  36. data/lib/basecamp/generated/services/schedules_service.rb +90 -16
  37. data/lib/basecamp/generated/services/search_service.rb +6 -4
  38. data/lib/basecamp/generated/services/templates_service.rb +6 -4
  39. data/lib/basecamp/generated/services/timeline_service.rb +6 -3
  40. data/lib/basecamp/generated/services/timesheets_service.rb +22 -8
  41. data/lib/basecamp/generated/services/todolist_groups_service.rb +7 -4
  42. data/lib/basecamp/generated/services/todolists_service.rb +11 -9
  43. data/lib/basecamp/generated/services/todos_service.rb +6 -14
  44. data/lib/basecamp/generated/services/uploads_service.rb +28 -6
  45. data/lib/basecamp/generated/services/vaults_service.rb +6 -3
  46. data/lib/basecamp/generated/services/webhooks_service.rb +4 -3
  47. data/lib/basecamp/generated/types.rb +603 -139
  48. data/lib/basecamp/http.rb +352 -163
  49. data/lib/basecamp/limit_exceeded_error.rb +22 -0
  50. data/lib/basecamp/list_enumerator.rb +29 -0
  51. data/lib/basecamp/list_meta.rb +44 -0
  52. data/lib/basecamp/services/authorization_service.rb +11 -2
  53. data/lib/basecamp/services/cards_extensions.rb +35 -27
  54. data/lib/basecamp/services/documents_extensions.rb +136 -0
  55. data/lib/basecamp/services/merge_safe.rb +255 -0
  56. data/lib/basecamp/services/schedules_extensions.rb +354 -0
  57. data/lib/basecamp/services/todolists_extensions.rb +274 -0
  58. data/lib/basecamp/services/todos_extensions.rb +22 -6
  59. data/lib/basecamp/validation_error.rb +11 -1
  60. data/lib/basecamp/version.rb +2 -2
  61. data/lib/basecamp.rb +98 -4
  62. data/scripts/generate-metadata.rb +3 -1
  63. data/scripts/generate-services.rb +78 -27
  64. data/scripts/generate-types.rb +4 -2
  65. data/scripts/go_type_spellings.rb +26 -0
  66. 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: todo["content"] || "",
91
- description: todo["description"] || "",
92
- assignee_ids: (todo["assignees"] || []).map { |p| p["id"] },
93
- completion_subscriber_ids: (todo["completion_subscribers"] || []).map { |p| p["id"] },
94
- due_on: todo["due_on"] || "",
95
- starts_on: todo["starts_on"] || "",
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