Module: Basecamp::Services::MergeSafe
- Defined in:
- lib/basecamp/services/merge_safe.rb
Overview
Response guards shared by the merge-safe composites.
A merge-safe +update+/+edit+ GETs a record, reads each writable field, and PUTs the full representation back. The endpoint is full-replace, so every value read here is written — including one the caller never mentioned. If the read step coerces or forwards a malformed value instead of refusing it, that value lands on the record.
Two failure modes, the same defect wearing different clothes:
- erasure — || "" turns
falseinto "", wiping the field; - corruption — everything else falsey-in-other-languages (+0+,
[], {}) and every truthy non-string (+42+,true, ["x"]) is forwarded verbatim, writing a number, boolean, array or hash where a String belongs.
Ruby's || treats only nil and false as falsy, so it erases in one
case and corrupts in the rest. Testing only for erasure is what let this
class survive five review passes, so both are refused here.
The rule: a composite is safe exactly when a typed decoder sits between the GET and the field read. Go (+json.Unmarshal+), Swift (+Codable+) and Kotlin (kotlinx.serialization) get one for free from their models. Ruby does not — the generated services return a raw Hash, so nothing rejects a wrong-typed field and the check has to be explicit. That is why these guards exist in Ruby, Python and TypeScript and nowhere else (#576).
Todolists carries its own copy of these guards (#574). #544 flattened the shape those guards read — dropping the envelope-arm rung, not the guards — but did not unify them here. A generated validating layer (#578) is the intended end state for all of them.
Constant Summary collapse
- RESEND_HINT =
"The merge-safe update/edit resend this field verbatim, so a coerced or " \ "empty value would overwrite the current one. Use %<escape>s to write the record " \ "deliberately."
Class Method Summary collapse
-
.describe(value) ⇒ Object
Renders a value for an error message without ever throwing.
-
.malformed(message, hint) ⇒ Object
Builds the malformed-response error.
-
.person_id(element, index, key, record:, escape:) ⇒ Object
Validates one element of an id-list field and returns its id.
-
.require_hash(body, record:, operation:, escape:) ⇒ Object
The response must be a Hash before any field is read.
-
.required_writable_boolean(body, key, record:, escape:) ⇒ Object
Reads a writable boolean the record is required to carry.
-
.required_writable_string(body, key, record:, escape:) ⇒ Object
Reads a writable string the record is required to carry.
-
.writable_boolean(body, key, record:, escape:) ⇒ Object
Reads an optional writable boolean, refusing to coerce a malformed one.
-
.writable_id_list(body, key, record:, escape:) ⇒ Object
Reads a list of person records and projects it to their Integer ids.
-
.writable_string(body, key, record:, escape:) ⇒ Object
Reads a writable string field, refusing to coerce a malformed one.
Class Method Details
.describe(value) ⇒ Object
Renders a value for an error message without ever throwing.
The guard's own error path must not fail while explaining a failure:
inspect is arbitrary user code and can raise. The class name is always
available; the rendering is a bonus, capped per SPEC section 9 and
dropped if it fails.
50 51 52 53 54 55 56 57 |
# File 'lib/basecamp/services/merge_safe.rb', line 50 def describe(value) kind = value.class.to_s begin Security.truncate("#{kind} #{value.inspect}") rescue StandardError kind end end |
.malformed(message, hint) ⇒ Object
Builds the malformed-response error.
ApiError, not UsageError: the value arrived in a successful API response, so nothing the caller passed is at fault. Non-retryable, because re-requesting cannot repair a malformed body.
64 65 66 |
# File 'lib/basecamp/services/merge_safe.rb', line 64 def malformed(, hint) ApiError.new(Security.truncate(), hint: hint, retryable: false) end |
.person_id(element, index, key, record:, escape:) ⇒ Object
Validates one element of an id-list field and returns its id.
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/basecamp/services/merge_safe.rb', line 228 def person_id(element, index, key, record:, escape:) unless element.is_a?(Hash) raise malformed( "#{record} field #{key.inspect}[#{index}] is not an object: #{describe(element)}", format(RESEND_HINT, escape: escape) ) end id = element["id"] if id.nil? raise malformed( "#{record} field #{key.inspect}[#{index}] has no \"id\"", format(RESEND_HINT, escape: escape) ) end unless id.is_a?(Integer) raise malformed( "#{record} field #{key.inspect}[#{index}].id is not an integer: #{describe(id)}", format(RESEND_HINT, escape: escape) ) end id end |
.require_hash(body, record:, operation:, escape:) ⇒ Object
The response must be a Hash before any field is read.
One level up from the malformed-field guards: a successful GET can
return a scalar, an Array, or nil. body raises
TypeError on an Integer or Array and returns a silent nil substring
match on a String, so a malformed envelope would surface as a native
TypeError instead of the documented statusless api_error.
75 76 77 78 79 80 81 82 83 |
# File 'lib/basecamp/services/merge_safe.rb', line 75 def require_hash(body, record:, operation:, escape:) return body if body.is_a?(Hash) raise malformed( "#{operation} returned #{describe(body)} where a #{record.downcase} object was expected", "The merge-safe update/edit read this record's fields before rewriting them, so a " \ "non-object body cannot be used. Use #{escape} to write the record deliberately." ) end |
.required_writable_boolean(body, key, record:, escape:) ⇒ Object
Reads a writable boolean the record is required to carry.
The boolean analogue of required_writable_string, and it cannot be
expressed with a truthiness test: the value this guard most needs to
admit is false, which every || idiom would treat as missing
and replace with a default. Schedule::Entry#all_day is NOT NULL with a
false default in BC3 and every partial emits it, so absent or nil is a
malformed response — and defaulting it to false would silently convert
an all-day event into a midnight-to-midnight timed one on a call that
only changed the summary.
+0+/+1+ are refused rather than coerced, for the same reason
writable_string refuses 42: JSON has a boolean type and the server
uses it.
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/basecamp/services/merge_safe.rb', line 151 def required_writable_boolean(body, key, record:, escape:) value = body[key] if value.nil? raise malformed( %(#{record} field "#{key}" is required but the response carried #{describe(value)}), "The merge-safe update/edit resend this field verbatim, so a missing value would " \ "replace the current one with a default. Use #{escape} to write the record deliberately." ) end unless [ true, false ].include?(value) raise malformed( "#{record} field #{key.inspect} is not a boolean: #{describe(value)}", format(RESEND_HINT, escape: escape) ) end value end |
.required_writable_string(body, key, record:, escape:) ⇒ Object
Reads a writable string the record is required to carry.
writable_string treats an absent key or an explicit nil as genuinely
empty, which is right for an optional field — "" is what the
server already holds. It is wrong for a required one. Where the spec
marks a response member @required and BC3 can never render it
blank, an absent, nil or blank value in a 2xx body is a malformed
response, not an empty field. Coalescing it to "" and sending
that in the full-replace PUT would blank the real value on a call that
never mentioned it — #576's defect exactly.
Two records rely on this today and for the same reason: Document#title
is super.presence || "Untitled" and Schedule::Entry#summary is
super.presence || "Untitled", so neither can come back blank
from a healthy server.
The wrong-type branch is delegated to writable_string, so a required field and an optional one report a non-string identically.
124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/basecamp/services/merge_safe.rb', line 124 def required_writable_string(body, key, record:, escape:) value = body[key] if value.nil? || (value.is_a?(String) && value.strip.empty?) raise malformed( %(#{record} field "#{key}" is required but the response carried #{describe(value)}), "The merge-safe update/edit resend this field verbatim, so a missing or blank " \ "value would blank the current one. Use #{escape} to write the record deliberately." ) end writable_string(body, key, record: record, escape: escape) end |
.writable_boolean(body, key, record:, escape:) ⇒ Object
Reads an optional writable boolean, refusing to coerce a malformed one.
writable_string's boolean sibling, standing in the same relation to
required_writable_boolean that writable_string does to
required_writable_string: a missing key or an explicit nil is
genuinely "not set" and returns false, because that is what the server
already holds.
ScheduleEntry#highlighted is the case it exists for. The entry partial
emits it unconditionally, but the reduced calendar partial behind
GetUpcomingSchedule does not, and both render through the same schema —
so the member is optional and absence is legitimate rather than
malformed.
What still cannot be tolerated is the wrong type: a "yes" or a
1 must be refused, not coerced, because a caller who assigns the seeded
value straight back sends whatever it was seeded with. That branch is
delegated to required_writable_boolean, so an optional boolean and a
required one report a non-boolean identically.
191 192 193 194 195 196 197 |
# File 'lib/basecamp/services/merge_safe.rb', line 191 def writable_boolean(body, key, record:, escape:) if body[key].nil? false else required_writable_boolean(body, key, record: record, escape: escape) end end |
.writable_id_list(body, key, record:, escape:) ⇒ Object
Reads a list of person records and projects it to their Integer ids.
The analogue of writable_string for the id-list fields. The map it
replaces ((body || []).map { |p| p }) has three ways
to go wrong on malformed data: a non-Array has no map (or, for a Hash,
maps over its pairs), a non-Hash element raises TypeError on [], and a
non-Integer id rides through verbatim into the full-replace PUT — the
same corruption as a wrong-typed string, one level down.
+true+/+false+ are refused explicitly: they are not Integers in Ruby, so
is_a?(Integer) already rejects them, but the message names them as ids
rather than as an unexplained type error.
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/basecamp/services/merge_safe.rb', line 211 def writable_id_list(body, key, record:, escape:) value = body[key] return [] if value.nil? unless value.is_a?(Array) raise malformed( "#{record} field #{key.inspect} is not an array: #{describe(value)}", format(RESEND_HINT, escape: escape) ) end value.each_with_index.map do |element, index| person_id(element, index, key, record: record, escape: escape) end end |
.writable_string(body, key, record:, escape:) ⇒ Object
Reads a writable string field, refusing to coerce a malformed one.
A missing key or an explicit nil is genuinely empty — there is nothing
to preserve and "" is what the server already holds. An actual
String passes verbatim. Anything else is a malformed response and is
refused before the PUT, naming the offending field.
91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/basecamp/services/merge_safe.rb', line 91 def writable_string(body, key, record:, escape:) value = body[key] if value.nil? "" elsif value.is_a?(String) value else raise malformed( "#{record} field #{key.inspect} is not a string: #{describe(value)}", format(RESEND_HINT, escape: escape) ) end end |