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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when an account limit blocks the request (HTTP 507) — file storage
5
+ # exhausted, or a webhook ceiling reached.
6
+ #
7
+ # Never retryable: no amount of backoff frees storage or raises a plan limit.
8
+ # That is the whole reason this is not an ApiError, which a 507 would
9
+ # otherwise become through the 5xx catch-all.
10
+ class LimitExceededError < Error
11
+ def initialize(message = "Account limit reached", hint: nil, cause: nil)
12
+ super(
13
+ code: ErrorCode::LIMIT_EXCEEDED,
14
+ message: message,
15
+ hint: hint,
16
+ http_status: 507,
17
+ retryable: false,
18
+ cause: cause
19
+ )
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # A lazy Enumerator over paginated items that carries pagination metadata.
5
+ #
6
+ # Behaves exactly like the plain Enumerator it replaces — each/next/peek/
7
+ # take/first/lazy all work, and pages beyond the first are fetched only as
8
+ # iteration demands them — while additionally exposing {#meta}.
9
+ #
10
+ # The metadata object is shared with the paginator: meta.total_count is
11
+ # populated from the eagerly fetched first page, and meta.truncated is
12
+ # finalized by consuming the enumeration (see {ListMeta}).
13
+ #
14
+ # Re-enumerating restarts pagination: the eagerly fetched first page is
15
+ # served from memory on the first pass only, and later passes refetch
16
+ # every page for a consistent snapshot, refreshing meta from their own
17
+ # first-page response — metadata always describes the most recent
18
+ # traversal.
19
+ class ListEnumerator < Enumerator
20
+ # @return [ListMeta] pagination metadata
21
+ attr_reader :meta
22
+
23
+ # @param meta [ListMeta] metadata shared with the producing paginator
24
+ def initialize(meta, &block)
25
+ @meta = meta
26
+ super(&block)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Pagination metadata carried by {ListEnumerator}.
5
+ #
6
+ # total_count comes from the X-Total-Count header on the first page (0 when
7
+ # absent) and is available as soon as the list call returns, because the
8
+ # first page is fetched eagerly.
9
+ #
10
+ # truncated starts false and flips to true when enumeration discovers that
11
+ # items beyond those yielded were available: items dropped by a max_items
12
+ # cap, or a next Link left unfetched when the max_items or max_pages cap
13
+ # stopped pagination. It is final only once enumeration completes; landing
14
+ # exactly on the final item is not truncation.
15
+ class ListMeta
16
+ # @return [Integer] total item count from X-Total-Count (0 if absent)
17
+ attr_reader :total_count
18
+
19
+ # @return [Boolean] whether items beyond those yielded were available
20
+ attr_reader :truncated
21
+
22
+ alias truncated? truncated
23
+
24
+ # @param total_count [Integer] value of the X-Total-Count header
25
+ def initialize(total_count: 0)
26
+ @total_count = total_count
27
+ @truncated = false
28
+ end
29
+
30
+ # Records that truncation was discovered during enumeration.
31
+ # @api private
32
+ def mark_truncated!
33
+ @truncated = true
34
+ end
35
+
36
+ # Re-initializes the metadata when a traversal restarts, so it describes
37
+ # the restarted pass's own snapshot rather than a previous traversal's.
38
+ # @api private
39
+ def restart!(total_count:)
40
+ @total_count = total_count
41
+ @truncated = false
42
+ end
43
+ end
44
+ end
@@ -5,11 +5,20 @@ module Basecamp
5
5
  # Service for authorization operations.
6
6
  # This is the only service that doesn't require an account context.
7
7
  #
8
+ # The document's shape depends on which issuer served it. Discovery selects a
9
+ # BC5 issuer whenever one is advertised, and a BC5 issuer serves its *own*
10
+ # document (+app/views/api/authorizations/show.json.jbuilder+), which is not
11
+ # Launchpad's: it carries +identity.id+ and nothing else of the identity, no
12
+ # +product+ or +app_href+ on accounts, an RFC 8707 +resource+ indicator
13
+ # instead, a top-level +scope+ for BC3-issued tokens, and +expires_at+ as
14
+ # integer epoch seconds rather than ISO-8601. Only +identity.id+,
15
+ # +accounts[].id+, +accounts[].name+ and +accounts[].href+ are common to both.
16
+ #
8
17
  # @example Get authorization info
