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); }); });