diff --git a/AGENTS.md b/AGENTS.md
index 554c1a4105..f9916b55c2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,6 +33,8 @@ All 243 operations across the ~50-service per-SDK layer are generated. Hand-writ
| Merge-safe Todos composites (update/edit over generated get+replace; SPEC.md §18) | `typescript/src/services/todos-extensions.ts`, `ruby/lib/basecamp/services/todos_extensions.rb`, `swift/Sources/Basecamp/TodosServiceExtensions.swift`, `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/TodosService.kt`, `python/src/basecamp/services/todos.py` |
| Merge-safe Cards composite (update over generated get+updateVerbatim; SPEC.md §18) | `typescript/src/services/cards-extensions.ts`, `ruby/lib/basecamp/services/cards_extensions.rb`, `swift/Sources/Basecamp/CardsServiceExtensions.swift`, `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/CardsService.kt`, `python/src/basecamp/services/cards.py` |
| Merge-safe Todolists composites (update/edit over generated get+replace; SPEC.md §18) | `typescript/src/services/todolists-extensions.ts`, `ruby/lib/basecamp/services/todolists_extensions.rb`, `swift/Sources/Basecamp/TodolistsServiceExtensions.swift`, `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/TodolistsService.kt`, `python/src/basecamp/services/todolists.py` |
+| Merge-safe Documents composites (update/edit over generated get+replace; SPEC.md §18) | `typescript/src/services/documents-extensions.ts`, `ruby/lib/basecamp/services/documents_extensions.rb`, `swift/Sources/Basecamp/DocumentsServiceExtensions.swift`, `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/DocumentsService.kt`, `python/src/basecamp/services/documents.py`, `go/pkg/basecamp/documents.go` |
+| Merge-safe response guards shared by those composites (#576) | `typescript/src/services/merge-safe.ts`, `ruby/lib/basecamp/services/merge_safe.rb`, `python/src/basecamp/services/_merge_safe.py` |
Hand-written service files in `typescript/src/services/` and `ruby/lib/basecamp/services/` beyond the table above are NOT loaded at runtime. They exist only as reference implementations.
diff --git a/SPEC.md b/SPEC.md
index 53364fe968..99bd774d21 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -386,6 +386,42 @@ The endpoint is polymorphic, and more literally so than the name suggests: there
Conformance: `conformance/tests/todolists_write.json` (`update-merge`, `update-group`, `edit-clear`, `replace-omission-clears`).
+### Merge-Safe Write Surface (Documents)
+
+The `PUT /{accountId}/documents/{documentId}` endpoint is **full replace, omission clears** (spec operation `ReplaceDocument`, declared via `x-basecamp-write-semantics: {mode: "replace", clearsOmitted: true}` and the `write` clause in `behavior-model.json`). BC3's `DocumentsController#update` runs `@recording.update! recording_attributes.merge(recordable: new_document)`, where `new_document` is `Document.new(params.require(:document).permit(:title, :content))` — it builds a *brand-new* `Document` from only the permitted params and swaps the recordable wholesale, so a field absent from the body is `nil` on the replacement. The public API docs say the same thing outright: "omitting a field clears its value."
+
+The writable set is exactly `{title, content}`, and **both are optional** — this is the one place Documents diverges from Todolists, and it is measured rather than assumed:
+
+- Omitting `title` returns `200` and the title becomes `"Untitled"`. `Document#title` is `super.presence || "Untitled"` (`app/models/document.rb:7-9`) and the attribute carries **no presence validation** — the model declares none, and neither `Recordable` nor `Recording` validates the recordable's title.
+- Omitting `content` returns `200` and clears it.
+- Neither is a `422`, so neither earns `@required` the way `ReplaceTodo`'s `content` or `UpdateTodolistOrGroup`'s `name` did. Modelling either as required would make the SDK reject a request the server accepts.
+
+What BC3 *does* require is the wrapping `document` object: `params.require(:document)` raises `ActionController::ParameterMissing` when absent, and Rails `wrap_parameters` synthesizes that wrapper from a flat body only when the body carries at least one `Document` attribute name. So a body naming **neither** field is a `400`, pinned upstream by `test "publishing a draft document requires the full payload and preserves it"`. Go's `Replace` refuses that body locally rather than spending a round-trip on it; the other five leave it to the server.
+
+Every SDK exposes the same three-method, two-state surface over it:
+
+- **`update`** — merge-safe. GET the current document → overlay only *explicitly-set* request fields → PUT the full representation. An omitted field is untouched, guaranteed. Set-detection is language-native: TypeScript `!== undefined`, Python/Ruby `None`/`nil` kwarg defaults, Kotlin `?.let`, Swift `if let`, Go zero-value guards.
+
+ In the five SDKs whose unset marker is distinct from the empty string, an explicitly-passed `""` is a set and therefore clears. **Go is the exception**, as for Todolists: `""` *is* its unset marker on `UpdateDocumentRequest`, so `Update` with an empty title does nothing to that field. To clear a field in Go, use `Edit` or `Replace`.
+
+ `ReplaceDocumentRequest` is the one Go request here that does **not** use zero-value guards. On a verbatim replace, absent and explicitly-empty are different requests and only one of them is legal alone: a body naming neither field is a `400`, while `{"title": "", "content": ""}` is a legal full replacement that clears both. Zero-value guards conflate those, so both fields are `*string` — nil omits, a pointer to `""` sends. Their server *effect* happens to coincide for `title` (omitted and empty both read back as `"Untitled"`), but the SDK must not collapse a distinction the wire makes.
+- **`edit`** — read-modify-write closure over the full writable state (`DocumentFields`: title, content). Clear = set empty (`""`); a closure error/throw aborts before the PUT. Python's form is a context manager (`with`/`async with`) whose `.result` holds the updated document after clean exit (RuntimeError before completion).
+- **`replace`** — the generated wire method: verbatim sparse PUT, no GET, omission clears. Renamed from nothing — the wire operation itself is `ReplaceDocument`, so the generated method is `replace` by the ordinary naming algorithm. This is the `ReplaceTodo` route (#375), not the `METHOD_NAME_OVERRIDES` route Todolists and Cards took, and it ships **without a deprecated alias**: `UpdateDocument` is gone.
+
+Full-state serialization (update/edit): both `title` and `content` are always sent, empties included, so clears survive. A field is cleared by sending `""` — never by sending null (§18), and never by omission, which would hand the clear back to the server's own rebuild and read as an accident rather than an intent.
+
+**Read-side, the two fields are not symmetric, and this is the inverse of the write side.** `Document.title` is `@required` on the *response* schema and BC3 can never render it blank (`Document#title` is `super.presence || "Untitled"`), so an absent or null `title` in a 2xx body is a **malformed response**, not an empty title — coalescing it to `""` and sending that in the full-replace PUT would blank the real title on a call that only touched `content`. All six SDKs refuse it: Kotlin and Swift get it from the decoder (`val title: String`, `public let title: String`), and Go, Python, Ruby and TypeScript check explicitly, because their reads would otherwise yield the string zero value. `content` is optional on the response schema, so absent or null there is genuinely empty and `""` is what the server already holds. Optionality on the request (both fields) and requiredness on the response (`title` only) are separate facts and are modelled separately.
+
+**Subscribers are the one field this surface must not touch, and the reason it could not ship earlier.** A full-representation PUT names neither `subscriptions` nor `notify`. BC3's `notify_param` defaults to `"custom"`, so `find_subscribers` used to run `where(id: params[:subscriptions])` → `where(id: nil)` → empty, and every sparse update to a **drafted** recording reset its subscriber list to the creator plus the updater. The list is also unreadable over the API — only `subscription_url` is emitted — so the composite could not have preserved it by resending. bc3 #12494 (`344581a379`) and #12501 (`2c0dafba13`) introduced `Recording::DraftSubscribers`, whose `update_subscribers?` is `params.key?(:subscriptions) || params.key?(:notify)`: a request addressing neither keeps the list it found. That predicate is what makes a merge-safe composite safe on a draft, and it is why this surface is pinned to a bc3 provenance at or after `2c0dafba13`.
+
+**Publishing a draft is not modeled.** Setting `status: "active"` is how a draft is published, and BC3 rejects a status-only update for the same reason it rejects an empty body — the recordable params are required alongside. `status` is on `CreateDocument` but not on `ReplaceDocument`; a caller who needs to publish sends the full payload plus `status` through the raw HTTP surface. Modelling it is deferred rather than declined.
+
+**Hook contract:** update/edit compose the public get + replace, so hooks observe the wire operations under each SDK's native identities (conceptually one `GetDocument` + one `ReplaceDocument`; one `ReplaceDocument` for replace) — never a synthetic composite.
+
+**Race:** update/edit are read-modify-write, not atomic. There is no conditional-update signal on this endpoint; a concurrent write between the GET and PUT is overwritten — last write wins for the whole representation, with a window of one round-trip. Use `replace` to overwrite deliberately.
+
+Conformance: `conformance/tests/documents_write.json` (`update-merge`, `edit-clear`, `replace-omission-clears`).
+
### Known Gaps (informational, not prescriptive)
- Go is missing a standalone `automation` service; `clientVisibility` is implemented on `RecordingsService` (not a separate service); uses singular `Timesheet` vs `timesheets`
@@ -1688,9 +1724,21 @@ All wire operations are generated (rubric 1A.6). One narrow exception is sanctio
5. **Declared placement.** The composite lives in the language's designated hand-written extension point (Kotlin generator `EXTENSIBLE_SERVICES`/`HAND_WRITTEN_SERVICES`, TS `src/services/*-extensions.ts` wired in `client.ts`, Ruby zeitwerk `prepend` module, Python service subclass re-exported by the client, Swift same-module extension) so regeneration can never silently drop or fork it.
6. **The raw operation stays reachable.** When a composite takes over the plain method name, the generated single-request method is renamed (via `METHOD_NAME_OVERRIDES`) rather than hidden, and gets its own conformance case asserting it makes exactly one request with no read-before-write. Without that second case, later generator drift could silently turn both public methods into composite behavior and nothing would notice.
+### Replace-Semantic Operation Naming `[static]`
+
+A wire operation is named for what the server does with the body, not for what the caller usually intends:
+
+- **`Replace*`** when the endpoint takes a complete representation and clears what the body omits — `ReplaceTodo`, `ReplaceDocument`. This holds even where the replacement carries *declared carve-outs*: `ReplaceDocument` does not touch a drafted document's subscribers, and that does not make the operation a merge. A carve-out is one named field the server excludes from the swap; a merge is the server preserving anything the body omits. The rule keys on the default, and the carve-out is documented on the operation.
+- **`Update*`** when the endpoint merges — the server preserves fields the body omits (`Recordable#changing` and friends), as Messages does — or when it is genuinely hybrid, as Cards is: merge for `title`/`content`, key-guarded for `assignee_ids`, forced-replace for `due_on` (#467).
+
+Two shipped operations are replace-semantic but still named `Update*`. `UpdateTodolistOrGroup` reached the honest *method* name through `METHOD_NAME_OVERRIDES` (rule 6) rather than a wire rename, so its SDK surface reads `replace` while the operationId does not. `UpdateScheduleEntry` has neither yet — its method is still `updateEntry` and its composite is unbuilt (#546/#547). Both are naming debt, not a second sanctioned pattern; the wave that closes them is #374. New replace-semantic operations take the wire rename.
+
+A rename is breaking and ships **without a deprecated alias** (`ReplaceTodo`, #375; `ReplaceDocument`, #543). An alias would keep the destructive method reachable under the name that misdescribes it, which is the defect the rename exists to remove.
+
Current composites:
- **Todos** `update` (merge-safe) and `edit` (read-modify-write) — see §5 "Merge-Safe Write Surface (Todos)".
- **Todolists** `update` (merge-safe) and `edit` (read-modify-write) — see §5 "Merge-Safe Write Surface (Todolists)". The raw path is `replace`, renamed from `update` via `METHOD_NAME_OVERRIDES` (rule 6) rather than by renaming the wire operation.
+- **Documents** `update` (merge-safe) and `edit` (read-modify-write) — see §5 "Merge-Safe Write Surface (Documents)". The raw path is `replace`, and it needs no override: the wire operation is `ReplaceDocument`, so the ordinary naming algorithm produces it.
- **Cards** `update` (merge-safe) — see §5 "Merge-Safe Write Surface (Cards)". The raw path is `updateVerbatim`.
- **Uploads** `download` — composes the generated `get` (GetUpload) with the client-level `downloadURL` primitive (§14), erroring when the upload carries no `download_url`; the result's filename prefers the upload metadata's `filename`.
diff --git a/behavior-model.json b/behavior-model.json
index 1cee67dc16..d7376513b4 100644
--- a/behavior-model.json
+++ b/behavior-model.json
@@ -2436,6 +2436,22 @@
]
}
},
+ "ReplaceDocument": {
+ "idempotent": true,
+ "write": {
+ "mode": "replace",
+ "clearsOmitted": true
+ },
+ "retry": {
+ "max": 3,
+ "base_delay_ms": 1000,
+ "backoff": "exponential",
+ "retry_on": [
+ 429,
+ 503
+ ]
+ }
+ },
"ReplaceTodo": {
"idempotent": true,
"write": {
@@ -2827,18 +2843,6 @@
]
}
},
- "UpdateDocument": {
- "idempotent": true,
- "retry": {
- "max": 3,
- "base_delay_ms": 1000,
- "backoff": "exponential",
- "retry_on": [
- 429,
- 503
- ]
- }
- },
"UpdateFolder": {
"idempotent": true,
"retry": {
diff --git a/conformance/runner/go/main.go b/conformance/runner/go/main.go
index 731e8a077a..2ba1dd6559 100644
--- a/conformance/runner/go/main.go
+++ b/conformance/runner/go/main.go
@@ -773,6 +773,51 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T
_, err := account.Todolists().Replace(ctx, todolistID, req)
return operationResult{err: err}
+ case "UpdateDocument":
+ // Synthetic scenario key (not a wire operation): drives the SDK's
+ // merge-safe composite, which GETs the current document, overlays only
+ // the explicitly-set fields, and PUTs the full representation back.
+ documentID := getInt64Param(tc.PathParams, "documentId")
+ req := &basecamp.UpdateDocumentRequest{
+ Title: getStringParam(tc.RequestBody, "title"),
+ Content: getStringParam(tc.RequestBody, "content"),
+ }
+ _, err := account.Documents().Update(ctx, documentID, req)
+ return operationResult{err: err}
+
+ case "EditDocument":
+ // Synthetic scenario key (not a wire operation): drives the SDK's
+ // edit closure, assigning each fixture requestBody key onto the
+ // corresponding DocumentFields member (data-driven mutation). Absence
+ // stays absence, so an untouched field keeps its fetched value.
+ documentID := getInt64Param(tc.PathParams, "documentId")
+ _, err := account.Documents().Edit(ctx, documentID, func(f *basecamp.DocumentFields) error {
+ if _, ok := tc.RequestBody["title"]; ok {
+ f.Title = getStringParam(tc.RequestBody, "title")
+ }
+ if _, ok := tc.RequestBody["content"]; ok {
+ f.Content = getStringParam(tc.RequestBody, "content")
+ }
+ return nil
+ })
+ return operationResult{err: err}
+
+ case "ReplaceDocument":
+ // Presence-bearing: only keys the fixture carries become pointers, so
+ // an absent field stays absent on the wire and an explicit "" is sent.
+ documentID := getInt64Param(tc.PathParams, "documentId")
+ req := &basecamp.ReplaceDocumentRequest{}
+ if _, ok := tc.RequestBody["title"]; ok {
+ title := getStringParam(tc.RequestBody, "title")
+ req.Title = &title
+ }
+ if _, ok := tc.RequestBody["content"]; ok {
+ content := getStringParam(tc.RequestBody, "content")
+ req.Content = &content
+ }
+ _, err := account.Documents().Replace(ctx, documentID, req)
+ return operationResult{err: err}
+
case "GetTimesheetEntry":
entryID := getInt64Param(tc.PathParams, "entryId")
_, err := account.Timesheet().Get(ctx, entryID)
diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py
index abd914dfe6..938f4055b0 100644
--- a/conformance/runner/python/runner.py
+++ b/conformance/runner/python/runner.py
@@ -30,6 +30,7 @@
# todolist group — the composite is deliberately variant-agnostic, so nothing
# downstream branches on which shape came back from the GET.
_TODOLIST_WRITE_FIELDS = ("name", "description")
+_DOCUMENT_WRITE_FIELDS = ("title", "content")
_SCHEDULE_ENTRY_WRITE_FIELDS = ("summary", "starts_at", "ends_at", "description", "participant_ids", "all_day", "notify")
_CARD_WRITE_FIELDS = ("title", "content", "due_on", "assignee_ids")
@@ -266,6 +267,29 @@ def __call__(self, operation: str, *, path_params: dict, query_params: dict, bod
id=path_params["id"],
**{k: body[k] for k in _TODOLIST_WRITE_FIELDS if k in body},
)
+ case "UpdateDocument":
+ # Synthetic scenario key (not a wire op): the merge-safe
+ # composite, GET then PUT of the full {title, content}.
+ return self._account.documents.update(
+ document_id=path_params["documentId"],
+ **{k: body[k] for k in _DOCUMENT_WRITE_FIELDS if k in body},
+ )
+ case "EditDocument":
+ # Synthetic scenario key (not a wire op): drive the edit
+ # context manager, assigning each fixture requestBody key
+ # onto the same-named attribute.
+ with self._account.documents.edit(document_id=path_params["documentId"]) as doc:
+ for key in _DOCUMENT_WRITE_FIELDS:
+ if key in body:
+ setattr(doc, key, body[key])
+ return doc.result
+ case "ReplaceDocument":
+ # The raw single PUT, no read-before-write: an omitted field is
+ # omitted on the wire and the server clears it.
+ return self._account.documents.replace(
+ document_id=path_params["documentId"],
+ **{k: body[k] for k in _DOCUMENT_WRITE_FIELDS if k in body},
+ )
case "GetTimesheetEntry":
return self._account.timesheets.get(entry_id=path_params["entryId"])
case "GetProjectTimeline":
diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb
index 87ed76d875..dd6d566a84 100644
--- a/conformance/runner/ruby/runner.rb
+++ b/conformance/runner/ruby/runner.rb
@@ -289,6 +289,16 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "", max_
when "ReplaceTodolist"
# Raw single PUT, no read-before-write: omitted fields stay omitted.
@account.todolists.replace(id: path_params["id"], **todolist_write_kwargs(body))
+ when "UpdateDocument"
+ # Merge-safe composite: GET then PUT of the full {title, content}.
+ @account.documents.update(document_id: path_params["documentId"], **document_write_kwargs(body))
+ when "EditDocument"
+ @account.documents.edit(document_id: path_params["documentId"]) do |doc|
+ (body || {}).each { |key, value| doc.public_send("#{key}=", value) }
+ end
+ when "ReplaceDocument"
+ # Raw single PUT, no read-before-write: omitted fields stay omitted.
+ @account.documents.replace(document_id: path_params["documentId"], **document_write_kwargs(body))
when "GetEverythingMessages"
@account.everything.get_everything_messages.to_a
when "GetEverythingComments"
@@ -366,11 +376,17 @@ def todo_write_kwargs(body)
# signal and compact_params strips it on the raw path, so an absent key must
# stay absent rather than arriving as an explicit nil.
TODOLIST_WRITE_KEYS = %w[name description].freeze
+ DOCUMENT_WRITE_KEYS = %w[title content].freeze
def todolist_write_kwargs(body)
TODOLIST_WRITE_KEYS.select { |key| (body || {}).key?(key) } \
.to_h { |key| [key.to_sym, body[key]] }
end
+
+ def document_write_kwargs(body)
+ DOCUMENT_WRITE_KEYS.select { |key| (body || {}).key?(key) } \
+ .to_h { |key| [key.to_sym, body[key]] }
+ end
end
# Test result
diff --git a/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift b/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift
index 04674b6fae..aee58aa1c0 100644
--- a/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift
+++ b/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift
@@ -183,6 +183,42 @@ func dispatchOperation(_ tc: TestCase, _ account: AccountClient) async throws ->
name: rb.stringParam("name")))
return DispatchResult()
+ // Synthetic scenario key (not a wire operation): the merge-safe composite,
+ // GET then a full PUT of {title, content}.
+ case "UpdateDocument":
+ _ = try await account.documents.update(
+ documentId: pathParams.longParam("documentId"),
+ req: UpdateDocumentRequest(
+ content: rb.optString("content"),
+ title: rb.optString("title")))
+ return DispatchResult()
+
+ // Synthetic scenario key (not a wire operation): exercises the
+ // read-modify-write edit closure by assigning each fixture key onto the
+ // corresponding DocumentFields member.
+ case "EditDocument":
+ // Read every fixture key before the call: the edit closure is
+ // non-throwing, and validating up front means a malformed parameter
+ // fails the test instead of reaching the wire half-applied.
+ let editDocumentTitle = try rb.optString("title")
+ let editDocumentContent = try rb.optString("content")
+ _ = try await account.documents.edit(documentId: pathParams.longParam("documentId")) {
+ fields in
+ if let editDocumentTitle { fields.title = editDocumentTitle }
+ if let editDocumentContent { fields.content = editDocumentContent }
+ }
+ return DispatchResult()
+
+ // Raw single PUT, no read-before-write. Neither field is required by the
+ // schema, so an omitted one stays omitted and the server clears it.
+ case "ReplaceDocument":
+ _ = try await account.documents.replace(
+ documentId: pathParams.longParam("documentId"),
+ req: ReplaceDocumentRequest(
+ content: rb.optString("content"),
+ title: rb.optString("title")))
+ return DispatchResult()
+
// Participants are presence-bearing: an absent key must not become an
// empty list on the wire, or BC3 clears the participants.
case "UpdateScheduleEntry":
diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts
index 4025271f49..4ab1e7e2fc 100644
--- a/conformance/runner/typescript/runner.test.ts
+++ b/conformance/runner/typescript/runner.test.ts
@@ -412,6 +412,33 @@ async function executeOperation(
);
break;
+ case "UpdateDocument":
+ // Synthetic scenario key: the merge-safe composite, not a wire
+ // operation. GET then full PUT; only fixture-present keys are passed.
+ await client.documents.update(Number(params.documentId), {
+ ...(body.title !== undefined ? { title: String(body.title) } : {}),
+ ...(body.content !== undefined ? { content: String(body.content) } : {}),
+ });
+ break;
+
+ case "EditDocument":
+ // Synthetic scenario key: read-modify-write via the edit callback,
+ // assigning each fixture-present key onto the DocumentFields member.
+ await client.documents.edit(Number(params.documentId), (d) => {
+ if (body.title !== undefined) d.title = String(body.title);
+ if (body.content !== undefined) d.content = String(body.content);
+ });
+ break;
+
+ case "ReplaceDocument":
+ // Verbatim sparse PUT — no GET. Neither field is required server-side,
+ // so an omitted one stays omitted and the server clears it.
+ await client.documents.replace(Number(params.documentId), {
+ ...(body.title !== undefined ? { title: String(body.title) } : {}),
+ ...(body.content !== undefined ? { content: String(body.content) } : {}),
+ });
+ break;
+
case "GetTimesheetEntry":
await client.timesheets.get(Number(params.entryId));
break;
diff --git a/conformance/tests/documents_write.json b/conformance/tests/documents_write.json
new file mode 100644
index 0000000000..288f3ebc65
--- /dev/null
+++ b/conformance/tests/documents_write.json
@@ -0,0 +1,385 @@
+[
+ {
+ "name": "update-merge: title-only update preserves the content",
+ "description": "The merge-safe update GETs the current document, overlays only the explicitly-set fields, and PUTs the full representation back to the canonical flat route. A title-only update must carry the existing content over: BC3 rebuilds the recordable from the permitted params alone, so a sparse PUT that omits content clears it.",
+ "operation": "UpdateDocument",
+ "method": "PUT",
+ "path": "/documents/{documentId}",
+ "pathParams": {
+ "documentId": 456
+ },
+ "requestBody": {
+ "title": "Q3 Plan"
+ },
+ "mockResponses": [
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 456,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-15T10:00:00Z",
+ "updated_at": "2024-01-15T10:00:00Z",
+ "title": "Project Overview",
+ "inherits_status": true,
+ "type": "Document",
+ "url": "https://3.basecampapi.com/999/buckets/1/documents/456.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/documents/456",
+ "bookmark_url": "https://3.basecampapi.com/999/my/bookmarks/abc123.json",
+ "subscription_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/subscription.json",
+ "comments_count": 0,
+ "comments_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/comments.json",
+ "position": 1,
+ "parent": {
+ "id": 2,
+ "title": "Docs & Files",
+ "type": "Vault",
+ "url": "https://3.basecampapi.com/999/buckets/1/vaults/2.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/vaults/2"
+ },
+ "bucket": {
+ "id": 1,
+ "name": "Project",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1,
+ "name": "Test User",
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ },
+ "content": "
Milestones and owners.
",
+ "content_attachments": []
+ }
+ },
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 456,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-15T10:00:00Z",
+ "updated_at": "2024-01-15T10:05:00Z",
+ "title": "Q3 Plan",
+ "inherits_status": true,
+ "type": "Document",
+ "url": "https://3.basecampapi.com/999/buckets/1/documents/456.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/documents/456",
+ "bookmark_url": "https://3.basecampapi.com/999/my/bookmarks/abc123.json",
+ "subscription_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/subscription.json",
+ "comments_count": 0,
+ "comments_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/comments.json",
+ "position": 1,
+ "parent": {
+ "id": 2,
+ "title": "Docs & Files",
+ "type": "Vault",
+ "url": "https://3.basecampapi.com/999/buckets/1/vaults/2.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/vaults/2"
+ },
+ "bucket": {
+ "id": 1,
+ "name": "Project",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1,
+ "name": "Test User",
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ },
+ "content": "Milestones and owners.
",
+ "content_attachments": []
+ }
+ }
+ ],
+ "assertions": [
+ {
+ "type": "requestCount",
+ "expected": 2
+ },
+ {
+ "type": "requestMethod",
+ "expected": "GET",
+ "index": 0
+ },
+ {
+ "type": "requestMethod",
+ "expected": "PUT",
+ "index": 1
+ },
+ {
+ "type": "requestPath",
+ "expected": "/999/documents/456",
+ "index": 0
+ },
+ {
+ "type": "requestPath",
+ "expected": "/999/documents/456",
+ "index": 1
+ },
+ {
+ "type": "requestBody",
+ "path": "title",
+ "expected": "Q3 Plan",
+ "index": 1
+ },
+ {
+ "type": "requestBody",
+ "path": "content",
+ "expected": "Milestones and owners.
",
+ "index": 1
+ },
+ {
+ "type": "noError"
+ }
+ ],
+ "tags": [
+ "documents",
+ "write",
+ "merge"
+ ]
+ },
+ {
+ "name": "edit-clear: setting content empty clears it while the title is preserved",
+ "description": "The edit closure receives the document's full writable state; the runner assigns each requestBody key onto the corresponding field. Clearing content (set empty) must arrive present-and-empty in the PUT body — never as JSON null (SPEC 18 body compaction) — while the untouched title is carried over from the GET.",
+ "operation": "EditDocument",
+ "method": "PUT",
+ "path": "/documents/{documentId}",
+ "pathParams": {
+ "documentId": 456
+ },
+ "requestBody": {
+ "content": ""
+ },
+ "mockResponses": [
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 456,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-15T10:00:00Z",
+ "updated_at": "2024-01-15T10:00:00Z",
+ "title": "Project Overview",
+ "inherits_status": true,
+ "type": "Document",
+ "url": "https://3.basecampapi.com/999/buckets/1/documents/456.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/documents/456",
+ "bookmark_url": "https://3.basecampapi.com/999/my/bookmarks/abc123.json",
+ "subscription_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/subscription.json",
+ "comments_count": 0,
+ "comments_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/comments.json",
+ "position": 1,
+ "parent": {
+ "id": 2,
+ "title": "Docs & Files",
+ "type": "Vault",
+ "url": "https://3.basecampapi.com/999/buckets/1/vaults/2.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/vaults/2"
+ },
+ "bucket": {
+ "id": 1,
+ "name": "Project",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1,
+ "name": "Test User",
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ },
+ "content": "Milestones and owners.
",
+ "content_attachments": []
+ }
+ },
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 456,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-15T10:00:00Z",
+ "updated_at": "2024-01-15T10:05:00Z",
+ "title": "Project Overview",
+ "inherits_status": true,
+ "type": "Document",
+ "url": "https://3.basecampapi.com/999/buckets/1/documents/456.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/documents/456",
+ "bookmark_url": "https://3.basecampapi.com/999/my/bookmarks/abc123.json",
+ "subscription_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/subscription.json",
+ "comments_count": 0,
+ "comments_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/comments.json",
+ "position": 1,
+ "parent": {
+ "id": 2,
+ "title": "Docs & Files",
+ "type": "Vault",
+ "url": "https://3.basecampapi.com/999/buckets/1/vaults/2.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/vaults/2"
+ },
+ "bucket": {
+ "id": 1,
+ "name": "Project",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1,
+ "name": "Test User",
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ },
+ "content": "",
+ "content_attachments": []
+ }
+ }
+ ],
+ "assertions": [
+ {
+ "type": "requestCount",
+ "expected": 2
+ },
+ {
+ "type": "requestMethod",
+ "expected": "GET",
+ "index": 0
+ },
+ {
+ "type": "requestMethod",
+ "expected": "PUT",
+ "index": 1
+ },
+ {
+ "type": "requestPath",
+ "expected": "/999/documents/456",
+ "index": 1
+ },
+ {
+ "type": "requestBody",
+ "path": "content",
+ "expected": "",
+ "index": 1
+ },
+ {
+ "type": "requestBody",
+ "path": "title",
+ "expected": "Project Overview",
+ "index": 1
+ },
+ {
+ "type": "noError"
+ }
+ ],
+ "tags": [
+ "documents",
+ "write",
+ "edit",
+ "clear"
+ ]
+ },
+ {
+ "name": "replace-omission-clears: sparse replace sends the request verbatim with no GET",
+ "description": "Replace is the server-native verbatim PUT: exactly one request, no read-before-write, and any field omitted from the request is omitted from the body. BC3 clears what it does not receive, so this single request silently empties the content — that is the whole reason the raw path is named replace and kept separately covered (SPEC 18 rule 6). Neither title nor content is required: BC3 presence-validates neither, so a title-only body is a 200, not a 422.",
+ "operation": "ReplaceDocument",
+ "method": "PUT",
+ "path": "/documents/{documentId}",
+ "pathParams": {
+ "documentId": 456
+ },
+ "requestBody": {
+ "title": "The whole new document"
+ },
+ "mockResponses": [
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 456,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-15T10:00:00Z",
+ "updated_at": "2024-01-15T10:05:00Z",
+ "title": "The whole new document",
+ "inherits_status": true,
+ "type": "Document",
+ "url": "https://3.basecampapi.com/999/buckets/1/documents/456.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/documents/456",
+ "bookmark_url": "https://3.basecampapi.com/999/my/bookmarks/abc123.json",
+ "subscription_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/subscription.json",
+ "comments_count": 0,
+ "comments_url": "https://3.basecampapi.com/999/buckets/1/recordings/456/comments.json",
+ "position": 1,
+ "parent": {
+ "id": 2,
+ "title": "Docs & Files",
+ "type": "Vault",
+ "url": "https://3.basecampapi.com/999/buckets/1/vaults/2.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/vaults/2"
+ },
+ "bucket": {
+ "id": 1,
+ "name": "Project",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1,
+ "name": "Test User",
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ },
+ "content": "",
+ "content_attachments": []
+ }
+ }
+ ],
+ "assertions": [
+ {
+ "type": "requestCount",
+ "expected": 1
+ },
+ {
+ "type": "requestMethod",
+ "expected": "PUT",
+ "index": 0
+ },
+ {
+ "type": "requestPath",
+ "expected": "/999/documents/456",
+ "index": 0
+ },
+ {
+ "type": "requestBody",
+ "path": "title",
+ "expected": "The whole new document",
+ "index": 0
+ },
+ {
+ "type": "requestBodyAbsent",
+ "path": "content",
+ "index": 0
+ },
+ {
+ "type": "noError"
+ }
+ ],
+ "tags": [
+ "documents",
+ "write",
+ "replace"
+ ]
+ }
+]
diff --git a/go/pkg/basecamp/documents.go b/go/pkg/basecamp/documents.go
new file mode 100644
index 0000000000..be94fa88c3
--- /dev/null
+++ b/go/pkg/basecamp/documents.go
@@ -0,0 +1,299 @@
+package basecamp
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// The Documents read surface (Get, List, Create, Trash) and the Document type
+// itself live in vaults.go, alongside the vault they hang off. This file holds
+// the write surface, because that is where the endpoint's semantics bite:
+//
+// PUT /{accountId}/documents/{documentId} is a FULL REPLACE. BC3's
+// DocumentsController#update runs
+//
+// @recording.update! recording_attributes.merge(recordable: new_document)
+//
+// where new_document is Document.new(params.require(:document).permit(:title,
+// :content)) — a brand-new recordable built from only the permitted params and
+// swapped in wholesale. A field absent from the body is nil on the replacement,
+// so a sparse PUT that omits Content ERASES it, and one that omits Title erases
+// that too (the document then reads back as "Untitled", because Document#title
+// falls back when blank). Neither attribute carries a presence validation, so
+// NEITHER OMISSION IS A 422 — both are a 200 that quietly clears. What BC3 does
+// require is the wrapping document object, so a body naming neither field is a
+// 400.
+//
+// Hence the three-method surface: Update overlays onto the current state, Edit
+// hands the caller that state, and Replace stays verbatim and destructive by
+// design.
+
+// UpdateDocumentRequest specifies the fields to set on a document, preserving
+// everything the caller leaves unset.
+//
+// Set-detection is by zero value, as elsewhere in the Go SDK: an empty string
+// reads as "unaddressed", not as "clear". To CLEAR a field, use Edit or
+// Replace, where "" is unambiguous.
+type UpdateDocumentRequest struct {
+ // Title is the document title. Empty leaves the current title untouched.
+ Title string `json:"title,omitempty"`
+ // Content is the document body in HTML. Empty leaves the current body untouched.
+ Content string `json:"content,omitempty"`
+}
+
+// ReplaceDocumentRequest specifies a document's complete new representation.
+//
+// This is the verbatim request: whatever it omits, the server clears. Neither
+// field is required — BC3 presence-validates neither — but a request that
+// carries neither is rejected with a 400, because BC3 requires the wrapping
+// document object.
+//
+// Both fields are presence-bearing pointers rather than plain strings, because
+// on a verbatim replace "absent" and "explicitly empty" are different requests
+// and only one of them is legal on its own. A nil field is omitted from the
+// body; a pointer to "" is SENT as "", which is the only way to say "clear both
+// fields" — the all-nil body is the 400. (Their server EFFECT happens to
+// coincide, since an omitted title and an empty one both read back as
+// "Untitled", but the SDK must not collapse a distinction the wire makes.)
+//
+// empty := ""
+// svc.Replace(ctx, id, &ReplaceDocumentRequest{Title: &empty, Content: &empty})
+type ReplaceDocumentRequest struct {
+ // Title is the document title. Nil omits it — the server clears it and the
+ // document reads back as "Untitled". A pointer to "" sends it explicitly.
+ Title *string `json:"title,omitempty"`
+ // Content is the document body in HTML. Nil omits it — the server clears
+ // it. A pointer to "" sends it explicitly.
+ Content *string `json:"content,omitempty"`
+}
+
+// DocumentFields is a document's full writable state, handed to the Edit
+// callback. The whole value is PUT back to the server, so clearing a field
+// means setting it empty ("") — there is no third state. BC3's writable set on
+// this endpoint is exactly {title, content}.
+type DocumentFields struct {
+ // Title is the document title. Set "" to clear; it then reads back as "Untitled".
+ Title string
+ // Content is the document body in HTML. Set "" to clear.
+ Content string
+}
+
+// fieldsFromDocument derives a document's full writable state from a GET.
+//
+// Go decodes the response into the typed Document before this runs, so
+// encoding/json has already rejected a wrong-TYPED field — the type guards the
+// Python, Ruby and TypeScript composites carry (#576) have no Go analogue to
+// write.
+//
+// A MISSING field is the hole encoding/json leaves open, and it matters here
+// because Title is required. An absent "title" decodes to the string zero
+// value, and the full-replace PUT would then send title:"" — blanking the real
+// title on a call that only touched Content, which is #576's defect in the one
+// shape a typed decoder does not catch. BC3 can never render title blank
+// (Document#title is super.presence || "Untitled") and the spec marks it
+// @required, so an empty title on a 2xx read is a malformed response rather
+// than an empty title. Content is optional in the spec, so empty is genuinely
+// empty and is left alone.
+func fieldsFromDocument(d *Document) (*DocumentFields, error) {
+ if strings.TrimSpace(d.Title) == "" {
+ return nil, &Error{
+ Code: CodeAPI,
+ Message: `GetDocument returned a document with no "title", but the field is required`,
+ Hint: "The merge-safe Update/Edit resend this field verbatim, so a missing value " +
+ "would blank the current one. Use Replace to write the record deliberately.",
+ Retryable: false,
+ }
+ }
+ return &DocumentFields{Title: d.Title, Content: d.Content}, nil
+}
+
+// fullBody serializes the full writable state for the replace transport.
+//
+// Both fields are ALWAYS sent, empties included, so a clear survives: on a
+// full-replace endpoint "" is how a clear is expressed — never JSON null (SPEC
+// §18 body compaction), and never by omission, which would hand the clear back
+// to the server's own rebuild and read as an accident rather than an intent.
+func (f *DocumentFields) fullBody() (map[string]any, error) {
+ return map[string]any{
+ "title": f.Title,
+ "content": f.Content,
+ }, nil
+}
+
+// documentDecodeError renders a response-decoder failure in the SPEC §6 shape.
+//
+// Go's json.Unmarshal is the typed guard the dynamic SDKs write by hand, and it
+// rejects a wrong-typed field before a composite ever sees it — but it reports
+// that as a raw decoder error, which callers switching on *Error would miss and
+// which carries no hint. (The Swift composite does the same with DecodingError.)
+//
+// There is no classification here, deliberately. Deciding whether an error came
+// from the decoder by INSPECTING it does not work in either direction: decoder
+// errors are not enumerable (created_at/updated_at are time.Time, whose
+// UnmarshalJSON returns *time.ParseError rather than an encoding/json sentinel,
+// and content_attachments carries *types.FlexInt dimensions rejected with a
+// plain fmt.Errorf that is no named type at all), and neither are the errors
+// that precede a response — a gating hook, a token provider or a custom
+// AuthStrategy may each return any sentinel they like. So DocumentsService.Get
+// splits the request from the decode and calls this on the decode step only,
+// where the origin is known by construction rather than guessed.
+func documentDecodeError(err error) error {
+ return &Error{
+ Code: CodeAPI,
+ Message: truncate(fmt.Sprintf("GetDocument returned a body that does not decode as a document: %v", err)),
+ Hint: "The merge-safe Update/Edit resend this record's fields verbatim, so a malformed " +
+ "response cannot be written back safely. Use Replace to write the record deliberately.",
+ Retryable: false,
+ }
+}
+
+// Update sets the given fields on a document and preserves everything else:
+// GETs the current document, overlays the explicitly-set request fields, and
+// PUTs the full representation back.
+//
+// An unset (empty) field is untouched, guaranteed. Strings cannot be CLEARED
+// through Update — "" is Go's unset marker here — so use Edit or Replace to
+// clear one.
+//
+// Composes the public Get and Replace paths, so hooks observe both wire
+// operations (Documents.Get then Documents.Replace) rather than a synthetic
+// composite.
+//
+// Not atomic: there is no conditional-update signal on this endpoint, so a
+// concurrent write between the GET and PUT is overwritten — last write wins for
+// the whole representation, with a window of one round-trip. Use Replace to
+// overwrite deliberately.
+func (s *DocumentsService) Update(ctx context.Context, documentID int64, req *UpdateDocumentRequest) (*Document, error) {
+ if req == nil {
+ return nil, ErrUsage("update request is required")
+ }
+
+ current, err := s.Get(ctx, documentID)
+ if err != nil {
+ return nil, err
+ }
+
+ fields, err := fieldsFromDocument(current)
+ if err != nil {
+ return nil, err
+ }
+ if req.Title != "" {
+ fields.Title = req.Title
+ }
+ if req.Content != "" {
+ fields.Content = req.Content
+ }
+
+ return s.replaceDocument(ctx, documentID, fields.fullBody)
+}
+
+// Edit applies a read-modify-write callback to a document: GETs the current
+// document, hands the callback its full writable state, and PUTs the whole
+// thing back. Clearing a field means setting it empty ("") — an untouched field
+// keeps its current value. If the callback returns an error, the edit aborts
+// and nothing is written.
+//
+// Not atomic — see Update for the GET→PUT race.
+func (s *DocumentsService) Edit(ctx context.Context, documentID int64, fn func(*DocumentFields) error) (*Document, error) {
+ if fn == nil {
+ return nil, ErrUsage("edit callback is required")
+ }
+
+ current, err := s.Get(ctx, documentID)
+ if err != nil {
+ return nil, err
+ }
+
+ fields, err := fieldsFromDocument(current)
+ if err != nil {
+ return nil, err
+ }
+ if err := fn(fields); err != nil {
+ return nil, err
+ }
+
+ return s.replaceDocument(ctx, documentID, fields.fullBody)
+}
+
+// Replace overwrites a document with the given representation verbatim: one
+// PUT, no read-before-write. Every writable field the request omits is omitted
+// from the body, and the server clears it.
+//
+// Sharp by construction. Use Update or Edit to preserve the fields the call
+// does not name.
+func (s *DocumentsService) Replace(ctx context.Context, documentID int64, req *ReplaceDocumentRequest) (*Document, error) {
+ return s.replaceDocument(ctx, documentID, func() (map[string]any, error) {
+ if req == nil {
+ return nil, ErrUsage("replace request is required")
+ }
+ body := map[string]any{}
+ if req.Title != nil {
+ body["title"] = *req.Title
+ }
+ if req.Content != nil {
+ body["content"] = *req.Content
+ }
+ if len(body) == 0 {
+ // BC3 does params.require(:document), which Rails wrap_parameters
+ // synthesizes from a flat body — so a body naming neither field
+ // carries no document object at all and is a 400. Refuse it here
+ // rather than spend a round-trip discovering that.
+ return nil, ErrUsage("replace request must set title or content; a body naming neither is rejected by the server with a 400")
+ }
+ return body, nil
+ })
+}
+
+// replaceDocument is the single transport behind Update, Edit and Replace. It
+// owns the hook envelope and the one *WithBody call site.
+//
+// The body is a hand-marshaled map rather than the generated request struct:
+// generated.ReplaceDocumentJSONRequestBody uses omitempty, which cannot express
+// the always-send-empty semantics a full-replace composite needs (an empty
+// title is a clear, and it has to reach the wire). SPEC §18 rule 1 sanctions
+// exactly this carve-out — the generated wrapper still owns path, verb, content
+// type and response decoding, and the operation identity still reaches hooks
+// and retry.
+func (s *DocumentsService) replaceDocument(ctx context.Context, documentID int64, buildBody func() (map[string]any, error)) (result *Document, err error) {
+ op := OperationInfo{
+ Service: "Documents", Operation: "Replace",
+ ResourceType: "document", IsMutation: true,
+ ResourceID: documentID,
+ }
+ if gater, ok := s.client.parent.hooks.(GatingHooks); ok {
+ if ctx, err = gater.OnOperationGate(ctx, op); err != nil {
+ return
+ }
+ }
+ start := time.Now()
+ ctx = s.client.parent.hooks.OnOperationStart(ctx, op)
+ defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }()
+
+ // Built inside the envelope so a usage error is observable to hooks.
+ body, err := buildBody()
+ if err != nil {
+ return nil, err
+ }
+ bodyReader, err := marshalBody(body)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := s.client.parent.gen.ReplaceDocumentWithBodyWithResponse(
+ ctx, s.client.accountID, documentID, "application/json", bodyReader)
+ if err != nil {
+ return nil, err
+ }
+ if err = checkResponse(resp.HTTPResponse, resp.Body); err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ err = fmt.Errorf("unexpected empty response")
+ return nil, err
+ }
+
+ document := documentFromGenerated(*resp.JSON200)
+ return &document, nil
+}
diff --git a/go/pkg/basecamp/documents_test.go b/go/pkg/basecamp/documents_test.go
new file mode 100644
index 0000000000..8b7f9ca28f
--- /dev/null
+++ b/go/pkg/basecamp/documents_test.go
@@ -0,0 +1,958 @@
+package basecamp
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// The documents write surface: the merge-safe Update, the read-modify-write
+// Edit, and the verbatim Replace. The read surface (Get/List/Create/Trash) and
+// the Document type stay tested in vaults_test.go, alongside the vault.
+//
+// PUT /documents/{id} is a FULL REPLACE — BC3 rebuilds the recordable from only
+// the permitted params — so what these tests pin is which bytes reach the wire.
+// A field the caller never mentioned is written on every one of these calls,
+// and the writable set is exactly {title, content}: both optional, neither
+// presence-validated, so an omitted title silently becomes "Untitled" and an
+// omitted content is silently erased. Both are 200s, so nothing but the request
+// body itself distinguishes a preserve from a clear.
+
+// patchDocumentFixture returns the fixture JSON with the given fields replaced.
+func patchDocumentFixture(t *testing.T, base []byte, patch map[string]any) []byte {
+ t.Helper()
+ var m map[string]any
+ if err := json.Unmarshal(base, &m); err != nil {
+ t.Fatalf("failed to unmarshal fixture: %v", err)
+ }
+ for k, v := range patch {
+ m[k] = v
+ }
+ b, err := json.Marshal(m)
+ if err != nil {
+ t.Fatalf("failed to marshal patched fixture: %v", err)
+ }
+ return b
+}
+
+// capturedDocumentRequest records one request seen by testDocumentsCaptureServer.
+type capturedDocumentRequest struct {
+ method string
+ path string
+ body map[string]any
+}
+
+// testDocumentsCaptureServer serves getBody for GETs and putBody for PUTs while
+// recording every request's method, path, and (for PUTs) decoded body.
+// The extra hooks, when non-nil, are installed on the client.
+func testDocumentsCaptureServer(t *testing.T, getBody, putBody []byte, hooks Hooks) (*DocumentsService, *[]capturedDocumentRequest) {
+ t.Helper()
+ reqs := &[]capturedDocumentRequest{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cr := capturedDocumentRequest{method: r.Method, path: r.URL.Path}
+ if r.Method == "PUT" {
+ cr.body = decodeRequestBody(t, r)
+ }
+ *reqs = append(*reqs, cr)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+ if r.Method == "GET" {
+ w.Write(getBody)
+ } else {
+ w.Write(putBody)
+ }
+ }))
+ t.Cleanup(server.Close)
+
+ cfg := DefaultConfig()
+ cfg.BaseURL = server.URL
+ token := &StaticTokenProvider{Token: "test-token"}
+ var opts []ClientOption
+ if hooks != nil {
+ opts = append(opts, WithHooks(hooks))
+ }
+ client := NewClient(cfg, token, opts...)
+ return client.ForAccount("99999").Documents(), reqs
+}
+
+// The fixture's content, which every merge-safe call must carry back out
+// untouched unless the caller says otherwise.
+const fixtureDocumentContent = "This document contains the project overview and key milestones.
"
+
+// TestDocumentWriteRequests_WritableSetMatchesFixture pins the writable set of
+// the document write surface — exactly {title, content}, per BC3's
+// `params.require(:document).permit(:title, :content)` — against the wire
+// fixture, for both the composite input and the verbatim request.
+//
+// Moved here from vaults_test.go (TestUpdateDocumentRequest_Marshal) when the
+// write surface moved to documents.go and UpdateDocument became ReplaceDocument.
+func TestDocumentWriteRequests_WritableSetMatchesFixture(t *testing.T) {
+ data := loadDocumentsFixture(t, "update-request.json")
+
+ // The composite input: the fields a caller may set on a merge-safe update.
+ var update UpdateDocumentRequest
+ if err := json.Unmarshal(data, &update); err != nil {
+ t.Fatalf("failed to unmarshal update-request.json into UpdateDocumentRequest: %v", err)
+ }
+ if update.Title != "Updated Document Title" {
+ t.Errorf("expected title 'Updated Document Title', got %q", update.Title)
+ }
+ if update.Content == "" {
+ t.Error("expected non-empty Content")
+ }
+
+ // The verbatim request carries the same set, so the two are interchangeable
+ // at the call site and a caller can move between them without rewriting.
+ var replace ReplaceDocumentRequest
+ if err := json.Unmarshal(data, &replace); err != nil {
+ t.Fatalf("failed to unmarshal update-request.json into ReplaceDocumentRequest: %v", err)
+ }
+ // Replace's fields are presence-bearing pointers — on a verbatim replace,
+ // absent and explicitly-empty are different requests — so the mirror check
+ // dereferences rather than comparing the two shapes directly.
+ if replace.Title == nil || *replace.Title != update.Title {
+ t.Errorf("replace title %v does not mirror update title %q", replace.Title, update.Title)
+ }
+ if replace.Content == nil || *replace.Content != update.Content {
+ t.Errorf("replace content %v does not mirror update content %q", replace.Content, update.Content)
+ }
+
+ // The fixture itself must not grow a third writable field without this
+ // surface growing one too.
+ var raw map[string]any
+ if err := json.Unmarshal(data, &raw); err != nil {
+ t.Fatalf("failed to unmarshal update-request.json: %v", err)
+ }
+ if len(raw) != 2 {
+ t.Errorf("expected the writable set to be exactly {title, content}, got %v", raw)
+ }
+ for _, key := range []string{"title", "content"} {
+ if _, ok := raw[key]; !ok {
+ t.Errorf("expected %q in the writable set, got %v", key, raw)
+ }
+ }
+}
+
+func TestDocumentsService_UpdateMergesUnsetFields(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ // Title-only update: content must be carried over from the GET. Omitting it
+ // from the PUT would be a silent erase, not a preserve.
+ document, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Title: "new title",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if document.ID != 1069479300 {
+ t.Errorf("expected ID 1069479300, got %d", document.ID)
+ }
+
+ if len(*reqs) != 2 {
+ t.Fatalf("expected 2 requests (GET then PUT), got %d", len(*reqs))
+ }
+ if (*reqs)[0].method != "GET" || (*reqs)[1].method != "PUT" {
+ t.Fatalf("expected GET then PUT, got %s then %s", (*reqs)[0].method, (*reqs)[1].method)
+ }
+
+ body := (*reqs)[1].body
+ if body["title"] != "new title" {
+ t.Errorf("expected title 'new title', got %v", body["title"])
+ }
+ if body["content"] != fixtureDocumentContent {
+ t.Errorf("expected preserved content, got %v", body["content"])
+ }
+ // The writable set is exactly {title, content}; nothing else rides along.
+ if len(body) != 2 {
+ t.Errorf("expected exactly {title, content} in the body, got %v", body)
+ }
+}
+
+func TestDocumentsService_UpdateMergesContentOnly(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ // The mirror case: a content-only update must preserve the title, which the
+ // server would otherwise reset to "Untitled".
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Content: "new body
",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := (*reqs)[len(*reqs)-1].body
+ if body["content"] != "new body
" {
+ t.Errorf("expected content 'new body
', got %v", body["content"])
+ }
+ if body["title"] != "Project Overview" {
+ t.Errorf("expected preserved title 'Project Overview', got %v", body["title"])
+ }
+}
+
+// TestDocumentsService_UpdateCannotClearWithEmptyString pins the Go zero-value
+// guard: set-detection here is by zero value, as everywhere else in this SDK,
+// so "" reads as "unaddressed", never as "clear". The fetched value has to go
+// back out — a caller who wants the clear reaches for Edit or Replace.
+func TestDocumentsService_UpdateCannotClearWithEmptyString(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Title: "new title",
+ Content: "",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := (*reqs)[len(*reqs)-1].body
+ content, ok := body["content"]
+ if !ok {
+ t.Fatal("expected content present in the PUT body, but it was omitted")
+ }
+ if content != fixtureDocumentContent {
+ t.Errorf("expected the fetched content to be resent (\"\" is unset, not a clear), got %v", content)
+ }
+
+ // And the same in the other direction: an empty Title leaves the current
+ // title alone rather than letting the server reset it to "Untitled".
+ svc2, reqs2 := testDocumentsCaptureServer(t, fixture, fixture, nil)
+ if _, err := svc2.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Content: "new body
",
+ Title: "",
+ }); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if title := (*reqs2)[len(*reqs2)-1].body["title"]; title != "Project Overview" {
+ t.Errorf("expected the fetched title to be resent, got %v", title)
+ }
+}
+
+func TestDocumentsService_UpdateNilRequestIsUsageError(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ _, err := svc.Update(context.Background(), 1069479300, nil)
+ if err == nil {
+ t.Fatal("expected usage error for a nil update request")
+ }
+ usageErr, ok := errors.AsType[*Error](err)
+ if !ok || usageErr.Code != CodeUsage {
+ t.Fatalf("expected CodeUsage, got %T %v", err, err)
+ }
+ // Refused before the read-before-write, so not even the GET is spent.
+ if len(*reqs) != 0 {
+ t.Fatalf("expected no requests, got %+v", *reqs)
+ }
+}
+
+func TestDocumentsService_UpdateHooksObserveGetAndReplace(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ recorder := &recordingHooks{}
+ svc, _ := testDocumentsCaptureServer(t, fixture, fixture, recorder)
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{Title: "x"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Update composes the public Get and Replace paths, so hooks see the two
+ // wire operations rather than one synthetic composite.
+ ops := make([]string, 0, len(recorder.opStartCalls))
+ for _, op := range recorder.opStartCalls {
+ ops = append(ops, op.Service+"."+op.Operation)
+ }
+ if len(ops) != 2 || ops[0] != "Documents.Get" || ops[1] != "Documents.Replace" {
+ t.Errorf("expected operations [Documents.Get Documents.Replace], got %v", ops)
+ }
+ if len(recorder.opEndCalls) != 2 {
+ t.Errorf("expected 2 OnOperationEnd calls, got %d", len(recorder.opEndCalls))
+ }
+}
+
+func TestDocumentsService_Edit(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ document, err := svc.Edit(context.Background(), 1069479300, func(f *DocumentFields) error {
+ if f.Title != "Project Overview" {
+ t.Errorf("expected Title from the GET, got %q", f.Title)
+ }
+ if f.Content != fixtureDocumentContent {
+ t.Errorf("expected Content from the GET, got %q", f.Content)
+ }
+ f.Title = "🚨 " + f.Title
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if document.ID != 1069479300 {
+ t.Errorf("expected ID 1069479300, got %d", document.ID)
+ }
+
+ if len(*reqs) != 2 || (*reqs)[0].method != "GET" || (*reqs)[1].method != "PUT" {
+ t.Fatalf("expected GET then PUT, got %+v", *reqs)
+ }
+ // The full state goes back, not just the field the callback touched.
+ body := (*reqs)[1].body
+ if body["title"] != "🚨 Project Overview" {
+ t.Errorf("expected prefixed title, got %v", body["title"])
+ }
+ if body["content"] != fixtureDocumentContent {
+ t.Errorf("expected preserved content, got %v", body["content"])
+ }
+}
+
+// TestDocumentsService_EditClearsContentPresentAndEmpty is the clear that
+// Update cannot express. On a full-replace endpoint "" is how a clear is
+// stated, and it has to REACH THE WIRE as a present key: omitting it would
+// hand the clear back to the server's own rebuild — the same 200, but as an
+// accident rather than an intent — and JSON null is out (SPEC §18).
+func TestDocumentsService_EditClearsContentPresentAndEmpty(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ _, err := svc.Edit(context.Background(), 1069479300, func(f *DocumentFields) error {
+ f.Content = ""
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := (*reqs)[len(*reqs)-1].body
+ content, ok := body["content"]
+ if !ok {
+ t.Fatal("expected content present in the PUT body, but it was omitted")
+ }
+ if content != "" {
+ t.Errorf("expected content \"\", got %v", content)
+ }
+ if content == nil {
+ t.Error("expected content \"\", got JSON null")
+ }
+ // The untouched field still rides along in full.
+ if body["title"] != "Project Overview" {
+ t.Errorf("expected preserved title, got %v", body["title"])
+ }
+}
+
+// TestDocumentsService_EditClearsTitlePresentAndEmpty is the same rule for the
+// other field. BC3 presence-validates neither, so this is a 200 and the
+// document reads back as "Untitled" — the clear is only visible in the body.
+func TestDocumentsService_EditClearsTitlePresentAndEmpty(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ _, err := svc.Edit(context.Background(), 1069479300, func(f *DocumentFields) error {
+ f.Title = ""
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := (*reqs)[len(*reqs)-1].body
+ title, ok := body["title"]
+ if !ok {
+ t.Fatal("expected title present in the PUT body, but it was omitted")
+ }
+ if title != "" {
+ t.Errorf("expected title \"\", got %v", title)
+ }
+ if body["content"] != fixtureDocumentContent {
+ t.Errorf("expected preserved content, got %v", body["content"])
+ }
+}
+
+// TestDocumentsService_EditCarriesAMissingFieldAsEmpty covers the GET that
+// omits a writable field: Go's typed decode leaves it the zero value, and the
+// full-replace body still states it rather than dropping the key.
+func TestDocumentsService_EditCarriesAMissingFieldAsEmpty(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ getBody := patchDocumentFixture(t, fixture, map[string]any{"content": nil})
+ svc, reqs := testDocumentsCaptureServer(t, getBody, fixture, nil)
+
+ _, err := svc.Edit(context.Background(), 1069479300, func(f *DocumentFields) error {
+ if f.Content != "" {
+ t.Errorf("expected empty Content for a null field, got %q", f.Content)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := (*reqs)[len(*reqs)-1].body
+ content, ok := body["content"]
+ if !ok || content != "" {
+ t.Errorf("expected content \"\" present in the body, got %v (present=%v)", content, ok)
+ }
+}
+
+func TestDocumentsService_EditCallbackErrorAbortsWithoutPUT(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ wantErr := errors.New("nope")
+ _, err := svc.Edit(context.Background(), 1069479300, func(f *DocumentFields) error {
+ f.Title = "should never be written"
+ f.Content = ""
+ return wantErr
+ })
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("expected callback error, got %v", err)
+ }
+
+ for _, r := range *reqs {
+ if r.method == "PUT" {
+ t.Fatalf("expected no PUT after a callback error, got %+v", r)
+ }
+ }
+}
+
+func TestDocumentsService_EditNilCallbackIsUsageError(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ _, err := svc.Edit(context.Background(), 1069479300, nil)
+ if err == nil {
+ t.Fatal("expected usage error for a nil edit callback")
+ }
+ usageErr, ok := errors.AsType[*Error](err)
+ if !ok || usageErr.Code != CodeUsage {
+ t.Fatalf("expected CodeUsage, got %T %v", err, err)
+ }
+ if len(*reqs) != 0 {
+ t.Fatalf("expected no requests, got %+v", *reqs)
+ }
+}
+
+func TestDocumentsService_EditHooksObserveGetAndReplace(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ recorder := &recordingHooks{}
+ svc, _ := testDocumentsCaptureServer(t, fixture, fixture, recorder)
+
+ _, err := svc.Edit(context.Background(), 1069479300, func(f *DocumentFields) error { return nil })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ ops := make([]string, 0, len(recorder.opStartCalls))
+ for _, op := range recorder.opStartCalls {
+ ops = append(ops, op.Service+"."+op.Operation)
+ }
+ if len(ops) != 2 || ops[0] != "Documents.Get" || ops[1] != "Documents.Replace" {
+ t.Errorf("expected operations [Documents.Get Documents.Replace], got %v", ops)
+ }
+}
+
+func TestDocumentsService_ReplaceSendsSparseVerbatim(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ recorder := &recordingHooks{}
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, recorder)
+
+ replaceTitle := "the whole new document"
+ document, err := svc.Replace(context.Background(), 1069479300, &ReplaceDocumentRequest{
+ Title: &replaceTitle,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if document.ID != 1069479300 {
+ t.Errorf("expected ID 1069479300, got %d", document.ID)
+ }
+
+ // No GET: replace is the server-native verbatim PUT.
+ if len(*reqs) != 1 || (*reqs)[0].method != "PUT" {
+ t.Fatalf("expected exactly one PUT, got %+v", *reqs)
+ }
+ body := (*reqs)[0].body
+ if body["title"] != "the whole new document" {
+ t.Errorf("expected title in body, got %v", body["title"])
+ }
+ // The unnamed field is omitted, and the server clears it. That is the sharp
+ // edge Update and Edit exist to blunt.
+ if _, ok := body["content"]; ok {
+ t.Errorf("expected content omitted from a sparse replace, got %v", body["content"])
+ }
+ if len(body) != 1 {
+ t.Errorf("expected exactly {title} in the body, got %v", body)
+ }
+
+ // Hooks observe a single Documents.Replace operation.
+ if len(recorder.opStartCalls) != 1 ||
+ recorder.opStartCalls[0].Service != "Documents" || recorder.opStartCalls[0].Operation != "Replace" {
+ t.Errorf("expected single Documents.Replace operation, got %+v", recorder.opStartCalls)
+ }
+}
+
+// TestDocumentsService_ReplaceEmptyRequestIsUsageError covers the one shape BC3
+// rejects outright: `params.require(:document)` is synthesized from a flat body,
+// so a body naming neither field carries no document object and is a 400. The
+// SDK refuses it locally rather than spending a round-trip to learn that.
+func TestDocumentsService_ReplaceEmptyRequestIsUsageError(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ recorder := &recordingHooks{}
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, recorder)
+
+ _, err := svc.Replace(context.Background(), 1069479300, &ReplaceDocumentRequest{})
+ if err == nil {
+ t.Fatal("expected usage error for a replace request naming neither field")
+ }
+ usageErr, ok := errors.AsType[*Error](err)
+ if !ok || usageErr.Code != CodeUsage {
+ t.Fatalf("expected CodeUsage, got %T %v", err, err)
+ }
+ if len(*reqs) != 0 {
+ t.Fatalf("expected no requests, got %+v", *reqs)
+ }
+ // The body is built inside the hook envelope, so the refusal is observable.
+ if len(recorder.opStartCalls) != 1 || len(recorder.opEndCalls) != 1 {
+ t.Errorf("expected the usage error to be observable to hooks, got %d starts / %d ends",
+ len(recorder.opStartCalls), len(recorder.opEndCalls))
+ }
+}
+
+func TestDocumentsService_ReplaceNilRequestIsUsageError(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ _, err := svc.Replace(context.Background(), 1069479300, nil)
+ if err == nil {
+ t.Fatal("expected usage error for a nil replace request")
+ }
+ usageErr, ok := errors.AsType[*Error](err)
+ if !ok || usageErr.Code != CodeUsage {
+ t.Fatalf("expected CodeUsage, got %T %v", err, err)
+ }
+ if len(*reqs) != 0 {
+ t.Fatalf("expected no requests, got %+v", *reqs)
+ }
+}
+
+// TestDocumentsService_ReplaceSendsBothFieldsWhenBothSet is the shape Update
+// and Edit always produce, exercised through the verbatim path.
+func TestDocumentsService_ReplaceSendsBothFieldsWhenBothSet(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ bothTitle, bothContent := "Both", "set
"
+ _, err := svc.Replace(context.Background(), 1069479300, &ReplaceDocumentRequest{
+ Title: &bothTitle,
+ Content: &bothContent,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := (*reqs)[0].body
+ if body["title"] != "Both" || body["content"] != "set
" {
+ t.Errorf("expected both fields sent verbatim, got %v", body)
+ }
+}
+
+// Document.title is @required in the spec, and BC3 can never render it blank
+// (Document#title is super.presence || "Untitled"). encoding/json does not
+// enforce required-ness, though: an absent "title" decodes to the string zero
+// value, and the full-replace PUT would then send title:"" — blanking the real
+// title on a call that only touched Content. That is #576's defect in the one
+// shape a typed decoder does not catch, so the composite refuses it explicitly.
+//
+// The assertion that matters is the ORDERING: no PUT. A guard that fires after
+// the PUT has already lost the field.
+func TestDocumentsService_UpdateRefusesAMissingTitleBeforeWriting(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ for _, tc := range []struct {
+ name string
+ patch map[string]any
+ }{
+ {"absent", map[string]any{"title": nil}},
+ {"empty", map[string]any{"title": ""}},
+ {"whitespace", map[string]any{"title": " "}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ getBody := patchDocumentFixture(t, fixture, tc.patch)
+ svc, reqs := testDocumentsCaptureServer(t, getBody, fixture, nil)
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Content: "New body.
",
+ })
+ if err == nil {
+ t.Fatal("expected the call to fail, but it succeeded")
+ }
+ var apiErr *Error
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected *Error, got %T: %v", err, err)
+ }
+ // api_error, not usage: the value arrived in a successful API
+ // response, so nothing the caller passed is at fault.
+ if apiErr.Code != CodeAPI {
+ t.Errorf("expected code %q, got %q", CodeAPI, apiErr.Code)
+ }
+ if apiErr.HTTPStatus != 0 {
+ t.Errorf("expected a statusless error, got HTTP %d", apiErr.HTTPStatus)
+ }
+ if apiErr.Retryable {
+ t.Error("re-requesting cannot repair a malformed body")
+ }
+ for _, r := range *reqs {
+ if r.method == "PUT" {
+ t.Fatalf("expected no PUT before the guard fired, got %+v", r)
+ }
+ }
+ })
+ }
+}
+
+func TestDocumentsService_EditRefusesAMissingTitleBeforeWriting(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ getBody := patchDocumentFixture(t, fixture, map[string]any{"title": nil})
+ svc, reqs := testDocumentsCaptureServer(t, getBody, fixture, nil)
+
+ called := false
+ _, err := svc.Edit(context.Background(), 1069479300, func(f *DocumentFields) error {
+ called = true
+ f.Content = "New body.
"
+ return nil
+ })
+ if err == nil {
+ t.Fatal("expected the call to fail, but it succeeded")
+ }
+ if called {
+ t.Error("the callback must not run on a malformed read")
+ }
+ for _, r := range *reqs {
+ if r.method == "PUT" {
+ t.Fatalf("expected no PUT before the guard fired, got %+v", r)
+ }
+ }
+}
+
+// The table deliberately spans three different decoder error types, because
+// fetchDocument classifies by EXCLUSION rather than by an allowlist: an
+// allowlist of encoding/json sentinels leaked *time.ParseError, and adding that
+// still leaked types.FlexInt's bare fmt.Errorf.
+//
+// json.Unmarshal is Go's answer to the hand-written type guards the dynamic
+// SDKs carry, and it does refuse a wrong-typed field before the composite can
+// write it back. But it reports that as a raw *json.UnmarshalTypeError, which
+// is not the shape SPEC §6 defines for a malformed 2xx body: a caller switching
+// on *Error would miss it entirely and it carries no hint. The composite
+// normalizes it the way the Swift one normalizes DecodingError, so a malformed
+// response looks the same in every SDK.
+func TestDocumentsService_UpdateWrapsADecodeFailureAsStatuslessAPIError(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ for _, tc := range []struct {
+ name string
+ patch map[string]any
+ }{
+ {"title is a number", map[string]any{"title": 42}},
+ {"content is an object", map[string]any{"content": map[string]any{"a": 1}}},
+ // The two shapes an allowlist of encoding/json sentinels misses.
+ // created_at is time.Time, so a bad timestamp is *time.ParseError.
+ // A non-integral attachment dimension is rejected by
+ // types.FlexInt.UnmarshalJSON itself, which returns a plain
+ // fmt.Errorf that is no named type at all — the case that shows an
+ // allowlist can never be completed, only extended. (A *string-typed*
+ // height would surface as *json.UnmarshalTypeError from the nested
+ // json.Unmarshal and so would not discriminate.)
+ {"created_at is not a timestamp", map[string]any{"created_at": "not-a-timestamp"}},
+ {"attachment height is non-integral", map[string]any{
+ "content_attachments": []any{map[string]any{"height": 1024.5}},
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ getBody := patchDocumentFixture(t, fixture, tc.patch)
+ svc, reqs := testDocumentsCaptureServer(t, getBody, fixture, nil)
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Title: "Q3 Plan",
+ })
+ if err == nil {
+ t.Fatal("expected the call to fail, but it succeeded")
+ }
+ var apiErr *Error
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected *Error, got %T: %v", err, err)
+ }
+ if apiErr.Code != CodeAPI {
+ t.Errorf("expected code %q, got %q", CodeAPI, apiErr.Code)
+ }
+ if apiErr.HTTPStatus != 0 {
+ t.Errorf("expected a statusless error, got HTTP %d", apiErr.HTTPStatus)
+ }
+ if apiErr.Retryable {
+ t.Error("re-requesting cannot repair a malformed body")
+ }
+ if apiErr.Hint == "" {
+ t.Error("expected a hint naming the deliberate-overwrite escape hatch")
+ }
+ for _, r := range *reqs {
+ if r.method == "PUT" {
+ t.Fatalf("expected no PUT before the guard fired, got %+v", r)
+ }
+ }
+ })
+ }
+}
+
+// A transport or HTTP error must pass through untouched — the wrapper is for
+// decode failures only, and swallowing everything would hide a 404 behind a
+// "does not decode" message.
+func TestDocumentsService_UpdatePassesNonDecodeErrorsThrough(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ fmt.Fprint(w, `{"error":"Not Found"}`)
+ }))
+ t.Cleanup(srv.Close)
+
+ cfg := DefaultConfig()
+ cfg.BaseURL = srv.URL
+ client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"})
+ svc := client.ForAccount("999").Documents()
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{Title: "Q3 Plan"})
+ if err == nil {
+ t.Fatal("expected the call to fail, but it succeeded")
+ }
+ var apiErr *Error
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected *Error, got %T: %v", err, err)
+ }
+ if apiErr.HTTPStatus != http.StatusNotFound {
+ t.Errorf("expected the 404 to survive, got HTTP %d (%s)", apiErr.HTTPStatus, apiErr.Message)
+ }
+}
+
+// An all-nil request is the 400 (BC3 requires the wrapping document object), but
+// a request naming both fields as "" is a legal full replacement that clears
+// both. Zero-value guards conflated the two and made the clear unreachable from
+// Go's raw path; presence-bearing pointers keep them distinct. Raised by Codex
+// review on #601.
+func TestDocumentsService_ReplaceSendsExplicitEmptyStrings(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, nil)
+
+ empty := ""
+ if _, err := svc.Replace(context.Background(), 1069479300, &ReplaceDocumentRequest{
+ Title: &empty,
+ Content: &empty,
+ }); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(*reqs) != 1 {
+ t.Fatalf("expected exactly 1 request, got %d", len(*reqs))
+ }
+ body := (*reqs)[0].body
+ for _, key := range []string{"title", "content"} {
+ value, ok := body[key]
+ if !ok {
+ t.Fatalf("expected %q present-and-empty in the body, got %+v", key, body)
+ }
+ if value != "" {
+ t.Errorf("expected %q to be the empty string, got %#v", key, value)
+ }
+ }
+}
+
+// The decode-failure normalizer has to cover every error the response decoder
+// can produce, not just the two encoding/json sentinels. Document carries
+// time.Time fields, and time.Time.UnmarshalJSON reports a bad timestamp as
+// *time.ParseError — which is neither *json.UnmarshalTypeError nor
+// *json.SyntaxError, so a two-type allowlist would leak it raw.
+//
+// Those three are structurally the complete set for this model: json.Unmarshal
+// reports a wrong type as *json.UnmarshalTypeError and malformed JSON as
+// *json.SyntaxError, and the only field type on Document with its own
+// UnmarshalJSON is time.Time.
+func TestDocumentsService_UpdateNormalizesABadTimestamp(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ getBody := patchDocumentFixture(t, fixture, map[string]any{"created_at": "not-a-timestamp"})
+ svc, reqs := testDocumentsCaptureServer(t, getBody, fixture, nil)
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Content: "New body.
",
+ })
+ if err == nil {
+ t.Fatal("expected the call to fail, but it succeeded")
+ }
+
+ var apiErr *Error
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected *Error, got %T: %v", err, err)
+ }
+ if apiErr.Code != CodeAPI {
+ t.Errorf("expected code %q, got %q", CodeAPI, apiErr.Code)
+ }
+ if apiErr.HTTPStatus != 0 {
+ t.Errorf("expected a statusless error, got HTTP %d", apiErr.HTTPStatus)
+ }
+ if apiErr.Retryable {
+ t.Error("re-requesting cannot repair a malformed body")
+ }
+ for _, r := range *reqs {
+ if r.method == "PUT" {
+ t.Fatalf("expected no PUT after a decode failure, got %+v", r)
+ }
+ }
+}
+
+// A GatingHooks implementation rejects the composite's GET before any request
+// is made — circuit breakers and bulkheads are explicitly allowed to do that
+// with an ordinary sentinel. That decision is local, so it must reach the
+// caller verbatim: classifying it as a malformed response would break
+// errors.Is and blame the server for a choice the client made.
+func TestDocumentsService_UpdatePreservesAGateError(t *testing.T) {
+ errGated := errors.New("circuit open")
+ fixture := loadDocumentsFixture(t, "get.json")
+ hooks := &testGatingHooks{
+ onGate: func(ctx context.Context, op OperationInfo) (context.Context, error) {
+ if op.Operation == "Get" {
+ return ctx, errGated
+ }
+ return ctx, nil
+ },
+ }
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, hooks)
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Content: "New body.
",
+ })
+ if !errors.Is(err, errGated) {
+ t.Fatalf("expected the gate sentinel to survive errors.Is, got %T: %v", err, err)
+ }
+ if len(*reqs) != 0 {
+ t.Fatalf("a gate rejection must issue no request, got %+v", *reqs)
+ }
+}
+
+// gatingHooks refuses the operation before any request is made — a circuit
+// breaker or bulkhead, which SPEC §12 explicitly permits.
+type documentGatingHooks struct {
+ recordingHooks
+ err error
+}
+
+func (h *documentGatingHooks) OnOperationGate(ctx context.Context, op OperationInfo) (context.Context, error) {
+ return ctx, h.err
+}
+
+// A gate error is not a decode error. The classifier lives below the gate, at
+// the one call site whose only origins are the transport and the decoder, so a
+// gating hook's own sentinel travels back untouched and errors.Is still works.
+// Wrapping it would have reported a malformed body for a request that never ran.
+func TestDocumentsService_UpdatePreservesAGatingHookError(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ sentinel := errors.New("circuit breaker open")
+ hooks := &documentGatingHooks{err: sentinel}
+ svc, reqs := testDocumentsCaptureServer(t, fixture, fixture, hooks)
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{Title: "Q3 Plan"})
+ if err == nil {
+ t.Fatal("expected the gate to refuse the call")
+ }
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("expected the gate's own error to survive, got %T: %v", err, err)
+ }
+ var apiErr *Error
+ if errors.As(err, &apiErr) && apiErr.Code == CodeAPI {
+ t.Error("a gate refusal must not be reported as a malformed response")
+ }
+ if len(*reqs) != 0 {
+ t.Fatalf("expected no requests when the gate refuses, got %+v", *reqs)
+ }
+}
+
+// failingAuth is an AuthStrategy that refuses to sign the request, standing in
+// for a token refresh or keyring failure.
+type failingAuth struct{ err error }
+
+func (a failingAuth) Authenticate(context.Context, *http.Request) error { return a.err }
+
+// The authEditor runs inside the generated client, per request, so an auth
+// failure surfaces from the same call the response decoder does — with no HTTP
+// response behind it. It must keep its own taxonomy: reporting a credential
+// failure as malformed document JSON leaves callers unable to recognize it.
+func TestDocumentsService_UpdatePreservesAnAuthError(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ reqs := &[]capturedDocumentRequest{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *reqs = append(*reqs, capturedDocumentRequest{method: r.Method, path: r.URL.Path})
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+ w.Write(fixture)
+ }))
+ t.Cleanup(server.Close)
+
+ cfg := DefaultConfig()
+ cfg.BaseURL = server.URL
+ authErr := ErrAuth("token refresh failed")
+ client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"},
+ WithAuthStrategy(failingAuth{err: authErr}))
+ svc := client.ForAccount("99999").Documents()
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Content: "New body.
",
+ })
+ if err == nil {
+ t.Fatal("expected the call to fail, but it succeeded")
+ }
+
+ var apiErr *Error
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected *Error, got %T: %v", err, err)
+ }
+ if apiErr.Code != CodeAuth {
+ t.Fatalf("expected the auth taxonomy to survive, got code %q (message %q)", apiErr.Code, apiErr.Message)
+ }
+ for _, r := range *reqs {
+ if r.method == "PUT" {
+ t.Fatalf("expected no PUT after an auth failure, got %+v", r)
+ }
+ }
+}
+
+// AuthStrategy.Authenticate permits ANY error, and BearerAuth propagates a
+// token provider's error unchanged — so an auth failure is not reliably an
+// *Error. Splitting the request from the decode is what makes this work:
+// nothing inspects the error, so an ordinary sentinel survives errors.Is.
+func TestDocumentsService_UpdatePreservesAnArbitraryAuthError(t *testing.T) {
+ fixture := loadDocumentsFixture(t, "get.json")
+ reqs := &[]capturedDocumentRequest{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *reqs = append(*reqs, capturedDocumentRequest{method: r.Method, path: r.URL.Path})
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+ w.Write(fixture)
+ }))
+ t.Cleanup(server.Close)
+
+ sentinel := errors.New("keyring locked")
+ cfg := DefaultConfig()
+ cfg.BaseURL = server.URL
+ client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"},
+ WithAuthStrategy(failingAuth{err: sentinel}))
+ svc := client.ForAccount("99999").Documents()
+
+ _, err := svc.Update(context.Background(), 1069479300, &UpdateDocumentRequest{
+ Content: "New body.
",
+ })
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("expected the auth sentinel to survive errors.Is, got %T: %v", err, err)
+ }
+ for _, r := range *reqs {
+ if r.method == "PUT" {
+ t.Fatalf("expected no PUT after an auth failure, got %+v", r)
+ }
+ }
+}
diff --git a/go/pkg/basecamp/url-routes.json b/go/pkg/basecamp/url-routes.json
index 35be039511..cb5fe21920 100644
--- a/go/pkg/basecamp/url-routes.json
+++ b/go/pkg/basecamp/url-routes.json
@@ -871,7 +871,7 @@
"resource": "Files",
"operations": {
"GET": "GetDocument",
- "PUT": "UpdateDocument"
+ "PUT": "ReplaceDocument"
},
"params": {
"accountId": {
diff --git a/go/pkg/basecamp/vaults.go b/go/pkg/basecamp/vaults.go
index 2e3a30d730..e6506ec694 100644
--- a/go/pkg/basecamp/vaults.go
+++ b/go/pkg/basecamp/vaults.go
@@ -208,14 +208,6 @@ type CreateDocumentRequest struct {
VisibleToClients *bool `json:"visible_to_clients,omitempty"`
}
-// UpdateDocumentRequest specifies the parameters for updating a document.
-type UpdateDocumentRequest struct {
- // Title is the document title.
- Title string `json:"title,omitempty"`
- // Content is the document body in HTML.
- Content string `json:"content,omitempty"`
-}
-
// UpdateUploadRequest specifies the parameters for updating an upload.
type UpdateUploadRequest struct {
// Description is the upload description.
@@ -484,15 +476,32 @@ func (s *DocumentsService) Get(ctx context.Context, documentID int64) (result *D
ctx = s.client.parent.hooks.OnOperationStart(ctx, op)
defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }()
- resp, err := s.client.parent.gen.GetDocumentWithResponse(ctx, s.client.accountID, documentID)
+ // Split into request and decode rather than calling GetDocumentWithResponse,
+ // so the two error origins never mix. The merge-safe composites read this
+ // body and write every field of it back, so a malformed one has to arrive as
+ // the documented statusless api_error (documentDecodeError in documents.go)
+ // — but everything BEFORE the response is a different failure with its own
+ // meaning, and no inspection of the returned error can reliably tell them
+ // apart. GetDocument covers the gate's successors: the per-request auth
+ // editor (a token provider or custom AuthStrategy may return ANY error), the
+ // transport, and context cancellation. Those return verbatim, so errors.Is
+ // keeps working; only ParseGetDocumentResponse's failure is a decode failure.
+ //nolint:bodyclose // ParseGetDocumentResponse below closes the body (it defers
+ // rsp.Body.Close()), and it is called unconditionally on the next line.
+ httpResp, err := s.client.parent.gen.GetDocument(ctx, s.client.accountID, documentID)
if err != nil {
return nil, err
}
+ resp, decodeErr := generated.ParseGetDocumentResponse(httpResp)
+ if decodeErr != nil {
+ err = documentDecodeError(decodeErr)
+ return nil, err
+ }
if err = checkResponse(resp.HTTPResponse, resp.Body); err != nil {
return nil, err
}
if resp.JSON200 == nil {
- err = fmt.Errorf("unexpected empty response")
+ err = documentDecodeError(fmt.Errorf("the response carried no document object"))
return nil, err
}
@@ -634,51 +643,8 @@ func (s *DocumentsService) Create(ctx context.Context, vaultID int64, req *Creat
return &document, nil
}
-// Update updates an existing document.
-// Returns the updated document.
-func (s *DocumentsService) Update(ctx context.Context, documentID int64, req *UpdateDocumentRequest) (result *Document, err error) {
- op := OperationInfo{
- Service: "Documents", Operation: "Update",
- ResourceType: "document", IsMutation: true,
- ResourceID: documentID,
- }
- if gater, ok := s.client.parent.hooks.(GatingHooks); ok {
- if ctx, err = gater.OnOperationGate(ctx, op); err != nil {
- return
- }
- }
- start := time.Now()
- ctx = s.client.parent.hooks.OnOperationStart(ctx, op)
- defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }()
-
- if req == nil {
- err = ErrUsage("update request is required")
- return nil, err
- }
-
- body := generated.UpdateDocumentJSONRequestBody{}
- if req.Title != "" {
- body.Title = &req.Title
- }
- if req.Content != "" {
- body.Content = &req.Content
- }
-
- resp, err := s.client.parent.gen.UpdateDocumentWithResponse(ctx, s.client.accountID, documentID, body)
- if err != nil {
- return nil, err
- }
- if err = checkResponse(resp.HTTPResponse, resp.Body); err != nil {
- return nil, err
- }
- if resp.JSON200 == nil {
- err = fmt.Errorf("unexpected empty response")
- return nil, err
- }
-
- document := documentFromGenerated(*resp.JSON200)
- return &document, nil
-}
+// The Documents write surface — the merge-safe Update, the read-modify-write
+// Edit, and the verbatim Replace — lives in documents.go.
// Trash moves a document to the trash.
// Trashed documents can be recovered from the trash.
diff --git a/go/pkg/basecamp/vaults_test.go b/go/pkg/basecamp/vaults_test.go
index a508a50a86..83f80a28cf 100644
--- a/go/pkg/basecamp/vaults_test.go
+++ b/go/pkg/basecamp/vaults_test.go
@@ -421,21 +421,12 @@ func TestCreateDocumentRequest_SubscriptionsNil(t *testing.T) {
}
}
-func TestUpdateDocumentRequest_Marshal(t *testing.T) {
- data := loadDocumentsFixture(t, "update-request.json")
-
- var req UpdateDocumentRequest
- if err := json.Unmarshal(data, &req); err != nil {
- t.Fatalf("failed to unmarshal update-request.json: %v", err)
- }
-
- if req.Title != "Updated Document Title" {
- t.Errorf("expected title 'Updated Document Title', got %q", req.Title)
- }
- if req.Content == "" {
- t.Error("expected non-empty Content")
- }
-}
+// The document WRITE surface — UpdateDocumentRequest, ReplaceDocumentRequest,
+// and the Update/Edit/Replace triad — moved to documents.go when
+// UpdateDocument became the full-replace ReplaceDocument. Its tests, including
+// the writable-set fixture check that used to live here as
+// TestUpdateDocumentRequest_Marshal, moved with it to documents_test.go. What
+// stays here is the read surface the write surface hangs off.
func TestDocument_TimestampParsing(t *testing.T) {
data := loadDocumentsFixture(t, "get.json")
diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go
index c0969a123d..66ff725585 100644
--- a/go/pkg/generated/client.gen.go
+++ b/go/pkg/generated/client.gen.go
@@ -2329,6 +2329,15 @@ type ReorderUpNextRequestContent struct {
SourceId int64 `json:"source_id"`
}
+// ReplaceDocumentRequestContent defines model for ReplaceDocumentRequestContent.
+type ReplaceDocumentRequestContent struct {
+ Content *string `json:"content,omitempty"`
+ Title *string `json:"title,omitempty"`
+}
+
+// ReplaceDocumentResponseContent defines model for ReplaceDocumentResponseContent.
+type ReplaceDocumentResponseContent = Document
+
// ReplaceTodoRequestContent defines model for ReplaceTodoRequestContent.
type ReplaceTodoRequestContent struct {
AssigneeIds *[]int64 `json:"assignee_ids,omitempty"`
@@ -3092,15 +3101,6 @@ type UpdateCommentRequestContent struct {
// UpdateCommentResponseContent defines model for UpdateCommentResponseContent.
type UpdateCommentResponseContent = Comment
-// UpdateDocumentRequestContent defines model for UpdateDocumentRequestContent.
-type UpdateDocumentRequestContent struct {
- Content *string `json:"content,omitempty"`
- Title *string `json:"title,omitempty"`
-}
-
-// UpdateDocumentResponseContent defines model for UpdateDocumentResponseContent.
-type UpdateDocumentResponseContent = Document
-
// UpdateFolderRequestContent defines model for UpdateFolderRequestContent.
type UpdateFolderRequestContent struct {
// Name The folder's new name. Blank is rejected with 422 — unlike create, update
@@ -4234,8 +4234,8 @@ type UpdateCommentJSONRequestBody = UpdateCommentRequestContent
// UpdateToolJSONRequestBody defines body for UpdateTool for application/json ContentType.
type UpdateToolJSONRequestBody = UpdateToolRequestContent
-// UpdateDocumentJSONRequestBody defines body for UpdateDocument for application/json ContentType.
-type UpdateDocumentJSONRequestBody = UpdateDocumentRequestContent
+// ReplaceDocumentJSONRequestBody defines body for ReplaceDocument for application/json ContentType.
+type ReplaceDocumentJSONRequestBody = ReplaceDocumentRequestContent
// UpdateGaugeNeedleJSONRequestBody defines body for UpdateGaugeNeedle for application/json ContentType.
type UpdateGaugeNeedleJSONRequestBody = UpdateGaugeNeedleRequestContent
@@ -5219,10 +5219,10 @@ type ClientInterface interface {
// GetDocument request
GetDocument(ctx context.Context, accountId string, documentId int64, reqEditors ...RequestEditorFn) (*http.Response, error)
- // UpdateDocumentWithBody request with any body
- UpdateDocumentWithBody(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
+ // ReplaceDocumentWithBody request with any body
+ ReplaceDocumentWithBody(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
- UpdateDocument(ctx context.Context, accountId string, documentId int64, body UpdateDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
+ ReplaceDocument(ctx context.Context, accountId string, documentId int64, body ReplaceDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// GetEverythingFiles request
GetEverythingFiles(ctx context.Context, accountId string, params *GetEverythingFilesParams, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -6967,21 +6967,21 @@ func (c *Client) GetDocument(ctx context.Context, accountId string, documentId i
}
-// UpdateDocumentWithBody is marked as idempotent and will be retried on transient failures.
+// ReplaceDocumentWithBody is marked as idempotent and will be retried on transient failures.
-func (c *Client) UpdateDocumentWithBody(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
+func (c *Client) ReplaceDocumentWithBody(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
return c.doWithRetry(ctx, func() (*http.Request, error) {
- return NewUpdateDocumentRequestWithBody(c.Server, accountId, documentId, contentType, body)
- }, true, "UpdateDocument", reqEditors...)
+ return NewReplaceDocumentRequestWithBody(c.Server, accountId, documentId, contentType, body)
+ }, true, "ReplaceDocument", reqEditors...)
}
-func (c *Client) UpdateDocument(ctx context.Context, accountId string, documentId int64, body UpdateDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
+func (c *Client) ReplaceDocument(ctx context.Context, accountId string, documentId int64, body ReplaceDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
return c.doWithRetry(ctx, func() (*http.Request, error) {
- return NewUpdateDocumentRequest(c.Server, accountId, documentId, body)
- }, true, "UpdateDocument", reqEditors...)
+ return NewReplaceDocumentRequest(c.Server, accountId, documentId, body)
+ }, true, "ReplaceDocument", reqEditors...)
}
@@ -13495,19 +13495,19 @@ func NewGetDocumentRequest(server string, accountId string, documentId int64) (*
return req, nil
}
-// NewUpdateDocumentRequest calls the generic UpdateDocument builder with application/json body
-func NewUpdateDocumentRequest(server string, accountId string, documentId int64, body UpdateDocumentJSONRequestBody) (*http.Request, error) {
+// NewReplaceDocumentRequest calls the generic ReplaceDocument builder with application/json body
+func NewReplaceDocumentRequest(server string, accountId string, documentId int64, body ReplaceDocumentJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
- return NewUpdateDocumentRequestWithBody(server, accountId, documentId, "application/json", bodyReader)
+ return NewReplaceDocumentRequestWithBody(server, accountId, documentId, "application/json", bodyReader)
}
-// NewUpdateDocumentRequestWithBody generates requests for UpdateDocument with any type of body
-func NewUpdateDocumentRequestWithBody(server string, accountId string, documentId int64, contentType string, body io.Reader) (*http.Request, error) {
+// NewReplaceDocumentRequestWithBody generates requests for ReplaceDocument with any type of body
+func NewReplaceDocumentRequestWithBody(server string, accountId string, documentId int64, contentType string, body io.Reader) (*http.Request, error) {
var err error
var pathParam0 string
@@ -22694,7 +22694,7 @@ var operationMetadata = map[string]OperationMetadata{
"GetTool": {Idempotent: true, HasSensitiveParams: false},
"UpdateTool": {Idempotent: true, HasSensitiveParams: false},
"GetDocument": {Idempotent: true, HasSensitiveParams: false},
- "UpdateDocument": {Idempotent: true, HasSensitiveParams: false},
+ "ReplaceDocument": {Idempotent: true, HasSensitiveParams: false},
"GetEverythingFiles": {Idempotent: true, HasSensitiveParams: false},
"GetEverythingForwards": {Idempotent: true, HasSensitiveParams: false},
"DestroyGaugeNeedle": {Idempotent: true, HasSensitiveParams: false},
@@ -22946,7 +22946,7 @@ var operationRetryMax = map[string]int{
"GetTool": 3,
"UpdateTool": 3,
"GetDocument": 3,
- "UpdateDocument": 3,
+ "ReplaceDocument": 3,
"GetEverythingFiles": 3,
"GetEverythingForwards": 3,
"DestroyGaugeNeedle": 2,
@@ -23196,7 +23196,7 @@ var operationRetryOn = map[string][]int{
"GetTool": {429, 503},
"UpdateTool": {429, 503},
"GetDocument": {429, 503},
- "UpdateDocument": {429, 503},
+ "ReplaceDocument": {429, 503},
"GetEverythingFiles": {429, 503},
"GetEverythingForwards": {429, 503},
"DestroyGaugeNeedle": {429, 503},
@@ -24018,12 +24018,12 @@ func (s *DocumentsService) Get(ctx context.Context, accountId string, documentId
return s.client.GetDocument(ctx, accountId, documentId, reqEditors...)
}
-func (s *DocumentsService) UpdateWithBody(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
- return s.client.UpdateDocumentWithBody(ctx, accountId, documentId, contentType, body, reqEditors...)
+func (s *DocumentsService) ReplaceWithBody(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ return s.client.ReplaceDocumentWithBody(ctx, accountId, documentId, contentType, body, reqEditors...)
}
-func (s *DocumentsService) Update(ctx context.Context, accountId string, documentId int64, body UpdateDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
- return s.client.UpdateDocument(ctx, accountId, documentId, body, reqEditors...)
+func (s *DocumentsService) Replace(ctx context.Context, accountId string, documentId int64, body ReplaceDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ return s.client.ReplaceDocument(ctx, accountId, documentId, body, reqEditors...)
}
func (s *MessagesService) List(ctx context.Context, accountId string, boardId int64, params *ListMessagesParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
@@ -24603,10 +24603,10 @@ type ClientWithResponsesInterface interface {
// GetDocumentWithResponse request
GetDocumentWithResponse(ctx context.Context, accountId string, documentId int64, reqEditors ...RequestEditorFn) (*GetDocumentResponse, error)
- // UpdateDocumentWithBodyWithResponse request with any body
- UpdateDocumentWithBodyWithResponse(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateDocumentResponse, error)
+ // ReplaceDocumentWithBodyWithResponse request with any body
+ ReplaceDocumentWithBodyWithResponse(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ReplaceDocumentResponse, error)
- UpdateDocumentWithResponse(ctx context.Context, accountId string, documentId int64, body UpdateDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateDocumentResponse, error)
+ ReplaceDocumentWithResponse(ctx context.Context, accountId string, documentId int64, body ReplaceDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*ReplaceDocumentResponse, error)
// GetEverythingFilesWithResponse request
GetEverythingFilesWithResponse(ctx context.Context, accountId string, params *GetEverythingFilesParams, reqEditors ...RequestEditorFn) (*GetEverythingFilesResponse, error)
@@ -27822,10 +27822,10 @@ func (r GetDocumentResponse) ContentType() string {
return ""
}
-type UpdateDocumentResponse struct {
+type ReplaceDocumentResponse struct {
Body []byte
HTTPResponse *http.Response
- JSON200 *UpdateDocumentResponseContent
+ JSON200 *ReplaceDocumentResponseContent
JSON401 *UnauthorizedErrorResponseContent
JSON403 *ForbiddenErrorResponseContent
JSON404 *NotFoundErrorResponseContent
@@ -27834,7 +27834,7 @@ type UpdateDocumentResponse struct {
}
// Status returns HTTPResponse.Status
-func (r UpdateDocumentResponse) Status() string {
+func (r ReplaceDocumentResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
@@ -27842,7 +27842,7 @@ func (r UpdateDocumentResponse) Status() string {
}
// StatusCode returns HTTPResponse.StatusCode
-func (r UpdateDocumentResponse) StatusCode() int {
+func (r ReplaceDocumentResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
@@ -27850,7 +27850,7 @@ func (r UpdateDocumentResponse) StatusCode() int {
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
-func (r UpdateDocumentResponse) ContentType() string {
+func (r ReplaceDocumentResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
@@ -34433,21 +34433,21 @@ func (c *ClientWithResponses) GetDocumentWithResponse(ctx context.Context, accou
return ParseGetDocumentResponse(rsp)
}
-// UpdateDocumentWithBodyWithResponse request with arbitrary body returning *UpdateDocumentResponse
-func (c *ClientWithResponses) UpdateDocumentWithBodyWithResponse(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateDocumentResponse, error) {
- rsp, err := c.UpdateDocumentWithBody(ctx, accountId, documentId, contentType, body, reqEditors...)
+// ReplaceDocumentWithBodyWithResponse request with arbitrary body returning *ReplaceDocumentResponse
+func (c *ClientWithResponses) ReplaceDocumentWithBodyWithResponse(ctx context.Context, accountId string, documentId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ReplaceDocumentResponse, error) {
+ rsp, err := c.ReplaceDocumentWithBody(ctx, accountId, documentId, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
- return ParseUpdateDocumentResponse(rsp)
+ return ParseReplaceDocumentResponse(rsp)
}
-func (c *ClientWithResponses) UpdateDocumentWithResponse(ctx context.Context, accountId string, documentId int64, body UpdateDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateDocumentResponse, error) {
- rsp, err := c.UpdateDocument(ctx, accountId, documentId, body, reqEditors...)
+func (c *ClientWithResponses) ReplaceDocumentWithResponse(ctx context.Context, accountId string, documentId int64, body ReplaceDocumentJSONRequestBody, reqEditors ...RequestEditorFn) (*ReplaceDocumentResponse, error) {
+ rsp, err := c.ReplaceDocument(ctx, accountId, documentId, body, reqEditors...)
if err != nil {
return nil, err
}
- return ParseUpdateDocumentResponse(rsp)
+ return ParseReplaceDocumentResponse(rsp)
}
// GetEverythingFilesWithResponse request returning *GetEverythingFilesResponse
@@ -40342,22 +40342,22 @@ func ParseGetDocumentResponse(rsp *http.Response) (*GetDocumentResponse, error)
return response, nil
}
-// ParseUpdateDocumentResponse parses an HTTP response from a UpdateDocumentWithResponse call
-func ParseUpdateDocumentResponse(rsp *http.Response) (*UpdateDocumentResponse, error) {
+// ParseReplaceDocumentResponse parses an HTTP response from a ReplaceDocumentWithResponse call
+func ParseReplaceDocumentResponse(rsp *http.Response) (*ReplaceDocumentResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
- response := &UpdateDocumentResponse{
+ response := &ReplaceDocumentResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
- var dest UpdateDocumentResponseContent
+ var dest ReplaceDocumentResponseContent
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
diff --git a/go/templates/client.tmpl b/go/templates/client.tmpl
index 1af0f4efd4..e7d2feaec5 100644
--- a/go/templates/client.tmpl
+++ b/go/templates/client.tmpl
@@ -1701,12 +1701,12 @@ func (s *DocumentsService) Create{{.Suffix}}(ctx context.Context{{genParamArgs $
return s.client.{{$opid}}{{.Suffix}}(ctx{{genParamNames $pathParams}}{{if $hasParams}}, params{{end}}, body, reqEditors...)
}
{{end}}{{end}}
-{{else if eq $opid "UpdateDocument"}}
-func (s *DocumentsService) UpdateWithBody(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, contentType string, body io.Reader, reqEditors... RequestEditorFn) (*http.Response, error) {
+{{else if eq $opid "ReplaceDocument"}}
+func (s *DocumentsService) ReplaceWithBody(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, contentType string, body io.Reader, reqEditors... RequestEditorFn) (*http.Response, error) {
return s.client.{{$opid}}WithBody(ctx{{genParamNames .PathParams}}{{if $hasParams}}, params{{end}}, contentType, body, reqEditors...)
}
{{range .Bodies}}{{if .IsSupportedByClient}}
-func (s *DocumentsService) Update{{.Suffix}}(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) {
+func (s *DocumentsService) Replace{{.Suffix}}(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) {
return s.client.{{$opid}}{{.Suffix}}(ctx{{genParamNames $pathParams}}{{if $hasParams}}, params{{end}}, body, reqEditors...)
}
{{end}}{{end}}
diff --git a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt
index 09332882c3..cb023aff99 100644
--- a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt
+++ b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt
@@ -818,6 +818,43 @@ private suspend fun dispatchOperation(tc: TestCase, account: AccountClient): Dis
DispatchResult()
}
+ // Synthetic scenario key (not a wire operation): the merge-safe
+ // composite, GET then a full PUT of {title, content}.
+ "UpdateDocument" -> {
+ val documentId = tc.pathParams.longParam("documentId")
+ val rb = tc.requestBody
+ account.documents.update(documentId, UpdateDocumentBody(
+ title = rb?.get("title")?.jsonPrimitive?.contentOrNull,
+ content = rb?.get("content")?.jsonPrimitive?.contentOrNull,
+ ))
+ DispatchResult()
+ }
+
+ // Synthetic scenario key (not a wire operation): exercises the
+ // read-modify-write edit closure by assigning each fixture key onto
+ // the corresponding DocumentFields member.
+ "EditDocument" -> {
+ val documentId = tc.pathParams.longParam("documentId")
+ val rb = tc.requestBody
+ account.documents.edit(documentId) {
+ rb?.get("title")?.jsonPrimitive?.content?.let { title = it }
+ rb?.get("content")?.jsonPrimitive?.content?.let { content = it }
+ }
+ DispatchResult()
+ }
+
+ // Raw single PUT, no read-before-write: neither field is required, and
+ // an omitted one is omitted on the wire (the server clears it).
+ "ReplaceDocument" -> {
+ val documentId = tc.pathParams.longParam("documentId")
+ val rb = tc.requestBody
+ account.documents.replace(documentId, ReplaceDocumentBody(
+ title = rb?.get("title")?.jsonPrimitive?.contentOrNull,
+ content = rb?.get("content")?.jsonPrimitive?.contentOrNull,
+ ))
+ DispatchResult()
+ }
+
"CreateTodo" -> {
val todolistId = tc.pathParams.longParam("todolistId")
val content = tc.requestBody.stringParam("content")
diff --git a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt
index 6ecdc61ec6..31fe0ecc0f 100644
--- a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt
+++ b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt
@@ -51,7 +51,7 @@ val SERVICE_SPLITS: Map>> = mapOf(
"Attachments" to listOf("CreateAttachment"),
"Uploads" to listOf("GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"),
"Vaults" to listOf("GetVault", "UpdateVault", "ListVaults", "CreateVault"),
- "Documents" to listOf("GetDocument", "UpdateDocument", "ListDocuments", "CreateDocument"),
+ "Documents" to listOf("GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"),
),
"Automation" to mapOf(
"Tools" to listOf("GetTool", "UpdateTool", "DeleteTool", "CreateTool", "EnableTool", "DisableTool", "RepositionTool"),
@@ -117,7 +117,7 @@ val SERVICE_SPLITS: Map>> = mapOf(
* com.basecamp.sdk.services can add convenience methods (e.g. Todos
* gains merge-safe update/edit on top of the generated replace).
*/
-val EXTENSIBLE_SERVICES = setOf("Todos", "Todolists", "Cards", "Uploads")
+val EXTENSIBLE_SERVICES = setOf("Todos", "Todolists", "Cards", "Uploads", "Documents")
/**
* Services whose accessor constructs and declares a hand-written subclass
@@ -130,6 +130,7 @@ val HAND_WRITTEN_SERVICES = mapOf(
"Todolists" to "com.basecamp.sdk.services.TodolistsService",
"Cards" to "com.basecamp.sdk.services.CardsService",
"Uploads" to "com.basecamp.sdk.services.UploadsService",
+ "Documents" to "com.basecamp.sdk.services.DocumentsService",
)
/**
diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated-compat/UpdateDocumentBody.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated-compat/UpdateDocumentBody.kt
new file mode 100644
index 0000000000..32c740e52a
--- /dev/null
+++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated-compat/UpdateDocumentBody.kt
@@ -0,0 +1,21 @@
+// Hand-written companion type, NOT generated.
+//
+// The spec renamed the UpdateDocument wire operation to ReplaceDocument (the
+// PUT was always full-replace), so the generator now emits ReplaceDocumentBody
+// and no longer declares UpdateDocumentBody. The name is re-declared here in
+// the generated package — alongside the UpdateTodoBody and UpdateTodolistBody
+// shims that arrived the same way — as the request body of the hand-written
+// merge-safe com.basecamp.sdk.services.DocumentsService.update, where a null
+// field is left untouched rather than cleared.
+//
+// This is NOT a deprecated alias for the old operation: ReplaceDocument ships
+// without one (the ReplaceTodo precedent, #375), and the two types differ in
+// meaning — null here preserves, where the generated body's null omits and the
+// server clears.
+package com.basecamp.sdk.generated.services
+
+/** Request body for the merge-safe Documents update: null fields are untouched. */
+data class UpdateDocumentBody(
+ val title: String? = null,
+ val content: String? = null
+)
diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt
index 853c8a355e..95e7554930 100644
--- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt
+++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt
@@ -206,6 +206,7 @@ object Metadata {
"PrioritizeAssignment" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
"RemoveAccountLogo" to OperationConfig(true, RetryConfig(2, 1000L, "exponential", setOf(429, 503))),
"ReorderUpNext" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
+ "ReplaceDocument" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
"ReplaceTodo" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
"RepositionCardStep" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))),
"RepositionTodo" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
@@ -238,7 +239,6 @@ object Metadata {
"UpdateCardStep" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
"UpdateChatbot" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
"UpdateComment" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
- "UpdateDocument" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
"UpdateFolder" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
"UpdateGaugeNeedle" to OperationConfig(true, RetryConfig(2, 1000L, "exponential", setOf(429, 503))),
"UpdateHillChartSettings" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))),
diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt
index 7fe7f52f25..9843f043b9 100644
--- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt
+++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt
@@ -80,8 +80,8 @@ val AccountClient.comments: CommentsService
get() = service("Comments") { CommentsService(this) }
/** Documents operations. */
-val AccountClient.documents: DocumentsService
- get() = service("Documents") { DocumentsService(this) }
+val AccountClient.documents: com.basecamp.sdk.services.DocumentsService
+ get() = service("Documents") { com.basecamp.sdk.services.DocumentsService(this) }
/** Drafts operations. */
val AccountClient.drafts: DraftsService
diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt
index 747a3329b2..bac9aba406 100644
--- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt
+++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt
@@ -322,8 +322,8 @@ data class CreateCommentBody(
val content: String
)
-/** Request body for UpdateDocument. */
-data class UpdateDocumentBody(
+/** Request body for ReplaceDocument. */
+data class ReplaceDocumentBody(
val title: String? = null,
val content: String? = null
)
diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/documents.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/documents.kt
index 90335057a0..71cd741784 100644
--- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/documents.kt
+++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/documents.kt
@@ -10,7 +10,7 @@ import kotlinx.serialization.json.JsonElement
*
* @generated from OpenAPI spec — do not edit directly
*/
-class DocumentsService(client: AccountClient) : BaseService(client) {
+open class DocumentsService(client: AccountClient) : BaseService(client) {
/**
* Get a single document by id
@@ -33,14 +33,14 @@ class DocumentsService(client: AccountClient) : BaseService(client) {
}
/**
- * Update an existing document
+ * Replace a document with a new complete representation.
* @param documentId The document ID
* @param body Request body
*/
- suspend fun update(documentId: Long, body: UpdateDocumentBody): Document {
+ suspend fun replace(documentId: Long, body: ReplaceDocumentBody): Document {
val info = OperationInfo(
service = "Documents",
- operation = "UpdateDocument",
+ operation = "ReplaceDocument",
resourceType = "document",
isMutation = true,
projectId = null,
diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/DocumentsService.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/DocumentsService.kt
new file mode 100644
index 0000000000..7e0511ad7d
--- /dev/null
+++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/DocumentsService.kt
@@ -0,0 +1,157 @@
+package com.basecamp.sdk.services
+
+import com.basecamp.sdk.AccountClient
+import com.basecamp.sdk.BasecampException
+import com.basecamp.sdk.generated.models.Document
+import com.basecamp.sdk.generated.services.ReplaceDocumentBody
+import com.basecamp.sdk.generated.services.UpdateDocumentBody
+import kotlinx.serialization.SerializationException
+
+/**
+ * A document's full writable state, the receiver of the
+ * [DocumentsService.edit] block. The whole object is PUT back to the server,
+ * so clearing a field means setting it empty (`""`) — there is no third
+ * state. The writable set is exactly `{title, content}`.
+ */
+class DocumentFields internal constructor(
+ /** Plain-text title. Set `""` to clear — the document then reads back as "Untitled". */
+ var title: String,
+ /** Rich text body (HTML). Set `""` to clear. */
+ var content: String,
+)
+
+/**
+ * Documents service with merge-safe [update] and read-modify-write [edit] on
+ * top of the generated surface (`get`, `replace`, ...).
+ *
+ * The endpoint is full-replace: BC3's `DocumentsController#update` builds a
+ * brand-new `Document` from only the permitted params and swaps the recordable
+ * wholesale, so a sparse PUT that omits `content` erases it. Omitting `title`
+ * erases that too — the document then reads back as `"Untitled"`, because
+ * `Document#title` falls back when blank. Neither attribute is
+ * presence-validated, so **neither omission is a 422**; both are a 200 that
+ * quietly clears. What BC3 does require is the wrapping `document` object, so
+ * a body naming neither field is a 400. The raw, destructive-by-design path
+ * stays reachable as `replace`.
+ *
+ * Both composites call the public `get` and `replace` methods, so hooks
+ * observe the two wire operations (`GetDocument` then `ReplaceDocument`), not
+ * a synthetic composite.
+ *
+ * Neither is atomic: there is no conditional-update signal on this endpoint,
+ * so a concurrent write between the GET and PUT is overwritten — last write
+ * wins for the whole representation. The window is one round-trip. Use
+ * `replace` to overwrite deliberately.
+ */
+class DocumentsService(client: AccountClient) :
+ com.basecamp.sdk.generated.services.DocumentsService(client) {
+
+ /**
+ * Sets the given fields on a document and preserves everything else: GETs
+ * the current document, overlays the explicitly-set (non-null) body
+ * fields, and PUTs the full representation back. A null field is
+ * untouched, guaranteed; an explicitly-passed `""` clears.
+ *
+ * Not atomic — see the class docs for the GET→PUT race.
+ */
+ suspend fun update(documentId: Long, body: UpdateDocumentBody): Document {
+ val fields = fieldsFromDocument(fetchDocument(documentId))
+ body.title?.let { fields.title = it }
+ body.content?.let { fields.content = it }
+ return putFields(documentId, fields)
+ }
+
+ /**
+ * Applies a read-modify-write block to a document: GETs the current
+ * document, runs the block with the full writable state
+ * ([DocumentFields]) as receiver, and PUTs the whole thing back. Clearing
+ * a field means setting it empty (`""`) — an untouched field keeps its
+ * current value. If the block throws, the edit aborts and nothing is
+ * written.
+ *
+ * ```kotlin
+ * account.documents.edit(documentId) {
+ * title = "🚨 $title"
+ * content = "" // clearing = setting empty on a full object
+ * }
+ * ```
+ *
+ * Not atomic — see the class docs for the GET→PUT race.
+ */
+ suspend fun edit(documentId: Long, block: DocumentFields.() -> Unit): Document {
+ val fields = fieldsFromDocument(fetchDocument(documentId))
+ fields.block()
+ return putFields(documentId, fields)
+ }
+
+ /**
+ * GETs the document the composites read their writable state from,
+ * normalizing a decode failure into the SPEC §6 shape.
+ *
+ * kotlinx.serialization is the typed guard the dynamic SDKs write by hand,
+ * and it rejects a structurally wrong-typed field before this composite
+ * ever sees it — but it reports that as a raw [SerializationException],
+ * which is not the shape SPEC §6 defines for a malformed 2xx body: callers
+ * catching [BasecampException] would miss it entirely and it carries no
+ * hint. Wrap it, so a malformed response looks the same in every SDK.
+ *
+ * (The client-wide `coerceInputValues`/`isLenient` scalar hole means a bare
+ * JSON scalar is coerced rather than rejected. That is a cross-service gap
+ * tracked out of #576, not something this composite can close.)
+ */
+ private suspend fun fetchDocument(documentId: Long): Document =
+ try {
+ get(documentId)
+ } catch (e: SerializationException) {
+ throw BasecampException.Api(
+ message = "GetDocument returned a body that does not decode as a document: ${e.message}",
+ hint = "The merge-safe update/edit resend this record's fields verbatim, so a " +
+ "malformed response cannot be written back safely. Use replace to write the " +
+ "record deliberately.",
+ retryable = false,
+ cause = e,
+ )
+ }
+
+ /**
+ * Reads the writable state out of a fetched document.
+ *
+ * No hand-written type guard here, unlike the Todolists composite: `get`
+ * returns a decoded [Document], so kotlinx.serialization has already
+ * rejected a structurally wrong-typed field before this runs. (The
+ * client-wide `coerceInputValues`/`isLenient` scalar hole is a known
+ * cross-service gap tracked out of #576, not something this composite can
+ * close.) `content` is nullable on the model — absent or JSON null is
+ * genuinely empty, and `""` is what the server already holds.
+ *
+ * `title` is the exception, and it needs a hand-written check the decoder
+ * cannot supply. The field is non-nullable on the model, so an absent or
+ * null title is already refused — but `""` decodes fine, and BC3 can never
+ * render it blank (`Document#title` is `super.presence || "Untitled"`).
+ * A blank title on a 2xx read is therefore a malformed response, and
+ * carrying it into the full-replace PUT would blank the real title on a
+ * call that only touched `content`.
+ */
+ private fun fieldsFromDocument(document: Document): DocumentFields {
+ if (document.title.isBlank()) {
+ throw BasecampException.Api(
+ message = "GetDocument returned a document with a blank \"title\", " +
+ "but the API never renders it blank",
+ hint = "The merge-safe update/edit resend this field verbatim, so a blank value " +
+ "would blank the current one. Use replace to write the record deliberately.",
+ retryable = false,
+ )
+ }
+ return DocumentFields(title = document.title, content = document.content ?: "")
+ }
+
+ /**
+ * PUTs the full writable state via `replace`. Both fields are always sent,
+ * empties included: on a full-replace endpoint `""` is how a clear is
+ * expressed — never JSON null (SPEC §18 body compaction), and never by
+ * omission, which would leave the field to the server's own
+ * clear-by-default and read as an accident rather than an intent.
+ */
+ private suspend fun putFields(documentId: Long, fields: DocumentFields): Document =
+ replace(documentId, ReplaceDocumentBody(title = fields.title, content = fields.content))
+}
diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/DocumentsServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/DocumentsServiceTest.kt
new file mode 100644
index 0000000000..ee7866e2d3
--- /dev/null
+++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/DocumentsServiceTest.kt
@@ -0,0 +1,289 @@
+package com.basecamp.sdk
+
+import com.basecamp.sdk.generated.documents
+import com.basecamp.sdk.generated.services.ReplaceDocumentBody
+import com.basecamp.sdk.generated.services.UpdateDocumentBody
+import io.ktor.client.engine.mock.*
+import io.ktor.http.*
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+/**
+ * The merge-safe `update` / read-modify-write `edit` composites and the raw
+ * `replace` they are built on.
+ *
+ * `PUT /documents/{id}` is a full replace: BC3 rebuilds the Document from only
+ * the permitted params, so a sparse PUT that omits `content` erases it and one
+ * that omits `title` leaves the document reading back as "Untitled". Neither
+ * omission is a 422 — both are a 200 that quietly clears. That is why the
+ * composites always send BOTH writable fields, empties included: on this
+ * endpoint `""` is how a clear is expressed, and omission is indistinguishable
+ * from an accident.
+ */
+class DocumentsServiceTest {
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ private fun mockClient(handler: MockRequestHandler): BasecampClient {
+ val engine = MockEngine(handler)
+ return testBasecampClient {
+ accessToken("test-token")
+ this.engine = engine
+ }
+ }
+
+ // -- Merge-safe update / edit / replace --
+
+ private fun fullDocumentJson(id: Long = 42) = """{
+ "id": $id, "status": "active", "visible_to_clients": false,
+ "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
+ "title": "Kickoff notes", "inherits_status": true, "type": "Document",
+ "url": "https://3.basecampapi.com/12345/buckets/1/documents/$id.json",
+ "app_url": "https://3.basecamp.com/12345/buckets/1/documents/$id",
+ "parent": {"id": 2, "title": "Docs & Files", "type": "Vault", "url": "https://3.basecampapi.com/12345/buckets/1/vaults/2.json", "app_url": "https://3.basecamp.com/12345/buckets/1/vaults/2"},
+ "bucket": {"id": 1, "name": "Project", "type": "Project"},
+ "creator": {"id": 1, "name": "Test", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"},
+ "content_attachments": [],
+ "content": "From the kickoff
",
+ "position": 1
+ }"""
+
+ private class WriteCapture {
+ val methods = mutableListOf()
+ var putBody: kotlinx.serialization.json.JsonObject? = null
+ }
+
+ private fun captureClient(capture: WriteCapture): BasecampClient = mockClient { request ->
+ capture.methods.add(request.method.value)
+ if (request.method == HttpMethod.Put) {
+ capture.putBody = json.parseToJsonElement(
+ (request.body as io.ktor.http.content.TextContent).text
+ ).jsonObject
+ }
+ respond(
+ content = fullDocumentJson(),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
+ )
+ }
+
+ // kotlinx.serialization is Kotlin's answer to the hand-written type guards
+ // the dynamic SDKs carry, and it does refuse a structurally wrong-typed
+ // field before the composite can write it back. But it reports that as a
+ // raw SerializationException, which is not the shape SPEC 6 defines for a
+ // malformed 2xx body: a caller catching BasecampException would miss it
+ // entirely. The composite normalizes it, so a malformed response looks the
+ // same in every SDK.
+ //
+ // An ARRAY, not a scalar: the client-wide `coerceInputValues`/`isLenient`
+ // settings coerce a bare JSON scalar into a String rather than rejecting
+ // it (a cross-service gap tracked out of #576). An array is refused.
+ @Test
+ fun updateNormalizesADecodeFailure() = runTest {
+ val capture = WriteCapture()
+ val client = mockClient { request ->
+ capture.methods.add(request.method.value)
+ respond(
+ content = fullDocumentJson().replace(
+ "\"title\": \"Kickoff notes\"", "\"title\": [\"nope\"]"
+ ),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
+ )
+ }
+
+ val error = assertFailsWith {
+ client.forAccount("12345").documents
+ .update(42, UpdateDocumentBody(content = "New body.
"))
+ }
+
+ // Statusless and non-retryable: the transport succeeded, and
+ // re-requesting cannot repair a malformed body.
+ assertEquals(null, error.httpStatus)
+ assertEquals(false, error.retryable)
+ assertTrue(error.hint != null, "expected a hint naming the escape hatch")
+ // The ordering is what matters: no PUT. A guard that fires after the
+ // PUT has already lost the field.
+ assertEquals(listOf("GET"), capture.methods)
+
+ client.close()
+ }
+
+ // BC3 can never render a blank title (Document#title is
+ // super.presence || "Untitled"), so "" on a 2xx read is malformed. The
+ // model's non-null String already refuses absent/null; "" decodes fine and
+ // needs the hand-written check. The ordering is what matters: no PUT.
+ @Test
+ fun updateRefusesABlankTitle() = runTest {
+ val capture = WriteCapture()
+ val client = mockClient { request ->
+ capture.methods.add(request.method.value)
+ respond(
+ content = fullDocumentJson().replace("\"title\": \"Kickoff notes\"", "\"title\": \" \""),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
+ )
+ }
+
+ val error = assertFailsWith {
+ client.forAccount("12345").documents
+ .update(42, UpdateDocumentBody(content = "New body.
"))
+ }
+
+ assertEquals(null, error.httpStatus)
+ assertEquals(false, error.retryable)
+ assertEquals(listOf("GET"), capture.methods)
+
+ client.close()
+ }
+
+ @Test
+ fun updateMergesUnsetFields() = runTest {
+ val capture = WriteCapture()
+ val client = captureClient(capture)
+
+ val document = client.forAccount("12345").documents
+ .update(42, UpdateDocumentBody(title = "Kickoff notes, revised"))
+
+ assertEquals(42L, document.id)
+ assertEquals(listOf("GET", "PUT"), capture.methods)
+ val body = capture.putBody!!
+ assertEquals("Kickoff notes, revised", body["title"]?.jsonPrimitive?.content)
+ // content was never named, so the GET's value is written straight back
+ // rather than left to the server's clear-by-default.
+ assertEquals("From the kickoff
", body["content"]?.jsonPrimitive?.content)
+
+ client.close()
+ }
+
+ @Test
+ fun updateExplicitEmptyStringClears() = runTest {
+ val capture = WriteCapture()
+ val client = captureClient(capture)
+
+ client.forAccount("12345").documents.update(42, UpdateDocumentBody(content = ""))
+
+ val body = capture.putBody!!
+ // An explicitly-passed "" is a set, not an unset: present and empty.
+ assertTrue("content" in body, "an explicit clear must be sent, not omitted")
+ assertEquals("", body["content"]?.jsonPrimitive?.content)
+ assertEquals("Kickoff notes", body["title"]?.jsonPrimitive?.content)
+
+ client.close()
+ }
+
+ @Test
+ fun updateHooksObserveGetThenReplace() = runTest {
+ val operations = mutableListOf()
+ val hooks = object : BasecampHooks {
+ override fun onOperationStart(info: OperationInfo) {
+ operations.add(info.operation)
+ }
+ }
+ val engine = MockEngine { _ ->
+ respond(
+ content = fullDocumentJson(),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
+ )
+ }
+ val client = testBasecampClient {
+ accessToken("test-token")
+ this.engine = engine
+ this.hooks = hooks
+ }
+
+ client.forAccount("12345").documents.update(42, UpdateDocumentBody(title = "observed"))
+
+ // The composite is built from the public get/replace, so hooks see the
+ // two wire operations, not a synthetic composite.
+ assertEquals(listOf("GetDocument", "ReplaceDocument"), operations)
+
+ client.close()
+ }
+
+ @Test
+ fun editPutsFullStateBack() = runTest {
+ val capture = WriteCapture()
+ val client = captureClient(capture)
+
+ val document = client.forAccount("12345").documents.edit(42) {
+ assertEquals("Kickoff notes", title)
+ assertEquals("From the kickoff
", content)
+ title = "🚨 $title"
+ }
+
+ assertEquals(42L, document.id)
+ assertEquals(listOf("GET", "PUT"), capture.methods)
+ val body = capture.putBody!!
+ assertEquals("🚨 Kickoff notes", body["title"]?.jsonPrimitive?.content)
+ assertEquals("From the kickoff
", body["content"]?.jsonPrimitive?.content)
+
+ client.close()
+ }
+
+ @Test
+ fun editClearsContentPresentAndEmpty() = runTest {
+ val capture = WriteCapture()
+ val client = captureClient(capture)
+
+ client.forAccount("12345").documents.edit(42) {
+ content = ""
+ }
+
+ val body = capture.putBody!!
+ // Clearing on a full-replace endpoint is an explicit "": never JSON
+ // null (SPEC §18 body compaction), and never by omission, which would
+ // leave the clear to the server and read as an accident.
+ assertTrue("content" in body, "a cleared content must be sent present-and-empty")
+ assertEquals("", body["content"]?.jsonPrimitive?.content)
+ assertEquals("Kickoff notes", body["title"]?.jsonPrimitive?.content)
+
+ client.close()
+ }
+
+ @Test
+ fun editBlockErrorAbortsWithoutPut() = runTest {
+ val capture = WriteCapture()
+ val client = captureClient(capture)
+
+ try {
+ client.forAccount("12345").documents.edit(42) {
+ title = "never written"
+ error("abort")
+ }
+ kotlin.test.fail("expected the block error to propagate")
+ } catch (e: IllegalStateException) {
+ assertEquals("abort", e.message)
+ }
+
+ assertEquals(listOf("GET"), capture.methods)
+
+ client.close()
+ }
+
+ @Test
+ fun replaceSendsSparseVerbatimWithNoGet() = runTest {
+ val capture = WriteCapture()
+ val client = captureClient(capture)
+
+ val document = client.forAccount("12345").documents
+ .replace(42, ReplaceDocumentBody(title = "the whole new document"))
+
+ assertEquals(42L, document.id)
+ assertEquals(listOf("PUT"), capture.methods)
+ val body = capture.putBody!!
+ assertEquals("the whole new document", body["title"]?.jsonPrimitive?.content)
+ // The raw path is destructive by design: what the caller left out stays
+ // out, and the server clears it.
+ assertTrue("content" !in body, "content must be omitted from a sparse replace")
+
+ client.close()
+ }
+}
diff --git a/openapi.json b/openapi.json
index 67274f5d48..055255c0a4 100644
--- a/openapi.json
+++ b/openapi.json
@@ -8061,13 +8061,13 @@
}
},
"put": {
- "description": "Update an existing document",
- "operationId": "UpdateDocument",
+ "description": "Replace a document with a new complete representation.\nThe request body is the document's full writable state: any writable field\nomitted from the request is cleared server-side. Omitting content clears it;\nomitting title clears it too, and the document then reads back as\n\"Untitled\" (Document#title falls back when blank).\nNeither field is required. BC3 builds a brand-new Document from the\npermitted params and swaps the recordable wholesale, and neither attribute\ncarries a presence validation — so an omission is a 200 that clears, not a\n422. What BC3 does require is the wrapping document object, which Rails\nsynthesizes from a flat body, so a request naming neither field is a 400.\nPublishing a draft (status: \"active\") is not modeled: the SDK sends only\ntitle and content, and BC3 rejects a status-only update for the same\nreason it 400s an empty body.\nSubscribers are the one exception to omission-clears. A drafted document\nkeeps its current subscribers when the request addresses neither\nsubscriptions nor notify, so a full-representation PUT that mentions\nneither is safe on a draft.\nTo set some fields while preserving the rest, use the SDK's merge-safe\nupdate or edit methods, which GET the current document and PUT the full\nrepresentation back. Those read-modify-write helpers are not atomic:\na concurrent write between the GET and PUT is overwritten (last write\nwins for the whole representation; the window is one round-trip).",
+ "operationId": "ReplaceDocument",
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateDocumentRequestContent"
+ "$ref": "#/components/schemas/ReplaceDocumentRequestContent"
}
}
}
@@ -8096,11 +8096,11 @@
],
"responses": {
"200": {
- "description": "UpdateDocument 200 response",
+ "description": "ReplaceDocument 200 response",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateDocumentResponseContent"
+ "$ref": "#/components/schemas/ReplaceDocumentResponseContent"
}
}
}
@@ -8170,6 +8170,10 @@
429,
503
]
+ },
+ "x-basecamp-write-semantics": {
+ "mode": "replace",
+ "clearsOmitted": true
}
}
},
@@ -31688,6 +31692,20 @@
"source_id"
]
},
+ "ReplaceDocumentRequestContent": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "content": {
+ "type": "string"
+ }
+ }
+ },
+ "ReplaceDocumentResponseContent": {
+ "$ref": "#/components/schemas/Document"
+ },
"ReplaceTodoRequestContent": {
"type": "object",
"properties": {
@@ -33564,20 +33582,6 @@
"UpdateCommentResponseContent": {
"$ref": "#/components/schemas/Comment"
},
- "UpdateDocumentRequestContent": {
- "type": "object",
- "properties": {
- "title": {
- "type": "string"
- },
- "content": {
- "type": "string"
- }
- }
- },
- "UpdateDocumentResponseContent": {
- "$ref": "#/components/schemas/Document"
- },
"UpdateFolderRequestContent": {
"type": "object",
"properties": {
diff --git a/python/scripts/generate_services.py b/python/scripts/generate_services.py
index ed4086c2a4..fb2096d676 100644
--- a/python/scripts/generate_services.py
+++ b/python/scripts/generate_services.py
@@ -68,7 +68,7 @@
"Attachments": ["CreateAttachment"],
"Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"],
"Vaults": ["GetVault", "UpdateVault", "ListVaults", "CreateVault"],
- "Documents": ["GetDocument", "UpdateDocument", "ListDocuments", "CreateDocument"],
+ "Documents": ["GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"],
},
"Automation": {
"Tools": ["GetTool", "UpdateTool", "DeleteTool", "CreateTool", "EnableTool", "DisableTool", "RepositionTool"],
diff --git a/python/src/basecamp/async_client.py b/python/src/basecamp/async_client.py
index a3e534532b..5db0523daf 100644
--- a/python/src/basecamp/async_client.py
+++ b/python/src/basecamp/async_client.py
@@ -255,7 +255,7 @@ def vaults(self):
@property
def documents(self):
- from basecamp.generated.services.documents import AsyncDocumentsService
+ from basecamp.services.documents import AsyncDocumentsService
return self._service("documents", lambda: AsyncDocumentsService(self))
diff --git a/python/src/basecamp/client.py b/python/src/basecamp/client.py
index 9a75eea947..55961882ab 100644
--- a/python/src/basecamp/client.py
+++ b/python/src/basecamp/client.py
@@ -256,7 +256,7 @@ def vaults(self):
@property
def documents(self):
- from basecamp.generated.services.documents import DocumentsService
+ from basecamp.services.documents import DocumentsService
return self._service("documents", lambda: DocumentsService(self))
diff --git a/python/src/basecamp/generated/metadata.json b/python/src/basecamp/generated/metadata.json
index 4a9ac173f7..d667370c21 100644
--- a/python/src/basecamp/generated/metadata.json
+++ b/python/src/basecamp/generated/metadata.json
@@ -2069,6 +2069,18 @@
]
}
},
+ "ReplaceDocument": {
+ "idempotent": true,
+ "retry": {
+ "backoff": "exponential",
+ "base_delay_ms": 1000,
+ "max": 3,
+ "retry_on": [
+ 429,
+ 503
+ ]
+ }
+ },
"ReplaceTodo": {
"idempotent": true,
"retry": {
@@ -2451,18 +2463,6 @@
]
}
},
- "UpdateDocument": {
- "idempotent": true,
- "retry": {
- "backoff": "exponential",
- "base_delay_ms": 1000,
- "max": 3,
- "retry_on": [
- 429,
- 503
- ]
- }
- },
"UpdateFolder": {
"idempotent": true,
"retry": {
diff --git a/python/src/basecamp/generated/services/documents.py b/python/src/basecamp/generated/services/documents.py
index 225407922e..dab6d983c6 100644
--- a/python/src/basecamp/generated/services/documents.py
+++ b/python/src/basecamp/generated/services/documents.py
@@ -19,13 +19,13 @@ def get(self, *, document_id: int) -> dict[str, Any]:
operation="GetDocument",
)
- def update(self, *, document_id: int, title: str | None = None, content: str | None = None) -> dict[str, Any]:
+ def replace(self, *, document_id: int, title: str | None = None, content: str | None = None) -> dict[str, Any]:
return self._request(
- OperationInfo(service="documents", operation="update", is_mutation=True, resource_id=document_id),
+ OperationInfo(service="documents", operation="replace", is_mutation=True, resource_id=document_id),
"PUT",
f"/documents/{document_id}",
json_body=self._compact(title=title, content=content),
- operation="UpdateDocument",
+ operation="ReplaceDocument",
)
def list(self, *, vault_id: int, page: int | None = None, max_items: int | None = None) -> ListResult:
@@ -71,13 +71,15 @@ async def get(self, *, document_id: int) -> dict[str, Any]:
operation="GetDocument",
)
- async def update(self, *, document_id: int, title: str | None = None, content: str | None = None) -> dict[str, Any]:
+ async def replace(
+ self, *, document_id: int, title: str | None = None, content: str | None = None
+ ) -> dict[str, Any]:
return await self._request(
- OperationInfo(service="documents", operation="update", is_mutation=True, resource_id=document_id),
+ OperationInfo(service="documents", operation="replace", is_mutation=True, resource_id=document_id),
"PUT",
f"/documents/{document_id}",
json_body=self._compact(title=title, content=content),
- operation="UpdateDocument",
+ operation="ReplaceDocument",
)
async def list(self, *, vault_id: int, page: int | None = None, max_items: int | None = None) -> ListResult:
diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py
index 3cc528b163..add9d62782 100644
--- a/python/src/basecamp/generated/types.py
+++ b/python/src/basecamp/generated/types.py
@@ -1409,6 +1409,11 @@ class ReorderUpNextRequestContent(TypedDict):
source_id: int
+class ReplaceDocumentRequestContent(TypedDict):
+ content: NotRequired[str]
+ title: NotRequired[str]
+
+
class ReplaceTodoRequestContent(TypedDict):
assignee_ids: NotRequired[list[int]]
completion_subscriber_ids: NotRequired[list[int]]
@@ -1864,11 +1869,6 @@ class UpdateCommentRequestContent(TypedDict):
content: str
-class UpdateDocumentRequestContent(TypedDict):
- content: NotRequired[str]
- title: NotRequired[str]
-
-
class UpdateFolderRequestContent(TypedDict):
name: str
diff --git a/python/src/basecamp/services/__init__.py b/python/src/basecamp/services/__init__.py
index 45cc61c1c9..17501a1df7 100644
--- a/python/src/basecamp/services/__init__.py
+++ b/python/src/basecamp/services/__init__.py
@@ -1,4 +1,10 @@
from basecamp.services.authorization import AsyncAuthorizationService, AuthorizationService
+from basecamp.services.documents import (
+ AsyncDocumentEdit,
+ AsyncDocumentsService,
+ DocumentEdit,
+ DocumentsService,
+)
from basecamp.services.todolists import (
AsyncTodolistEdit,
AsyncTodolistsService,
@@ -11,6 +17,10 @@
__all__ = [
"AuthorizationService",
"AsyncAuthorizationService",
+ "DocumentsService",
+ "AsyncDocumentsService",
+ "DocumentEdit",
+ "AsyncDocumentEdit",
"TodolistsService",
"AsyncTodolistsService",
"TodolistEdit",
diff --git a/python/src/basecamp/services/_merge_safe.py b/python/src/basecamp/services/_merge_safe.py
new file mode 100644
index 0000000000..39d459be3f
--- /dev/null
+++ b/python/src/basecamp/services/_merge_safe.py
@@ -0,0 +1,150 @@
+"""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.
+
+There are two failure modes and they are the same defect wearing different
+clothes:
+
+* **erasure** — a falsey non-string coalesced to ``""`` by a plain ``or ""``
+ (``False``, ``0``, ``[]``, ``{}`` all become ``""``), wiping the field;
+* **corruption** — a truthy non-string forwarded verbatim (``42``, ``True``,
+ ``["x"]``), writing a number, boolean, array or object where a string
+ belongs.
+
+Testing only the first 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. Python does
+not — the generated services return ``dict[str, Any]``, so nothing rejects a
+wrong-typed field and the check has to be explicit. That is why these guards
+exist in Python, Ruby and TypeScript and nowhere else (#576).
+
+Todolists carries its own copy of these guards (#574, landed a commit earlier);
+it is left alone here because the flat-shape work in #544 owns those files. A
+generated validating layer (#578) is the intended end state for all of them.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from basecamp._security import truncate as _truncate
+from basecamp.errors import ApiError
+
+_RESEND_HINT = (
+ "The merge-safe update/edit resend this field verbatim, so a coerced or empty value "
+ "would overwrite the current one. Use {escape} to write the record deliberately."
+)
+
+
+def describe(value: object) -> str:
+ """Render a value for an error message without ever throwing.
+
+ The guard's own error path must not fail while explaining a failure:
+ ``repr`` is arbitrary user code and can raise. The type name is always
+ available; the rendering is a bonus, capped per SPEC section 9 and dropped
+ if it fails.
+ """
+ kind = type(value).__name__
+ try:
+ return f"{kind} {_truncate(repr(value))}"
+ except Exception:
+ return kind
+
+
+def malformed(message: str, hint: str) -> ApiError:
+ """Build 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.
+ """
+ return ApiError(_truncate(message), hint=hint, retryable=False)
+
+
+def require_mapping(body: object, *, record: str, operation: str, escape: str) -> dict[str, Any]:
+ """The response must be a JSON object before any field is read.
+
+ One level up from the malformed-*field* guards: a successful GET can return
+ a scalar, a list, or null. ``body.get(key)`` raises ``AttributeError`` on a
+ scalar or ``None`` and is absent entirely on a list, so a malformed envelope
+ would surface as a native ``TypeError``/``AttributeError`` instead of the
+ documented statusless ``api_error``.
+ """
+ if not isinstance(body, dict):
+ raise malformed(
+ f"{operation} returned {describe(body)} where a {record.lower()} object was expected",
+ "The merge-safe update/edit read this record's fields before rewriting them, so a "
+ f"non-object body cannot be used. Use {escape} to write the record deliberately.",
+ )
+ return body
+
+
+def writable_string(body: dict[str, Any], key: str, *, record: str, escape: str) -> str:
+ """Read a writable string field, refusing to coerce a malformed one.
+
+ An absent key or an explicit ``None`` 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.
+ """
+ value = body.get(key)
+ if value is None:
+ return ""
+ if not isinstance(value, str):
+ raise malformed(
+ f"{record} field {key!r} is not a string: {describe(value)}",
+ _RESEND_HINT.format(escape=escape),
+ )
+ return value
+
+
+def writable_id_list(body: dict[str, Any], key: str, *, record: str, escape: str) -> list[int]:
+ """Read a list of person records and project it to their integer IDs.
+
+ The analogue of :func:`writable_string` for the ID-list fields. The list
+ comprehension it replaces (``[p["id"] for p in body.get(key) or []]``) has
+ three ways to go wrong on malformed data: a non-list iterates as something
+ else (a string yields characters, a dict yields its keys), a non-mapping
+ element raises ``TypeError``, and a non-integer ``id`` rides through
+ verbatim into the full-replace PUT — the same corruption as a wrong-typed
+ string, one level down.
+
+ ``bool`` is excluded explicitly: it subclasses ``int`` in Python, so
+ ``isinstance(True, int)`` is true and ``True`` would otherwise pass as a
+ person ID.
+ """
+ value = body.get(key)
+ if value is None:
+ return []
+ if not isinstance(value, list):
+ raise malformed(
+ f"{record} field {key!r} is not an array: {describe(value)}",
+ _RESEND_HINT.format(escape=escape),
+ )
+ ids: list[int] = []
+ for index, element in enumerate(value):
+ if not isinstance(element, dict):
+ raise malformed(
+ f"{record} field {key!r}[{index}] is not an object: {describe(element)}",
+ _RESEND_HINT.format(escape=escape),
+ )
+ person_id = element.get("id")
+ if person_id is None:
+ raise malformed(
+ f"{record} field {key!r}[{index}] has no 'id'",
+ _RESEND_HINT.format(escape=escape),
+ )
+ if isinstance(person_id, bool) or not isinstance(person_id, int):
+ raise malformed(
+ f"{record} field {key!r}[{index}].id is not an integer: {describe(person_id)}",
+ _RESEND_HINT.format(escape=escape),
+ )
+ ids.append(person_id)
+ return ids
diff --git a/python/src/basecamp/services/documents.py b/python/src/basecamp/services/documents.py
new file mode 100644
index 0000000000..b232ed87cf
--- /dev/null
+++ b/python/src/basecamp/services/documents.py
@@ -0,0 +1,250 @@
+"""Documents service with merge-safe ``update`` and read-modify-write ``edit``.
+
+``PUT /{accountId}/documents/{documentId}`` is a full replace: BC3's
+``DocumentsController#update`` builds a brand-new ``Document`` from only the
+permitted params and swaps the recordable wholesale, so a sparse PUT that
+omits ``content`` erases it. Omitting ``title`` erases that too — the document
+then reads back as ``"Untitled"``, because ``Document#title`` falls back when
+blank. Neither attribute is presence-validated, so **neither omission is a
+422**; both are a ``200`` that quietly clears. What BC3 does require is the
+wrapping ``document`` object, so a body naming neither field is a ``400``.
+
+Both composites compose the public ``get`` and ``replace`` methods, so hooks
+observe the two wire operations (``get`` then ``replace``), not a synthetic
+composite.
+
+Neither is atomic: there is no conditional-update signal on this endpoint,
+so a concurrent write between the GET and PUT is overwritten — last write
+wins for the whole representation. The window is one round-trip. Use
+``replace`` to overwrite deliberately.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from basecamp.generated.services.documents import (
+ AsyncDocumentsService as _GeneratedAsyncDocumentsService,
+)
+from basecamp.generated.services.documents import DocumentsService as _GeneratedDocumentsService
+from basecamp.services._merge_safe import describe, malformed, require_mapping, writable_string
+
+_ESCAPE = "replace()"
+
+
+def _required_writable_string(body: dict[str, Any], key: str, *, record: str, escape: str) -> str:
+ """Read a writable string the record is *required* to carry.
+
+ :func:`writable_string` treats an absent key or an explicit ``None`` as
+ genuinely empty, which is right for an optional field — ``""`` is what the
+ server already holds. It is wrong for a required one. ``Document.title`` is
+ ``@required`` in the spec and BC3 can never render it blank (``Document#title``
+ is ``super.presence || "Untitled"``), so an absent or null ``title`` in a 2xx
+ body is a malformed response, not an empty title. Coalescing it to ``""`` and
+ sending that in the full-replace PUT would blank the real title on a call that
+ only touched ``content`` — #576's defect exactly: a value the caller never
+ mentioned, silently substituted.
+
+ The wrong-type branch is delegated to :func:`writable_string`, so a required
+ field and an optional one report a non-string identically.
+ """
+ value = body.get(key)
+ if value is None or (isinstance(value, str) and not value.strip()):
+ raise malformed(
+ f'{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 "
+ f"would blank the current one. Use {escape} to write the record deliberately.",
+ )
+ return writable_string(body, key, record=record, escape=escape)
+
+
+def _fields_from_document(document: dict[str, Any]) -> dict[str, Any]:
+ """Derive a document's full writable state from a GET response.
+
+ Every value here is resent in the full-replace PUT, so every value is
+ validated before it is read. A plain ``or ""`` would coerce each falsey
+ non-string (``False``, ``0``, ``[]``, ``{}``) to ``""`` — erasing the field
+ on a call that never mentioned it — and pass ``42``/``True`` straight
+ through to be written verbatim. Python has no typed decoder between the GET
+ and this read (``get`` returns ``dict[str, Any]``), so the check is
+ explicit work here rather than something the layer below already did. See
+ :mod:`basecamp.services._merge_safe` and #576.
+
+ The two writable fields read differently because the spec models them
+ differently: ``title`` is ``@required``, so absent or null is malformed;
+ ``content`` is optional, so absent or null is a genuinely empty body.
+ """
+ body = require_mapping(document, record="Document", operation="GetDocument", escape=_ESCAPE)
+ return {
+ "title": _required_writable_string(body, "title", record="Document", escape=_ESCAPE),
+ "content": writable_string(body, "content", record="Document", escape=_ESCAPE),
+ }
+
+
+def _replace_kwargs(fields: dict[str, Any]) -> dict[str, Any]:
+ """Serialize full writable state for the replace transport.
+
+ Both fields are always sent, empties included: on a full-replace endpoint
+ ``""`` is how a clear is expressed — never JSON null (SPEC section 18 body
+ compaction), and never by omission, which would leave the field to the
+ server's own clear-by-default and read as an accident rather than an
+ intent.
+ """
+ return {"title": fields["title"], "content": fields["content"]}
+
+
+class _DocumentEditBase:
+ """Shared writable state for :class:`DocumentEdit` / :class:`AsyncDocumentEdit`.
+
+ Inside the ``with`` block the edit object exposes the document's full
+ writable state: ``title`` and ``content``. Clearing a field means setting
+ it empty (``""``) — an untouched field keeps its current value.
+ """
+
+ title: str
+ content: str
+
+ def __init__(self, document_id: int) -> None:
+ self._document_id = document_id
+ self._result: dict[str, Any] | None = None
+ self._completed = False
+
+ def _load(self, document: dict[str, Any]) -> None:
+ for key, value in _fields_from_document(document).items():
+ setattr(self, key, value)
+
+ def _fields(self) -> dict[str, Any]:
+ return {"title": self.title, "content": self.content}
+
+ @property
+ def result(self) -> dict[str, Any]:
+ """The updated document, available after the ``with`` block exits cleanly."""
+ if not self._completed:
+ raise RuntimeError("edit has not completed")
+ assert self._result is not None
+ return self._result
+
+
+class DocumentEdit(_DocumentEditBase):
+ """Read-modify-write context manager returned by :meth:`DocumentsService.edit`.
+
+ Entering the block GETs the current document; exiting cleanly PUTs the
+ whole representation back. If the block raises, the edit aborts and
+ nothing is written.
+ """
+
+ def __init__(self, service: DocumentsService, document_id: int) -> None:
+ super().__init__(document_id)
+ self._service = service
+
+ def __enter__(self) -> DocumentEdit:
+ self._load(self._service.get(document_id=self._document_id))
+ return self
+
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
+ if exc_type is None:
+ self._result = self._service.replace(document_id=self._document_id, **_replace_kwargs(self._fields()))
+ self._completed = True
+
+
+class AsyncDocumentEdit(_DocumentEditBase):
+ """Async twin of :class:`DocumentEdit`, for ``async with``."""
+
+ def __init__(self, service: AsyncDocumentsService, document_id: int) -> None:
+ super().__init__(document_id)
+ self._service = service
+
+ async def __aenter__(self) -> AsyncDocumentEdit:
+ self._load(await self._service.get(document_id=self._document_id))
+ return self
+
+ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
+ if exc_type is None:
+ self._result = await self._service.replace(document_id=self._document_id, **_replace_kwargs(self._fields()))
+ self._completed = True
+
+
+def _overlay(fields: dict[str, Any], **updates: Any) -> dict[str, Any]:
+ for key, value in updates.items():
+ if value is not None:
+ fields[key] = value
+ return fields
+
+
+class DocumentsService(_GeneratedDocumentsService):
+ """Documents service with merge-safe ``update`` and ``edit`` on top of the
+ generated surface (``get``, ``replace``, ...)."""
+
+ def update(
+ self,
+ *,
+ document_id: int,
+ title: str | None = None,
+ content: str | None = None,
+ ) -> dict[str, Any]:
+ """Set the given fields on a document and preserve everything else.
+
+ GETs the current document, overlays the explicitly-passed keyword
+ arguments, and PUTs the full representation back. An omitted
+ (``None``) field is untouched, guaranteed; an explicitly-passed
+ ``""`` clears.
+
+ Not atomic: a concurrent write between the GET and PUT is
+ overwritten (last write wins for the whole representation; the
+ window is one round-trip). Use :meth:`replace` to overwrite
+ deliberately.
+ """
+ fields = _overlay(
+ _fields_from_document(self.get(document_id=document_id)),
+ title=title,
+ content=content,
+ )
+ return self.replace(document_id=document_id, **_replace_kwargs(fields))
+
+ def edit(self, *, document_id: int) -> DocumentEdit:
+ """Open a read-modify-write edit of a document, as a context manager.
+
+ Entering the ``with`` block GETs the current document and exposes its
+ full writable state; exiting cleanly PUTs the whole representation
+ back. Clearing a field means setting it empty (``""``). If the block
+ raises, nothing is written. The updated document is available as
+ ``.result`` after the block::
+
+ with client.documents.edit(document_id=123) as d:
+ d.title = f"🚨 {d.title}"
+ d.content = "" # clearing = setting empty on a full object
+ updated = d.result
+
+ Not atomic: a concurrent write between the GET and PUT is
+ overwritten (last write wins for the whole representation; the
+ window is one round-trip).
+ """
+ return DocumentEdit(self, document_id)
+
+
+class AsyncDocumentsService(_GeneratedAsyncDocumentsService):
+ """Async documents service with merge-safe ``update`` and ``edit``."""
+
+ async def update(
+ self,
+ *,
+ document_id: int,
+ title: str | None = None,
+ content: str | None = None,
+ ) -> dict[str, Any]:
+ """Async twin of :meth:`DocumentsService.update`."""
+ fields = _overlay(
+ _fields_from_document(await self.get(document_id=document_id)),
+ title=title,
+ content=content,
+ )
+ return await self.replace(document_id=document_id, **_replace_kwargs(fields))
+
+ def edit(self, *, document_id: int) -> AsyncDocumentEdit:
+ """Async twin of :meth:`DocumentsService.edit`, for ``async with``::
+
+ async with client.documents.edit(document_id=123) as d:
+ d.title = f"🚨 {d.title}"
+ updated = d.result
+ """
+ return AsyncDocumentEdit(self, document_id)
diff --git a/python/tests/services/test_documents.py b/python/tests/services/test_documents.py
new file mode 100644
index 0000000000..e91c713b68
--- /dev/null
+++ b/python/tests/services/test_documents.py
@@ -0,0 +1,523 @@
+"""Tests for the documents merge-safe update / edit / replace surface (sync + async).
+
+``PUT /documents/{id}`` is a full replace: BC3 rebuilds the Document from the
+permitted params and swaps the recordable wholesale. The writable set is exactly
+``{title, content}`` and **both are optional** — omitting ``title`` is a 200 that
+leaves the document reading back as "Untitled", omitting ``content`` is a 200
+that clears it. Neither omission is a 422, so nothing on the wire tells you the
+sparse PUT went wrong; only the next GET does.
+
+That is what ``update`` and ``edit`` exist to prevent, and what these tests pin:
+every PUT they issue names both fields, empties included, never JSON null.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import httpx
+import pytest
+import respx
+
+from basecamp import AsyncClient, Client
+from basecamp.errors import ApiError
+from basecamp.hooks import BasecampHooks, OperationInfo
+
+BASE = "https://3.basecampapi.com/12345"
+
+_FIXTURES = Path(__file__).resolve().parents[3] / "spec" / "fixtures"
+
+
+def load_fixture(rel: str) -> dict:
+ return json.loads((_FIXTURES / rel).read_text(encoding="utf-8"))
+
+
+def _document(document_id: int = 5001, **overrides) -> dict:
+ # Source the full validated fixture for shape (every required Document field
+ # is present), then keep the test-critical override values that the
+ # assertions verify flow through to the PUT body.
+ return {
+ **load_fixture("documents/get.json"),
+ "id": document_id,
+ "title": "Project Overview",
+ "content": "The plan so far.
",
+ **overrides,
+ }
+
+
+def _put_body(route) -> dict:
+ return json.loads(route.calls[-1].request.content)
+
+
+class _RecordingHooks(BasecampHooks):
+ def __init__(self) -> None:
+ self.operations: list[str] = []
+
+ def on_operation_start(self, info: OperationInfo) -> None:
+ self.operations.append(f"{info.service}.{info.operation}")
+
+
+def _sync_documents(hooks: BasecampHooks | None = None):
+ return Client(access_token="test-token", hooks=hooks).for_account("12345").documents
+
+
+def _async_documents(hooks: BasecampHooks | None = None):
+ return AsyncClient(access_token="test-token", hooks=hooks).for_account("12345").documents
+
+
+def _routes(document: dict | None = None):
+ body = _document() if document is None else document
+ get_route = respx.get(f"{BASE}/documents/5001").mock(return_value=httpx.Response(200, json=body))
+ put_route = respx.put(f"{BASE}/documents/5001").mock(return_value=httpx.Response(200, json=_document()))
+ return get_route, put_route
+
+
+class TestSyncUpdate:
+ @respx.mock
+ def test_merges_unset_content(self):
+ get_route, put_route = _routes()
+
+ result = _sync_documents().update(document_id=5001, title="Q3 Plan")
+
+ assert result["id"] == 5001
+ assert get_route.called
+ body = _put_body(put_route)
+ assert body["title"] == "Q3 Plan"
+ # The field the caller never mentioned rides back verbatim. A sparse PUT
+ # here would have been a silent 200 that erased it.
+ assert body["content"] == "The plan so far.
"
+
+ @respx.mock
+ def test_merges_unset_title(self):
+ _, put_route = _routes()
+
+ _sync_documents().update(document_id=5001, content="Rewritten.
")
+
+ body = _put_body(put_route)
+ assert body["content"] == "Rewritten.
"
+ # Omitting title on the wire is a 200 that leaves the document titled
+ # "Untitled" — never a 422 — so preservation is the only defence.
+ assert body["title"] == "Project Overview"
+
+ @respx.mock
+ def test_explicit_empty_string_clears_content(self):
+ _, put_route = _routes()
+
+ _sync_documents().update(document_id=5001, content="")
+
+ body = _put_body(put_route)
+ assert body["content"] == ""
+ assert "content" in body, "a clear is an empty string, never an omission"
+ assert body["title"] == "Project Overview"
+
+ @respx.mock
+ def test_explicit_empty_string_clears_title(self):
+ _, put_route = _routes()
+
+ _sync_documents().update(document_id=5001, title="")
+
+ body = _put_body(put_route)
+ assert body["title"] == ""
+ assert body["content"] == "The plan so far.
"
+
+ @respx.mock
+ def test_never_sends_json_null(self):
+ _, put_route = _routes()
+
+ _sync_documents().update(document_id=5001, title="Q3 Plan", content="")
+
+ body = _put_body(put_route)
+ assert set(body) == {"title", "content"}
+ assert all(value is not None for value in body.values())
+
+ @respx.mock
+ def test_hooks_observe_get_then_replace(self):
+ _routes()
+
+ hooks = _RecordingHooks()
+ _sync_documents(hooks).update(document_id=5001, title="observed")
+
+ assert hooks.operations == ["documents.get", "documents.replace"]
+
+
+class TestSyncEdit:
+ @respx.mock
+ def test_edit_puts_full_state_back(self):
+ _, put_route = _routes()
+
+ with _sync_documents().edit(document_id=5001) as d:
+ assert d.title == "Project Overview"
+ assert d.content == "The plan so far.
"
+ d.title = f"🚨 {d.title}"
+
+ assert d.result["id"] == 5001
+ body = _put_body(put_route)
+ assert body["title"] == "🚨 Project Overview"
+ assert body["content"] == "The plan so far.
"
+
+ @respx.mock
+ def test_clear_content_present_and_empty(self):
+ _, put_route = _routes()
+
+ with _sync_documents().edit(document_id=5001) as d:
+ d.content = ""
+
+ body = _put_body(put_route)
+ # Present and empty, not omitted: on a full-replace endpoint an omission
+ # is the server's own clear-by-default and reads as an accident.
+ assert "content" in body
+ assert body["content"] == ""
+ assert body["title"] == "Project Overview"
+
+ @respx.mock
+ def test_clear_title_present_and_empty(self):
+ _, put_route = _routes()
+
+ with _sync_documents().edit(document_id=5001) as d:
+ d.title = ""
+
+ body = _put_body(put_route)
+ assert "title" in body
+ assert body["title"] == ""
+ assert body["content"] == "The plan so far.
"
+
+ @respx.mock
+ def test_exception_aborts_without_put(self):
+ _, put_route = _routes()
+
+ with pytest.raises(RuntimeError, match="abort"), _sync_documents().edit(document_id=5001) as d:
+ d.content = "never written"
+ raise RuntimeError("abort")
+
+ assert not put_route.called
+
+ @respx.mock
+ def test_result_raises_before_completion(self):
+ _routes()
+
+ edit = _sync_documents().edit(document_id=5001)
+ with pytest.raises(RuntimeError, match="edit has not completed"):
+ _ = edit.result
+
+ @respx.mock
+ def test_hooks_observe_get_then_replace(self):
+ _routes()
+
+ hooks = _RecordingHooks()
+ with _sync_documents(hooks).edit(document_id=5001) as d:
+ d.title = "observed"
+
+ assert hooks.operations == ["documents.get", "documents.replace"]
+
+
+class TestSyncReplace:
+ @respx.mock
+ def test_sparse_replace_issues_no_get_and_omits_unset(self):
+ get_route, put_route = _routes()
+
+ result = _sync_documents().replace(document_id=5001, title="the whole new document")
+
+ assert result["id"] == 5001
+ assert not get_route.called, "replace is the deliberate overwrite — it reads nothing first"
+ body = _put_body(put_route)
+ assert body["title"] == "the whole new document"
+ # Sent verbatim: the unset field is omitted and the server clears it.
+ assert "content" not in body
+
+
+class TestAsyncUpdate:
+ @respx.mock
+ @pytest.mark.asyncio
+ async def test_merges_unset_content(self):
+ get_route, put_route = _routes()
+
+ result = await _async_documents().update(document_id=5001, title="Q3 Plan")
+
+ assert result["id"] == 5001
+ assert get_route.called
+ body = _put_body(put_route)
+ assert body["title"] == "Q3 Plan"
+ assert body["content"] == "The plan so far.
"
+
+ @respx.mock
+ @pytest.mark.asyncio
+ async def test_explicit_empty_string_clears_content(self):
+ _, put_route = _routes()
+
+ await _async_documents().update(document_id=5001, content="")
+
+ body = _put_body(put_route)
+ assert "content" in body
+ assert body["content"] == ""
+ assert body["title"] == "Project Overview"
+
+ @respx.mock
+ @pytest.mark.asyncio
+ async def test_hooks_observe_get_then_replace(self):
+ _routes()
+
+ hooks = _RecordingHooks()
+ await _async_documents(hooks).update(document_id=5001, title="observed")
+
+ assert hooks.operations == ["documents.get", "documents.replace"]
+
+
+class TestAsyncEdit:
+ @respx.mock
+ @pytest.mark.asyncio
+ async def test_edit_puts_full_state_back(self):
+ _, put_route = _routes()
+
+ async with _async_documents().edit(document_id=5001) as d:
+ assert d.content == "The plan so far.
"
+ d.title = f"🚨 {d.title}"
+
+ assert d.result["id"] == 5001
+ body = _put_body(put_route)
+ assert body["title"] == "🚨 Project Overview"
+ assert body["content"] == "The plan so far.
"
+
+ @respx.mock
+ @pytest.mark.asyncio
+ async def test_clear_content_present_and_empty(self):
+ _, put_route = _routes()
+
+ async with _async_documents().edit(document_id=5001) as d:
+ d.content = ""
+
+ body = _put_body(put_route)
+ assert "content" in body
+ assert body["content"] == ""
+ assert body["title"] == "Project Overview"
+
+ @respx.mock
+ @pytest.mark.asyncio
+ async def test_exception_aborts_without_put(self):
+ _, put_route = _routes()
+
+ with pytest.raises(RuntimeError, match="abort"):
+ async with _async_documents().edit(document_id=5001) as d:
+ d.content = "never written"
+ raise RuntimeError("abort")
+
+ assert not put_route.called
+
+ @respx.mock
+ @pytest.mark.asyncio
+ async def test_result_raises_before_completion(self):
+ _routes()
+
+ edit = _async_documents().edit(document_id=5001)
+ with pytest.raises(RuntimeError, match="edit has not completed"):
+ _ = edit.result
+
+ @respx.mock
+ @pytest.mark.asyncio
+ async def test_hooks_observe_get_then_replace(self):
+ _routes()
+
+ hooks = _RecordingHooks()
+ async with _async_documents(hooks).edit(document_id=5001) as d:
+ d.title = "observed"
+
+ assert hooks.operations == ["documents.get", "documents.replace"]
+
+
+class TestAsyncReplace:
+ @respx.mock
+ @pytest.mark.asyncio
+ async def test_sparse_replace_issues_no_get(self):
+ get_route, put_route = _routes()
+
+ await _async_documents().replace(document_id=5001, title="verbatim")
+
+ assert not get_route.called
+ body = _put_body(put_route)
+ assert body["title"] == "verbatim"
+ assert "content" not in body
+
+
+# --- #576: a malformed GET field must never reach the full-replace PUT -------
+#
+# `update`/`edit` GET the document, read each writable field, and PUT the FULL
+# representation back. Every value read is therefore written, including one the
+# caller never mentioned. A plain `body.get(key) or ""` coerces each falsey
+# non-string to `""` (erasure) and passes `42`/`True` through verbatim
+# (corruption). Python has no typed decoder between the GET and the read — the
+# generated `get` returns `dict[str, Any]` — so the refusal is explicit.
+#
+# The assertion that matters is the ORDERING: exactly one request may leave the
+# client. A guard that fires after the PUT has already lost the field.
+
+_MALFORMED = [
+ pytest.param(False, id="false"),
+ pytest.param(0, id="zero"),
+ pytest.param([], id="empty-list"),
+ pytest.param({}, id="empty-dict"),
+ pytest.param(42, id="number"),
+ pytest.param(True, id="true"),
+ pytest.param(["x"], id="list"),
+ pytest.param({"a": 1}, id="dict"),
+]
+
+_WRITABLE_STRINGS = ["title", "content"]
+
+
+class TestMalformedResponseFields:
+ @respx.mock
+ @pytest.mark.parametrize("field", _WRITABLE_STRINGS)
+ @pytest.mark.parametrize("value", _MALFORMED)
+ def test_update_refuses_a_non_string_before_writing(self, field, value):
+ get_route, put_route = _routes(_document(**{field: value}))
+
+ with pytest.raises(ApiError) as excinfo:
+ _sync_documents().update(document_id=5001, title="New title")
+
+ assert f"Document field {field!r} is not a string" in str(excinfo.value)
+ # api_error, not usage: the value arrived in a successful response.
+ assert excinfo.value.code == "api_error"
+ assert get_route.called
+ assert not put_route.called, "the guard must fire BEFORE the full-replace PUT"
+ assert respx.calls.call_count == 1
+
+ @respx.mock
+ @pytest.mark.parametrize("field", _WRITABLE_STRINGS)
+ def test_edit_refuses_a_non_string_before_writing(self, field):
+ get_route, put_route = _routes(_document(**{field: 42}))
+
+ with pytest.raises(ApiError) as excinfo, _sync_documents().edit(document_id=5001) as d:
+ d.title = "New title"
+
+ assert f"Document field {field!r} is not a string" in str(excinfo.value)
+ assert get_route.called
+ assert not put_route.called
+ assert respx.calls.call_count == 1
+
+ @respx.mock
+ @pytest.mark.parametrize("field", _WRITABLE_STRINGS)
+ @pytest.mark.asyncio
+ async def test_async_update_refuses_a_non_string_before_writing(self, field):
+ get_route, put_route = _routes(_document(**{field: ["x"]}))
+
+ with pytest.raises(ApiError) as excinfo:
+ await _async_documents().update(document_id=5001, title="New title")
+
+ assert f"Document field {field!r} is not a string" in str(excinfo.value)
+ assert get_route.called
+ assert not put_route.called
+ assert respx.calls.call_count == 1
+
+ @respx.mock
+ @pytest.mark.parametrize("field", _WRITABLE_STRINGS)
+ @pytest.mark.asyncio
+ async def test_async_edit_refuses_a_non_string_before_writing(self, field):
+ _, put_route = _routes(_document(**{field: 42}))
+
+ with pytest.raises(ApiError):
+ async with _async_documents().edit(document_id=5001) as d:
+ d.title = "New title"
+
+ assert not put_route.called
+ assert respx.calls.call_count == 1
+
+ @respx.mock
+ @pytest.mark.parametrize("absent", [True, False], ids=["absent", "null"])
+ def test_absent_and_null_content_stays_genuinely_empty(self, absent):
+ # The other half of the rule: for an OPTIONAL field, absent and null
+ # are not malformed, they are empty. Guarding types must not turn a
+ # legitimately blank field into an error. The call sets the other
+ # writable string so that ``content`` is never overwritten by the
+ # caller.
+ #
+ # ``content`` only. ``title`` is ``@required`` in the spec and gets the
+ # opposite treatment below.
+ document = _document()
+ if absent:
+ document.pop("content", None)
+ else:
+ document["content"] = None
+ _, put_route = _routes(document)
+
+ _sync_documents().update(document_id=5001, title="set by the caller")
+
+ body = _put_body(put_route)
+ assert body["content"] == ""
+ assert body["title"] == "set by the caller"
+
+ # ``Document.title`` is ``@required`` in the spec, and BC3 can never render
+ # it blank (``Document#title`` is ``super.presence or "Untitled"``). So an
+ # absent or null title in a 2xx body is a MALFORMED RESPONSE, not an empty
+ # title — and coalescing it to ``""`` would blank the real title on a call
+ # that only touched ``content``. Same defect class as a forwarded
+ # non-string, in the one shape ``or ""`` looks correct.
+ @respx.mock
+ # BC3 can never render a blank title, so "" is malformed too — and it is
+ # the shape a missing/null check alone would let through.
+ @pytest.mark.parametrize("mangle", ["absent", "null", "blank", "whitespace"])
+ def test_update_refuses_an_absent_title_before_writing(self, mangle):
+ document = _document()
+ if mangle == "absent":
+ document.pop("title", None)
+ else:
+ document["title"] = {"null": None, "blank": "", "whitespace": " "}[mangle]
+ _, put_route = _routes(document)
+
+ with pytest.raises(ApiError, match=r'field "title" is required'):
+ _sync_documents().update(document_id=5001, content="New body.
")
+
+ assert not put_route.called
+ assert respx.calls.call_count == 1
+
+ @respx.mock
+ @pytest.mark.parametrize("mangle", ["absent", "null", "blank", "whitespace"])
+ def test_edit_refuses_an_absent_title_before_writing(self, mangle):
+ document = _document()
+ if mangle == "absent":
+ document.pop("title", None)
+ else:
+ document["title"] = {"null": None, "blank": "", "whitespace": " "}[mangle]
+ _, put_route = _routes(document)
+
+ with (
+ pytest.raises(ApiError, match=r'field "title" is required'),
+ _sync_documents().edit(document_id=5001) as d,
+ ):
+ d.content = "New body.
"
+
+ assert not put_route.called
+ assert respx.calls.call_count == 1
+
+ @respx.mock
+ @pytest.mark.parametrize("raw", [b"[]", b'"document"', b"42", b"null", b"true"])
+ def test_update_refuses_a_non_object_response_before_writing(self, raw):
+ # One level up from the field guards: a successful GET can return a
+ # scalar, a list or null, and `body.get(key)` would raise a raw
+ # AttributeError instead of the documented statusless api_error.
+ get_route = respx.get(f"{BASE}/documents/5001").mock(
+ return_value=httpx.Response(200, content=raw, headers={"Content-Type": "application/json"})
+ )
+ put_route = respx.put(f"{BASE}/documents/5001").mock(return_value=httpx.Response(200, json=_document()))
+
+ with pytest.raises(ApiError) as excinfo:
+ _sync_documents().update(document_id=5001, title="New title")
+
+ assert "GetDocument returned" in str(excinfo.value)
+ assert excinfo.value.code == "api_error"
+ assert get_route.called
+ assert not put_route.called
+ assert respx.calls.call_count == 1
+
+ @respx.mock
+ @pytest.mark.parametrize("raw", [b"[]", b"null"])
+ def test_edit_refuses_a_non_object_response_before_writing(self, raw):
+ respx.get(f"{BASE}/documents/5001").mock(
+ return_value=httpx.Response(200, content=raw, headers={"Content-Type": "application/json"})
+ )
+ put_route = respx.put(f"{BASE}/documents/5001").mock(return_value=httpx.Response(200, json=_document()))
+
+ with pytest.raises(ApiError), _sync_documents().edit(document_id=5001) as d:
+ d.title = "New title"
+
+ assert not put_route.called
+ assert respx.calls.call_count == 1
diff --git a/ruby/lib/basecamp.rb b/ruby/lib/basecamp.rb
index 7367d60a49..b31443ce49 100644
--- a/ruby/lib/basecamp.rb
+++ b/ruby/lib/basecamp.rb
@@ -20,6 +20,11 @@
loader.on_load("Basecamp::Services::TodolistsService") do |klass, _abspath|
klass.prepend(Basecamp::Services::TodolistsExtensions)
end
+# And for documents: PUT /documents/{id} is a full replace, so the generated
+# class owns `replace` and the merge-safe update/edit surface is prepended.
+loader.on_load("Basecamp::Services::DocumentsService") do |klass, _abspath|
+ klass.prepend(Basecamp::Services::DocumentsExtensions)
+end
loader.setup
# Load generated types if available
diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json
index 83b05d4c31..9bc40e537f 100644
--- a/ruby/lib/basecamp/generated/metadata.json
+++ b/ruby/lib/basecamp/generated/metadata.json
@@ -1,7 +1,7 @@
{
"$schema": "https://basecamp.com/schemas/sdk-metadata.json",
"version": "1.0.0",
- "generated": "2026-08-03T05:51:51Z",
+ "generated": "2026-08-03T07:11:03Z",
"operations": {
"GetAccount": {
"retry": {
@@ -1004,7 +1004,7 @@
]
}
},
- "UpdateDocument": {
+ "ReplaceDocument": {
"retry": {
"maxAttempts": 3,
"baseDelayMs": 1000,
diff --git a/ruby/lib/basecamp/generated/services/documents_service.rb b/ruby/lib/basecamp/generated/services/documents_service.rb
index ad6a15246a..264f2879f2 100644
--- a/ruby/lib/basecamp/generated/services/documents_service.rb
+++ b/ruby/lib/basecamp/generated/services/documents_service.rb
@@ -16,13 +16,13 @@ def get(document_id:)
end
end
- # Update an existing document
+ # Replace a document with a new complete representation.
# @param document_id [Integer] document id ID
# @param title [String, nil] title
# @param content [String, nil] content
# @return [Hash] response data
- def update(document_id:, title: nil, content: nil)
- with_operation(service: "documents", operation: "update", is_mutation: true, resource_id: document_id) do
+ def replace(document_id:, title: nil, content: nil)
+ with_operation(service: "documents", operation: "replace", is_mutation: true, resource_id: document_id) do
http_put("/documents/#{document_id}", body: compact_params(title: title, content: content)).json
end
end
diff --git a/ruby/lib/basecamp/services/documents_extensions.rb b/ruby/lib/basecamp/services/documents_extensions.rb
new file mode 100644
index 0000000000..ed4b527374
--- /dev/null
+++ b/ruby/lib/basecamp/services/documents_extensions.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+module Basecamp
+ module Services
+ # Merge-safe +update+ and read-modify-write +edit+ for documents,
+ # prepended onto the generated {DocumentsService} (see the +on_load+ hook
+ # in +basecamp.rb+).
+ #
+ # BC3's +DocumentsController#update+ builds a brand-new +Document+ from
+ # only the permitted params and swaps the recordable wholesale, so
+ # PUT /documents/{id} is a full replace: a body that omits
+ # +content+ ERASES it, and one that omits +title+ erases that too — the
+ # document then reads back as "Untitled", because +Document#title+
+ # falls back when blank. Neither attribute is presence-validated, so
+ # *neither omission is a 422*; both are a 200 that quietly clears. What BC3
+ # does require is the wrapping +document+ object, so a body naming neither
+ # field is a 400. The sparse PUT — the natural thing to write — is
+ # therefore destructive on the raw endpoint, which stays available as
+ # {#replace}.
+ #
+ # Both compose the public +get+ and +replace+ methods, so hooks observe
+ # the two wire operations (+get+ then +replace+), not a synthetic
+ # composite.
+ #
+ # Neither is atomic: there is no conditional-update signal on this
+ # endpoint, so a concurrent write between the GET and PUT is
+ # overwritten — last write wins for the whole representation. The
+ # window is one round-trip. Use +replace+ to overwrite deliberately.
+ module DocumentsExtensions
+ # A document's full writable state, yielded to the +edit+ block. The
+ # whole struct is PUT back to the server, so clearing a field means
+ # setting it empty ("") — there is no third state. The writable
+ # set is exactly what BC3 permits: +title+ and +content+.
+ DocumentFields = Struct.new(:title, :content, keyword_init: true)
+
+ # The deliberate-overwrite escape hatch named in every malformed-response
+ # hint raised out of this composite.
+ ESCAPE_HATCH = "replace"
+
+ # Sets the given fields on a document and preserves everything else:
+ # GETs the current document, overlays the explicitly-passed keyword
+ # arguments, and PUTs the full representation back. An omitted (+nil+)
+ # field is untouched, guaranteed; an explicitly-passed ""
+ # clears.
+ #
+ # Not atomic — see the module docs for the GET→PUT race. Use {#replace}
+ # to overwrite deliberately.
+ #
+ # @param document_id [Integer] document id
+ # @param title [String, nil] new title (nil = keep current, "" clears)
+ # @param content [String, nil] new content (nil = keep current, "" clears)
+ # @return [Hash] the updated document
+ def update(document_id:, title: nil, content: nil)
+ fields = fields_from_document(get(document_id: document_id))
+ fields.title = title unless title.nil?
+ fields.content = content unless content.nil?
+ put_fields(document_id, fields)
+ end
+
+ # Applies a read-modify-write block to a document: GETs the current
+ # document, yields its full writable state ({DocumentFields}), and PUTs
+ # the whole thing back. Clearing a field means setting it empty
+ # ("") — an untouched field keeps its current value. If the
+ # block raises, the edit aborts and nothing is written.
+ #
+ # Not atomic — see the module docs for the GET→PUT race.
+ #
+ # @example
+ # account.documents.edit(document_id: 123) do |doc|
+ # doc.title = "🚨 #{doc.title}"
+ # doc.content = "" # clearing = setting empty on a full object
+ # end
+ #
+ # @param document_id [Integer] document id
+ # @yieldparam fields [DocumentFields] the document's writable state, to mutate in place
+ # @return [Hash] the updated document
+ # @raise [ArgumentError] if no block is given
+ def edit(document_id:)
+ raise ArgumentError, "edit requires a block" unless block_given?
+
+ fields = fields_from_document(get(document_id: document_id))
+ yield fields
+ put_fields(document_id, fields)
+ end
+
+ private
+
+ # Derives the full writable state from a GET response.
+ #
+ # Every value here is resent in the full-replace PUT, so every value is
+ # validated before it is read. A plain || "" would turn +false+
+ # into "" — erasing the field on a call that never mentioned
+ # it — and pass arrays, hashes, numbers and +true+ straight through to be
+ # written verbatim. Ruby has no typed decoder between the GET and this
+ # read (+get+ returns a raw Hash), so the check is explicit work here
+ # rather than something the layer below already did. See {MergeSafe} and
+ # #576.
+ #
+ # The two writable fields read differently because the spec models them
+ # differently: +title+ is @required, so absent or nil is
+ # malformed; +content+ is optional, so absent or nil is a genuinely empty
+ # body.
+ def fields_from_document(document)
+ body = MergeSafe.require_hash(
+ document, record: "Document", operation: "GetDocument", escape: ESCAPE_HATCH
+ )
+ DocumentFields.new(
+ title: required_writable_string(body, "title"),
+ content: MergeSafe.writable_string(body, "content", record: "Document", escape: ESCAPE_HATCH)
+ )
+ end
+
+ # Reads a writable string the record is *required* to carry.
+ #
+ # +MergeSafe.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.
+ # +Document.title+ is @required in the spec and BC3 can never
+ # render it blank (+Document#title+ is
+ # super.presence || "Untitled"), so an absent or nil +title+ in a
+ # 2xx body is a malformed response, not an empty title. Coalescing it to
+ # "" and sending that in the full-replace PUT would blank the real
+ # title on a call that only touched +content+ — #576's defect exactly: a
+ # value the caller never mentioned, silently substituted.
+ #
+ # The wrong-type branch is delegated to +MergeSafe.writable_string+, so a
+ # required field and an optional one report a non-string identically.
+ def required_writable_string(body, key)
+ value = body[key]
+ if value.nil? || (value.is_a?(String) && value.strip.empty?)
+ raise MergeSafe.malformed(
+ %(Document field "#{key}" is required but the response carried #{MergeSafe.describe(value)}),
+ "The merge-safe update/edit resend this field verbatim, so a missing or blank value " \
+ "would blank the current one. Use #{ESCAPE_HATCH} to write the record deliberately."
+ )
+ end
+
+ MergeSafe.writable_string(body, key, record: "Document", escape: ESCAPE_HATCH)
+ end
+
+ # PUTs the full writable state via +replace+. Both fields are always
+ # sent, empties included: the generated layer's +compact_params+ strips
+ # nils, so a cleared field travels as "" rather than JSON null
+ # (SPEC section 18 body compaction) — and omitting it would hand the
+ # clear back to the server's rebuild instead of stating it.
+ #
+ # +nil+ is normalised to "" rather than coerced with +to_s+: the
+ # struct starts nil-valued and clearing by assigning nil is idiomatic,
+ # but +to_s+ would silently turn a block's +42+ into "42" and
+ # write it — the same corruption {MergeSafe} refuses on the read side.
+ # Validating what the *caller* assigns is the mirror of that rule and is
+ # deliberately out of scope here, exactly as in #576: a value the caller
+ # chose is not a value silently substituted for one they asked to
+ # preserve.
+ def put_fields(document_id, fields)
+ replace(
+ document_id: document_id,
+ title: fields.title.nil? ? "" : fields.title,
+ content: fields.content.nil? ? "" : fields.content
+ )
+ end
+ end
+ end
+end
diff --git a/ruby/lib/basecamp/services/merge_safe.rb b/ruby/lib/basecamp/services/merge_safe.rb
new file mode 100644
index 0000000000..b243d56fa8
--- /dev/null
+++ b/ruby/lib/basecamp/services/merge_safe.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+module Basecamp
+ module Services
+ # 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 +false+ into "", 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, landed a commit
+ # earlier); it is left alone here because the flat-shape work in #544 owns
+ # those files. A generated validating layer (#578) is the intended end
+ # state for all of them.
+ module MergeSafe
+ RESEND_HINT = "The merge-safe update/edit resend this field verbatim, so a coerced or " \
+ "empty value would overwrite the current one. Use %s to write the record " \
+ "deliberately."
+
+ module_function
+
+ # 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.
+ def describe(value)
+ kind = value.class.to_s
+ begin
+ Security.truncate("#{kind} #{value.inspect}")
+ rescue StandardError
+ kind
+ end
+ end
+
+ # 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.
+ def malformed(message, hint)
+ ApiError.new(Security.truncate(message), hint: hint, retryable: false)
+ end
+
+ # 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["due_on"] 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+.
+ 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
+
+ # 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.
+ 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
+
+ # 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[key] || []).map { |p| p["id"] }) 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.
+ 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
+
+ # Validates one element of an id-list field and returns its id.
+ 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
+ end
+ end
+end
diff --git a/ruby/scripts/generate-services.rb b/ruby/scripts/generate-services.rb
index 6ec234b44b..4ecab18af8 100644
--- a/ruby/scripts/generate-services.rb
+++ b/ruby/scripts/generate-services.rb
@@ -67,7 +67,7 @@ class ServiceGenerator
'Attachments' => %w[CreateAttachment],
'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions],
'Vaults' => %w[GetVault UpdateVault ListVaults CreateVault],
- 'Documents' => %w[GetDocument UpdateDocument ListDocuments CreateDocument]
+ 'Documents' => %w[GetDocument ReplaceDocument ListDocuments CreateDocument]
},
'Automation' => {
'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool],
diff --git a/ruby/test/basecamp/services/documents_service_test.rb b/ruby/test/basecamp/services/documents_service_test.rb
index 13855586ab..f7c45d961c 100644
--- a/ruby/test/basecamp/services/documents_service_test.rb
+++ b/ruby/test/basecamp/services/documents_service_test.rb
@@ -1,9 +1,22 @@
# frozen_string_literal: true
-# Tests for the DocumentsService (generated from OpenAPI spec)
+# Tests for the DocumentsService.
#
-# Note: Generated services are spec-conformant:
-# - Single-resource paths without .json (get, update)
+# Two layers here:
+#
+# * the generated, spec-conformant surface — list, get, create, and the raw
+# full-replace +replace+ — on single-resource paths without .json;
+# * the hand-written merge-safe composites +update+ and +edit+, prepended by
+# the +on_load+ hook in basecamp.rb.
+#
+# BC3's DocumentsController#update rebuilds the recordable from only the
+# permitted params, so PUT /documents/{id} is a FULL REPLACE: a body that omits
+# +content+ erases it, and one that omits +title+ erases that too (the document
+# then reads back as "Untitled"). Neither field is presence-validated, so
+# neither omission is a 422 — both are a 200 that quietly clears, and nothing
+# but the request body distinguishes a preserve from a clear. That is why the
+# composites exist, and why these tests assert on captured bodies rather than
+# on the response.
require "test_helper"
@@ -19,6 +32,31 @@ def sample_document(id: nil, title: nil)
fixture.merge "id" => id || fixture["id"], "title" => title || fixture["title"]
end
+ # The canonical fixture with a known title and content, so "preserved" and
+ # "cleared" are distinguishable in the PUT body.
+ def full_document(**overrides)
+ load_fixture("documents/get.json").merge(
+ "id" => 200,
+ "title" => "Project Overview",
+ "content" => "From the store
"
+ ).merge(overrides)
+ end
+
+ # Captures every PUT body so a test can assert the exact request count and
+ # the exact bytes, not just "a PUT happened".
+ def capture_put(response)
+ captured = { bodies: [] }
+ stub_request(:put, "#{BASE_URL}/12345/documents/200")
+ .with { |req| captured[:bodies] << JSON.parse(req.body) }
+ .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" })
+ captured
+ end
+
+ def stub_document_get_and_put(document: full_document)
+ stub_get("/12345/documents/200", response_body: document)
+ capture_put(document)
+ end
+
def test_list_documents
stub_get("/12345/vaults/200/documents.json",
response_body: [ sample_document, sample_document(id: 2, title: "Project Plan") ])
@@ -81,17 +119,398 @@ def test_create_with_subscriptions
body: hash_including("subscriptions" => [ 111, 222 ]))
end
- def test_update_document
- # Generated service: /documents/{id} without .json
- updated_document = sample_document(id: 200, title: "Updated Title")
- stub_put("/12345/documents/200", response_body: updated_document)
+ # ---------------------------------------------------------------------
+ # replace: the server-native verbatim PUT.
+ #
+ # Sharp by construction — every field the body omits, the server clears.
+ # +replace+ keeps that raw operation reachable; the composites below blunt it.
+ # ---------------------------------------------------------------------
- document = @account.documents.update(
- document_id: 200,
- title: "Updated Title",
- content: "New content
"
- )
+ def test_replace_sends_sparse_verbatim_with_no_get
+ captured = capture_put(full_document("title" => "Updated Title", "content" => ""))
+
+ document = @account.documents.replace(document_id: 200, title: "Updated Title")
assert_equal "Updated Title", document["title"]
+ # One request, no read-before-write.
+ assert_requested :put, "#{BASE_URL}/12345/documents/200", times: 1
+ assert_not_requested :get, "#{BASE_URL}/12345/documents/200"
+ assert_equal 1, captured[:bodies].length
+ # Omitted stays omitted: replace never invents a content, and the server
+ # clears what the body leaves out.
+ assert_equal({ "title" => "Updated Title" }, captured[:bodies].first)
+ end
+
+ def test_replace_sends_an_explicit_empty_content
+ captured = capture_put(full_document("content" => ""))
+
+ @account.documents.replace(document_id: 200, title: "Updated Title", content: "")
+
+ # "" survives compact_params (which strips only nil), so a caller who
+ # states the clear gets a present-and-empty key, never JSON null.
+ assert_equal({ "title" => "Updated Title", "content" => "" }, captured[:bodies].first)
+ end
+
+ # ---------------------------------------------------------------------
+ # update / edit: the merge-safe composites (GET then PUT).
+ # ---------------------------------------------------------------------
+
+ def test_update_merges_unset_fields
+ captured = stub_document_get_and_put
+
+ document = @account.documents.update(document_id: 200, title: "Updated Title")
+
+ assert_equal 200, document["id"]
+ assert_requested :get, "#{BASE_URL}/12345/documents/200", times: 1
+ assert_equal 1, captured[:bodies].length
+ # The writable set is exactly {title, content}: the unmentioned field is
+ # carried out of the GET, and nothing else rides along.
+ assert_equal({ "title" => "Updated Title", "content" => "From the store
" },
+ captured[:bodies].first)
+ end
+
+ def test_update_merges_content_only
+ captured = stub_document_get_and_put
+
+ @account.documents.update(document_id: 200, content: "new body
")
+
+ # The mirror case: the title must survive, or the server resets it to
+ # "Untitled" on a call that never mentioned it.
+ assert_equal({ "title" => "Project Overview", "content" => "new body
" },
+ captured[:bodies].first)
+ end
+
+ def test_update_clears_content_with_explicit_empty_string
+ captured = stub_document_get_and_put
+
+ # Ruby distinguishes "omitted" (nil) from "stated empty" (""), so unlike
+ # Go — where "" is the zero value and reads as unset — the composite update
+ # can clear.
+ @account.documents.update(document_id: 200, content: "")
+
+ body = captured[:bodies].first
+ assert_includes body.keys, "content"
+ assert_equal "", body["content"]
+ assert_equal "Project Overview", body["title"]
+ end
+
+ def test_update_clears_title_with_explicit_empty_string
+ captured = stub_document_get_and_put
+
+ @account.documents.update(document_id: 200, title: "")
+
+ body = captured[:bodies].first
+ assert_includes body.keys, "title"
+ assert_equal "", body["title"]
+ assert_equal "From the store
", body["content"]
+ end
+
+ def test_update_hooks_observe_get_then_replace
+ events = []
+ account = create_account_client(account_id: "12345", hooks: TrackingHooks.new(events))
+ stub_document_get_and_put
+
+ account.documents.update(document_id: 200, title: "observed")
+
+ # The composite composes the public get and replace, so hooks see the two
+ # wire operations rather than one synthetic composite.
+ starts = events.select { |e| e[:event] == :on_operation_start }
+ assert_equal [ %w[documents get], %w[documents replace] ], \
+ starts.map { |e| [ e[:info].service, e[:info].operation ] }
+ end
+
+ def test_edit_puts_full_state_back
+ captured = stub_document_get_and_put
+
+ document = @account.documents.edit(document_id: 200) do |doc|
+ assert_equal "Project Overview", doc.title
+ assert_equal "From the store
", doc.content
+ doc.title = "🚨 #{doc.title}"
+ end
+
+ assert_equal 200, document["id"]
+ assert_equal({ "title" => "🚨 Project Overview", "content" => "From the store
" },
+ captured[:bodies].first)
+ end
+
+ # A clear has to REACH THE WIRE as a present-and-empty key. Omitting it would
+ # hand the clear back to the server's own rebuild — the same 200, but as an
+ # accident rather than an intent — and JSON null is out (SPEC section 18 body
+ # compaction, which is why put_fields normalises nil to "").
+ def test_edit_clears_content_present_and_empty
+ captured = stub_document_get_and_put
+
+ @account.documents.edit(document_id: 200) { |doc| doc.content = "" }
+
+ body = captured[:bodies].first
+ assert_includes body.keys, "content"
+ assert_equal "", body["content"]
+ assert_not_nil body["content"], "a clear must travel as \"\", never as JSON null"
+ assert_equal "Project Overview", body["title"]
+ end
+
+ def test_edit_clears_title_present_and_empty
+ captured = stub_document_get_and_put
+
+ @account.documents.edit(document_id: 200) { |doc| doc.title = "" }
+
+ body = captured[:bodies].first
+ assert_includes body.keys, "title"
+ assert_equal "", body["title"]
+ assert_not_nil body["title"], "a clear must travel as \"\", never as JSON null"
+ assert_equal "From the store
", body["content"]
+ end
+
+ # nil is the struct's own starting state, so clearing by assigning nil is
+ # idiomatic; put_fields normalises it to "" rather than letting compact_params
+ # drop the key.
+ def test_edit_nil_assignment_travels_as_empty_string
+ captured = stub_document_get_and_put
+
+ @account.documents.edit(document_id: 200) { |doc| doc.content = nil }
+
+ body = captured[:bodies].first
+ assert_includes body.keys, "content"
+ assert_equal "", body["content"]
+ end
+
+ def test_edit_block_error_aborts_without_put
+ captured = stub_document_get_and_put
+
+ assert_raises(RuntimeError) do
+ @account.documents.edit(document_id: 200) do |doc|
+ doc.title = "never written"
+ raise "abort"
+ end
+ end
+
+ assert_empty captured[:bodies]
+ assert_not_requested :put, "#{BASE_URL}/12345/documents/200"
+ end
+
+ def test_edit_requires_a_block
+ assert_raises(ArgumentError) { @account.documents.edit(document_id: 200) }
+ end
+
+ def test_edit_hooks_observe_get_then_replace
+ events = []
+ account = create_account_client(account_id: "12345", hooks: TrackingHooks.new(events))
+ stub_document_get_and_put
+
+ account.documents.edit(document_id: 200) { |doc| doc.title = "observed" }
+
+ starts = events.select { |e| e[:event] == :on_operation_start }
+ assert_equal [ %w[documents get], %w[documents replace] ], \
+ starts.map { |e| [ e[:info].service, e[:info].operation ] }
+ end
+
+ # ---------------------------------------------------------------------
+ # A malformed GET field must never reach the full-replace PUT (#576).
+ #
+ # update/edit GET the document, read each writable field, and PUT the FULL
+ # representation back, so every value read is written — including one the
+ # caller never mentioned. Ruby's +||+ treats only nil and false as falsy, so a
+ # plain body["content"] || "" ERASES the field on +false+ and passes
+ # arrays, hashes, numbers and +true+ straight through to be written verbatim.
+ # There is no typed decoder between the GET and the read: the generated +get+
+ # returns a raw Hash. That is exactly why MergeSafe's guards exist in Ruby
+ # (and Python and TypeScript) and nowhere else.
+ #
+ # The assertion that matters is the ORDERING — assert_not_requested :put. A
+ # guard that fires after the PUT has already lost the field.
+ # ---------------------------------------------------------------------
+
+ MALFORMED_VALUES = [ false, 0, [], {}, 42, true, [ "x" ], { "a" => 1 } ].freeze
+ WRITABLE_STRINGS = %w[title content].freeze
+ # The other writable field, so a test can name one and probe the other.
+ OTHER_FIELD = { "title" => :content, "content" => :title }.freeze
+
+ WRITABLE_STRINGS.each do |field|
+ MALFORMED_VALUES.each do |malformed|
+ define_method("test_update_refuses_a_malformed_#{field}_#{malformed.inspect}") do
+ captured = stub_document_get_and_put(document: full_document(field => malformed))
+
+ # Names the OTHER field, so nothing the caller passed masks the
+ # malformed one.
+ args = { document_id: 200, OTHER_FIELD.fetch(field) => "New value" }
+ error = assert_raises(Basecamp::ApiError) { @account.documents.update(**args) }
+
+ assert_includes error.message, "Document field #{field.inspect} is not a string"
+ # api_error, not usage: the value arrived in a successful API response.
+ assert_equal Basecamp::ErrorCode::API, error.code
+ assert_requested :get, "#{BASE_URL}/12345/documents/200", times: 1
+ assert_not_requested :put, "#{BASE_URL}/12345/documents/200"
+ assert_empty captured[:bodies]
+ end
+ end
+
+ define_method("test_edit_refuses_a_malformed_#{field}_before_writing") do
+ captured = stub_document_get_and_put(document: full_document(field => 42))
+
+ error = assert_raises(Basecamp::ApiError) do
+ @account.documents.edit(document_id: 200) { |doc| doc.title = "New value" }
+ end
+
+ assert_includes error.message, "Document field #{field.inspect} is not a string"
+ assert_not_requested :put, "#{BASE_URL}/12345/documents/200"
+ assert_empty captured[:bodies]
+ end
+ end
+
+ # The other half of the rule, for an OPTIONAL field: missing and nil are not
+ # malformed, they are empty. "" is what the server already holds, so there is
+ # nothing to preserve and nothing to refuse.
+ #
+ # +content+ only. +title+ is @required in the spec and gets the
+ # opposite treatment below.
+ { "missing" => ->(doc) { doc.except("content") }, "nil" => ->(doc) { doc.merge("content" => nil) } }
+ .each do |label, mangle|
+ define_method("test_#{label}_content_stays_genuinely_empty") do
+ captured = stub_document_get_and_put(document: mangle.call(full_document))
+
+ @account.documents.update(document_id: 200, title: "New value")
+
+ assert_equal "", captured[:bodies].first["content"]
+ assert_equal "New value", captured[:bodies].first["title"]
+ end
+
+ # Document#title is super.presence || "Untitled" and the spec marks
+ # the field @required, so BC3 can never render it blank: a missing
+ # or nil title in a 2xx body is a MALFORMED RESPONSE, not an empty title.
+ # Coalescing it to "" would blank the real title on a call that only touched
+ # +content+ — the same defect class as a forwarded non-string, in the one
+ # shape || "" looks correct.
+ define_method("test_update_refuses_a_#{label}_title_before_writing") do
+ captured = stub_document_get_and_put(document: title_mangled(full_document, label))
+
+ error = assert_raises(Basecamp::ApiError) do
+ @account.documents.update(document_id: 200, content: "New body.
")
+ end
+
+ assert_includes error.message, %(Document field "title" is required)
+ assert_equal Basecamp::ErrorCode::API, error.code
+ assert_not_requested :put, "#{BASE_URL}/12345/documents/200"
+ assert_empty captured[:bodies]
+ end
+
+ define_method("test_edit_refuses_a_#{label}_title_before_writing") do
+ captured = stub_document_get_and_put(document: title_mangled(full_document, label))
+
+ error = assert_raises(Basecamp::ApiError) do
+ @account.documents.edit(document_id: 200) { |doc| doc.content = "New body.
" }
+ end
+
+ assert_includes error.message, %(Document field "title" is required)
+ assert_not_requested :put, "#{BASE_URL}/12345/documents/200"
+ assert_empty captured[:bodies]
+ end
+ end
+
+ # BC3 can never render a blank title, so "" is malformed too — and it is the
+ # shape a missing/nil check alone would let through.
+ def test_update_refuses_a_blank_title_before_writing
+ captured = stub_document_get_and_put(document: full_document("title" => " "))
+
+ error = assert_raises(Basecamp::ApiError) do
+ @account.documents.update(document_id: 200, content: "New body.
")
+ end
+
+ assert_includes error.message, %(Document field "title" is required)
+ assert_equal Basecamp::ErrorCode::API, error.code
+ assert_not_requested :put, "#{BASE_URL}/12345/documents/200"
+ assert_empty captured[:bodies]
+ end
+
+ def test_edit_refuses_a_blank_title_before_writing
+ captured = stub_document_get_and_put(document: full_document("title" => " "))
+
+ assert_raises(Basecamp::ApiError) do
+ @account.documents.edit(document_id: 200) { |doc| doc.content = "New body.
" }
+ end
+
+ assert_not_requested :put, "#{BASE_URL}/12345/documents/200"
+ assert_empty captured[:bodies]
+ end
+
+ # Drops or nils the title, mirroring the "missing"/"nil" labels above.
+ def title_mangled(document, label)
+ label == "missing" ? document.except("title") : document.merge("title" => nil)
+ end
+
+ # One level up from the field guards: a successful GET can return a scalar,
+ # an Array or nil, and body["title"] would raise a raw TypeError on
+ # an Integer or Array — or return a silent nil substring match on a String —
+ # instead of the documented statusless api_error.
+ [ 42, "nope", nil, [ "a" ], true ].each do |body|
+ define_method("test_update_refuses_a_non_object_response_body_#{body.inspect}") do
+ # stub_get passes Strings through verbatim, so encode first: a bare
+ # `nope` is not JSON and would fail transport decode before the guard.
+ stub_get("/12345/documents/200", response_body: body.to_json)
+ captured = capture_put(full_document)
+
+ error = assert_raises(Basecamp::ApiError) do
+ @account.documents.update(document_id: 200, title: "New Title")
+ end
+
+ assert_includes error.message, "GetDocument returned"
+ assert_includes error.message, "where a document object was expected"
+ assert_equal Basecamp::ErrorCode::API, error.code
+ assert_not_requested :put, "#{BASE_URL}/12345/documents/200"
+ assert_empty captured[:bodies]
+ end
+
+ define_method("test_edit_refuses_a_non_object_response_body_#{body.inspect}") do
+ stub_get("/12345/documents/200", response_body: body.to_json)
+ captured = capture_put(full_document)
+
+ assert_raises(Basecamp::ApiError) do
+ @account.documents.edit(document_id: 200) { |doc| doc.title = "New Title" }
+ end
+
+ assert_not_requested :put, "#{BASE_URL}/12345/documents/200"
+ assert_empty captured[:bodies]
+ end
+ end
+
+ # The malformed value is interpolated into the message, so SPEC section 9's
+ # 500-byte cap has to survive a huge body.
+ def test_malformed_message_is_capped
+ stub_document_get_and_put(document: full_document("content" => [ "x" ] * 50_000))
+
+ error = assert_raises(Basecamp::ApiError) do
+ @account.documents.update(document_id: 200, title: "New Title")
+ end
+
+ assert_operator error.message.bytesize, :<=, 500
+ end
+
+ # The malformed-response errors point at the deliberate-overwrite escape
+ # hatch, and it has to name a method that actually exists on the service.
+ def test_malformed_error_names_the_escape_hatch
+ stub_document_get_and_put(document: full_document("content" => 42))
+
+ error = assert_raises(Basecamp::ApiError) do
+ @account.documents.update(document_id: 200, title: "New Title")
+ end
+
+ assert_includes error.hint, Basecamp::Services::DocumentsExtensions::ESCAPE_HATCH
+ assert_respond_to @account.documents, :replace
+ assert_not error.retryable, "re-requesting cannot repair a malformed body"
+ end
+
+ class TrackingHooks
+ include Basecamp::Hooks
+
+ def initialize(events)
+ @events = events
+ end
+
+ def on_operation_start(info)
+ @events << { event: :on_operation_start, info: info }
+ end
+
+ def on_operation_end(info, result)
+ @events << { event: :on_operation_end, info: info, result: result }
+ end
end
end
diff --git a/ruby/test/basecamp/zeitwerk_test.rb b/ruby/test/basecamp/zeitwerk_test.rb
index 0b021429aa..91e5aa384f 100644
--- a/ruby/test/basecamp/zeitwerk_test.rb
+++ b/ruby/test/basecamp/zeitwerk_test.rb
@@ -45,4 +45,24 @@ def test_todolists_composite_surface_is_reachable
assert_equal Basecamp::Services::TodolistsService, \
Basecamp::Services::TodolistsService.instance_method(:replace).owner
end
+
+ # And for documents, the same shape as todolists: PUT /documents/{id} is a
+ # full replace, so the generated class owns `replace` and the prepended
+ # module contributes the merge-safe `update`/`edit`.
+ def test_documents_extensions_prepended
+ assert_includes Basecamp::Services::DocumentsService.ancestors, \
+ Basecamp::Services::DocumentsExtensions
+ assert Basecamp::Services::DocumentsService.ancestors.index(Basecamp::Services::DocumentsExtensions) <
+ Basecamp::Services::DocumentsService.ancestors.index(Basecamp::Services::DocumentsService),
+ "extensions must be prepended (before the class in the ancestor chain)"
+ end
+
+ def test_documents_composite_surface_is_reachable
+ assert_equal Basecamp::Services::DocumentsExtensions, \
+ Basecamp::Services::DocumentsService.instance_method(:update).owner
+ assert_equal Basecamp::Services::DocumentsExtensions, \
+ Basecamp::Services::DocumentsService.instance_method(:edit).owner
+ assert_equal Basecamp::Services::DocumentsService, \
+ Basecamp::Services::DocumentsService.instance_method(:replace).owner
+ end
end
diff --git a/scripts/check-service-drift.sh b/scripts/check-service-drift.sh
index 8a8d947d2c..5c977fd8c8 100755
--- a/scripts/check-service-drift.sh
+++ b/scripts/check-service-drift.sh
@@ -37,14 +37,30 @@ grep "^func (c \*ClientWithResponses)" "$GENERATED_FILE" 2>/dev/null \
# Extract service layer calls to gen.*WithResponse (excluding test files)
# Normalize WithBodyWithResponse calls to base operation name
+#
+# A wrapper may also call the two stages separately — gen.(...) for the
+# request, then generated.ParseResponse(...) for the decode — when it has to
+# tell a preflight or transport failure apart from a malformed body, which the
+# combined WithResponse conflates into one error (DocumentsService.Get does
+# this; see documentDecodeError). ParseResponse names the operation exactly
+# and appears nowhere else, so it is counted as a wrapper too.
for f in "$SERVICE_DIR"/*.go; do
case "$f" in
*_test.go) continue ;;
esac
grep "\.gen\.[A-Za-z]*WithResponse" "$f" 2>/dev/null || true
done | sed 's/.*\.gen\.\([A-Za-z]*\)WithResponse.*/\1/' \
- | sed 's/WithBody$//' \
- | sort -u > "$SVC_OPS"
+ | sed 's/WithBody$//' > "$SVC_OPS.raw"
+
+for f in "$SERVICE_DIR"/*.go; do
+ case "$f" in
+ *_test.go) continue ;;
+ esac
+ grep -o "generated\.Parse[A-Za-z]*Response" "$f" 2>/dev/null || true
+done | sed 's/generated\.Parse\([A-Za-z]*\)Response/\1/' >> "$SVC_OPS.raw"
+
+sort -u "$SVC_OPS.raw" > "$SVC_OPS"
+rm -f "$SVC_OPS.raw"
# Count operations
GEN_COUNT=$(wc -l < "$GEN_OPS" | tr -d ' ')
diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy
index e768c55a2c..b84d12e928 100644
--- a/spec/basecamp.smithy
+++ b/spec/basecamp.smithy
@@ -108,7 +108,7 @@ service Basecamp {
ListDocuments,
GetDocument,
CreateDocument,
- UpdateDocument,
+ ReplaceDocument,
ListUploads,
GetUpload,
CreateUpload,
@@ -2432,18 +2432,40 @@ structure CreateDocumentOutput {
document: Document
}
-/// Update an existing document
+/// Replace a document with a new complete representation.
+/// The request body is the document's full writable state: any writable field
+/// omitted from the request is cleared server-side. Omitting content clears it;
+/// omitting title clears it too, and the document then reads back as
+/// "Untitled" (Document#title falls back when blank).
+/// Neither field is required. BC3 builds a brand-new Document from the
+/// permitted params and swaps the recordable wholesale, and neither attribute
+/// carries a presence validation — so an omission is a 200 that clears, not a
+/// 422. What BC3 does require is the wrapping document object, which Rails
+/// synthesizes from a flat body, so a request naming neither field is a 400.
+/// Publishing a draft (status: "active") is not modeled: the SDK sends only
+/// title and content, and BC3 rejects a status-only update for the same
+/// reason it 400s an empty body.
+/// Subscribers are the one exception to omission-clears. A drafted document
+/// keeps its current subscribers when the request addresses neither
+/// subscriptions nor notify, so a full-representation PUT that mentions
+/// neither is safe on a draft.
+/// To set some fields while preserving the rest, use the SDK's merge-safe
+/// update or edit methods, which GET the current document and PUT the full
+/// representation back. Those read-modify-write helpers are not atomic:
+/// a concurrent write between the GET and PUT is overwritten (last write
+/// wins for the whole representation; the window is one round-trip).
@idempotent
@basecampRetry(maxAttempts: 3, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503])
@basecampIdempotent(natural: true)
+@basecampWriteSemantics(mode: "replace", clearsOmitted: true)
@http(method: "PUT", uri: "/{accountId}/documents/{documentId}")
-operation UpdateDocument {
- input: UpdateDocumentInput
- output: UpdateDocumentOutput
+operation ReplaceDocument {
+ input: ReplaceDocumentInput
+ output: ReplaceDocumentOutput
errors: [NotFoundError, ValidationError, UnauthorizedError, ForbiddenError, InternalServerError]
}
-structure UpdateDocumentInput {
+structure ReplaceDocumentInput {
@required
@httpLabel
accountId: AccountId
@@ -2456,7 +2478,7 @@ structure UpdateDocumentInput {
content: DocumentContent
}
-structure UpdateDocumentOutput {
+structure ReplaceDocumentOutput {
document: Document
}
diff --git a/spec/overlays/tags.smithy b/spec/overlays/tags.smithy
index cc9c7acd5b..def5d88240 100644
--- a/spec/overlays/tags.smithy
+++ b/spec/overlays/tags.smithy
@@ -57,7 +57,7 @@ apply UpdateVault @tags(["Files"])
apply ListDocuments @tags(["Files"])
apply GetDocument @tags(["Files"])
apply CreateDocument @tags(["Files"])
-apply UpdateDocument @tags(["Files"])
+apply ReplaceDocument @tags(["Files"])
apply ListUploads @tags(["Files"])
apply GetUpload @tags(["Files"])
apply CreateUpload @tags(["Files"])
diff --git a/swift/Sources/Basecamp/DocumentsServiceExtensions.swift b/swift/Sources/Basecamp/DocumentsServiceExtensions.swift
new file mode 100644
index 0000000000..355e0a64c0
--- /dev/null
+++ b/swift/Sources/Basecamp/DocumentsServiceExtensions.swift
@@ -0,0 +1,144 @@
+// Hand-written merge-safe update / edit surface for DocumentsService.
+import Foundation
+
+/// Request parameters for the merge-safe ``DocumentsService/update(documentId:req:)``.
+/// Every field is optional: a `nil` field is left untouched on the document,
+/// guaranteed. An explicitly-passed empty string is a set (clears the field).
+public struct UpdateDocumentRequest: Codable, Sendable {
+ public var content: String?
+ public var title: String?
+
+ public init(
+ content: String? = nil,
+ title: String? = nil
+ ) {
+ self.content = content
+ self.title = title
+ }
+}
+
+/// A document's full writable state, handed to the
+/// ``DocumentsService/edit(documentId:_:)`` closure. The whole value is PUT
+/// back to the server, so clearing a field means setting it empty (`""`) —
+/// there is no third state. BC3's writable set on this endpoint is exactly
+/// `{title, content}`; everything else on a document is server-owned or has its
+/// own endpoint (position, status, subscriptions, client visibility).
+public struct DocumentFields: Sendable {
+ /// Plain-text title. Set `""` to clear — the document then reads back as
+ /// "Untitled", because `Document#title` falls back when blank.
+ public var title: String
+ /// Rich text body (HTML). Set `""` to clear.
+ public var content: String
+
+ /// `title` needs a hand-written check the decoder cannot supply. The field
+ /// is non-optional on the model, so an absent or null title is already
+ /// refused — but `""` decodes fine, and BC3 can never render it blank
+ /// (`Document#title` is `super.presence || "Untitled"`). A blank title on a
+ /// 2xx read is therefore a malformed response, and carrying it into the
+ /// full-replace PUT would blank the real title on a call that only touched
+ /// `content`.
+ init(from document: Document) throws {
+ guard !document.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw BasecampError.api(
+ message: "GetDocument returned a document with a blank \"title\", "
+ + "but the API never renders it blank",
+ httpStatus: nil,
+ hint: "The merge-safe update/edit resend this field verbatim, so a blank value "
+ + "would blank the current one. Use replace(documentId:req:) to write the "
+ + "record deliberately.",
+ requestId: nil
+ )
+ }
+ title = document.title
+ content = document.content ?? ""
+ }
+}
+
+// Merge-safe `update` and read-modify-write `edit`, composed from the public
+// `get` and `replace` methods — hooks observe the two wire operations
+// (`GetDocument` then `ReplaceDocument`), not a synthetic composite.
+//
+// `PUT /{accountId}/documents/{documentId}` is a full replace: BC3's
+// `DocumentsController#update` builds a brand-new `Document` from only the
+// permitted params and swaps the recordable wholesale, so a PUT that omits
+// `content` ERASES it, and one that omits `title` erases that too. Neither
+// attribute is presence-validated, so neither omission is a 422 — both are a
+// 200 that quietly clears. That makes the natural sparse write destructive on
+// the raw endpoint, which stays available as `replace`.
+//
+// Neither composite is atomic: there is no conditional-update signal on this
+// endpoint, so a concurrent write between the GET and PUT is overwritten — last
+// write wins for the whole representation. The window is one round-trip. Use
+// `replace` to overwrite deliberately.
+extension DocumentsService {
+ /// Sets the given fields on a document and preserves everything else: GETs
+ /// the current document, overlays the explicitly-set (non-`nil`) request
+ /// fields, and PUTs the full representation back. A `nil` field is
+ /// untouched, guaranteed; an explicitly-passed `""` clears.
+ ///
+ /// Not atomic — see the extension docs for the GET→PUT race.
+ public func update(documentId: Int, req: UpdateDocumentRequest) async throws -> Document {
+ var fields = try DocumentFields(from: try await fetchDocument(documentId: documentId))
+ if let title = req.title { fields.title = title }
+ if let content = req.content { fields.content = content }
+ return try await putFields(documentId: documentId, fields: fields)
+ }
+
+ /// Applies a read-modify-write closure to a document: GETs the current
+ /// document, hands the closure the full writable representation
+ /// (``DocumentFields``), and PUTs the whole thing back. Clearing a field
+ /// means setting it empty (`""`) — an untouched field keeps its current
+ /// value. If the closure throws, the edit aborts and nothing is written.
+ ///
+ /// ```swift
+ /// try await account.documents.edit(documentId: 123) {
+ /// $0.title = "🚨 " + $0.title
+ /// $0.content = "" // clearing = setting empty on a full object
+ /// }
+ /// ```
+ ///
+ /// Not atomic — see the extension docs for the GET→PUT race.
+ public func edit(
+ documentId: Int, _ mutate: (inout DocumentFields) throws -> Void
+ ) async throws -> Document {
+ var fields = try DocumentFields(from: try await fetchDocument(documentId: documentId))
+ try mutate(&fields)
+ return try await putFields(documentId: documentId, fields: fields)
+ }
+
+ /// PUTs the full writable state via `replace`. Both fields are ALWAYS sent,
+ /// empties included: `""` is how a clear is expressed on a full-replace
+ /// endpoint, and an explicit JSON null is never sent (SPEC §18 body
+ /// compaction). Neither field is presence-validated server-side, so there
+ /// is nothing to reject here — an empty title is a legitimate clear that
+ /// reads back as "Untitled".
+ private func putFields(documentId: Int, fields: DocumentFields) async throws -> Document {
+ try await replace(
+ documentId: documentId,
+ req: ReplaceDocumentRequest(content: fields.content, title: fields.title)
+ )
+ }
+
+ /// GETs the document the composites read their writable state from.
+ private func fetchDocument(documentId: Int) async throws -> Document {
+ // Swift's decoder is the typed guard the dynamic SDKs have to write by
+ // hand, and it rejects a wrong-typed field before this composite ever
+ // sees it. But it reports that as a raw `DecodingError`, which is not
+ // the shape SPEC §6 defines for a malformed 2xx body: callers checking
+ // for `BasecampError` would miss it entirely, and it carries no hint.
+ // Wrap it, so a malformed response looks the same in every SDK.
+ do {
+ return try await get(documentId: documentId)
+ } catch let error as DecodingError {
+ throw BasecampError.api(
+ message: BasecampError.truncate(
+ "GetDocument returned a body that does not decode as a document: \(error)"),
+ httpStatus: nil,
+ hint: "The merge-safe update/edit resend this record's fields verbatim, so a "
+ + "malformed response cannot be written back safely. Use "
+ + "replace(documentId:req:) to write the record deliberately.",
+ requestId: nil
+ )
+ }
+ }
+}
diff --git a/swift/Sources/Basecamp/Generated/Metadata.swift b/swift/Sources/Basecamp/Generated/Metadata.swift
index 0395cddaf0..d3cc84cb37 100644
--- a/swift/Sources/Basecamp/Generated/Metadata.swift
+++ b/swift/Sources/Basecamp/Generated/Metadata.swift
@@ -189,6 +189,7 @@ enum Metadata {
"PrioritizeAssignment": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"RemoveAccountLogo": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"ReorderUpNext": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
+ "ReplaceDocument": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"ReplaceTodo": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"RepositionCardStep": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"RepositionTodo": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
@@ -221,7 +222,6 @@ enum Metadata {
"UpdateCardStep": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"UpdateChatbot": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"UpdateComment": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
- "UpdateDocument": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"UpdateFolder": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"UpdateGaugeNeedle": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
"UpdateHillChartSettings": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]),
@@ -277,6 +277,7 @@ enum Metadata {
"PauseQuestion",
"PrioritizeAssignment",
"RemoveAccountLogo",
+ "ReplaceDocument",
"ReplaceTodo",
"RepositionTodo",
"RepositionTodolist",
@@ -307,7 +308,6 @@ enum Metadata {
"UpdateCardStep",
"UpdateChatbot",
"UpdateComment",
- "UpdateDocument",
"UpdateFolder",
"UpdateGaugeNeedle",
"UpdateHillChartSettings",
diff --git a/swift/Sources/Basecamp/Generated/Models/UpdateDocumentRequest.swift b/swift/Sources/Basecamp/Generated/Models/ReplaceDocumentRequest.swift
similarity index 82%
rename from swift/Sources/Basecamp/Generated/Models/UpdateDocumentRequest.swift
rename to swift/Sources/Basecamp/Generated/Models/ReplaceDocumentRequest.swift
index debd7bb0a7..2aeb3c1b57 100644
--- a/swift/Sources/Basecamp/Generated/Models/UpdateDocumentRequest.swift
+++ b/swift/Sources/Basecamp/Generated/Models/ReplaceDocumentRequest.swift
@@ -1,7 +1,7 @@
// @generated from OpenAPI spec — do not edit directly
import Foundation
-public struct UpdateDocumentRequest: Codable, Sendable {
+public struct ReplaceDocumentRequest: Codable, Sendable {
public var content: String?
public var title: String?
diff --git a/swift/Sources/Basecamp/Generated/Services/DocumentsService.swift b/swift/Sources/Basecamp/Generated/Services/DocumentsService.swift
index 4518abdfac..41378c86b1 100644
--- a/swift/Sources/Basecamp/Generated/Services/DocumentsService.swift
+++ b/swift/Sources/Basecamp/Generated/Services/DocumentsService.swift
@@ -47,13 +47,13 @@ public final class DocumentsService: BaseService, @unchecked Sendable {
)
}
- public func update(documentId: Int, req: UpdateDocumentRequest) async throws -> Document {
+ public func replace(documentId: Int, req: ReplaceDocumentRequest) async throws -> Document {
return try await request(
- OperationInfo(service: "Documents", operation: "UpdateDocument", resourceType: "document", isMutation: true, resourceId: documentId),
+ OperationInfo(service: "Documents", operation: "ReplaceDocument", resourceType: "document", isMutation: true, resourceId: documentId),
method: "PUT",
path: "/documents/\(documentId)",
body: req,
- retryConfig: Metadata.retryConfig(for: "UpdateDocument")
+ retryConfig: Metadata.retryConfig(for: "ReplaceDocument")
)
}
}
diff --git a/swift/Sources/BasecampGenerator/ServiceGrouper.swift b/swift/Sources/BasecampGenerator/ServiceGrouper.swift
index ddcc61e5e1..5b19bb62b5 100644
--- a/swift/Sources/BasecampGenerator/ServiceGrouper.swift
+++ b/swift/Sources/BasecampGenerator/ServiceGrouper.swift
@@ -50,7 +50,7 @@ let serviceSplits: [String: [String: [String]]] = [
"Attachments": ["CreateAttachment"],
"Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"],
"Vaults": ["GetVault", "UpdateVault", "ListVaults", "CreateVault"],
- "Documents": ["GetDocument", "UpdateDocument", "ListDocuments", "CreateDocument"],
+ "Documents": ["GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"],
],
"Automation": [
"Tools": ["GetTool", "UpdateTool", "DeleteTool", "CreateTool", "EnableTool", "DisableTool", "RepositionTool"],
diff --git a/swift/Tests/BasecampTests/DocumentsServiceExtensionsTests.swift b/swift/Tests/BasecampTests/DocumentsServiceExtensionsTests.swift
new file mode 100644
index 0000000000..ee56b006e2
--- /dev/null
+++ b/swift/Tests/BasecampTests/DocumentsServiceExtensionsTests.swift
@@ -0,0 +1,270 @@
+import XCTest
+
+@testable import Basecamp
+
+/// Thread-safe capture of requests seen by the mock transport.
+private final class DocumentRequestLog: @unchecked Sendable {
+ private let lock = NSLock()
+ private var _methods: [String] = []
+ private var _putBody: [String: Any]?
+
+ var methods: [String] { lock.withLock { _methods } }
+ var putBody: [String: Any]? { lock.withLock { _putBody } }
+
+ func record(_ request: URLRequest) {
+ lock.withLock {
+ _methods.append(request.httpMethod ?? "?")
+ if request.httpMethod == "PUT",
+ let data = request.httpBody ?? request.documentBodyStreamData()
+ {
+ _putBody = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
+ }
+ }
+ }
+}
+
+extension URLRequest {
+ /// URLSession moves httpBody into a stream in some paths; drain it if needed.
+ fileprivate func documentBodyStreamData() -> Data? {
+ guard let stream = httpBodyStream else { return nil }
+ stream.open()
+ defer { stream.close() }
+ var data = Data()
+ let bufferSize = 4096
+ let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
+ defer { buffer.deallocate() }
+ while stream.hasBytesAvailable {
+ let read = stream.read(buffer, maxLength: bufferSize)
+ if read <= 0 { break }
+ data.append(buffer, count: read)
+ }
+ return data
+ }
+}
+
+private final class DocumentOperationRecorder: BasecampHooks, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _operations: [String] = []
+
+ var operations: [String] { lock.withLock { _operations } }
+
+ func onOperationStart(_ info: OperationInfo) {
+ lock.withLock { _operations.append(info.operation) }
+ }
+}
+
+/// Full document JSON on wire (snake_case) keys, with both writable fields —
+/// `title` and `content` — populated.
+private func fullDocumentJSON(id: Int = 42) -> [String: Any] {
+ [
+ "id": id,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z",
+ "title": "Kickoff notes",
+ "inherits_status": true,
+ "type": "Document",
+ "url": "https://3.basecampapi.com/999999999/buckets/1/documents/\(id).json",
+ "app_url": "https://3.basecamp.com/999999999/buckets/1/documents/\(id)",
+ "parent": [
+ "id": 2, "title": "Docs & Files", "type": "Vault",
+ "url": "https://3.basecampapi.com/999999999/buckets/1/vaults/2.json",
+ "app_url": "https://3.basecamp.com/999999999/buckets/1/vaults/2",
+ ] as [String: Any],
+ "bucket": ["id": 1, "name": "Project", "type": "Project"] as [String: Any],
+ "creator": ["id": 1, "name": "Test User"] as [String: Any],
+ "content_attachments": [],
+ "content": "From the kickoff
",
+ "position": 1,
+ ]
+}
+
+/// The merge-safe `update` / read-modify-write `edit` composites and the raw
+/// `replace` they are built on.
+///
+/// `PUT /documents/{id}` is a full replace: BC3 rebuilds the Document from only
+/// the permitted params, so a sparse PUT that omits `content` erases it and one
+/// that omits `title` leaves the document reading back as "Untitled". Neither
+/// omission is a 422 — both are a 200 that quietly clears. That is why the
+/// composites always send BOTH writable fields, empties included: on this
+/// endpoint `""` is how a clear is expressed, and omission is indistinguishable
+/// from an accident.
+final class DocumentsServiceExtensionsTests: XCTestCase {
+
+ private func makeDocumentsClient(
+ log: DocumentRequestLog,
+ hooks: (any BasecampHooks)? = nil
+ ) throws -> AccountClient {
+ let documentData = try JSONSerialization.data(withJSONObject: fullDocumentJSON())
+ let transport = MockTransport { request in
+ log.record(request)
+ return (
+ documentData,
+ makeHTTPResponse(
+ url: request.url!.absoluteString,
+ statusCode: 200,
+ headers: ["Content-Type": "application/json"]
+ )
+ )
+ }
+ return makeTestAccountClient(transport: transport, hooks: hooks)
+ }
+
+ // MARK: - update (merge-safe)
+
+ /// BC3 can never render a blank title (`Document#title` is
+ /// `super.presence || "Untitled"`), so `""` on a 2xx read is malformed.
+ /// `Codable` already refuses an absent or null title because the model
+ /// field is non-optional; `""` decodes fine and needs the hand-written
+ /// check. The ordering is what matters: no PUT.
+ func testUpdate_refusesABlankTitle() async throws {
+ let log = DocumentRequestLog()
+ var blank = fullDocumentJSON()
+ blank["title"] = " "
+ let blankData = try JSONSerialization.data(withJSONObject: blank)
+ let transport = MockTransport { request in
+ log.record(request)
+ return (
+ blankData,
+ makeHTTPResponse(
+ url: request.url!.absoluteString,
+ statusCode: 200,
+ headers: ["Content-Type": "application/json"]
+ )
+ )
+ }
+ let account = makeTestAccountClient(transport: transport)
+
+ do {
+ _ = try await account.documents.update(
+ documentId: 42, req: UpdateDocumentRequest(content: "New body.
"))
+ XCTFail("expected the call to fail, but it succeeded")
+ } catch let error as BasecampError {
+ guard case .api(_, let httpStatus, let hint, _) = error else {
+ return XCTFail("expected .api, got \(error)")
+ }
+ XCTAssertNil(httpStatus, "a malformed 2xx body carries no status")
+ XCTAssertNotNil(hint, "expected a hint naming the escape hatch")
+ }
+
+ XCTAssertEqual(log.methods, ["GET"], "the guard must fire before the PUT")
+ }
+
+
+ func testUpdate_mergesUnsetFields() async throws {
+ let log = DocumentRequestLog()
+ let account = try makeDocumentsClient(log: log)
+
+ let document = try await account.documents.update(
+ documentId: 42, req: UpdateDocumentRequest(title: "Kickoff notes, revised"))
+
+ XCTAssertEqual(document.id, 42)
+ XCTAssertEqual(log.methods, ["GET", "PUT"])
+ let body = try XCTUnwrap(log.putBody)
+ XCTAssertEqual(body["title"] as? String, "Kickoff notes, revised")
+ // content was never named, so the GET's value is written straight back
+ // rather than left to the server's clear-by-default.
+ XCTAssertEqual(body["content"] as? String, "From the kickoff
")
+ }
+
+ func testUpdate_explicitEmptyStringClears() async throws {
+ let log = DocumentRequestLog()
+ let account = try makeDocumentsClient(log: log)
+
+ _ = try await account.documents.update(documentId: 42, req: UpdateDocumentRequest(content: ""))
+
+ let body = try XCTUnwrap(log.putBody)
+ // An explicitly-passed "" is a set, not an unset: present and empty.
+ XCTAssertNotNil(body["content"], "an explicit clear must be sent, not omitted")
+ XCTAssertEqual(body["content"] as? String, "")
+ XCTAssertEqual(body["title"] as? String, "Kickoff notes")
+ }
+
+ func testUpdate_hooksObserveGetThenReplace() async throws {
+ let log = DocumentRequestLog()
+ let recorder = DocumentOperationRecorder()
+ let account = try makeDocumentsClient(log: log, hooks: recorder)
+
+ _ = try await account.documents.update(
+ documentId: 42, req: UpdateDocumentRequest(title: "observed"))
+
+ // The composite is built from the public get/replace, so hooks see the
+ // two wire operations, not a synthetic composite.
+ XCTAssertEqual(recorder.operations, ["GetDocument", "ReplaceDocument"])
+ }
+
+ // MARK: - edit (read-modify-write closure)
+
+ func testEdit_putsFullStateBack() async throws {
+ let log = DocumentRequestLog()
+ let account = try makeDocumentsClient(log: log)
+
+ let document = try await account.documents.edit(documentId: 42) { fields in
+ XCTAssertEqual(fields.title, "Kickoff notes")
+ XCTAssertEqual(fields.content, "From the kickoff
")
+ fields.title = "🚨 " + fields.title
+ }
+
+ XCTAssertEqual(document.id, 42)
+ XCTAssertEqual(log.methods, ["GET", "PUT"])
+ let body = try XCTUnwrap(log.putBody)
+ XCTAssertEqual(body["title"] as? String, "🚨 Kickoff notes")
+ XCTAssertEqual(body["content"] as? String, "From the kickoff
")
+ }
+
+ func testEdit_clearsContentPresentAndEmpty() async throws {
+ let log = DocumentRequestLog()
+ let account = try makeDocumentsClient(log: log)
+
+ _ = try await account.documents.edit(documentId: 42) { fields in
+ fields.content = ""
+ }
+
+ let body = try XCTUnwrap(log.putBody)
+ // Clearing on a full-replace endpoint is an explicit "": never JSON
+ // null (SPEC §18 body compaction), and never by omission, which would
+ // leave the clear to the server and read as an accident.
+ XCTAssertNotNil(body["content"], "a cleared content must be sent present-and-empty")
+ XCTAssertEqual(body["content"] as? String, "")
+ XCTAssertEqual(body["title"] as? String, "Kickoff notes")
+ }
+
+ func testEdit_closureErrorAbortsWithoutPut() async throws {
+ struct Abort: Error {}
+ let log = DocumentRequestLog()
+ let account = try makeDocumentsClient(log: log)
+
+ do {
+ _ = try await account.documents.edit(documentId: 42) { fields in
+ fields.title = "never written"
+ throw Abort()
+ }
+ XCTFail("expected the closure error to propagate")
+ } catch is Abort {
+ // expected
+ }
+
+ XCTAssertEqual(log.methods, ["GET"], "no PUT after a closure error")
+ }
+
+ // MARK: - replace (server-native verbatim PUT)
+
+ func testReplace_sendsSparseVerbatimWithNoGet() async throws {
+ let log = DocumentRequestLog()
+ let recorder = DocumentOperationRecorder()
+ let account = try makeDocumentsClient(log: log, hooks: recorder)
+
+ let document = try await account.documents.replace(
+ documentId: 42, req: ReplaceDocumentRequest(title: "the whole new document"))
+
+ XCTAssertEqual(document.id, 42)
+ XCTAssertEqual(log.methods, ["PUT"], "replace must not GET")
+ let body = try XCTUnwrap(log.putBody)
+ XCTAssertEqual(body["title"] as? String, "the whole new document")
+ // The raw path is destructive by design: what the caller left out stays
+ // out, and the server clears it.
+ XCTAssertNil(body["content"], "content must be omitted from a sparse replace")
+ XCTAssertEqual(recorder.operations, ["ReplaceDocument"])
+ }
+}
diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts
index 1ad4a950d8..a541fdbf29 100644
--- a/typescript/scripts/generate-services.ts
+++ b/typescript/scripts/generate-services.ts
@@ -210,7 +210,7 @@ const SERVICE_SPLITS: Record> = {
Attachments: ["CreateAttachment"],
Uploads: ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"],
Vaults: ["GetVault", "UpdateVault", "ListVaults", "CreateVault"],
- Documents: ["GetDocument", "UpdateDocument", "ListDocuments", "CreateDocument"],
+ Documents: ["GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"],
},
Automation: {
Tools: ["GetTool", "UpdateTool", "DeleteTool", "CreateTool", "EnableTool", "DisableTool", "RepositionTool"],
diff --git a/typescript/src/client.ts b/typescript/src/client.ts
index 3b4bec8567..01a5901404 100644
--- a/typescript/src/client.ts
+++ b/typescript/src/client.ts
@@ -58,7 +58,7 @@ import { MyNotesService } from "./generated/services/my-notes.js";
import { SubscriptionsService } from "./generated/services/subscriptions.js";
import { AttachmentsService } from "./generated/services/attachments.js";
import { VaultsService } from "./generated/services/vaults.js";
-import { DocumentsService } from "./generated/services/documents.js";
+import { DocumentsService } from "./services/documents-extensions.js";
import { UploadsService } from "./services/uploads-extensions.js";
import { SchedulesService } from "./generated/services/schedules.js";
import { EventsService } from "./generated/services/events.js";
diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts
index 6b280f3f74..958778ed2c 100644
--- a/typescript/src/generated/metadata.ts
+++ b/typescript/src/generated/metadata.ts
@@ -37,7 +37,7 @@ export interface MetadataOutput {
const metadata: MetadataOutput = {
"$schema": "https://basecamp.com/schemas/sdk-metadata.json",
"version": "1.0.0",
- "generated": "2026-08-03T05:51:49.671Z",
+ "generated": "2026-08-03T07:11:02.911Z",
"operations": {
"GetAccount": {
"retry": {
@@ -1040,7 +1040,7 @@ const metadata: MetadataOutput = {
]
}
},
- "UpdateDocument": {
+ "ReplaceDocument": {
"retry": {
"maxAttempts": 3,
"baseDelayMs": 1000,
diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json
index 01ba565b17..ddac7317df 100644
--- a/typescript/src/generated/openapi-stripped.json
+++ b/typescript/src/generated/openapi-stripped.json
@@ -7220,13 +7220,13 @@
}
},
"put": {
- "description": "Update an existing document",
- "operationId": "UpdateDocument",
+ "description": "Replace a document with a new complete representation.\nThe request body is the document's full writable state: any writable field\nomitted from the request is cleared server-side. Omitting content clears it;\nomitting title clears it too, and the document then reads back as\n\"Untitled\" (Document#title falls back when blank).\nNeither field is required. BC3 builds a brand-new Document from the\npermitted params and swaps the recordable wholesale, and neither attribute\ncarries a presence validation — so an omission is a 200 that clears, not a\n422. What BC3 does require is the wrapping document object, which Rails\nsynthesizes from a flat body, so a request naming neither field is a 400.\nPublishing a draft (status: \"active\") is not modeled: the SDK sends only\ntitle and content, and BC3 rejects a status-only update for the same\nreason it 400s an empty body.\nSubscribers are the one exception to omission-clears. A drafted document\nkeeps its current subscribers when the request addresses neither\nsubscriptions nor notify, so a full-representation PUT that mentions\nneither is safe on a draft.\nTo set some fields while preserving the rest, use the SDK's merge-safe\nupdate or edit methods, which GET the current document and PUT the full\nrepresentation back. Those read-modify-write helpers are not atomic:\na concurrent write between the GET and PUT is overwritten (last write\nwins for the whole representation; the window is one round-trip).",
+ "operationId": "ReplaceDocument",
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateDocumentRequestContent"
+ "$ref": "#/components/schemas/ReplaceDocumentRequestContent"
}
}
}
@@ -7244,11 +7244,11 @@
],
"responses": {
"200": {
- "description": "UpdateDocument 200 response",
+ "description": "ReplaceDocument 200 response",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateDocumentResponseContent"
+ "$ref": "#/components/schemas/ReplaceDocumentResponseContent"
}
}
}
@@ -7318,6 +7318,10 @@
429,
503
]
+ },
+ "x-basecamp-write-semantics": {
+ "mode": "replace",
+ "clearsOmitted": true
}
}
},
@@ -28925,6 +28929,20 @@
"source_id"
]
},
+ "ReplaceDocumentRequestContent": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "content": {
+ "type": "string"
+ }
+ }
+ },
+ "ReplaceDocumentResponseContent": {
+ "$ref": "#/components/schemas/Document"
+ },
"ReplaceTodoRequestContent": {
"type": "object",
"properties": {
@@ -30801,20 +30819,6 @@
"UpdateCommentResponseContent": {
"$ref": "#/components/schemas/Comment"
},
- "UpdateDocumentRequestContent": {
- "type": "object",
- "properties": {
- "title": {
- "type": "string"
- },
- "content": {
- "type": "string"
- }
- }
- },
- "UpdateDocumentResponseContent": {
- "$ref": "#/components/schemas/Document"
- },
"UpdateFolderRequestContent": {
"type": "object",
"properties": {
diff --git a/typescript/src/generated/path-mapping.ts b/typescript/src/generated/path-mapping.ts
index ccceb03bb7..f27168506d 100644
--- a/typescript/src/generated/path-mapping.ts
+++ b/typescript/src/generated/path-mapping.ts
@@ -81,7 +81,7 @@ export const PATH_TO_OPERATION: Record = {
"GET:/{accountId}/dock/tools/{toolId}": "GetTool",
"PUT:/{accountId}/dock/tools/{toolId}": "UpdateTool",
"GET:/{accountId}/documents/{documentId}": "GetDocument",
- "PUT:/{accountId}/documents/{documentId}": "UpdateDocument",
+ "PUT:/{accountId}/documents/{documentId}": "ReplaceDocument",
"GET:/{accountId}/files.json": "GetEverythingFiles",
"GET:/{accountId}/forwards.json": "GetEverythingForwards",
"DELETE:/{accountId}/gauge_needles/{needleId}": "DestroyGaugeNeedle",
diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts
index d07a1c88dc..3d24f1bd48 100644
--- a/typescript/src/generated/schema.d.ts
+++ b/typescript/src/generated/schema.d.ts
@@ -1002,8 +1002,31 @@ export interface paths {
};
/** @description Get a single document by id */
get: operations["GetDocument"];
- /** @description Update an existing document */
- put: operations["UpdateDocument"];
+ /**
+ * @description Replace a document with a new complete representation.
+ * The request body is the document's full writable state: any writable field
+ * omitted from the request is cleared server-side. Omitting content clears it;
+ * omitting title clears it too, and the document then reads back as
+ * "Untitled" (Document#title falls back when blank).
+ * Neither field is required. BC3 builds a brand-new Document from the
+ * permitted params and swaps the recordable wholesale, and neither attribute
+ * carries a presence validation — so an omission is a 200 that clears, not a
+ * 422. What BC3 does require is the wrapping document object, which Rails
+ * synthesizes from a flat body, so a request naming neither field is a 400.
+ * Publishing a draft (status: "active") is not modeled: the SDK sends only
+ * title and content, and BC3 rejects a status-only update for the same
+ * reason it 400s an empty body.
+ * Subscribers are the one exception to omission-clears. A drafted document
+ * keeps its current subscribers when the request addresses neither
+ * subscriptions nor notify, so a full-representation PUT that mentions
+ * neither is safe on a draft.
+ * To set some fields while preserving the rest, use the SDK's merge-safe
+ * update or edit methods, which GET the current document and PUT the full
+ * representation back. Those read-modify-write helpers are not atomic:
+ * a concurrent write between the GET and PUT is overwritten (last write
+ * wins for the whole representation; the window is one round-trip).
+ */
+ put: operations["ReplaceDocument"];
post?: never;
delete?: never;
options?: never;
@@ -5112,6 +5135,11 @@ export interface components {
*/
position: number;
};
+ ReplaceDocumentRequestContent: {
+ title?: string;
+ content?: string;
+ };
+ ReplaceDocumentResponseContent: components["schemas"]["Document"];
ReplaceTodoRequestContent: {
content: string;
description?: string;
@@ -5814,11 +5842,6 @@ export interface components {
content: string;
};
UpdateCommentResponseContent: components["schemas"]["Comment"];
- UpdateDocumentRequestContent: {
- title?: string;
- content?: string;
- };
- UpdateDocumentResponseContent: components["schemas"]["Document"];
UpdateFolderRequestContent: {
/**
* @description The folder's new name. Blank is rejected with 422 — unlike create, update
@@ -11096,7 +11119,7 @@ export interface operations {
};
};
};
- UpdateDocument: {
+ ReplaceDocument: {
parameters: {
query?: never;
header?: never;
@@ -11107,17 +11130,17 @@ export interface operations {
};
requestBody?: {
content: {
- "application/json": components["schemas"]["UpdateDocumentRequestContent"];
+ "application/json": components["schemas"]["ReplaceDocumentRequestContent"];
};
};
responses: {
- /** @description UpdateDocument 200 response */
+ /** @description ReplaceDocument 200 response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["UpdateDocumentResponseContent"];
+ "application/json": components["schemas"]["ReplaceDocumentResponseContent"];
};
};
/** @description UnauthorizedError 401 response */
diff --git a/typescript/src/generated/services/documents.ts b/typescript/src/generated/services/documents.ts
index e2c4ac8d50..b0c1496187 100644
--- a/typescript/src/generated/services/documents.ts
+++ b/typescript/src/generated/services/documents.ts
@@ -18,9 +18,9 @@ import { Errors } from "../../errors.js";
export type Document = components["schemas"]["Document"];
/**
- * Request parameters for update.
+ * Request parameters for replace.
*/
-export interface UpdateDocumentRequest {
+export interface ReplaceDocumentRequest {
/** Title */
title?: string;
/** Text content */
@@ -92,22 +92,22 @@ export class DocumentsService extends BaseService {
}
/**
- * Update an existing document
+ * Replace a document with a new complete representation.
* @param documentId - The document ID
- * @param req - Document update parameters
+ * @param req - Document request parameters
* @returns The Document
- * @throws {BasecampError} If the resource is not found or fields are invalid
+ * @throws {BasecampError} If the request fails
*
* @example
* ```ts
- * const result = await client.documents.update(123, { });
+ * const result = await client.documents.replace(123, { });
* ```
*/
- async update(documentId: number, req: UpdateDocumentRequest): Promise {
+ async replace(documentId: number, req: ReplaceDocumentRequest): Promise {
const response = await this.request(
{
service: "Documents",
- operation: "UpdateDocument",
+ operation: "ReplaceDocument",
resourceType: "document",
isMutation: true,
resourceId: documentId,
diff --git a/typescript/src/index.ts b/typescript/src/index.ts
index bf7e75f8a0..efe69e1db7 100644
--- a/typescript/src/index.ts
+++ b/typescript/src/index.ts
@@ -359,9 +359,13 @@ export {
export {
DocumentsService,
+ type UpdateDocumentRequest,
+ type DocumentFields,
+} from "./services/documents-extensions.js";
+export {
type Document,
type CreateDocumentRequest,
- type UpdateDocumentRequest,
+ type ReplaceDocumentRequest,
} from "./generated/services/documents.js";
export { UploadsService } from "./services/uploads-extensions.js";
diff --git a/typescript/src/services/documents-extensions.ts b/typescript/src/services/documents-extensions.ts
new file mode 100644
index 0000000000..f0b057c900
--- /dev/null
+++ b/typescript/src/services/documents-extensions.ts
@@ -0,0 +1,188 @@
+import { DocumentsService as GeneratedDocumentsService } from "../generated/services/documents.js";
+import type { Document } from "../generated/services/documents.js";
+import { describeValue, malformedResponse, requireRecord, writableString } from "./merge-safe.js";
+
+/** The deliberate-overwrite escape hatch named in this composite's error hints. */
+const ESCAPE = "replace()";
+
+/**
+ * Reads a writable string the record is *required* to carry.
+ *
+ * `writableString` treats an absent key or an explicit `null` as genuinely
+ * empty, which is right for an optional field — `""` is what the server already
+ * holds. It is wrong for a required one. `Document.title` is `@required` in the
+ * spec and BC3 can never render it blank (`Document#title` is
+ * `super.presence || "Untitled"`), so an absent or null `title` in a 2xx body is
+ * a malformed response, not an empty title. Coalescing it to `""` and sending
+ * that in the full-replace PUT would blank the real title on a call that only
+ * touched `content` — #576's defect exactly: a value the caller never mentioned,
+ * silently substituted.
+ *
+ * The wrong-type branch is delegated to `writableString`, so a required field
+ * and an optional one report a non-string identically.
+ */
+function requiredWritableString(
+ body: Record,
+ key: string,
+ opts: { record: string; escape: string }
+): string {
+ const value = body[key];
+ if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) {
+ throw malformedResponse(
+ `${opts.record} field "${key}" is required but the response carried ${describeValue(value)}`,
+ `The merge-safe update/edit resend this field verbatim, so a missing or blank value would ` +
+ `blank the current one. Use ${opts.escape} to write the record deliberately.`
+ );
+ }
+ return writableString(body, key, opts);
+}
+
+/**
+ * Request parameters for update. Both fields are optional: an omitted field
+ * is left untouched on the document, guaranteed. An explicitly-passed empty
+ * string is a set (clears the field).
+ */
+export interface UpdateDocumentRequest {
+ /** Plain-text title. Omit to leave unchanged; `""` clears it. */
+ title?: string;
+ /** Rich text body (HTML). Omit to leave unchanged; `""` clears it. */
+ content?: string;
+}
+
+/**
+ * A document's full writable state, handed to the `edit` callback. The whole
+ * object is PUT back to the server, so clearing a field means setting it empty
+ * (`""`) — there is no third state. The writable set is exactly
+ * `{title, content}`.
+ */
+export interface DocumentFields {
+ /** Plain-text title. Set `""` to clear — the document then reads back as "Untitled". */
+ title: string;
+ /** Rich text body (HTML). Set `""` to clear. */
+ content: string;
+}
+
+/**
+ * DocumentsService with merge-safe `update` and read-modify-write `edit` on
+ * top of the generated surface (`get`, `replace`, ...).
+ *
+ * `PUT /{accountId}/documents/{documentId}` is a full replace: BC3's
+ * `DocumentsController#update` builds a brand-new `Document` from only the
+ * permitted params and swaps the recordable wholesale, so a sparse PUT that
+ * omits `content` erases it. Omitting `title` erases that too — the document
+ * then reads back as `"Untitled"`, because `Document#title` falls back when
+ * blank. Neither field is presence-validated, so **neither omission is a 422**;
+ * both are a `200` that quietly clears. What BC3 does require is the wrapping
+ * `document` object, so a body naming neither field is a `400`.
+ *
+ * Both methods compose the public `get` and `replace` methods, so hooks
+ * observe the two wire operations (`GetDocument` then `ReplaceDocument`), not
+ * a synthetic composite.
+ *
+ * Publishing a draft (`status: "active"`) is not part of this surface: the
+ * spec models only `title` and `content`, and BC3 rejects a status-only update
+ * for the same reason it rejects an empty body.
+ */
+export class DocumentsService extends GeneratedDocumentsService {
+ /**
+ * Sets the given fields on a document and preserves everything else: GETs
+ * the current document, overlays the explicitly-set request fields, and PUTs
+ * the full representation back. An omitted (`undefined`) field is untouched,
+ * guaranteed; an explicitly-passed `""` clears.
+ *
+ * Not atomic: there is no conditional-update signal on this endpoint, so a
+ * concurrent write between the GET and PUT is overwritten — last write wins
+ * for the whole representation. The window is one round-trip. Use `replace`
+ * to overwrite deliberately.
+ *
+ * @param documentId - The document ID
+ * @param req - Fields to set; omitted fields are preserved
+ * @returns The updated Document
+ * @throws {BasecampError} If the request fails
+ *
+ * @example
+ * ```ts
+ * // Retitle without erasing the body.
+ * await client.documents.update(123, { title: "Q3 Plan" });
+ * ```
+ */
+ async update(documentId: number, req: UpdateDocumentRequest): Promise {
+ const fields = await this.currentFields(documentId);
+ if (req.title !== undefined) fields.title = req.title;
+ if (req.content !== undefined) fields.content = req.content;
+ return this.putFields(documentId, fields);
+ }
+
+ /**
+ * Applies a read-modify-write callback to a document: GETs the current
+ * document, hands the callback its full writable representation, and PUTs
+ * the whole thing back. Clearing a field means setting it empty (`""`) — an
+ * untouched field keeps its current value. If the callback throws (or
+ * rejects), the edit aborts and nothing is written.
+ *
+ * Not atomic: there is no conditional-update signal on this endpoint, so a
+ * concurrent write between the GET and PUT is overwritten — last write wins
+ * for the whole representation. The window is one round-trip. Use `replace`
+ * to overwrite deliberately.
+ *
+ * @param documentId - The document ID
+ * @param fn - Callback that mutates the document's writable fields in place
+ * @returns The updated Document
+ * @throws {BasecampError} If the request fails
+ *
+ * @example
+ * ```ts
+ * await client.documents.edit(123, (d) => {
+ * d.title = `🚨 ${d.title}`;
+ * d.content = ""; // clearing = setting empty on a full object
+ * });
+ * ```
+ */
+ async edit(
+ documentId: number,
+ fn: (d: DocumentFields) => void | Promise
+ ): Promise {
+ const fields = await this.currentFields(documentId);
+ await fn(fields);
+ return this.putFields(documentId, fields);
+ }
+
+ /**
+ * Fetches the document and derives its full writable state.
+ *
+ * Every value here is resent in the full-replace PUT, so every value is
+ * validated before it is read. `?? ""` coalesces only `null` and
+ * `undefined`, leaving corruption wide open: `false`, `0`, `[]`, `{}`, `42`,
+ * `true` and friends would all be forwarded **verbatim** and written to the
+ * document on a call that never mentioned the field. Nothing below this
+ * rejects them — `schema.d.ts` is erased at build time, so `Document` is a
+ * compile-time claim about runtime data. See `merge-safe.ts` and #576.
+ *
+ * The two writable fields read differently because the spec models them
+ * differently: `title` is `@required`, so absent or null is malformed;
+ * `content` is optional, so absent or null is a genuinely empty body.
+ */
+ private async currentFields(documentId: number): Promise {
+ const current = requireRecord(await this.get(documentId), {
+ record: "Document",
+ operation: "GetDocument",
+ escape: ESCAPE,
+ });
+ const opts = { record: "Document", escape: ESCAPE };
+ return {
+ title: requiredWritableString(current, "title", opts),
+ content: writableString(current, "content", opts),
+ };
+ }
+
+ /**
+ * PUTs the full writable state via `replace`. Both fields are always sent,
+ * empties included: on a full-replace endpoint `""` is how a clear is
+ * expressed — never JSON null (SPEC §18 body compaction), and never by
+ * omission, which would leave the field to the server's own clear-by-default
+ * and read as an accident rather than an intent.
+ */
+ private putFields(documentId: number, f: DocumentFields): Promise {
+ return this.replace(documentId, { title: f.title, content: f.content });
+ }
+}
diff --git a/typescript/src/services/merge-safe.ts b/typescript/src/services/merge-safe.ts
new file mode 100644
index 0000000000..46eb042fd2
--- /dev/null
+++ b/typescript/src/services/merge-safe.ts
@@ -0,0 +1,168 @@
+/**
+ * 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** — a falsey value dropped or coalesced away, wiping the field;
+ * - **corruption** — a non-string forwarded verbatim, writing a number,
+ * boolean, array or object where a string belongs.
+ *
+ * `?? ""` catches only `null` and `undefined`, so it rules out erasure while
+ * leaving corruption wide open — every one of `false`, `0`, `[]`, `{}`, `42`,
+ * `true`, `["x"]` and `{a:1}` rides through unchanged. 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 decoder *rejects* a
+ * wrong-typed field at runtime — not when a type merely claims one.** Go
+ * (`json.Unmarshal`), Swift (`Codable`) and Kotlin (kotlinx.serialization)
+ * genuinely refuse. TypeScript's `schema.d.ts` is erased at build time, so the
+ * type on a GET result is a compile-time claim nothing validates; structurally
+ * this sits with Python and Ruby, not with Go and Swift (#576).
+ *
+ * Todolists carries its own copy of these guards (#574, landed a commit
+ * earlier); it is left alone here because the flat-shape work in #544 owns
+ * those files. A generated validating layer (#578) is the intended end state
+ * for all of them.
+ */
+import { Errors, truncateErrorMessage, type BasecampError } from "../errors.js";
+
+const resendHint = (escape: string): string =>
+ "The merge-safe update/edit resend this field verbatim, so a coerced or empty value " +
+ `would overwrite the current one. Use ${escape} to write the record deliberately.`;
+
+/**
+ * Renders a value for an error message without ever throwing.
+ *
+ * The guard's own error path must not fail while explaining a failure.
+ * `JSON.stringify` raises `TypeError` on a circular structure, and a value can
+ * carry a `toJSON` that throws — either would replace a clean `api_error` with
+ * an incidental `TypeError` and lose the diagnosis. The type name is always
+ * available; the rendering is a bonus, capped per SPEC §9 and dropped if it
+ * fails.
+ */
+export function describeValue(value: unknown): string {
+ const kind = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
+ try {
+ const rendered = JSON.stringify(value);
+ return rendered === undefined ? kind : `${kind} ${truncateErrorMessage(rendered)}`;
+ } catch {
+ return kind;
+ }
+}
+
+/**
+ * Builds the malformed-response error, with the message capped per SPEC §9.
+ *
+ * `api_error`, not `usage`: the value arrived in a successful API response, so
+ * nothing the caller passed is at fault. Statusless — the transport succeeded,
+ * there is no HTTP status to attribute — and non-retryable, because
+ * re-requesting cannot repair a malformed body.
+ */
+export function malformedResponse(message: string, hint: string): BasecampError {
+ return Errors.apiError(truncateErrorMessage(message), undefined, { hint, retryable: false });
+}
+
+/**
+ * The response must be a JSON object before any field is read.
+ *
+ * One level up from the malformed-*field* guards: a successful GET can return a
+ * scalar, an array, or null. Reading a property off `null` throws a raw
+ * `TypeError`, and off an array or a string it silently yields `undefined`,
+ * which the field guards would then read as "genuinely empty" and write back —
+ * so the envelope needs checking before the fields.
+ */
+export function requireRecord(
+ body: unknown,
+ opts: { record: string; operation: string; escape: string }
+): Record {
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
+ throw malformedResponse(
+ `${opts.operation} returned ${describeValue(body)} where a ${opts.record.toLowerCase()} 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 ${opts.escape} to write the record deliberately.`
+ );
+ }
+ return body as Record;
+}
+
+/**
+ * Reads a writable string field, refusing to pass a malformed one through.
+ *
+ * An absent key or an explicit `null` 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.
+ */
+export function writableString(
+ body: Record,
+ key: string,
+ opts: { record: string; escape: string }
+): string {
+ const value = body[key];
+ if (value === undefined || value === null) return "";
+ if (typeof value !== "string") {
+ throw malformedResponse(
+ `${opts.record} field "${key}" is not a string: ${describeValue(value)}`,
+ resendHint(opts.escape)
+ );
+ }
+ return value;
+}
+
+/**
+ * Reads a list of person records and projects it to their integer IDs.
+ *
+ * The analogue of {@link writableString} for the ID-list fields. The `.map()`
+ * it replaces (`(body[key] ?? []).map((p) => p.id)`) has three ways to go wrong
+ * on malformed data: a non-array has no `.map` (a raw `TypeError`), a
+ * non-object element yields `undefined`, and a non-integer `id` rides through
+ * verbatim into the full-replace PUT — the same corruption as a wrong-typed
+ * string, one level down.
+ *
+ * `Number.isInteger` is the test rather than `typeof === "number"`: `1.5` and
+ * `NaN` are numbers and neither is a person ID. Booleans fail it outright,
+ * which is what we want — JavaScript would happily coerce `true` to `1`
+ * downstream.
+ */
+export function writableIdList(
+ body: Record,
+ key: string,
+ opts: { record: string; escape: string }
+): number[] {
+ const value = body[key];
+ if (value === undefined || value === null) return [];
+ if (!Array.isArray(value)) {
+ throw malformedResponse(
+ `${opts.record} field "${key}" is not an array: ${describeValue(value)}`,
+ resendHint(opts.escape)
+ );
+ }
+ return value.map((element: unknown, index: number) => {
+ if (typeof element !== "object" || element === null || Array.isArray(element)) {
+ throw malformedResponse(
+ `${opts.record} field "${key}"[${index}] is not an object: ${describeValue(element)}`,
+ resendHint(opts.escape)
+ );
+ }
+ const id = (element as Record)["id"];
+ if (id === undefined || id === null) {
+ throw malformedResponse(
+ `${opts.record} field "${key}"[${index}] has no "id"`,
+ resendHint(opts.escape)
+ );
+ }
+ if (typeof id !== "number" || !Number.isInteger(id)) {
+ throw malformedResponse(
+ `${opts.record} field "${key}"[${index}].id is not an integer: ${describeValue(id)}`,
+ resendHint(opts.escape)
+ );
+ }
+ return id;
+ });
+}
diff --git a/typescript/tests/services/documents.test.ts b/typescript/tests/services/documents.test.ts
index a4d79bd584..87dcce28eb 100644
--- a/typescript/tests/services/documents.test.ts
+++ b/typescript/tests/services/documents.test.ts
@@ -1,19 +1,43 @@
/**
- * Tests for the Documents service (generated from OpenAPI spec)
+ * Tests for the Documents service.
*
- * Note: Generated services are spec-conformant:
+ * Notes:
* - Client-side check: create() rejects a missing title; the API validates the rest
* - No domain-specific trash() (use recordings.trash())
+ * - The write surface is a triad: `replace` is the generated full-replace PUT,
+ * `update` and `edit` are the merge-safe composites layered over it in
+ * `services/documents-extensions.ts`.
+ *
+ * `PUT /documents/{id}` is a full replace: BC3 rebuilds the Document from the
+ * permitted params and swaps the recordable wholesale. The writable set is
+ * exactly `{title, content}` and **both are optional** — omitting `title` is a
+ * 200 that leaves the document reading back as "Untitled", omitting `content`
+ * is a 200 that clears it. Neither omission is a 422, so nothing on the wire
+ * tells you the sparse PUT went wrong; only the next GET does. That is what
+ * `update` and `edit` exist to prevent, and what these tests pin: every PUT
+ * they issue names both fields, empties included, never JSON null.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "../setup.js";
-import type { DocumentsService } from "../../src/generated/services/documents.js";
+import type { DocumentsService } from "../../src/services/documents-extensions.js";
import { BasecampError } from "../../src/errors.js";
import { createBasecampClient } from "../../src/client.js";
const BASE_URL = "https://3.basecampapi.com/12345";
+/** A GET-shaped Document, with the two writable fields the triad round-trips. */
+const sampleDocument = (id = 5001, overrides: Record = {}) => ({
+ id,
+ title: "Project Overview",
+ content: "The plan so far.
",
+ content_attachments: [],
+ status: "active",
+ created_at: "2022-11-22T08:30:00.000Z",
+ updated_at: "2022-11-22T08:30:00.000Z",
+ ...overrides,
+});
+
describe("DocumentsService", () => {
let service: DocumentsService;
@@ -185,47 +209,522 @@ describe("DocumentsService", () => {
});
});
- describe("update", () => {
- it("should update an existing document", async () => {
- const updatedDocument = {
- id: 5001,
- title: "Updated Title",
- content: "Updated content
",
- content_attachments: [],
- status: "active",
- };
+ describe("replace", () => {
+ it("sends the sparse request verbatim with no GET", async () => {
+ const requests: string[] = [];
+ let putBody: Record = {};
server.use(
- http.put(`${BASE_URL}/documents/5001`, () => {
- return HttpResponse.json(updatedDocument);
+ http.get(`${BASE_URL}/documents/5001`, () => {
+ requests.push("GET");
+ return HttpResponse.json(sampleDocument());
+ }),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ requests.push("PUT");
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument(5001, { title: "the whole new document" }));
}),
);
- const result = await service.update(5001, {
+ const result = await service.replace(5001, { title: "the whole new document" });
+
+ expect(result.id).toBe(5001);
+ // replace is the deliberate overwrite — it reads nothing first.
+ expect(requests).toEqual(["PUT"]);
+ expect(putBody.title).toBe("the whole new document");
+ // The unset field is omitted and the server clears it. That is the whole
+ // reason `update`/`edit` exist.
+ expect(putBody).not.toHaveProperty("content");
+ });
+
+ it("sends both fields when both are given", async () => {
+ let putBody: Record = {};
+
+ server.use(
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(
+ sampleDocument(5001, { title: "Updated Title", content: "Updated content
" }),
+ );
+ }),
+ );
+
+ const result = await service.replace(5001, {
title: "Updated Title",
content: "Updated content
",
});
expect(result.title).toBe("Updated Title");
expect(result.content).toContain("Updated content");
+ expect(putBody.title).toBe("Updated Title");
+ expect(putBody.content).toBe("Updated content
");
+ });
+
+ it("surfaces a 404 as BasecampError", async () => {
+ server.use(
+ http.put(`${BASE_URL}/documents/9999`, () => {
+ return HttpResponse.json({ error: "Not found" }, { status: 404 });
+ }),
+ );
+
+ await expect(service.replace(9999, { title: "gone" })).rejects.toThrow(BasecampError);
+ });
+ });
+
+ describe("update", () => {
+ it("merges: an omitted content is preserved from the GET", async () => {
+ const requests: string[] = [];
+ let putBody: Record = {};
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => {
+ requests.push("GET");
+ return HttpResponse.json(sampleDocument());
+ }),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ requests.push("PUT");
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument(5001, { title: "Q3 Plan" }));
+ }),
+ );
+
+ const document = await service.update(5001, { title: "Q3 Plan" });
+
+ expect(document.id).toBe(5001);
+ expect(requests).toEqual(["GET", "PUT"]);
+ expect(putBody.title).toBe("Q3 Plan");
+ // The field the caller never mentioned rides back verbatim. A sparse PUT
+ // here would have been a silent 200 that erased it.
+ expect(putBody.content).toBe("The plan so far.
");
+ });
+
+ it("merges: an omitted title is preserved from the GET", async () => {
+ let putBody: Record = {};
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
+
+ await service.update(5001, { content: "Rewritten.
" });
+
+ expect(putBody.content).toBe("Rewritten.
");
+ // Omitting title on the wire is a 200 that leaves the document titled
+ // "Untitled" — never a 422 — so preservation is the only defence.
+ expect(putBody.title).toBe("Project Overview");
+ });
+
+ it("clears content with an explicitly-passed empty string", async () => {
+ let putBody: Record = {};
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
+
+ await service.update(5001, { content: "" });
+
+ // A clear is an empty string, never an omission and never JSON null.
+ expect(putBody).toHaveProperty("content");
+ expect(putBody.content).toBe("");
+ expect(putBody.title).toBe("Project Overview");
+ });
+
+ it("clears title with an explicitly-passed empty string", async () => {
+ let putBody: Record = {};
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
+
+ await service.update(5001, { title: "" });
+
+ expect(putBody.title).toBe("");
+ expect(putBody.content).toBe("The plan so far.
");
+ });
+
+ it("names exactly title and content, never JSON null", async () => {
+ let putBody: Record = {};
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
+
+ await service.update(5001, { title: "Q3 Plan", content: "" });
+
+ expect(Object.keys(putBody).sort()).toEqual(["content", "title"]);
+ expect(Object.values(putBody).every((v) => v !== null)).toBe(true);
+ });
+
+ it("hooks observe the wire operations GetDocument then ReplaceDocument", async () => {
+ const operations: string[] = [];
+ const hookedClient = createBasecampClient({
+ accountId: "12345",
+ accessToken: "test-token",
+ enableRetry: false,
+ hooks: {
+ onOperationStart: (info) => {
+ operations.push(info.operation);
+ },
+ },
+ });
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ );
+
+ await hookedClient.documents.update(5001, { title: "observed" });
+
+ // The composite is not a synthetic operation: hooks see the two real ones.
+ expect(operations).toEqual(["GetDocument", "ReplaceDocument"]);
});
+ });
- it("should send updated fields in request body", async () => {
- let capturedBody: { title?: string; content?: string } | null = null;
+ describe("edit", () => {
+ it("hands the callback current state and PUTs everything back", async () => {
+ const requests: string[] = [];
+ let putBody: Record = {};
server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => {
+ requests.push("GET");
+ return HttpResponse.json(sampleDocument());
+ }),
http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
- capturedBody = (await request.json()) as {
- title?: string;
- content?: string;
- };
- return HttpResponse.json({ id: 5001, title: "Updated" });
+ requests.push("PUT");
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument());
}),
);
- await service.update(5001, { title: "New Title" });
+ const document = await service.edit(5001, (d) => {
+ expect(d.title).toBe("Project Overview");
+ expect(d.content).toBe("The plan so far.
");
+ d.title = `🚨 ${d.title}`;
+ });
+
+ expect(document.id).toBe(5001);
+ expect(requests).toEqual(["GET", "PUT"]);
+ expect(putBody.title).toBe("🚨 Project Overview");
+ expect(putBody.content).toBe("The plan so far.
");
+ });
+
+ it("clears content by setting it empty — present-and-empty in the PUT body", async () => {
+ let putBody: Record = {};
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
- expect(capturedBody?.title).toBe("New Title");
+ await service.edit(5001, (d) => {
+ d.content = "";
+ });
+
+ // Present and empty, not omitted: on a full-replace endpoint an omission
+ // is the server's own clear-by-default and reads as an accident.
+ expect(putBody).toHaveProperty("content");
+ expect(putBody.content).toBe("");
+ expect(putBody.title).toBe("Project Overview");
+ });
+
+ it("clears title by setting it empty — present-and-empty in the PUT body", async () => {
+ let putBody: Record = {};
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
+
+ await service.edit(5001, (d) => {
+ d.title = "";
+ });
+
+ expect(putBody).toHaveProperty("title");
+ expect(putBody.title).toBe("");
+ expect(putBody.content).toBe("The plan so far.
");
+ });
+
+ it("aborts without a PUT when the callback throws", async () => {
+ let putCount = 0;
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, () => {
+ putCount++;
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
+
+ await expect(
+ service.edit(5001, () => {
+ throw new Error("abort");
+ }),
+ ).rejects.toThrow("abort");
+ expect(putCount).toBe(0);
+ });
+
+ it("supports async callbacks", async () => {
+ let putBody: Record = {};
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
+
+ await service.edit(5001, async (d) => {
+ d.content = await Promise.resolve("async content
");
+ });
+
+ expect(putBody.content).toBe("async content
");
+ expect(putBody.title).toBe("Project Overview");
+ });
+
+ it("hooks observe the wire operations GetDocument then ReplaceDocument", async () => {
+ const operations: string[] = [];
+ const hookedClient = createBasecampClient({
+ accountId: "12345",
+ accessToken: "test-token",
+ enableRetry: false,
+ hooks: {
+ onOperationStart: (info) => {
+ operations.push(info.operation);
+ },
+ },
+ });
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ http.put(`${BASE_URL}/documents/5001`, () => HttpResponse.json(sampleDocument())),
+ );
+
+ await hookedClient.documents.edit(5001, (d) => {
+ d.title = "observed";
+ });
+
+ expect(operations).toEqual(["GetDocument", "ReplaceDocument"]);
+ });
+ });
+
+ // --- #576: a malformed GET field must never reach the full-replace PUT ----
+ //
+ // `update`/`edit` GET the document, read each writable field, and PUT the
+ // FULL representation back, so every value read is written -- including one
+ // the caller never mentioned. `?? ""` coalesces only null and undefined, so
+ // it rules out *erasure* while leaving *corruption* wide open: all eight
+ // malformed shapes would ride through VERBATIM into the PUT.
+ //
+ // TypeScript has no runtime decoder to catch this -- `schema.d.ts` is erased
+ // at build time, so `Document` is a compile-time claim nothing validates.
+ // That places this composite with Python and Ruby, not with Go and Swift.
+ //
+ // The assertion that matters is the ORDERING: `requests` must be ["GET"] --
+ // exactly one request. A guard that fires after the PUT has already lost the
+ // field.
+ describe("malformed writable fields (#576)", () => {
+ const malformed: [string, unknown][] = [
+ ["false", false],
+ ["zero", 0],
+ ["empty array", []],
+ ["empty object", {}],
+ ["number", 42],
+ ["true", true],
+ ["array", ["x"]],
+ ["object", { a: 1 }],
+ ];
+
+ const writableStrings = ["title", "content"] as const;
+
+ // Serve a GET carrying `body` and a PUT that records that it happened.
+ const serve = (body: unknown, requests: string[]) => {
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => {
+ requests.push("GET");
+ return HttpResponse.json(body);
+ }),
+ http.put(`${BASE_URL}/documents/5001`, () => {
+ requests.push("PUT");
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
+ };
+
+ const rejection = async (promise: Promise): Promise =>
+ promise.then(
+ () => {
+ throw new Error("expected the call to reject, but it resolved");
+ },
+ (error: unknown) => error,
+ );
+
+ // Asserting only the message is vacuous about the taxonomy: a wrong `code`
+ // satisfies it. The value arrived in a successful API response, so this is
+ // `api_error` -- the caller passed nothing wrong.
+ const expectResponseError = (error: unknown, pattern: RegExp, requests: string[]) => {
+ expect(error).toBeInstanceOf(BasecampError);
+ expect((error as BasecampError).code).toBe("api_error");
+ expect((error as BasecampError).message).toMatch(pattern);
+ expect(requests).toEqual(["GET"]);
+ };
+
+ for (const field of writableStrings) {
+ it.each(malformed)(`update refuses a %s ${field} before writing`, async (_label, value) => {
+ const requests: string[] = [];
+ serve(sampleDocument(5001, { [field]: value }), requests);
+
+ const error = await rejection(service.update(5001, { title: "New title" }));
+ expectResponseError(
+ error,
+ new RegExp(`Document field "${field}" is not a string`),
+ requests,
+ );
+ });
+
+ it(`edit refuses a malformed ${field} before writing`, async () => {
+ const requests: string[] = [];
+ serve(sampleDocument(5001, { [field]: 42 }), requests);
+
+ const error = await rejection(
+ service.edit(5001, (d) => {
+ d.title = "New title";
+ }),
+ );
+ expectResponseError(
+ error,
+ new RegExp(`Document field "${field}" is not a string`),
+ requests,
+ );
+ });
+
+ }
+
+ // The other half of the rule: for an OPTIONAL field, absent and null are
+ // not malformed, they are empty. Guarding types must not turn a
+ // legitimately blank field into an error. The call sets the other writable
+ // string so that `content` is never overwritten by the caller.
+ //
+ // `content` only. `title` is `@required` in the spec and gets the opposite
+ // treatment below.
+ it.each([
+ ["absent", undefined],
+ ["null", null],
+ ])("treats a %s content as genuinely empty", async (_label, value) => {
+ let putBody: Record = {};
+ const body: Record = sampleDocument(5001, { content: value });
+ if (value === undefined) delete body["content"];
+
+ server.use(
+ http.get(`${BASE_URL}/documents/5001`, () => HttpResponse.json(body)),
+ http.put(`${BASE_URL}/documents/5001`, async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json(sampleDocument());
+ }),
+ );
+
+ await service.update(5001, { title: "set by the caller" });
+
+ expect(putBody["content"]).toBe("");
+ expect(putBody["title"]).toBe("set by the caller");
+ });
+
+ // `Document.title` is `@required` in the spec, and BC3 can never render it
+ // blank (`Document#title` is `super.presence || "Untitled"`). So an absent
+ // or null title in a 2xx body is a MALFORMED RESPONSE, not an empty title
+ // — and coalescing it to "" would blank the real title on a call that only
+ // touched `content`. Same defect class as a forwarded non-string, in the
+ // one shape `?? ""` looks correct.
+ it.each([
+ ["absent", undefined],
+ ["null", null],
+ // BC3 can never render a blank title, so "" is malformed too — and it is
+ // the shape a missing/null check alone would let through.
+ ["blank", ""],
+ // BC3 blanks via `presence`, whose blank case includes whitespace-only.
+ ["whitespace", " "],
+ ])("update refuses a %s title before writing", async (_label, value) => {
+ const requests: string[] = [];
+ const body: Record = sampleDocument(5001, { title: value });
+ if (value === undefined) delete body["title"];
+ serve(body, requests);
+
+ const error = await rejection(service.update(5001, { content: "New body.
" }));
+ expectResponseError(error, /Document field "title" is required/, requests);
+ });
+
+ it.each([
+ ["absent", undefined],
+ ["null", null],
+ ["blank", ""],
+ // BC3 blanks via `presence`, whose blank case includes whitespace-only.
+ ["whitespace", " "],
+ ])("edit refuses a %s title before writing", async (_label, value) => {
+ const requests: string[] = [];
+ const body: Record = sampleDocument(5001, { title: value });
+ if (value === undefined) delete body["title"];
+ serve(body, requests);
+
+ const error = await rejection(
+ service.edit(5001, (d) => {
+ d.content = "New body.
";
+ }),
+ );
+ expectResponseError(error, /Document field "title" is required/, requests);
+ });
+
+ // One level up from the field guards: a successful GET can return a
+ // scalar, an array or null, and reading a property off null throws a raw
+ // TypeError instead of the documented statusless api_error.
+ it.each([
+ ["array", []],
+ ["string", "document"],
+ ["number", 42],
+ ["null", null],
+ ["boolean", true],
+ ])("update refuses a %s response body before writing", async (_label, body) => {
+ const requests: string[] = [];
+ serve(body, requests);
+
+ const error = await rejection(service.update(5001, { title: "New title" }));
+ expectResponseError(error, /GetDocument returned/, requests);
+ });
+
+ it.each([
+ ["array", []],
+ ["null", null],
+ ])("edit refuses a %s response body before writing", async (_label, body) => {
+ const requests: string[] = [];
+ serve(body, requests);
+
+ const error = await rejection(
+ service.edit(5001, (d) => {
+ d.title = "New title";
+ }),
+ );
+ expectResponseError(error, /GetDocument returned/, requests);
});
});