9
18
  # auth = client.authorization.get
10
- # puts "Identity: #{auth["identity"]["email_address"]}"
19
+ # puts "Identity: #{auth["identity"]["id"]}"
11
20
  # auth["accounts"].each do |account|
12
- # puts "Account: #{account["name"]} (#{account["id"]})"
21
+ # puts "Account: #{account["name"]} (#{account["href"]})"
13
22
  # end
14
23
  class AuthorizationService < BaseService
15
24
  # Gets authorization information for the current user.
@@ -2,37 +2,45 @@
2
2
 
3
3
  module Basecamp
4
4
  module Services
5
- # Merge-safe +update+ for cards, prepended onto the generated
5
+ # Tri-state +due_on+ for card updates, prepended onto the generated
6
6
  # {CardsService} (see the +on_load+ hook in +basecamp.rb+).
7
7
  #
8
- # BC3 builds the card's update params as
9
- # <tt>{ due_on: nil }.merge(card_params)</tt>
10
- # (+kanban/cards_controller.rb+), so *any* update whose body omits +due_on+
11
- # erases the card's due date. A sparse PUT — the natural thing to write —
12
- # is therefore destructive on the raw endpoint, which remains available as
13
- # {#update_verbatim}.
8
+ # BC3's card controller is presence-aware on the JSON representation
9
+ # (+kanban/cards_controller.rb+, basecamp/bc3#12521): +card_update_params+
10
+ # is plain +card_params+, so an update writes exactly the keys the body
11
+ # carries. An omitted +due_on+ leaves the card's due date UNCHANGED; an
12
+ # explicit <tt>""</tt> (or +null+) clears it. The
13
+ # <tt>{ due_on: nil }.merge(card_params)</tt> default survives only for the
14
+ # HTML/turbo_stream web forms, which post every field on every submit.
14
15
  #
15
- # +update+ composes the public +get+ and +update_verbatim+ methods, so
16
- # hooks observe the two wire operations, not a synthetic composite.
16
+ # A clear therefore has to be *stated*, never encoded as an omission —
17
+ # omitting +due_on+ to clear it is a silent no-op. <tt>""</tt> is the
18
+ # spelling that travels: JSON +null+ cannot reach the wire from here at
19
+ # all, because +compact_params+ is +kwargs.compact+ and drops nils (SPEC
20
+ # section 18 body compaction). Rails casts the blank string to nil on the
21
+ # date column, so <tt>""</tt> is what a clear looks like end to end.
17
22
  #
18
- # Not atomic: a concurrent due-date change landing between the GET and the
19
- # PUT is overwritten with the value this call read. The window is one
20
- # round-trip.
23
+ # There is no read-before-write. An earlier version GET the card and resent
24
+ # its due date, because the server then nil'd an unmentioned one and a
25
+ # sparse PUT was destructive. Presence-awareness removed the hazard the
26
+ # extra round-trip covered, and with it the race the round-trip opened
27
+ # between the read and the write. Every case is a single PUT.
21
28
  module CardsExtensions
22
- # Updates a card without disturbing fields the caller did not mention.
29
+ # Updates a card, addressing only what the caller named.
23
30
  #
24
- # +due_on+ is tri-state, which is what makes this safe:
31
+ # +due_on+ is tri-state:
25
32
  #
26
- # * +nil+ (omitted) — the current due date is fetched and resent
27
- # * <tt>""</tt> — the due date is cleared
33
+ # * +nil+ (unaddressed) — no +due_on+ key is sent; BC3 leaves the current
34
+ # due date alone
35
+ # * <tt>""</tt> — a stated clear, sent as <tt>""</tt>
28
36
  # * a date — the due date is set
29
37
  #
30
- # The extra GET is only paid for in the +nil+ case, the one where the
31
- # API would otherwise destroy something.
38
+ # Every other argument is plain send-when-set: +nil+ leaves the field off
39
+ # the body, and BC3 leaves the stored value untouched.
32
40
  #
33
- # Assignees are never resent on the caller's behalf: BC3 filters incoming
34
- # IDs through +reachable_people+, so echoing back an id belonging to
35
- # someone who has since lost board access would silently unassign them.
41
+ # This is the same single PUT as {#update_verbatim}, which stays as the
42
+ # unnormalised path; +update+ differs only in mapping an empty +due_on+ to
43
+ # the <tt>""</tt> the server reads as a clear.
36
44
  #
37
45
  # @param card_id [Integer] card id
38
46
  # @param title [String, nil] new title (nil = keep current)
@@ -43,13 +51,13 @@ module Basecamp
43
51
  def update(card_id:, title: nil, content: nil, due_on: nil, assignee_ids: nil)
44
52
  resolved_due_on =
45
53
  if due_on.nil?
46
- get(card_id: card_id)["due_on"]
47
- elsif due_on.to_s.empty?
48
- # Clearing is encoded by OMITTING due_on — compact_params strips the
49
- # nil below, and BC3 nils an omitted due date. Sending an explicit
50
- # null would violate body compaction (SPEC §18), and sending ""
51
- # risks a date-format error.
54
+ # Unaddressed. +compact_params+ drops the nil, so no key is sent and
55
+ # the presence-aware update never touches the stored date.
52
56
  nil
57
+ elsif due_on.to_s.empty?
58
+ # A stated clear. "" survives +compact_params+ (it removes only
59
+ # nils) and reaches the wire as {"due_on": ""}.
60
+ ""
53
61
  else
54
62
  due_on
55
63
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Merge-safe +update+ and read-modify-write +edit+ for documents,
6
+ # prepended onto the generated {DocumentsService} (see the +on_load+ hook
7
+ # in +basecamp.rb+).
8
+ #
9
+ # BC3's +DocumentsController#update+ builds a brand-new +Document+ from
10
+ # only the permitted params and swaps the recordable wholesale, so
11
+ # <tt>PUT /documents/{id}</tt> is a full replace: a body that omits
12
+ # +content+ ERASES it, and one that omits +title+ erases that too — the
13
+ # document then reads back as <tt>"Untitled"</tt>, because +Document#title+
14
+ # falls back when blank. Neither attribute is presence-validated, so
15
+ # *neither omission is a 422*; both are a 200 that quietly clears. What BC3
16
+ # does require is the wrapping +document+ object, so a body naming neither
17
+ # field is a 400. The sparse PUT — the natural thing to write — is
18
+ # therefore destructive on the raw endpoint, which stays available as
19
+ # {#replace}.
20
+ #
21
+ # Both compose the public +get+ and +replace+ methods, so hooks observe
22
+ # the two wire operations (+get+ then +replace+), not a synthetic
23
+ # composite.
24
+ #
25
+ # Neither is atomic: there is no conditional-update signal on this
26
+ # endpoint, so a concurrent write between the GET and PUT is
27
+ # overwritten — last write wins for the whole representation. The
28
+ # window is one round-trip. Use +replace+ to overwrite deliberately.
29
+ module DocumentsExtensions
30
+ # A document's full writable state, yielded to the +edit+ block. The
31
+ # whole struct is PUT back to the server, so clearing a field means
32
+ # setting it empty (<tt>""</tt>) — there is no third state. The writable
33
+ # set is exactly what BC3 permits: +title+ and +content+.
34
+ DocumentFields = Struct.new(:title, :content, keyword_init: true)
35
+
36
+ # The deliberate-overwrite escape hatch named in every malformed-response
37
+ # hint raised out of this composite.
38
+ ESCAPE_HATCH = "replace"
39
+
40
+ # Sets the given fields on a document and preserves everything else:
41
+ # GETs the current document, overlays the explicitly-passed keyword
42
+ # arguments, and PUTs the full representation back. An omitted (+nil+)
43
+ # field is untouched, guaranteed; an explicitly-passed <tt>""</tt>
44
+ # clears.
45
+ #
46
+ # Not atomic — see the module docs for the GET→PUT race. Use {#replace}
47
+ # to overwrite deliberately.
48
+ #
49
+ # @param document_id [Integer] document id
50
+ # @param title [String, nil] new title (nil = keep current, "" clears)
51
+ # @param content [String, nil] new content (nil = keep current, "" clears)
52
+ # @return [Hash] the updated document
53
+ def update(document_id:, title: nil, content: nil)
54
+ fields = fields_from_document(get(document_id: document_id))
55
+ fields.title = title unless title.nil?
56
+ fields.content = content unless content.nil?
57
+ put_fields(document_id, fields)
58
+ end
59
+
60
+ # Applies a read-modify-write block to a document: GETs the current
61
+ # document, yields its full writable state ({DocumentFields}), and PUTs
62
+ # the whole thing back. Clearing a field means setting it empty
63
+ # (<tt>""</tt>) — an untouched field keeps its current value. If the
64
+ # block raises, the edit aborts and nothing is written.
65
+ #
66
+ # Not atomic — see the module docs for the GET→PUT race.
67
+ #
68
+ # @example
69
+ # account.documents.edit(document_id: 123) do |doc|
70
+ # doc.title = "🚨 #{doc.title}"
71
+ # doc.content = "" # clearing = setting empty on a full object
72
+ # end
73
+ #
74
+ # @param document_id [Integer] document id
75
+ # @yieldparam fields [DocumentFields] the document's writable state, to mutate in place
76
+ # @return [Hash] the updated document
77
+ # @raise [ArgumentError] if no block is given
78
+ def edit(document_id:)
79
+ raise ArgumentError, "edit requires a block" unless block_given?
80
+
81
+ fields = fields_from_document(get(document_id: document_id))
82
+ yield fields
83
+ put_fields(document_id, fields)
84
+ end
85
+
86
+ private
87
+
88
+ # Derives the full writable state from a GET response.
89
+ #
90
+ # Every value here is resent in the full-replace PUT, so every value is
91
+ # validated before it is read. A plain <tt>|| ""</tt> would turn +false+
92
+ # into <tt>""</tt> — erasing the field on a call that never mentioned
93
+ # it — and pass arrays, hashes, numbers and +true+ straight through to be
94
+ # written verbatim. Ruby has no typed decoder between the GET and this
95
+ # read (+get+ returns a raw Hash), so the check is explicit work here
96
+ # rather than something the layer below already did. See {MergeSafe} and
97
+ # #576.
98
+ #
99
+ # The two writable fields read differently because the spec models them
100
+ # differently: +title+ is <tt>@required</tt>, so absent or nil is
101
+ # malformed; +content+ is optional, so absent or nil is a genuinely empty
102
+ # body.
103
+ def fields_from_document(document)
104
+ body = MergeSafe.require_hash(
105
+ document, record: "Document", operation: "GetDocument", escape: ESCAPE_HATCH
106
+ )
107
+ DocumentFields.new(
108
+ title: MergeSafe.required_writable_string(body, "title", record: "Document", escape: ESCAPE_HATCH),
109
+ content: MergeSafe.writable_string(body, "content", record: "Document", escape: ESCAPE_HATCH)
110
+ )
111
+ end
112
+
113
+ # PUTs the full writable state via +replace+. Both fields are always
114
+ # sent, empties included: the generated layer's +compact_params+ strips
115
+ # nils, so a cleared field travels as <tt>""</tt> rather than JSON null
116
+ # (SPEC section 18 body compaction) — and omitting it would hand the
117
+ # clear back to the server's rebuild instead of stating it.
118
+ #
119
+ # +nil+ is normalised to <tt>""</tt> rather than coerced with +to_s+: the
120
+ # struct starts nil-valued and clearing by assigning nil is idiomatic,
121
+ # but +to_s+ would silently turn a block's +42+ into <tt>"42"</tt> and
122
+ # write it — the same corruption {MergeSafe} refuses on the read side.
123
+ # Validating what the *caller* assigns is the mirror of that rule and is
124
+ # deliberately out of scope here, exactly as in #576: a value the caller
125
+ # chose is not a value silently substituted for one they asked to
126
+ # preserve.
127
+ def put_fields(document_id, fields)
128
+ replace(
129
+ document_id: document_id,
130
+ title: fields.title.nil? ? "" : fields.title,
131
+ content: fields.content.nil? ? "" : fields.content
132
+ )
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Response guards shared by the merge-safe composites.
6
+ #
7
+ # A merge-safe +update+/+edit+ GETs a record, reads each writable field,
8
+ # and PUTs the *full* representation back. The endpoint is full-replace, so
9
+ # every value read here is written — including one the caller never
10
+ # mentioned. If the read step coerces or forwards a malformed value instead
11
+ # of refusing it, that value lands on the record.
12
+ #
13
+ # Two failure modes, the same defect wearing different clothes:
14
+ #
15
+ # * *erasure* — <tt>|| ""</tt> turns +false+ into <tt>""</tt>, wiping the
16
+ # field;
17
+ # * *corruption* — everything else falsey-in-other-languages (+0+, +[]+,
18
+ # <tt>{}</tt>) and every truthy non-string (+42+, +true+,
19
+ # <tt>["x"]</tt>) is forwarded verbatim, writing a number, boolean, array
20
+ # or hash where a String belongs.
21
+ #
22
+ # Ruby's +||+ treats only +nil+ and +false+ as falsy, so it erases in one
23
+ # case and corrupts in the rest. Testing only for erasure is what let this
24
+ # class survive five review passes, so both are refused here.
25
+ #
26
+ # *The rule: a composite is safe exactly when a typed decoder sits between
27
+ # the GET and the field read.* Go (+json.Unmarshal+), Swift (+Codable+) and
28
+ # Kotlin (kotlinx.serialization) get one for free from their models. Ruby
29
+ # does not — the generated services return a raw Hash, so nothing rejects a
30
+ # wrong-typed field and the check has to be explicit. That is why these
31
+ # guards exist in Ruby, Python and TypeScript and nowhere else (#576).
32
+ #
33
+ # Todolists carries its own copy of these guards (#574). #544 flattened the
34
+ # shape those guards read — dropping the envelope-arm rung, not the guards —
35
+ # but did not unify them here. A generated validating layer (#578) is the
36
+ # intended end state for all of them.
37
+ module MergeSafe
38
+ RESEND_HINT = "The merge-safe update/edit resend this field verbatim, so a coerced or " \
39
+ "empty value would overwrite the current one. Use %<escape>s to write the record " \
40
+ "deliberately."
41
+
42
+ module_function
43
+
44
+ # Renders a value for an error message without ever throwing.
45
+ #
46
+ # The guard's own error path must not fail while explaining a failure:
47
+ # +inspect+ is arbitrary user code and can raise. The class name is always
48
+ # available; the rendering is a bonus, capped per SPEC section 9 and
49
+ # dropped if it fails.
50
+ def describe(value)
51
+ kind = value.class.to_s
52
+ begin
53
+ Security.truncate("#{kind} #{value.inspect}")
54
+ rescue StandardError
55
+ kind
56
+ end
57
+ end
58
+
59
+ # Builds the malformed-response error.
60
+ #
61
+ # ApiError, not UsageError: the value arrived in a successful API
62
+ # response, so nothing the caller passed is at fault. Non-retryable,
63
+ # because re-requesting cannot repair a malformed body.
64
+ def malformed(message, hint)
65
+ ApiError.new(Security.truncate(message), hint: hint, retryable: false)
66
+ end
67
+
68
+ # The response must be a Hash before any field is read.
69
+ #
70
+ # One level up from the malformed-field guards: a successful GET can
71
+ # return a scalar, an Array, or nil. <tt>body["due_on"]</tt> raises
72
+ # TypeError on an Integer or Array and returns a silent nil substring
73
+ # match on a String, so a malformed envelope would surface as a native
74
+ # TypeError instead of the documented statusless +api_error+.
75
+ def require_hash(body, record:, operation:, escape:)
76
+ return body if body.is_a?(Hash)
77
+
78
+ raise malformed(
79
+ "#{operation} returned #{describe(body)} where a #{record.downcase} object was expected",
80
+ "The merge-safe update/edit read this record's fields before rewriting them, so a " \
81
+ "non-object body cannot be used. Use #{escape} to write the record deliberately."
82
+ )
83
+ end
84
+
85
+ # Reads a writable string field, refusing to coerce a malformed one.
86
+ #
87
+ # A missing key or an explicit +nil+ is genuinely empty — there is nothing
88
+ # to preserve and <tt>""</tt> is what the server already holds. An actual
89
+ # String passes verbatim. Anything else is a malformed response and is
90
+ # refused *before* the PUT, naming the offending field.
91
+ def writable_string(body, key, record:, escape:)
92
+ value = body[key]
93
+
94
+ if value.nil?
95
+ ""
96
+ elsif value.is_a?(String)
97
+ value
98
+ else
99
+ raise malformed(
100
+ "#{record} field #{key.inspect} is not a string: #{describe(value)}",
101
+ format(RESEND_HINT, escape: escape)
102
+ )
103
+ end
104
+ end
105
+
106
+ # Reads a writable string the record is *required* to carry.
107
+ #
108
+ # {writable_string} treats an absent key or an explicit +nil+ as genuinely
109
+ # empty, which is right for an optional field — <tt>""</tt> is what the
110
+ # server already holds. It is wrong for a required one. Where the spec
111
+ # marks a response member <tt>@required</tt> and BC3 can never render it
112
+ # blank, an absent, nil or blank value in a 2xx body is a *malformed
113
+ # response*, not an empty field. Coalescing it to <tt>""</tt> and sending
114
+ # that in the full-replace PUT would blank the real value on a call that
115
+ # never mentioned it — #576's defect exactly.
116
+ #
117
+ # Two records rely on this today and for the same reason: +Document#title+
118
+ # is <tt>super.presence || "Untitled"</tt> and +Schedule::Entry#summary+ is
119
+ # <tt>super.presence || "Untitled"</tt>, so neither can come back blank
120
+ # from a healthy server.
121
+ #
122
+ # The wrong-type branch is delegated to {writable_string}, so a required
123
+ # field and an optional one report a non-string identically.
124
+ def required_writable_string(body, key, record:, escape:)
125
+ value = body[key]
126
+ if value.nil? || (value.is_a?(String) && value.strip.empty?)
127
+ raise malformed(
128
+ %(#{record} field "#{key}" is required but the response carried #{describe(value)}),
129
+ "The merge-safe update/edit resend this field verbatim, so a missing or blank " \
130
+ "value would blank the current one. Use #{escape} to write the record deliberately."
131
+ )
132
+ end
133
+
134
+ writable_string(body, key, record: record, escape: escape)
135
+ end
136
+
137
+ # Reads a writable boolean the record is *required* to carry.
138
+ #
139
+ # The boolean analogue of {required_writable_string}, and it cannot be
140
+ # expressed with a truthiness test: the value this guard most needs to
141
+ # admit is +false+, which every <tt>||</tt> idiom would treat as missing
142
+ # and replace with a default. +Schedule::Entry#all_day+ is NOT NULL with a
143
+ # +false+ default in BC3 and every partial emits it, so absent or nil is a
144
+ # malformed response — and defaulting it to +false+ would silently convert
145
+ # an all-day event into a midnight-to-midnight timed one on a call that
146
+ # only changed the summary.
147
+ #
148
+ # +0+/+1+ are refused rather than coerced, for the same reason
149
+ # {writable_string} refuses +42+: JSON has a boolean type and the server
150
+ # uses it.
151
+ def required_writable_boolean(body, key, record:, escape:)
152
+ value = body[key]
153
+
154
+ if value.nil?
155
+ raise malformed(
156
+ %(#{record} field "#{key}" is required but the response carried #{describe(value)}),
157
+ "The merge-safe update/edit resend this field verbatim, so a missing value would " \
158
+ "replace the current one with a default. Use #{escape} to write the record deliberately."
159
+ )
160
+ end
161
+
162
+ unless [ true, false ].include?(value)
163
+ raise malformed(
164
+ "#{record} field #{key.inspect} is not a boolean: #{describe(value)}",
165
+ format(RESEND_HINT, escape: escape)
166
+ )
167
+ end
168
+
169
+ value
170
+ end
171
+
172
+ # Reads an *optional* writable boolean, refusing to coerce a malformed one.
173
+ #
174
+ # {writable_string}'s boolean sibling, standing in the same relation to
175
+ # {required_writable_boolean} that +writable_string+ does to
176
+ # +required_writable_string+: a missing key or an explicit +nil+ is
177
+ # genuinely "not set" and returns +false+, because that is what the server
178
+ # already holds.
179
+ #
180
+ # +ScheduleEntry#highlighted+ is the case it exists for. The entry partial
181
+ # emits it unconditionally, but the reduced calendar partial behind
182
+ # GetUpcomingSchedule does not, and both render through the same schema —
183
+ # so the member is optional and absence is legitimate rather than
184
+ # malformed.
185
+ #
186
+ # What still cannot be tolerated is the *wrong type*: a <tt>"yes"</tt> or a
187
+ # +1+ must be refused, not coerced, because a caller who assigns the seeded
188
+ # value straight back sends whatever it was seeded with. That branch is
189
+ # delegated to {required_writable_boolean}, so an optional boolean and a
190
+ # required one report a non-boolean identically.
191
+ def writable_boolean(body, key, record:, escape:)
192
+ if body[key].nil?
193
+ false
194
+ else
195
+ required_writable_boolean(body, key, record: record, escape: escape)
196
+ end
197
+ end
198
+
199
+ # Reads a list of person records and projects it to their Integer ids.
200
+ #
201
+ # The analogue of {writable_string} for the id-list fields. The +map+ it
202
+ # replaces (<tt>(body[key] || []).map { |p| p["id"] }</tt>) has three ways
203
+ # to go wrong on malformed data: a non-Array has no +map+ (or, for a Hash,
204
+ # maps over its pairs), a non-Hash element raises TypeError on +[]+, and a
205
+ # non-Integer +id+ rides through verbatim into the full-replace PUT — the
206
+ # same corruption as a wrong-typed string, one level down.
207
+ #
208
+ # +true+/+false+ are refused explicitly: they are not Integers in Ruby, so
209
+ # +is_a?(Integer)+ already rejects them, but the message names them as ids
210
+ # rather than as an unexplained type error.
211
+ def writable_id_list(body, key, record:, escape:)
212
+ value = body[key]
213
+ return [] if value.nil?
214
+
215
+ unless value.is_a?(Array)
216
+ raise malformed(
217
+ "#{record} field #{key.inspect} is not an array: #{describe(value)}",
218
+ format(RESEND_HINT, escape: escape)
219
+ )
220
+ end
221
+
222
+ value.each_with_index.map do |element, index|
223
+ person_id(element, index, key, record: record, escape: escape)
224
+ end
225
+ end
226
+
227
+ # Validates one element of an id-list field and returns its id.
228
+ def person_id(element, index, key, record:, escape:)
229
+ unless element.is_a?(Hash)
230
+ raise malformed(
231
+ "#{record} field #{key.inspect}[#{index}] is not an object: #{describe(element)}",
232
+ format(RESEND_HINT, escape: escape)
233
+ )
234
+ end
235
+
236
+ id = element["id"]
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
243
+
244
+ unless id.is_a?(Integer)
245
+ raise malformed(
246
+ "#{record} field #{key.inspect}[#{index}].id is not an integer: #{describe(id)}",
247
+ format(RESEND_HINT, escape: escape)
248
+ )
249
+ end
250
+
251
+ id
252
+ end
253
+ end
254
+ end
255
+ end