diff --git a/AGENTS.md b/AGENTS.md index 0ce632da0d..96276df299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Paths are from the repository root, since that is where you will be working. | **Kotlin** | Ktor via `BaseService` | `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/*.kt` | | **Python** | httpx via `HttpClient` | `python/src/basecamp/generated/services/*.py` | -All 249 operations across the ~50-service per-SDK layer are generated. Hand-written code is limited to infrastructure: +All 250 operations across the ~50-service per-SDK layer are generated. Hand-written code is limited to infrastructure: | Purpose | Location | |---------|----------| @@ -91,7 +91,7 @@ Pull the andon cord when you see: All new API coverage starts in `spec/basecamp.smithy`. Before writing SDK code, add operations and shapes to the spec. -`spec/basecamp.smithy` holds 249 worked operations. Copy the nearest one rather than +`spec/basecamp.smithy` holds 250 worked operations. Copy the nearest one rather than working from a skeleton here: it shows the live conventions for naming, `@http` URIs, pagination traits and shape reuse, and it cannot drift from itself. diff --git a/API-GAP-404.md b/API-GAP-404.md index a70391dddc..d2174c6564 100644 --- a/API-GAP-404.md +++ b/API-GAP-404.md @@ -2,6 +2,20 @@ Addresses basecamp/basecamp-cli#404. +> **Resolved.** BC3 closed this with a dedicated route rather than by widening +> the update: `POST /uploads/{id}/versions.json` (basecamp/bc3#12555, input +> contract settled in #12565). The SDK absorbed it as `CreateUploadVersion`, +> alongside the read-side retype that closes basecamp-sdk#649. +> +> **The finding below still stands and is why the guard stays.** `PUT +> /uploads/{id}.json` still ignores `attachable_sgid` — the hypothesis this +> document tested is still false, and `TestUpdateUploadRequest_HasNoFileReplacementField` +> still asserts it. What changed is that the guard now pins a design choice +> rather than a missing feature; its positive counterpart is +> `TestCreateUploadVersionRequest_HasFileReplacementField`. +> +> Registry entry: [`spec/api-gaps/upload-new-version.md`](spec/api-gaps/upload-new-version.md). + ## Question basecamp-cli#404 asks the SDK to support uploading a **new version** of an diff --git a/COORDINATION.md b/COORDINATION.md index e7b4175ba7..0b8735b022 100644 --- a/COORDINATION.md +++ b/COORDINATION.md @@ -9,7 +9,7 @@ live BC5 by the #11629 tooling. The historical server-side audit lived on the The SDK's conformance baseline is the pin in [`spec/api-provenance.json`](spec/api-provenance.json) — `bc3` `master` -`7fe1c63ab3` as of the 2026-08-05 sync. +`b5d8c9df8d` as of the 2026-08-05 sync. That file is the only authority; quote it here rather than a remembered SHA. `make sync-api-version` now rewrites the marked line above from it, and `make doc-constants-check` fails if the two disagree — this sentence sat two diff --git a/MIGRATING.md b/MIGRATING.md index eb034f33c8..e676ed40e4 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -11,6 +11,141 @@ what wrong behaviour you get if you ignore one. This file is that half. --- +# Unreleased + +Breaking in Go and in the shape every SDK decodes from +`GET /uploads/{id}/versions.json`. + +**Operation inventory: 249 → 250** — `CreateUploadVersion`. Derive both ends +rather than trusting either number: + +```bash +git show v0.13.0:openapi.json | jq '[.paths[]|keys[]]|length' +jq '[.paths[]|keys[]]|length' openapi.json +``` + +### `ListUploadVersions` returns versions, not uploads (#649) + +The endpoint has always returned **events**. The spec declared +`uploads: UploadList` anyway, and **11 of `Upload`'s 14 required members are +absent from every response** — which is why the CLI's versions command and the +MCP server's `list_upload_versions` printed blank fields rather than failing. +The output is now `versions: UploadVersionList`. + +| SDK | was | now | +|---|---|---| +| Go | `UploadVersionListResult.Versions []Upload` | `[]UploadVersion` | +| TypeScript | `ListResult` | `ListResult` | +| Swift | `ListResult` | `ListResult` | +| Kotlin | `ListResult` | `ListResult` | +| Ruby / Python | parsed body, unchanged at runtime | fields differ, see below | + +The four typed SDKs keep their `ListResult` wrapper, so `.meta.totalCount` and +the pagination surface are untouched; only the element type changes. Member +access moves with it — `version.filename` becomes `version.upload?.filename`, +and the event's own `action`, `createdAt` and `creator` sit alongside. + +**In Ruby and Python the compiler will not catch this.** Nothing changes in the +type; what changes is which keys are actually there. Code reading +`version["filename"]` was reading a key the server never sent and getting nil — +it now reads `version["upload"]["filename"]`, and the event's own metadata +(`action`, `created_at`, `creator`) is available where it previously looked like +a partly-empty upload. + +A version carries `upload` only when its recordable still resolves; a deleted +file leaves the event behind with no `upload` at all. Check before dereferencing. +`action` is `created`, `active` (the publication) or `blob_changed` (a file +replacement). To list the file's **past** versions, take the entries that carry +an `upload` with `current == false` — not the ones with `action == "blob_changed"`, +which drops the original (it arrives as `created` or `active`) and keeps the +current file. The per-version `download_url` serves **that** version's bytes; the +upload's own always serves the latest. + +### Go: `UpdateUploadRequest.Description` became `*string` + +Tri-state, following `UpdateGaugeNeedleRequest.Description` (#560): nil leaves +it untouched, `basecamp.Ptr("")` clears it, `basecamp.Ptr(v)` sets it. +Previously a plain `string` behind a zero-value guard, so `""` read as *unset* +and clearing a description through `Update` was unreachable — the divergence +SPEC §5 documented. + +```go +// Before — compiled, and silently did nothing to the description. +svc.Update(ctx, id, &UpdateUploadRequest{Description: ""}) + +// After — clears it. +svc.Update(ctx, id, &UpdateUploadRequest{Description: basecamp.Ptr("")}) + +// After — leaves it alone. +svc.Update(ctx, id, &UpdateUploadRequest{BaseName: "renamed"}) +``` + +The compiler catches this one: `Description: "text"` no longer type-checks. Wrap +it in `basecamp.Ptr`. + +`BaseName` is deliberately still a plain `string` on both this and +`CreateUploadVersionRequest`. `Upload#base_name=` guards on +`new_base_name.present?`, so `""` and absent are the same write server-side — +there is no third state for a pointer to express. + +### New: `UploadsService.CreateVersion` and a 507 error code + +Not breaking, but the reason for the above. `POST /uploads/{id}/versions.json` +replaces an upload's file in place, keeping the recording's id, URL and +comments, so a published link keeps working — which `CreateUpload` cannot do. + +A `507 Insufficient Storage` now maps to the new `limit_exceeded` code (exit +code 10) instead of `api_error`. **If you branch on `api_error` to decide +whether to back off, a limit failure no longer lands in that branch** — which is +the point: it was reported as retryable, and no retry can satisfy a plan limit. + +The mapping is by **status**, not by operation, so it reaches every 507 the spec +declares — all eight, across three different limits: + +| Operations | Limit | Error shape | +|---|---|---| +| `CreateUpload`, `CreateUploadVersion`, `CreateAttachment`, `CreateCampfireUpload` | file storage | `StorageLimitError` (new) | +| `CreateProject`, `UnarchiveProject` | project count | `ProjectLimitError` (v0.13.0) | +| `CreateWebhook`, `UpdateWebhook` | webhook count | `WebhookLimitError` (pre-existing) | + +Only the first row is new surface. The other four operations already returned +507 and already reported it as a retryable `api_error`; they are reclassified +here too, so **webhook and project callers need the same new branch even though +nothing about those endpoints changed**. Derive the list rather than trusting +it: + +```bash +jq -r '.paths[]|to_entries[]|select(.value.responses."507")|.value.operationId' openapi.json +``` + +### The new error code is source-breaking in four SDKs + +Adding a member to a closed type breaks exhaustive handling, so this is not +merely behavioural: + +| SDK | what changed | how it breaks | +|---|---|---| +| TypeScript | `ErrorCode` union gains `"limit_exceeded"` | a `Record` map, or a `switch` the compiler checks for exhaustiveness, stops compiling until it has a branch | +| Swift | `BasecampError` gains `case limitExceeded` | a `switch` over the enum without a `default` stops compiling | +| Kotlin | `BasecampException` gains `LimitExceeded` | a `when` over the sealed class used as an expression stops compiling | +| Python | `ErrorCode` (a `StrEnum`) gains `LIMIT_EXCEEDED` | a `match` over it ending in `typing.assert_never` stops type-checking — mypy reports the new member as unhandled | + +Python's break needs a type-checker to surface, not an interpreter: the module +imports and runs either way. If your CI runs mypy — this package does — it fails +there rather than at import, which makes it easier to miss in review and no less +of a break. + +Go and Ruby take a new constant rather than a new variant, so neither breaks a +build — which is exactly why they need reading for: a `case` or `when` falling +through to a default arm now routes storage and project limits wherever that +default goes. + +Add a `limit_exceeded` branch that surfaces the limit to the user and does not +retry. This SDK's own Kotlin test suite hit the compile error, which is what the +exhaustive `when` in `ErrorTest` exists to produce. + +--- + # v0.13.0 Breaking across all six SDKs — Go, TypeScript, Python, Ruby, Kotlin, Swift. diff --git a/SECURITY.md b/SECURITY.md index e0d38d7384..35a6d8082f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -184,9 +184,9 @@ print(f"Headers: {safe}") ## Retry Behavior Retry eligibility is decided per *operation*, not per HTTP method. `behavior-model.json` classifies -all 249 operations: the 125 GETs are retryable by method, and 83 mutations are flagged +all 250 operations: the 125 GETs are retryable by method, and 83 mutations are flagged `idempotent: true` — all 52 PUTs, all 24 DELETEs, and 7 POSTs (`CompleteTodo`, `PauseQuestion`, -`SubscribeToCardColumn`, `Subscribe`, `EnableCardColumnOnHold`, `CreateBookmark`, `PrioritizeAssignment`). The other 41 POSTs are attempted exactly once. SPEC.md §7 specifies the +`SubscribeToCardColumn`, `Subscribe`, `EnableCardColumnOnHold`, `CreateBookmark`, `PrioritizeAssignment`). The other 42 POSTs are attempted exactly once. SPEC.md §7 specifies the three-gate algorithm and the per-SDK divergences. - **Reads (GET)**: retried with exponential backoff on 429/503 in every SDK. (HEAD is idempotent by method too, but Ruby's transport gates on `method == :get` specifically, so a HEAD would not retry there. The API surface has no HEAD operations today, so this is theoretical.) diff --git a/SPEC.md b/SPEC.md index 1c27fcffc8..d16f4c3010 100644 --- a/SPEC.md +++ b/SPEC.md @@ -108,7 +108,7 @@ END **Naming note:** `max_retries` means total attempts (including the initial request), not the number of retries after the first attempt. With `max_retries = 3`, the transport makes at most 3 attempts total (1 initial + 2 retries). This name is inherited from the shipping Ruby SDK; the behavior-model.json uses `retry.max` with identical semantics. -**Per-operation retry ceiling.** Each operation carries a per-op `retry.max` in behavior-model.json (205 ops at `3`, 44 at `2`). **TypeScript and Swift** drive their retry loops directly from this per-op value, which is unambiguous there because neither exposes a numeric client-wide cap — only an on/off (`enableRetry`). Generated Go, Python, Kotlin (`BasecampConfig.maxRetries`), and Ruby's governed GET path (`config.max_retries`) expose a numeric client cap *and* honor the per-op value as a **ceiling**: `effective_attempts = min(client_cap, op_max)`. The ceiling can only reduce attempts below the client cap, never raise them, so a client that lowered its cap (e.g. to `1` to disable retries) is still honored. In Go, Python, and Ruby's governed path the cap is floored at one attempt before the ceiling applies (`min(max(1, cap), op_max)`), so a cap of `0` yields a single attempt rather than none whether or not the operation declares a retry block. Kotlin computes a different expression — `min(max(1, cap), op_max ?: cap)` — which is `0` for an ungoverned operation at a cap of `0`, but it lands on the same single attempt anyway because its loop consults the budget only after the first request has already gone out. Because every op's `max` is ≤ the default cap of `3`, a default or raised client makes exactly the per-op number of attempts in every capped SDK — matching TS/Swift. Observable changes from the former client-wide behavior, by client configuration: +**Per-operation retry ceiling.** Each operation carries a per-op `retry.max` in behavior-model.json (205 ops at `3`, 45 at `2`). **TypeScript and Swift** drive their retry loops directly from this per-op value, which is unambiguous there because neither exposes a numeric client-wide cap — only an on/off (`enableRetry`). Generated Go, Python, Kotlin (`BasecampConfig.maxRetries`), and Ruby's governed GET path (`config.max_retries`) expose a numeric client cap *and* honor the per-op value as a **ceiling**: `effective_attempts = min(client_cap, op_max)`. The ceiling can only reduce attempts below the client cap, never raise them, so a client that lowered its cap (e.g. to `1` to disable retries) is still honored. In Go, Python, and Ruby's governed path the cap is floored at one attempt before the ceiling applies (`min(max(1, cap), op_max)`), so a cap of `0` yields a single attempt rather than none whether or not the operation declares a retry block. Kotlin computes a different expression — `min(max(1, cap), op_max ?: cap)` — which is `0` for an ungoverned operation at a cap of `0`, but it lands on the same single attempt anyway because its loop consults the budget only after the first request has already gone out. Because every op's `max` is ≤ the default cap of `3`, a default or raised client makes exactly the per-op number of attempts in every capped SDK — matching TS/Swift. Observable changes from the former client-wide behavior, by client configuration: - **Default client (`max_retries = 3`):** only the **11 idempotent `max:2` operations** (account/gauge/preference writes plus two subscription-style POSTs: `UpdateAccountName`, `UpdateAccountLogo`, `RemoveAccountLogo`, `UpdateMyPreferences`, `DisableOutOfOffice`, `MarkAsRead`, `ToggleGauge`, `UpdateGaugeNeedle`, `DestroyGaugeNeedle`, `Subscribe`, `EnableCardColumnOnHold`) change — they now retry at most twice instead of three times. The other 197 retry-eligible ops are unaffected (`min(3, 3) = 3`). - **Client that raised its cap above 3:** **all 208 retry-eligible operations** are now clamped to their per-op `max` (197 to `3`, 11 to `2`) instead of retrying up to the raised cap. This is the intended meaning of a per-op ceiling and brings Go/Python into line with TS/Swift/Kotlin, which never retry beyond the per-op `max`. Go, Python, Kotlin, and Ruby's governed path all equally honor a caller who wants *fewer* attempts than the operation declares. @@ -338,6 +338,20 @@ Card **steps** share the contract: `title` is optional on update, an omitted key `"assignee_ids": []` removes everyone, and an assignee-only body is a valid partial update where it used to 400. `UpdateStepRequest.DueOn` is presence-bearing for the same reason as the card's. +**Uploads** share it too, on both write paths. `Uploads::VersionsController#create` reads +`description` with `key?`, so an omitted key carries the previous version's description forward and +`""` clears; `UploadsController#update` reaches the same `serialize(:description, coder: +ActionText::Content)` attribute through `@upload.changing`, with no blank-cast in between. Both are +pinned by BC3 server tests (basecamp/bc3#12565), so `""` cannot regress to a no-op on either. +`CreateUploadVersionRequest.Description` and `UpdateUploadRequest.Description` are therefore +presence-bearing in every SDK — including Go, which uses `*string` here rather than the zero-value +guard described under Todolists below. + +`base_name` is deliberately **not** presence-bearing on either: `Upload#base_name=` guards on +`new_base_name.present?`, so `""` and absent are the same server write and there is no third state +to model. Stating that is what keeps the asymmetry legible as a verified server fact rather than an +oversight. + ### Merge-Safe Write Surface (Todos) The `PUT /{accountId}/todos/{todoId}` endpoint is **full replace, omission clears** (spec operation `ReplaceTodo`, `content` required, declared via `x-basecamp-write-semantics: {mode: "replace", clearsOmitted: true}` and the `write` clause in `behavior-model.json`). Every SDK exposes a three-method, two-state surface over it: @@ -368,6 +382,14 @@ Every SDK exposes the same three-method, two-state surface over it: **Go is the exception, and this bites in practice.** Its request struct uses zero-value guards (`if req.Description != ""`), so `""` *is* the unset marker: `Update` with an empty description does **nothing to that field** rather than clearing it. **To clear a field in Go, use `Edit` or `Replace` — not `Update`.** + This still holds for `UpdateTodolistRequest` and `UpdateDocumentRequest`. It no + longer holds for uploads: `UpdateUploadRequest.Description` and + `CreateUploadVersionRequest.Description` are `*string`, following the + gauge-needle precedent, so `Ptr("")` clears and `nil` leaves the field alone. + `BaseName` stays a plain `string` on both — `Upload#base_name=` guards on + `new_base_name.present?`, so `""` and absent are the same write server-side and + there is no third state a pointer could express. + ```go // Does NOT clear the description — "" reads as "unaddressed". svc.Update(ctx, id, &UpdateTodolistRequest{Description: ""}) @@ -520,10 +542,11 @@ Status-mapped codes are verified per the Verification column and are `[conforman | `ambiguous` | 8 | — | false | Multiple matches found (CLI disambiguation) | `[static]` | | `validation` | 9 | 422 | false | Request validation failed | `[conformance]` | | `validation` | 9 | 400 | false | Request validation failed | `[conformance]` | +| `limit_exceeded` | 10 | 507 | false | An account limit blocks the request (file storage, webhooks) | `[conformance]` | ### HTTP Status Mapping Algorithm -Each explicitly enumerated status mapping below (steps 1–10) is `[conformance]`-verified. The two catch-all fallback steps (11: general 5xx; 12: any other non-mapped status) have no dedicated conformance case and are `[static]`. +Each explicitly enumerated status mapping below (steps 1–11) is `[conformance]`-verified. The two catch-all fallback steps (12: general 5xx; 13: any other non-mapped status) have no dedicated conformance case and are `[static]`. Given an HTTP response with status code `status` and body `body`: @@ -537,8 +560,11 @@ Given an HTTP response with status code `status` and body `body`: 8. If `status == 502` → `BasecampError(code: "api_error", http_status: 502, retryable: true)`. 9. If `status == 503` → `BasecampError(code: "api_error", http_status: 503, retryable: true)`. 10. If `status == 504` → `BasecampError(code: "api_error", http_status: 504, retryable: true)`. -11. If `status >= 500` → `BasecampError(code: "api_error", http_status: status, retryable: true)`. `[static]` -12. Otherwise → `BasecampError(code: "api_error", http_status: status, retryable: false)`. `[static]` +11. If `status == 507` → `BasecampError(code: "limit_exceeded", http_status: 507, retryable: false)`. +12. If `status >= 500` → `BasecampError(code: "api_error", http_status: status, retryable: true)`. `[static]` +13. Otherwise → `BasecampError(code: "api_error", http_status: status, retryable: false)`. `[static]` + +Step 11 must precede the 5xx catch-all. A 507 is a *server* status carrying a *client* fact: the account is out of storage, or at its webhook ceiling. Retrying cannot satisfy it, so classifying it by its 5xx range alone would report a plan limit as a transient server error — indistinguishable, to a caller deciding whether to back off, from a 500. Ordering is what makes the distinction, since both steps match. In all cases, extract `request_id` from `X-Request-Id` response header if present. `[conformance]` @@ -822,7 +848,7 @@ END ### behavior-model.json Retry Patterns -All 249 operations in `behavior-model.json` use `retry_on: [429, 503]`. Three `(max, base_delay_ms)` patterns exist: +All 250 operations in `behavior-model.json` use `retry_on: [429, 503]`. Three `(max, base_delay_ms)` patterns exist: - `(2, 1000)` — most create operations - `(3, 1000)` — most read/update/delete operations - `(3, 2000)` — `CreateAttachment`, `CreateCampfireUpload` (file uploads) @@ -1119,6 +1145,23 @@ Two corollaries worth stating, because both were violated here: with tests passing. Conformance cases and `spec/fixtures/` bodies are read out of the partial. +A second instance, absorbed the same way: `ListUploadVersions`. BC3 renders it +through `app/views/api/uploads/versions/_version.json.jbuilder` over the shared +`recordings/events/_event.json.jbuilder`, not through `uploads/_upload`. The +output declared `uploads: UploadList` anyway, which was a typed lie of the +sharpest kind — **11 of `Upload`'s 14 `@required` members are absent from every +response**, so the CLI's versions command and the MCP server's +`list_upload_versions` rendered blank fields (basecamp-sdk#649). It is now +`UploadVersion` / `UploadVersionFile`. + +The reason not to widen `Event` instead is stated by bc3's own commit for the +partial: doing so "would leak upload fields onto todo, message, and card +events". `UploadVersion` also demonstrates the first corollary above from the +other direction — it is an Event *plus* a member no other event projection +carries (`upload`), which is exactly why plain `EventList` would have needed +growing again the moment anyone wanted the filename, the only reason the +endpoint is called at all. + ### Integer Precision `[conformance]` All integer IDs must use at least 64 bits of precision (e.g., Go `int64`, Kotlin `Long`, Swift `Int` on 64-bit platforms). Note: Kotlin `Int` is 32-bit and must not be used for IDs — use `Long`. IDs up to 2^53 + 1 (`9007199254740993`) must survive JSON round-trip without precision loss. @@ -1372,7 +1415,7 @@ END ### Hop-1 Retry `[conformance]` -The authenticated first hop retries on **network errors plus {429, 502, 503, 504}** — never 500. The set is declared here rather than inherited from anywhere else, and it matches neither of the two sets an SDK already has to hand: it is broader than the per-operation `retry_on` in `behavior-model.json` (`{429, 503}` for all 249 operations, which never governs `DownloadURL` because it has no entry there), and narrower than the error taxonomy's "all 5xx retryable" flag, which would sweep in the 500 this policy deliberately excludes. It is the gateway-error set Go's hand-written `singleRequest` already uses for GETs. Backoff is exponential from a 1-second base with jitter; `Retry-After` is honored on 429. The second hop is exempt: no retry, no auth. +The authenticated first hop retries on **network errors plus {429, 502, 503, 504}** — never 500. The set is declared here rather than inherited from anywhere else, and it matches neither of the two sets an SDK already has to hand: it is broader than the per-operation `retry_on` in `behavior-model.json` (`{429, 503}` for all 250 operations, which never governs `DownloadURL` because it has no entry there), and narrower than the error taxonomy's "all 5xx retryable" flag, which would sweep in the 500 this policy deliberately excludes. It is the gateway-error set Go's hand-written `singleRequest` already uses for GETs. Backoff is exponential from a 1-second base with jitter; `Retry-After` is honored on 429. The second hop is exempt: no retry, no auth. "Network error" means a transport failure, with one carve-out that SDKs inherit from their main GET loop rather than restate: an attempt that exhausted the caller's entire per-attempt time budget (a request timeout) is not retried. The timeout is per attempt, so a retry spends another full budget on the same slowness rather than riding out a blip. Kotlin implements this explicitly; SDKs whose transports surface timeouts indistinguishably from other connection failures retry them. @@ -3384,6 +3427,10 @@ account, attachments, automation, boosts, campfires, cardColumns, cardSteps, car | `network-retry.json` | Network error on an idempotent POST is retried then succeeds | §7 (Gate 2) | | `uploads_download.json` | UploadsDownload delegates through DownloadURL primitive | §14, §18 | | `uploads_download.json` | UploadsDownload errors when upload has no download_url | §14, §18 | +| `uploads_write.json` | create-version presence states (unaddressed / clear / set) | §5 (Cards, Uploads), §18 | +| `uploads_write.json` | update presence states (unaddressed / clear) | §5 (Cards, Uploads), §18 | +| `uploads_write.json` | list-versions decodes the version payload | §10 (One Renderer, One Schema) | +| `uploads_write.json` | 507 → limit_exceeded, not retried | §6 | | `todos_write.json` | update-merge / edit-clear / replace-omission-clears | §5 (Todos), §18 | | `todolists_write.json` | update-merge / update-group / edit-clear / replace-omission-clears | §5 (Todolists), §18 | | `todolists_read.json` | list-read / group-read / group-list-read (one flat shape decodes for both variants) | §5 (Todolists) | @@ -3436,9 +3483,9 @@ Every operation has a `retry` block, including non-idempotent POSTs. For non-ide ### Operation Counts -- Total operations: 249 +- Total operations: 250 - Idempotent: 83 (flagged with `idempotent: true`) -- Non-idempotent: 166 (no `idempotent` field, or not present) +- Non-idempotent: 167 (no `idempotent` field, or not present) - All operations use `retry_on: [429, 503]` --- diff --git a/behavior-model.json b/behavior-model.json index 8bcaa8cd37..bd7cbf7880 100644 --- a/behavior-model.json +++ b/behavior-model.json @@ -392,6 +392,17 @@ ] } }, + "CreateUploadVersion": { + "retry": { + "max": 2, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "CreateVault": { "retry": { "max": 2, diff --git a/conformance/runner/go/main.go b/conformance/runner/go/main.go index 86cdd8bf84..fd1188b3cd 100644 --- a/conformance/runner/go/main.go +++ b/conformance/runner/go/main.go @@ -1165,6 +1165,77 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T } return operationResult{err: nil} + case "CreateUploadVersion": + // Presence-bearing, like ReplaceScheduleEntry: a key the fixture omits + // stays nil and never reaches the wire, so an unaddressed description + // carries forward while Ptr("") is sent and clears. + uploadID := getInt64Param(tc.PathParams, "uploadId") + req := &basecamp.CreateUploadVersionRequest{ + AttachableSGID: getStringParam(tc.RequestBody, "attachable_sgid"), + Description: optionalStringFrom(tc.RequestBody, "description"), + Notify: optionalStringFrom(tc.RequestBody, "notify"), + } + if _, ok := tc.RequestBody["base_name"]; ok { + req.BaseName = getStringParam(tc.RequestBody, "base_name") + } + if raw, ok := tc.RequestBody["subscriptions"]; ok { + ids := toInt64Slice(raw) + req.Subscriptions = &ids + } + _, err := account.Uploads().CreateVersion(ctx, uploadID, req) + return operationResult{err: err} + + case "UpdateUpload": + uploadID := getInt64Param(tc.PathParams, "uploadId") + req := &basecamp.UpdateUploadRequest{ + Description: optionalStringFrom(tc.RequestBody, "description"), + } + if _, ok := tc.RequestBody["base_name"]; ok { + req.BaseName = getStringParam(tc.RequestBody, "base_name") + } + _, err := account.Uploads().Update(ctx, uploadID, req) + return operationResult{err: err} + + case "ListUploadVersions": + // The endpoint returns an ARRAY and a responseBody path resolves as a + // top-level key only, so flatten to scalars the way GetUpcomingSchedule + // does. Built from the DECODED models, which is where the retype that + // closes #649 is actually enforced. + uploadID := getInt64Param(tc.PathParams, "uploadId") + result, err := account.Uploads().ListVersions(ctx, uploadID, nil) + if err != nil { + return operationResult{err: err} + } + if result == nil { + return operationResult{err: fmt.Errorf("ListVersions returned no result")} + } + currentCount := 0 + for _, v := range result.Versions { + if v.Upload != nil && v.Upload.Current { + currentCount++ + } + } + summary := map[string]interface{}{ + "versions_count": len(result.Versions), + "current_count": currentCount, + } + if len(result.Versions) > 0 { + first := result.Versions[0] + summary["first_action"] = first.Action + if first.Upload != nil { + summary["first_filename"] = first.Upload.Filename + summary["first_content_type"] = first.Upload.ContentType + summary["first_byte_size"] = first.Upload.ByteSize + summary["first_current"] = first.Upload.Current + } + last := result.Versions[len(result.Versions)-1] + summary["last_action"] = last.Action + // A version whose recordable no longer resolves omits the upload + // object entirely — the optionality UploadVersion.Upload declares. + summary["last_has_upload"] = last.Upload != nil + } + return operationResult{err: nil, result: summary} + case "UploadsDownload": uploadID := getInt64Param(tc.PathParams, "uploadId") result, err := account.Uploads().Download(ctx, uploadID) @@ -1905,6 +1976,31 @@ type scheduleEntryWrite struct { // Testing v != "" would collapse the two and let an explicit-clear fixture pass // as an omission — which is the whole distinction BC3's preserve-on-omission // carve-out makes. +// optionalStringFrom returns a pointer only when the fixture carries the key, +// so an unaddressed member stays nil and off the wire while an explicit "" is +// sent verbatim. The whole point of the *string members it feeds. +func optionalStringFrom(body map[string]interface{}, key string) *string { + if _, ok := body[key]; !ok { + return nil + } + s := getStringParam(body, key) + return &s +} + +func toInt64Slice(raw interface{}) []int64 { + items, ok := raw.([]interface{}) + if !ok { + return []int64{} + } + out := make([]int64, 0, len(items)) + for _, item := range items { + if f, isNum := item.(float64); isNum { + out = append(out, int64(f)) + } + } + return out +} + func scheduleEntryWriteFrom(body map[string]interface{}) scheduleEntryWrite { str := func(key string) *string { if _, ok := body[key]; !ok { diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index 42560aea49..ab3a408a06 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -209,6 +209,40 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: UPCOMING_WINDOW_END = "2026-06-30" +# attachable_sgid is passed explicitly by the dispatch (it is required), so it +# is deliberately absent here — this list is only the presence-bearing members, +# where "sent as empty" and "not sent" are different writes. +_UPLOAD_VERSION_WRITE_FIELDS = ("base_name", "description", "notify", "subscriptions") + + +def _summarize_upload_versions(versions: list) -> dict: + """Flatten the versions array into top-level scalars. + + GET /uploads/{id}/versions.json returns an ARRAY, and a responseBody path + resolves as a top-level key only, so the assertions cannot walk into it. + Same shape as _summarize_upcoming, for the same reason. + """ + summary: dict[str, Any] = { + "versions_count": len(versions), + "current_count": sum(1 for v in versions if (v.get("upload") or {}).get("current")), + } + if versions: + first = versions[0] + first_upload = first.get("upload") or {} + summary["first_action"] = first["action"] + summary["first_filename"] = first_upload.get("filename") + summary["first_content_type"] = first_upload.get("content_type") + summary["first_byte_size"] = first_upload.get("byte_size") + summary["first_current"] = first_upload.get("current") + + last = versions[-1] + summary["last_action"] = last["action"] + # A version whose recordable no longer resolves omits the upload object + # entirely — the optionality UploadVersion.upload declares. + summary["last_has_upload"] = last.get("upload") is not None + return summary + + def _summarize_upcoming(envelope: dict) -> dict: """Flatten the upcoming-schedule envelope into top-level scalars. @@ -543,6 +577,25 @@ def __call__( return self._account.tools.enable(tool_id=path_params["toolId"]) case "UploadsDownload": return self._account.uploads.download(upload_id=path_params["uploadId"]) + case "CreateUploadVersion": + # Presence-bearing, like ReplaceScheduleEntry: a key the fixture + # omits is never passed, so an unaddressed description stays off + # the wire while an explicit "" survives _compact (which strips + # None only) and clears. + return self._account.uploads.create_version( + upload_id=path_params["uploadId"], + attachable_sgid=body["attachable_sgid"], + **{k: body[k] for k in _UPLOAD_VERSION_WRITE_FIELDS if k in body}, + ) + case "UpdateUpload": + return self._account.uploads.update( + upload_id=path_params["uploadId"], + **{k: body[k] for k in _UPLOAD_VERSION_WRITE_FIELDS if k in body}, + ) + case "ListUploadVersions": + return _summarize_upload_versions( + self._account.uploads.list_versions(upload_id=path_params["uploadId"]) + ) case "GetEverythingMessages": return self._account.everything.get_everything_messages() case "GetEverythingComments": diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index 6b9f0060ec..a3e9f54c15 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -353,6 +353,19 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "", max_ ) when "UploadsDownload" @account.uploads.download(upload_id: path_params["uploadId"]) + when "CreateUploadVersion" + # Presence-bearing, like ReplaceScheduleEntry: only keys the fixture + # carries are passed, so an unaddressed description stays off the wire + # while an explicit "" survives compact_params (which strips only nil). + @account.uploads.create_version( + upload_id: path_params["uploadId"], + attachable_sgid: body["attachable_sgid"], + **upload_version_write_kwargs(body) + ) + when "UpdateUpload" + @account.uploads.update(upload_id: path_params["uploadId"], **upload_version_write_kwargs(body)) + when "ListUploadVersions" + summarize_upload_versions(@account.uploads.list_versions(upload_id: path_params["uploadId"]).to_a) when "UpdateTodo" @account.todos.update( todo_id: path_params["todoId"], @@ -652,6 +665,41 @@ def schedule_entry_write_kwargs(body) .to_h { |key| [key.to_sym, body[key]] } end + # attachable_sgid is passed positionally by the dispatch (it is required), so + # it is deliberately absent here — this list is only the presence-bearing + # members, where "sent as empty" and "not sent" are different writes. + UPLOAD_VERSION_WRITE_KEYS = %w[base_name description notify subscriptions].freeze + + def upload_version_write_kwargs(body) + UPLOAD_VERSION_WRITE_KEYS.select { |key| (body || {}).key?(key) } \ + .to_h { |key| [key.to_sym, body[key]] } + end + + # GET /uploads/{id}/versions.json returns an ARRAY, and the assertion path + # resolvers walk objects, not array indices. Flatten to the same summary shape + # summarize_upcoming uses so the fixture can assert on it. + def summarize_upload_versions(versions) + summary = { + "versions_count" => versions.length, + "current_count" => versions.count { |v| v.dig("upload", "current") } + } + if versions.any? + first = versions.first + summary["first_action"] = first["action"] + summary["first_filename"] = first.dig("upload", "filename") + summary["first_content_type"] = first.dig("upload", "content_type") + summary["first_byte_size"] = first.dig("upload", "byte_size") + summary["first_current"] = first.dig("upload", "current") + + last = versions.last + summary["last_action"] = last["action"] + # A version whose recordable no longer resolves omits the upload object + # entirely — the optionality UploadVersion.upload declares. + summary["last_has_upload"] = !last["upload"].nil? + end + summary + end + CARD_WRITE_KEYS = %w[title content due_on assignee_ids].freeze def card_write_kwargs(body) diff --git a/conformance/runner/swift/Sources/ConformanceRunner/Assertions.swift b/conformance/runner/swift/Sources/ConformanceRunner/Assertions.swift index 86c67cb334..39a0ce067f 100644 --- a/conformance/runner/swift/Sources/ConformanceRunner/Assertions.swift +++ b/conformance/runner/swift/Sources/ConformanceRunner/Assertions.swift @@ -36,6 +36,7 @@ func conformanceCode(_ error: BasecampError) -> String { case .network: "network" case .usage: "usage" case .ambiguous: "ambiguous" + case .limitExceeded: "limit_exceeded" } } @@ -44,7 +45,7 @@ func conformanceCode(_ error: BasecampError) -> String { /// catch a typo'd error type silently forbids a real one instead. private let knownErrorTypes: Set = [ "not_found", "auth_required", "forbidden", "rate_limit", - "validation", "api_error", "usage", "network", "ambiguous", + "validation", "api_error", "usage", "network", "ambiguous", "limit_exceeded", ] /// Compares an expected fixture value against an actual JSON value, diff --git a/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift b/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift index f876e7c4cc..f6fe292ebe 100644 --- a/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift +++ b/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift @@ -159,6 +159,35 @@ private func summarizeProjects(_ projects: [Project]) -> JSON { ]) } +/// Flattens the versions array into top-level scalars. +/// +/// GET /uploads/{id}/versions.json returns an ARRAY and a responseBody path +/// resolves as a top-level key only. Every value comes off the DECODED model, +/// which is what makes this a decode test of the retype that closes #649 rather +/// than a transport test. +private func summarizeUploadVersions(_ versions: [UploadVersion]) -> JSON { + var summary: [String: JSON] = [ + "versions_count": .int(Int64(versions.count)), + "current_count": .int(Int64(versions.filter { $0.upload?.current == true }.count)), + ] + if let first = versions.first { + summary["first_action"] = .string(first.action) + if let file = first.upload { + summary["first_filename"] = .string(file.filename) + if let contentType = file.contentType { summary["first_content_type"] = .string(contentType) } + if let byteSize = file.byteSize { summary["first_byte_size"] = .int(Int64(byteSize)) } + summary["first_current"] = .bool(file.current) + } + } + if let last = versions.last { + summary["last_action"] = .string(last.action) + // A version whose recordable no longer resolves omits the upload object + // entirely — the optionality UploadVersion.upload declares. + summary["last_has_upload"] = .bool(last.upload != nil) + } + return .object(summary) +} + /// Dispatches the test operation against the SDK and returns observed metadata. /// Direct port of the Kotlin dispatch table. func dispatchOperation(_ tc: TestCase, _ account: AccountClient) async throws -> DispatchResult { @@ -732,6 +761,33 @@ func dispatchOperation(_ tc: TestCase, _ account: AccountClient) async throws -> _ = try await account.uploads.download(uploadId: pathParams.longParam("uploadId")) return DispatchResult() + // Presence-bearing, like ReplaceScheduleEntry: optString yields nil for a + // key the fixture omits, and encodeIfPresent then keeps it off the wire, so + // an unaddressed description carries forward while an explicit "" clears. + case "CreateUploadVersion": + _ = try await account.uploads.createVersion( + uploadId: pathParams.longParam("uploadId"), + req: CreateUploadVersionRequest( + attachableSgid: rb.stringParam("attachable_sgid"), + baseName: rb.optString("base_name"), + description: rb.optString("description"), + notify: rb.optString("notify"), + subscriptions: rb.intArray("subscriptions"))) + return DispatchResult() + + case "UpdateUpload": + _ = try await account.uploads.update( + uploadId: pathParams.longParam("uploadId"), + req: UpdateUploadRequest( + baseName: rb.optString("base_name"), + description: rb.optString("description"))) + return DispatchResult() + + case "ListUploadVersions": + let versions = try await account.uploads.listVersions( + uploadId: pathParams.longParam("uploadId")) + return DispatchResult(resultJSON: summarizeUploadVersions(versions.items)) + // Pins the `inbox_forwards` collection segment. The shipped path said // `forwards`, which bc3 does not route, so the fixture is a wire assertion // on the segment rather than on any response shape. diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 4c30148d31..d476e4dbce 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -278,6 +278,34 @@ function summarizeProjects( last_project_id: projects.length > 0 ? projects[projects.length - 1]!.id : 0, }; } +/** + * GET /uploads/{id}/versions.json returns an ARRAY, and a responseBody path + * resolves as a top-level key only. Flatten to the same shape summarizeUpcoming + * uses so the fixture's assertions stay portable across all six runners. + */ +function summarizeUploadVersions( + versions: Awaited>, +): Record { + const summary: Record = { + versions_count: versions.length, + current_count: versions.filter((v) => v.upload?.current).length, + }; + if (versions.length > 0) { + const first = versions[0]; + summary.first_action = first.action; + summary.first_filename = first.upload?.filename; + summary.first_content_type = first.upload?.content_type; + summary.first_byte_size = first.upload?.byte_size; + summary.first_current = first.upload?.current; + + const last = versions[versions.length - 1]; + summary.last_action = last.action; + // A version whose recordable no longer resolves omits the upload object + // entirely — the optionality UploadVersion.upload declares. + summary.last_has_upload = last.upload !== undefined && last.upload !== null; + } + return summary; +} /** * Executes the appropriate SDK method for the given operation name. @@ -736,6 +764,33 @@ async function executeOperation( await client.tools.enable(Number(params.toolId)); break; + // Presence-bearing, like ReplaceScheduleEntry: a key the fixture omits + // never reaches the request object, so an unaddressed description stays + // off the wire while an explicit "" is sent and clears it. + case "CreateUploadVersion": + await client.uploads.createVersion(Number(params.uploadId), { + attachableSgid: String(body.attachable_sgid), + ...(body.base_name !== undefined ? { baseName: String(body.base_name) } : {}), + ...(body.description !== undefined ? { description: String(body.description) } : {}), + ...(body.notify !== undefined ? { notify: String(body.notify) } : {}), + ...(body.subscriptions !== undefined + ? { subscriptions: body.subscriptions as number[] } + : {}), + }); + break; + + case "UpdateUpload": + await client.uploads.update(Number(params.uploadId), { + ...(body.base_name !== undefined ? { baseName: String(body.base_name) } : {}), + ...(body.description !== undefined ? { description: String(body.description) } : {}), + }); + break; + + case "ListUploadVersions": { + const versions = await client.uploads.listVersions(Number(params.uploadId)); + return { result: summarizeUploadVersions(versions) }; + } + case "UploadsDownload": { const result = await client.uploads.download(Number(params.uploadId)); // Drain the stream so the socket can be reused and we don't leak. diff --git a/conformance/tests/uploads_write.json b/conformance/tests/uploads_write.json new file mode 100644 index 0000000000..ca85d37c8d --- /dev/null +++ b/conformance/tests/uploads_write.json @@ -0,0 +1,915 @@ +[ + { + "name": "create-version-unaddressed: description and base_name stay off the wire", + "description": "BC3 reads description with key?, so an omitted key carries the previous version's description forward and a present one replaces it. When the caller addresses neither description nor base_name, neither key may appear in the body \u2014 a compactor that emitted null, or a default that emitted \"\", would clear a description the server is holding for us. The requestBody assertion on attachable_sgid rides along deliberately: a requestBodyAbsent alone is satisfied by an empty body, which would pass while sending nothing at all.", + "operation": "CreateUploadVersion", + "method": "POST", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "attachable_sgid": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201" + }, + "mockResponses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "
The original
", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "logo.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestMethod", + "expected": "POST", + "index": 0 + }, + { + "type": "requestPath", + "expected": "/999/uploads/1069479400/versions.json", + "index": 0 + }, + { + "type": "requestBody", + "path": "attachable_sgid", + "expected": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201", + "index": 0 + }, + { + "type": "requestBodyAbsent", + "path": "description", + "index": 0 + }, + { + "type": "requestBodyAbsent", + "path": "base_name", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware", + "body-compaction" + ] + }, + { + "name": "create-version-clear: an explicit empty description reaches the wire", + "description": "The clear spelling is \"\", not null: SPEC \u00a718 body compaction forbids {\"field\": null}, and five of six SDKs strip nulls structurally before the wire (Python _compact, Ruby compact_params, Kotlin ?.let, Swift encodeIfPresent, Go omitempty). Only an empty string survives compaction in every language, so it is the only clear a caller can actually express. Pinned by BC3's own test at basecamp/bc3#12565.", + "operation": "CreateUploadVersion", + "method": "POST", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "attachable_sgid": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201", + "description": "" + }, + "mockResponses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "logo.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestBody", + "path": "attachable_sgid", + "expected": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201", + "index": 0 + }, + { + "type": "requestBody", + "path": "description", + "expected": "", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware", + "clear" + ] + }, + { + "name": "create-version-set: a supplied description reaches the wire verbatim", + "operation": "CreateUploadVersion", + "method": "POST", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "attachable_sgid": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201", + "description": "
The replacement
", + "base_name": "company-logo" + }, + "mockResponses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "
The replacement
", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "logo.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestBody", + "path": "description", + "expected": "
The replacement
", + "index": 0 + }, + { + "type": "requestBody", + "path": "base_name", + "expected": "company-logo", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware" + ] + }, + { + "name": "update-unaddressed: an unaddressed description stays off the wire", + "description": "UpdateUpload lands on the same serialized ActionText attribute as CreateUploadVersion. Go's UpdateUploadRequest.Description was a plain string behind omitzero(), so \"\" read as unset and the clear was unreachable \u2014 the divergence SPEC.md \u00a75 documented. It is a pointer now, and these two cases are what keep both request types honest about the same three states.", + "operation": "UpdateUpload", + "method": "PUT", + "path": "/uploads/{uploadId}", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "base_name": "renamed" + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "
The original
", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "renamed.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestMethod", + "expected": "PUT", + "index": 0 + }, + { + "type": "requestBody", + "path": "base_name", + "expected": "renamed", + "index": 0 + }, + { + "type": "requestBodyAbsent", + "path": "description", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware", + "body-compaction" + ] + }, + { + "name": "update-clear: an explicit empty description reaches the wire", + "operation": "UpdateUpload", + "method": "PUT", + "path": "/uploads/{uploadId}", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "description": "" + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "logo.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestBody", + "path": "description", + "expected": "", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware", + "clear" + ] + }, + { + "name": "list-versions decodes the version payload, filename and current included", + "description": "ListUploadVersionsOutput declared uploads: UploadList, but the endpoint returns events \u2014 11 of Upload's 14 required members are absent from every response, which is why the CLI's versions command and the MCP server's list_upload_versions rendered blank fields (basecamp-sdk#649). The body here is the shared spec/fixtures/uploads/versions.json, so this case and the per-SDK unit tests cannot drift apart. The runners flatten the array into a summary object \u2014 the same shape summarize_upcoming uses \u2014 because the assertion path resolvers walk objects, not array indices.", + "operation": "ListUploadVersions", + "method": "GET", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": [ + { + "id": 1069479501, + "recording_id": 1069479400, + "action": "blob_changed", + "details": {}, + "created_at": "2022-12-04T16:41:12.114Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "boosts_count": 2, + "boosts_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479400/events/1069479501/boosts.json", + "upload": { + "content_type": "image/png", + "byte_size": 184829, + "filename": "company-logo.png", + "download_url": "https://3.basecampapi.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479501/download/company-logo.png", + "app_download_url": "https://storage.3.basecamp.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479501/download/company-logo.png", + "current": true + } + }, + { + "id": 1069479500, + "recording_id": 1069479400, + "action": "active", + "details": {}, + "created_at": "2022-11-28T09:02:44.301Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd1e798e7c0e2b40cd88ed93c0b8", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkz4=--e609ef146e39f9ca5e4bb7f8bf7b7fecfff6e302/avatar?v=1", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + }, + "upload": { + "content_type": "image/png", + "byte_size": 172294, + "filename": "company-logo.png", + "download_url": "https://3.basecampapi.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479500/download/company-logo.png", + "app_download_url": "https://storage.3.basecamp.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479500/download/company-logo.png", + "current": false + } + }, + { + "id": 1069479499, + "recording_id": 1069479400, + "action": "created", + "details": {}, + "created_at": "2022-11-22T08:23:58.523Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd1e798e7c0e2b40cd88ed93c0b8", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkz4=--e609ef146e39f9ca5e4bb7f8bf7b7fecfff6e302/avatar?v=1", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + } + } + ] + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "responseBody", + "path": "versions_count", + "expected": 3 + }, + { + "type": "responseBody", + "path": "first_action", + "expected": "blob_changed" + }, + { + "type": "responseBody", + "path": "first_filename", + "expected": "company-logo.png" + }, + { + "type": "responseBody", + "path": "first_content_type", + "expected": "image/png" + }, + { + "type": "responseBody", + "path": "first_byte_size", + "expected": 184829 + }, + { + "type": "responseBody", + "path": "first_current", + "expected": true + }, + { + "type": "responseBody", + "path": "current_count", + "expected": 1 + }, + { + "type": "responseBody", + "path": "last_action", + "expected": "created" + }, + { + "type": "responseBody", + "path": "last_has_upload", + "expected": false + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "read", + "issue-649" + ] + }, + { + "name": "create-version storage limit is limit_exceeded, not a retryable api_error", + "description": "A replacement copies bytes into a new blob and keeps the old one, so it always grows recorded storage; ensure_account_can_upload_files guards it. SPEC \u00a76 step 11 decides 507 before the 5xx catch-all, because both match and only order separates them. Against un-fixed code every SDK reported api_error with retryable: true \u2014 a plan limit no backoff can satisfy, indistinguishable from a 500. requestCount pins that nothing retried it, so a future retryOn list that added 507 would fail here.", + "operation": "CreateUploadVersion", + "method": "POST", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "attachable_sgid": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201" + }, + "mockResponses": [ + { + "status": 507, + "headers": { + "Content-Type": "application/json", + "X-Request-Id": "req-507-storage" + }, + "body": { + "error": "The storage limit for this account has been reached." + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "errorCode", + "expected": "limit_exceeded" + }, + { + "type": "errorField", + "path": "httpStatus", + "expected": 507 + }, + { + "type": "errorField", + "path": "retryable", + "expected": false + }, + { + "type": "errorMessage", + "expected": "storage limit" + } + ], + "tags": [ + "uploads", + "write", + "error-mapping", + "limit-exceeded" + ] + } +] diff --git a/go/README.md b/go/README.md index c74e2295a7..e4c0bd9877 100644 --- a/go/README.md +++ b/go/README.md @@ -664,6 +664,7 @@ if err != nil { | `api_error` | Server error | 7 | | `ambiguous` | Multiple matches found | 8 | | `validation` | Validation error (400, 422) | 9 | +| `limit_exceeded` | Account limit reached (507) — never retryable | 10 | ### Validation Errors diff --git a/go/grouped-client-inventory.yml b/go/grouped-client-inventory.yml index 01d05c9f67..21823a9810 100644 --- a/go/grouped-client-inventory.yml +++ b/go/grouped-client-inventory.yml @@ -202,6 +202,7 @@ not_grouped: - CreateTodolistGroup - CreateTodosetTodo - CreateTool + - CreateUploadVersion - CreateWormhole - DeleteBookmark - DeleteBoost diff --git a/go/pkg/basecamp/api-provenance.json b/go/pkg/basecamp/api-provenance.json index cfd5e32b66..eb7a71835e 100644 --- a/go/pkg/basecamp/api-provenance.json +++ b/go/pkg/basecamp/api-provenance.json @@ -1,7 +1,7 @@ { "bc3": { "branch": "master", - "revision": "7fe1c63ab33be059605b58e3721cc5db02c9e59a", + "revision": "b5d8c9df8dd957e78bf2618807623d14b4704dc2", "date": "2026-08-05" }, "compatibility": { diff --git a/go/pkg/basecamp/client.go b/go/pkg/basecamp/client.go index 22ba70f52c..37d8275709 100644 --- a/go/pkg/basecamp/client.go +++ b/go/pkg/basecamp/client.go @@ -832,6 +832,22 @@ func (c *Client) singleRequest(ctx context.Context, method, url string, body any serverMsg, serverHint, fieldErrors := parseErrorBody(respBody) return nil, validationError(serverMsg, serverHint, fieldErrors, resp.StatusCode, requestID) + case http.StatusInsufficientStorage: // 507 + // Same reason the 400/422 arm above exists: the generated service layer + // maps this through checkResponse, and the raw escape hatch would + // otherwise fall through to the default arm and report an account limit + // as a generic api_error. Decided before the 5xx arms — a limit is not a + // transient failure, and no retry can satisfy it (SPEC §6, step 11). + respBody, _ := limitedReadAll(resp.Body, MaxErrorBodyBytes) + serverMsg, serverHint, _ := parseErrorBody(respBody) + return nil, (&Error{ + Code: CodeLimitExceeded, + Message: msgOrDefault(serverMsg, "account limit reached"), + Hint: serverHint, + HTTPStatus: 507, + Retryable: false, + }).withRequestID(requestID) + case http.StatusInternalServerError: // 500 return nil, ErrAPI(500, "Server error (500)").withRequestID(requestID) diff --git a/go/pkg/basecamp/errors.go b/go/pkg/basecamp/errors.go index 9ec09f92e0..b949329175 100644 --- a/go/pkg/basecamp/errors.go +++ b/go/pkg/basecamp/errors.go @@ -27,20 +27,24 @@ const ( CodeAPI = "api_error" CodeValidation = "validation" CodeAmbiguous = "ambiguous" + // CodeLimitExceeded is a 507: an account limit blocks the request (file + // storage, webhooks). Not retryable — no backoff can satisfy a plan limit. + CodeLimitExceeded = "limit_exceeded" ) // Exit codes for CLI tools. const ( - ExitOK = 0 // Success - ExitUsage = 1 // Invalid arguments or flags - ExitNotFound = 2 // Resource not found - ExitAuth = 3 // Not authenticated - ExitForbidden = 4 // Access denied (scope issue) - ExitRateLimit = 5 // Rate limited (429) - ExitNetwork = 6 // Connection/DNS/timeout error - ExitAPI = 7 // Server returned error - ExitAmbiguous = 8 // Multiple matches for name - ExitValidation = 9 // Validation error (422) + ExitOK = 0 // Success + ExitUsage = 1 // Invalid arguments or flags + ExitNotFound = 2 // Resource not found + ExitAuth = 3 // Not authenticated + ExitForbidden = 4 // Access denied (scope issue) + ExitRateLimit = 5 // Rate limited (429) + ExitNetwork = 6 // Connection/DNS/timeout error + ExitAPI = 7 // Server returned error + ExitAmbiguous = 8 // Multiple matches for name + ExitValidation = 9 // Validation error (422) + ExitLimit = 10 // Account limit reached (507) ) // requestIDHeader is the response header carrying the server-issued request ID. @@ -114,6 +118,8 @@ func ExitCodeFor(code string) int { return ExitValidation case CodeAmbiguous: return ExitAmbiguous + case CodeLimitExceeded: + return ExitLimit default: return ExitAPI } diff --git a/go/pkg/basecamp/helpers.go b/go/pkg/basecamp/helpers.go index ff07b2c752..f1ed406466 100644 --- a/go/pkg/basecamp/helpers.go +++ b/go/pkg/basecamp/helpers.go @@ -76,6 +76,11 @@ func checkResponse(resp *http.Response, body []byte) error { return &Error{Code: CodeNotFound, Message: msgOrDefault(serverMsg, "resource not found"), Hint: serverHint, HTTPStatus: 404, RequestID: requestID} case http.StatusTooManyRequests: return &Error{Code: CodeRateLimit, Message: msgOrDefault(serverMsg, "rate limited - try again later"), Hint: serverHint, HTTPStatus: 429, Retryable: true, RequestID: requestID} + case http.StatusInsufficientStorage: + // A 5xx status carrying a client fact: the account is out of storage, or + // at its webhook ceiling. Retrying cannot satisfy it, so this must be + // decided before the 5xx catch-all below. + return &Error{Code: CodeLimitExceeded, Message: msgOrDefault(serverMsg, "account limit reached"), Hint: serverHint, HTTPStatus: 507, Retryable: false, RequestID: requestID} default: retryable := resp.StatusCode >= 500 && resp.StatusCode < 600 return &Error{Code: CodeAPI, Message: msgOrDefault(serverMsg, fmt.Sprintf("API error: %s", resp.Status)), Hint: serverHint, HTTPStatus: resp.StatusCode, Retryable: retryable, RequestID: requestID} diff --git a/go/pkg/basecamp/optional_presence_test.go b/go/pkg/basecamp/optional_presence_test.go index e602cf15d3..68324b227b 100644 --- a/go/pkg/basecamp/optional_presence_test.go +++ b/go/pkg/basecamp/optional_presence_test.go @@ -7,6 +7,8 @@ import ( "math" "net/http" "net/http/httptest" + "reflect" + "strings" "testing" "time" @@ -333,3 +335,203 @@ func TestEventFromGenerated_PresentEmptyDetailsSurvives(t *testing.T) { t.Error("an absent details object must stay nil") } } + +// captureUploadBody runs fn against a server that records the JSON body it sent. +func captureUploadBody(t *testing.T, status int, respBody string, fn func(*UploadsService) error) map[string]any { + t.Helper() + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&got) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(respBody)) + })) + t.Cleanup(srv.Close) + + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + svc := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}).ForAccount("99999").Uploads() + if err := fn(svc); err != nil { + t.Fatalf("request: %v", err) + } + return got +} + +// CreateUploadVersion's Description is presence-aware server-side: omitted carries +// the previous version's description forward, "" clears it. A plain string behind +// omitzero() could not express the clear, because "" would read as unset. +func TestCreateUploadVersionRequest_DescriptionIsTriState(t *testing.T) { + capture := func(desc *string) (any, bool) { + t.Helper() + body := captureUploadBody(t, http.StatusCreated, `{"id":1,"filename":"a.png"}`, func(svc *UploadsService) error { + _, err := svc.CreateVersion(context.Background(), 1, &CreateUploadVersionRequest{ + AttachableSGID: "sgid", Description: desc, + }) + return err + }) + v, present := body["description"] + return v, present + } + + if _, present := capture(nil); present { + t.Error("nil Description must be omitted so the previous version's description carries forward") + } + + v, present := capture(ptr("")) + if !present { + t.Fatal(`Description: ptr("") must reach the wire to clear the description`) + } + if v != "" { + t.Errorf(`expected description "" on the wire, got %#v`, v) + } + + v, present = capture(ptr("
Set
")) + if !present || v != "
Set
" { + t.Errorf("expected the supplied description on the wire, got %#v (present=%v)", v, present) + } +} + +// UpdateUpload lands on the same serialized ActionText attribute as +// CreateUploadVersion, so its clear has to be reachable too. Leaving it a plain +// string would put one request type that can clear and one that silently cannot +// inside the same service. +func TestUpdateUploadRequest_DescriptionIsTriState(t *testing.T) { + capture := func(desc *string) (any, bool) { + t.Helper() + body := captureUploadBody(t, http.StatusOK, `{"id":1,"filename":"a.png"}`, func(svc *UploadsService) error { + _, err := svc.Update(context.Background(), 1, &UpdateUploadRequest{Description: desc}) + return err + }) + v, present := body["description"] + return v, present + } + + if _, present := capture(nil); present { + t.Error("nil Description must be omitted so the current description is left alone") + } + + v, present := capture(ptr("")) + if !present { + t.Fatal(`Description: ptr("") must reach the wire to clear the description`) + } + if v != "" { + t.Errorf(`expected description "" on the wire, got %#v`, v) + } +} + +// BaseName stays a plain string on both request types, deliberately. +// Upload#base_name= guards on new_base_name.present?, so "" and absent are the +// same write server-side — there is no third state a pointer could express. +func TestUploadRequests_BaseNameHasNoClearState(t *testing.T) { + for _, tc := range []struct { + name string + send func(*UploadsService) error + }{ + {"CreateVersion", func(svc *UploadsService) error { + _, err := svc.CreateVersion(context.Background(), 1, &CreateUploadVersionRequest{ + AttachableSGID: "sgid", BaseName: "", + }) + return err + }}, + {"Update", func(svc *UploadsService) error { + _, err := svc.Update(context.Background(), 1, &UpdateUploadRequest{BaseName: ""}) + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + status := http.StatusCreated + if tc.name == "Update" { + status = http.StatusOK + } + body := captureUploadBody(t, status, `{"id":1,"filename":"a.png"}`, tc.send) + if _, present := body["base_name"]; present { + t.Error(`an empty BaseName must stay off the wire; "" and absent are the same server write`) + } + }) + } +} + +// CreateUploadVersion carries the file reference that UpdateUpload deliberately +// does not — the positive counterpart to +// TestUpdateUploadRequest_HasNoFileReplacementField. +func TestCreateUploadVersionRequest_HasFileReplacementField(t *testing.T) { + f, ok := reflect.TypeOf(CreateUploadVersionRequest{}).FieldByName("AttachableSGID") + if !ok { + t.Fatal("CreateUploadVersionRequest must carry AttachableSGID: it is the sanctioned file-replacement path") + } + if got := f.Tag.Get("json"); got != "attachable_sgid" { + t.Errorf(`expected json tag "attachable_sgid", got %q`, got) + } +} + +// The versions partial renders details through the shared +// recordings/events/_event partial, which emits "details": {} for an event with +// no membership changes. UploadVersion has to keep the same present-empty +// distinction Event does, and must not drop the field outright. +func TestUploadVersionFromGenerated_DetailsSurvives(t *testing.T) { + present := uploadVersionFromGenerated(generated.UploadVersion{ + Details: &generated.EventDetails{}, + }) + if present.Details == nil { + t.Error("a present but empty details object must survive as non-nil") + } + + if absent := uploadVersionFromGenerated(generated.UploadVersion{}); absent.Details != nil { + t.Error("an absent details object must stay nil") + } + + populated := uploadVersionFromGenerated(generated.UploadVersion{ + Details: &generated.EventDetails{ + AddedPersonIds: []int64{1, 2}, + NotifiedRecipientIds: []int64{3}, + }, + }) + if populated.Details == nil { + t.Fatal("expected details") + } + if len(populated.Details.AddedPersonIDs) != 2 { + t.Errorf("added_person_ids must survive, got %v", populated.Details.AddedPersonIDs) + } + if len(populated.Details.NotifiedRecipientIDs) != 1 { + t.Errorf("notified_recipient_ids must survive, got %v", populated.Details.NotifiedRecipientIDs) + } +} + +// Go has two response handlers: checkResponse for the generated service layer, +// and doRequest for the raw Client.Get/Post/Put/Delete escape hatch. A status +// mapped in one and not the other is a real divergence — the 400/422 arm in +// doRequest exists because exactly that happened with field-keyed errors. +func TestRawClientRequest_MapsInsufficientStorage(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.StatusInsufficientStorage) + _, _ = w.Write([]byte(`{"error":"The storage limit for this account has been reached."}`)) + })) + t.Cleanup(srv.Close) + + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}) + + _, err := client.Get(context.Background(), "/99999/uploads/1") + if err == nil { + t.Fatal("expected an error on 507") + } + + var bcErr *Error + if !errors.As(err, &bcErr) { + t.Fatalf("error is not *basecamp.Error: %T", err) + } + if bcErr.Code != CodeLimitExceeded { + t.Errorf("code = %q, want %q", bcErr.Code, CodeLimitExceeded) + } + if bcErr.HTTPStatus != 507 { + t.Errorf("http status = %d, want 507", bcErr.HTTPStatus) + } + if bcErr.Retryable { + t.Error("an account limit must not be retryable") + } + if !strings.Contains(bcErr.Message, "storage limit") { + t.Errorf("server message must survive, got %q", bcErr.Message) + } +} diff --git a/go/pkg/basecamp/projects_test.go b/go/pkg/basecamp/projects_test.go index 141035b89c..551650cd5e 100644 --- a/go/pkg/basecamp/projects_test.go +++ b/go/pkg/basecamp/projects_test.go @@ -445,8 +445,9 @@ func TestProjectsService_ArchiveForbidden(t *testing.T) { } } -// The only behavioural evidence for ProjectLimitError. No SDK gives 507 a named -// class, so it surfaces as a generic api_error carrying the status (SPEC.md §7). +// The only behavioural evidence for ProjectLimitError. A 507 is an account limit, +// so it maps to limit_exceeded and is NOT retryable — no backoff frees a project +// slot (SPEC.md §6, step 11, which is ordered ahead of the 5xx catch-all). func TestProjectsService_UnarchiveAtProjectLimit(t *testing.T) { svc := testProjectsServer(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -463,12 +464,15 @@ func TestProjectsService_UnarchiveAtProjectLimit(t *testing.T) { if !errors.As(err, &bcErr) { t.Fatalf("error is not *basecamp.Error: %T", err) } - if bcErr.Code != CodeAPI { - t.Errorf("code = %q, want %q", bcErr.Code, CodeAPI) + if bcErr.Code != CodeLimitExceeded { + t.Errorf("code = %q, want %q", bcErr.Code, CodeLimitExceeded) } if bcErr.HTTPStatus != 507 { t.Errorf("http status = %d, want 507", bcErr.HTTPStatus) } + if bcErr.Retryable { + t.Error("a project limit must not be retryable") + } } // The low-level grouped client surface (generated.Client.Projects()) is emitted diff --git a/go/pkg/basecamp/url-routes.json b/go/pkg/basecamp/url-routes.json index 009f899351..fba04fbb61 100644 --- a/go/pkg/basecamp/url-routes.json +++ b/go/pkg/basecamp/url-routes.json @@ -2700,7 +2700,8 @@ "pattern": "/{accountId}/uploads/{uploadId}/versions", "resource": "Files", "operations": { - "GET": "ListUploadVersions" + "GET": "ListUploadVersions", + "POST": "CreateUploadVersion" }, "params": { "accountId": { diff --git a/go/pkg/basecamp/vaults.go b/go/pkg/basecamp/vaults.go index 7bf8383b40..31f6949f77 100644 --- a/go/pkg/basecamp/vaults.go +++ b/go/pkg/basecamp/vaults.go @@ -216,9 +216,15 @@ type CreateDocumentRequest struct { // UpdateUploadRequest specifies the parameters for updating an upload. type UpdateUploadRequest struct { - // Description is the upload description. - Description string `json:"description,omitempty"` + // Description is the upload description, and is presence-aware. + // nil omits the field, leaving the current description alone. + // Ptr("") clears it. Ptr(v) sets it. + Description *string `json:"description,omitempty"` // BaseName is the filename without extension. + // + // A plain string rather than a pointer, unlike Description: Upload#base_name= + // guards on new_base_name.present?, so "" and absent are the same write + // server-side. There is no third state for a pointer to express. BaseName string `json:"base_name,omitempty"` } @@ -831,9 +837,8 @@ func (s *UploadsService) Update(ctx context.Context, uploadID int64, req *Update return nil, err } - body := generated.UpdateUploadJSONRequestBody{} - if req.Description != "" { - body.Description = &req.Description + body := generated.UpdateUploadJSONRequestBody{ + Description: req.Description, } if req.BaseName != "" { body.BaseName = &req.BaseName @@ -902,6 +907,85 @@ func (s *UploadsService) Create(ctx context.Context, vaultID int64, req *CreateU return &upload, nil } +// CreateUploadVersionRequest specifies the parameters for replacing an upload's file. +type CreateUploadVersionRequest struct { + // AttachableSGID is the signed global ID for an uploaded attachment (required). + // See the Create Attachment endpoint for how to upload files. + AttachableSGID string `json:"attachable_sgid"` + // BaseName is the filename without extension (optional). Omit it to keep the + // name of the file you uploaded. + // + // A plain string rather than a pointer, unlike Description: Upload#base_name= + // guards on new_base_name.present?, so "" and absent are the same write + // server-side. There is no third state for a pointer to express. + BaseName string `json:"base_name,omitempty"` + // Description is the upload description in HTML, and is presence-aware. + // nil omits the field, carrying the previous version's description forward. + // Ptr("") clears it. Ptr(v) sets it. + Description *string `json:"description,omitempty"` + // Notify selects who to notify: "default", "everyone", or "custom" (the + // people in Subscriptions). nil omits the field. + // + // Leave both this and Subscriptions nil to notify nobody. A Subscriptions + // list sent without Notify is read as "custom". + Notify *string `json:"notify,omitempty"` + // Subscriptions are the people to notify about the replacement and subscribe + // to the upload. nil omits the field. + Subscriptions *[]int64 `json:"subscriptions,omitempty"` +} + +// CreateVersion replaces an upload's file with a new version. +// The attachable_sgid must be obtained from the Create Attachment endpoint. +// +// The recording keeps its id, its URL and its comments; the previous file +// becomes a past version. Use this instead of Create when publishing a new +// release of the same file, so its published link keeps working. +// +// Returns the upload with its new file. +func (s *UploadsService) CreateVersion(ctx context.Context, uploadID int64, req *CreateUploadVersionRequest) (result *Upload, err error) { + op := OperationInfo{ + Service: "Uploads", Operation: "CreateVersion", + ResourceType: "upload", IsMutation: true, + ResourceID: uploadID, + } + 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 || req.AttachableSGID == "" { + err = ErrUsage("upload version attachable_sgid is required") + return nil, err + } + + body := generated.CreateUploadVersionJSONRequestBody{ + AttachableSgid: req.AttachableSGID, + BaseName: omitzero(req.BaseName), + Description: req.Description, + Notify: req.Notify, + Subscriptions: req.Subscriptions, + } + + resp, err := s.client.parent.gen.CreateUploadVersionWithResponse(ctx, s.client.accountID, uploadID, body) + if err != nil { + return nil, err + } + if err = checkResponse(resp.HTTPResponse, resp.Body); err != nil { + return nil, err + } + if resp.JSON201 == nil { + err = fmt.Errorf("unexpected empty response") + return nil, err + } + + upload := uploadFromGenerated(*resp.JSON201) + return &upload, nil +} + // Trash moves an upload to the trash. // Trashed uploads can be recovered from the trash. func (s *UploadsService) Trash(ctx context.Context, uploadID int64) (err error) { @@ -938,10 +1022,106 @@ type UploadVersionListOptions struct { Page int } +// UploadVersion is a version event for an upload, plus the file it recorded. +// +// Action is one of "created", "active" (the upload's publication) or +// "blob_changed" (a file replacement). +// +// To list the file's PAST versions, select entries whose Upload is non-nil and +// whose Upload.Current is false. Filtering on "blob_changed" is the tempting +// shortcut and the wrong one: the original file is recorded by the "created" or +// "active" event, so that filter drops the original and keeps the current file +// — the opposite of what it reads like. +type UploadVersion struct { + // ID is the event ID, not the upload's. + ID int64 `json:"id"` + // RecordingID is the upload recording this version belongs to. + RecordingID int64 `json:"recording_id"` + // Action is "created", "active" or "blob_changed". + Action string `json:"action"` + // CreatedAt is when the version was recorded. + CreatedAt time.Time `json:"created_at"` + // Creator is the person who recorded it. + Creator Person `json:"creator"` + // Details carries the event's membership changes, when it has any. + // + // Presence is the pointer, matching Event: the versions partial renders + // details through the shared recordings/events/_event partial, which emits + // "details": {} for an event with no membership changes. Mapping that + // present-empty object to nil would lose the distinction between "no changes + // recorded" and "this event carries no details at all". + Details *EventDetails `json:"details,omitempty"` + // BoostsCount is the number of boosts (nil when the event isn't boostable). + BoostsCount *int32 `json:"boosts_count,omitempty"` + // BoostsURL links the event's boosts (nil when the event isn't boostable). + BoostsURL *string `json:"boosts_url,omitempty"` + // Upload is the file this version recorded. Nil when the recordable no + // longer resolves — a deleted file leaves its version event behind. + Upload *UploadVersionFile `json:"upload,omitempty"` +} + +// UploadVersionFile is the file a version event recorded. It is a reduced +// projection, not an Upload: the versions endpoint renders its own partial and +// emits only these fields. +type UploadVersionFile struct { + // Filename is the name of this version's file. + Filename string `json:"filename"` + // ContentType is the blob's MIME type (nil when the upload has no blob). + ContentType *string `json:"content_type,omitempty"` + // ByteSize is the blob's size (nil when the upload has no blob). + ByteSize *int64 `json:"byte_size,omitempty"` + // DownloadURL fetches THIS version's bytes. The upload's own download URL + // always serves the latest, which is the point of the feature. + DownloadURL string `json:"download_url"` + // AppDownloadURL is the same file on the storage host. + AppDownloadURL string `json:"app_download_url"` + // Current is true for the newest version event, and for exactly one element + // of any non-empty response. It is computed positionally by the renderer + // (event == @events.first over a reverse-chronological list), so it is a + // property of ordering rather than of what the upload points at, and is + // never zero or plural. + // + // It does NOT mean "the file the upload's own download URL serves". A + // metadata-only update swaps in a recordable carrying the same blob and + // emits no event, so afterwards no event references the upload's current + // recordable — and exactly one element is still Current. + Current bool `json:"current"` +} + +func uploadVersionFromGenerated(g generated.UploadVersion) UploadVersion { + v := UploadVersion{ + ID: g.Id, + RecordingID: g.RecordingId, + Action: g.Action, + CreatedAt: g.CreatedAt, + Creator: personFromGenerated(g.Creator), + BoostsCount: g.BoostsCount, + BoostsURL: g.BoostsUrl, + } + if g.Details != nil { + v.Details = &EventDetails{ + AddedPersonIDs: g.Details.AddedPersonIds, + RemovedPersonIDs: g.Details.RemovedPersonIds, + NotifiedRecipientIDs: g.Details.NotifiedRecipientIds, + } + } + if g.Upload != nil { + v.Upload = &UploadVersionFile{ + Filename: g.Upload.Filename, + ContentType: g.Upload.ContentType, + ByteSize: g.Upload.ByteSize, + DownloadURL: g.Upload.DownloadUrl, + AppDownloadURL: g.Upload.AppDownloadUrl, + Current: g.Upload.Current, + } + } + return v +} + // UploadVersionListResult contains the results from listing upload versions. type UploadVersionListResult struct { // Versions is the list of upload versions returned. - Versions []Upload + Versions []UploadVersion // Meta contains pagination metadata (total count, etc.). Meta ListMeta } @@ -984,10 +1164,10 @@ func (s *UploadsService) ListVersions(ctx context.Context, uploadID int64, opts totalCount := parseTotalCount(resp.HTTPResponse) // Parse first page - var versions []Upload + var versions []UploadVersion if resp.JSON200 != nil { - for _, gu := range *resp.JSON200 { - versions = append(versions, uploadFromGenerated(gu)) + for _, gv := range *resp.JSON200 { + versions = append(versions, uploadVersionFromGenerated(gv)) } } @@ -1016,11 +1196,11 @@ func (s *UploadsService) ListVersions(ctx context.Context, uploadID int64, opts // Parse additional pages for _, raw := range rawMore { - var gu generated.Upload - if err := json.Unmarshal(raw, &gu); err != nil { + var gv generated.UploadVersion + if err := json.Unmarshal(raw, &gv); err != nil { return nil, fmt.Errorf("failed to parse upload version: %w", err) } - versions = append(versions, uploadFromGenerated(gu)) + versions = append(versions, uploadVersionFromGenerated(gv)) } return &UploadVersionListResult{Versions: versions, Meta: ListMeta{TotalCount: totalCount, Truncated: truncated}}, nil diff --git a/go/pkg/basecamp/vaults_test.go b/go/pkg/basecamp/vaults_test.go index 83f80a28cf..5766d306c4 100644 --- a/go/pkg/basecamp/vaults_test.go +++ b/go/pkg/basecamp/vaults_test.go @@ -588,6 +588,13 @@ func TestUpload_UnmarshalList(t *testing.T) { } } +func equalStringPtr(a, b *string) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + func TestUpdateUploadRequest_Marshal(t *testing.T) { data := loadUploadsFixture(t, "update-request.json") @@ -596,8 +603,8 @@ func TestUpdateUploadRequest_Marshal(t *testing.T) { t.Fatalf("failed to unmarshal update-request.json: %v", err) } - if req.Description != "Updated description for the file" { - t.Errorf("expected description 'Updated description for the file', got %q", req.Description) + if req.Description == nil || *req.Description != "Updated description for the file" { + t.Errorf("expected description 'Updated description for the file', got %v", req.Description) } if req.BaseName != "new_filename" { t.Errorf("expected base_name 'new_filename', got %q", req.BaseName) @@ -614,7 +621,7 @@ func TestUpdateUploadRequest_Marshal(t *testing.T) { t.Fatalf("failed to unmarshal round-trip: %v", err) } - if roundtrip.Description != req.Description || roundtrip.BaseName != req.BaseName { + if !equalStringPtr(roundtrip.Description, req.Description) || roundtrip.BaseName != req.BaseName { t.Error("round-trip mismatch") } } @@ -625,6 +632,12 @@ func TestUpdateUploadRequest_Marshal(t *testing.T) { // basecamp/bc3 @ ba105ba7 — see /API-GAP-404.md), so the SDK must not offer it // as an upload-update field. // +// This stayed true when file replacement shipped. basecamp/bc3#12555 added a +// dedicated POST /uploads/{id}/versions.json rather than widening the update, so +// the guard now pins a design choice rather than a missing feature: the +// sanctioned path is UploadsService.CreateVersion, and its positive counterpart +// is TestCreateUploadVersionRequest_HasFileReplacementField. +// // This is asserted over the request type, not the wire: an omitempty field left // unset is simply absent from the body, so a wire-body check could not catch a // newly added field. Adding an attachable_sgid field to UpdateUploadRequest @@ -676,7 +689,7 @@ func TestUploadsService_Update_SendsDocumentedFields(t *testing.T) { ac := client.ForAccount("12345") _, err := ac.Uploads().Update(context.Background(), 1069479400, &UpdateUploadRequest{ - Description: "Updated description", + Description: Ptr("Updated description"), BaseName: "renamed", }) if err != nil { diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index b8ec7d0841..a8344bfe32 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -1014,6 +1014,30 @@ type CreateUploadRequestContent struct { // CreateUploadResponseContent defines model for CreateUploadResponseContent. type CreateUploadResponseContent = Upload +// CreateUploadVersionRequestContent defines model for CreateUploadVersionRequestContent. +type CreateUploadVersionRequestContent struct { + AttachableSgid string `json:"attachable_sgid"` + + // BaseName Omit to keep the uploaded file's own name. Sending "" also keeps it. + BaseName *string `json:"base_name,omitempty"` + + // Description Presence-aware: omit to carry the previous version's description forward, + // send "" to clear it, send a value to set it. + Description *string `json:"description,omitempty"` + + // Notify Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + // + // Omit both this and subscriptions to notify nobody. A subscriptions array sent + // without notify is read as "custom". + Notify *string `json:"notify,omitempty"` + + // Subscriptions People to notify about the replacement and subscribe to the upload. + Subscriptions *[]int64 `json:"subscriptions,omitempty"` +} + +// CreateUploadVersionResponseContent defines model for CreateUploadVersionResponseContent. +type CreateUploadVersionResponseContent = Upload + // CreateVaultRequestContent defines model for CreateVaultRequestContent. type CreateVaultRequestContent struct { Title string `json:"title"` @@ -1977,7 +2001,7 @@ type ListTodolistsResponseContent = []Todolist type ListTodosResponseContent = []Todo // ListUploadVersionsResponseContent defines model for ListUploadVersionsResponseContent. -type ListUploadVersionsResponseContent = []Upload +type ListUploadVersionsResponseContent = []UploadVersion // ListUploadsResponseContent defines model for ListUploadsResponseContent. type ListUploadsResponseContent = []Upload @@ -2968,6 +2992,16 @@ type SetClientVisibilityRequestContent struct { // SetClientVisibilityResponseContent defines model for SetClientVisibilityResponseContent. type SetClientVisibilityResponseContent = Recording +// StorageLimitErrorResponseContent The account has reached its file storage limit. +// +// Raised by ResourceLimits#ensure_account_can_upload_files ahead of any operation +// that stores new bytes. No retry can satisfy it: the account needs more storage, +// so this maps to `limit_exceeded` rather than a retryable server error. +type StorageLimitErrorResponseContent struct { + Error string `json:"error"` + Message *string `json:"message,omitempty"` +} + // SubscribeResponseContent defines model for SubscribeResponseContent. type SubscribeResponseContent = Subscription @@ -4036,6 +4070,55 @@ type Upload struct { Width *types.FlexInt `json:"width,omitempty"` } +// UploadVersion A version event for an upload, from GET /uploads/{id}/versions.json. +// +// `action` is one of `created`, `active` (the upload's publication) or `blob_changed` +// (a file replacement). To list the file's PAST versions, select entries that carry +// an `upload` whose `current` is false — the original file arrives as `created` or +// `active`, never `blob_changed`, so filtering on that action drops the original +// and keeps the current file instead. This is an Event plus the file it recorded, rendered by its own +// partial rather than the shared event one, so upload fields don't leak onto todo, +// message and card events. +type UploadVersion struct { + Action string `json:"action"` + BoostsCount *int32 `json:"boosts_count,omitempty"` + BoostsUrl *string `json:"boosts_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + Creator Person `json:"creator"` + Details *EventDetails `json:"details,omitempty"` + Id int64 `json:"id"` + RecordingId int64 `json:"recording_id"` + + // Upload The file a version event recorded — a reduced projection, not an Upload. + Upload *UploadVersionFile `json:"upload,omitempty"` +} + +// UploadVersionFile The file a version event recorded — a reduced projection, not an Upload. +type UploadVersionFile struct { + AppDownloadUrl string `json:"app_download_url"` + ByteSize *int64 `json:"byte_size,omitempty"` + ContentType *string `json:"content_type,omitempty"` + + // Current True for the newest version *event*, and for exactly one element of any + // non-empty response. The renderer computes it positionally — `event == + // @events.first` over a reverse-chronological list — so it is a property of + // ordering, not of what the upload currently points at, and it cannot be + // zero or plural. + // + // That distinction is the whole caveat: `current` does NOT mean "this is the + // file the upload's own download_url serves". A metadata-only PUT swaps in a + // recordable carrying the same blob and emits no event, so afterwards no + // event references the upload's current recordable — and this flag still + // marks exactly one element, the newest event. bc3 pins that case by name in + // "exactly one version is current after a metadata-only update". + Current bool `json:"current"` + + // DownloadUrl Fetches THIS version's bytes. The upload's own download_url always serves the + // latest, which is the whole point of the feature. + DownloadUrl string `json:"download_url"` + Filename string `json:"filename"` +} + // ValidationErrorResponseContent defines model for ValidationErrorResponseContent. type ValidationErrorResponseContent struct { Error string `json:"error"` @@ -5041,6 +5124,9 @@ type CreateTodolistJSONRequestBody = CreateTodolistRequestContent // UpdateUploadJSONRequestBody defines body for UpdateUpload for application/json ContentType. type UpdateUploadJSONRequestBody = UpdateUploadRequestContent +// CreateUploadVersionJSONRequestBody defines body for CreateUploadVersion for application/json ContentType. +type CreateUploadVersionJSONRequestBody = CreateUploadVersionRequestContent + // UpdateVaultJSONRequestBody defines body for UpdateVault for application/json ContentType. type UpdateVaultJSONRequestBody = UpdateVaultRequestContent @@ -6406,6 +6492,11 @@ type ClientInterface interface { // ListUploadVersions request ListUploadVersions(ctx context.Context, accountId string, uploadId int64, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateUploadVersionWithBody request with any body + CreateUploadVersionWithBody(ctx context.Context, accountId string, uploadId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateUploadVersion(ctx context.Context, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetVault request GetVault(ctx context.Context, accountId string, vaultId int64, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -9894,6 +9985,36 @@ func (c *Client) ListUploadVersions(ctx context.Context, accountId string, uploa } +// CreateUploadVersionWithBody executes the CreateUploadVersion operation. + +func (c *Client) CreateUploadVersionWithBody(ctx context.Context, accountId string, uploadId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + + req, err := NewCreateUploadVersionRequestWithBody(c.Server, accountId, uploadId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) + +} + +func (c *Client) CreateUploadVersion(ctx context.Context, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + + req, err := NewCreateUploadVersionRequest(c.Server, accountId, uploadId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) + +} + // GetVault is marked as idempotent and will be retried on transient failures. func (c *Client) GetVault(ctx context.Context, accountId string, vaultId int64, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -23079,6 +23200,60 @@ func NewListUploadVersionsRequest(server string, accountId string, uploadId int6 return req, nil } +// NewCreateUploadVersionRequest calls the generic CreateUploadVersion builder with application/json body +func NewCreateUploadVersionRequest(server string, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateUploadVersionRequestWithBody(server, accountId, uploadId, "application/json", bodyReader) +} + +// NewCreateUploadVersionRequestWithBody generates requests for CreateUploadVersion with any type of body +func NewCreateUploadVersionRequestWithBody(server string, accountId string, uploadId int64, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "accountId", runtime.ParamLocationPath, accountId) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "uploadId", runtime.ParamLocationPath, uploadId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/%s/uploads/%s/versions.json", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewGetVaultRequest generates requests for GetVault func NewGetVaultRequest(server string, accountId string, vaultId int64) (*http.Request, error) { var err error @@ -23925,6 +24100,7 @@ var operationMetadata = map[string]OperationMetadata{ "GetUpload": {Idempotent: true, HasSensitiveParams: false}, "UpdateUpload": {Idempotent: true, HasSensitiveParams: false}, "ListUploadVersions": {Idempotent: true, HasSensitiveParams: false}, + "CreateUploadVersion": {Idempotent: false, HasSensitiveParams: false}, "GetVault": {Idempotent: true, HasSensitiveParams: false}, "UpdateVault": {Idempotent: true, HasSensitiveParams: false}, "ListDocuments": {Idempotent: true, HasSensitiveParams: false}, @@ -24183,6 +24359,7 @@ var operationRetryMax = map[string]int{ "GetUpload": 3, "UpdateUpload": 3, "ListUploadVersions": 3, + "CreateUploadVersion": 2, "GetVault": 3, "UpdateVault": 3, "ListDocuments": 3, @@ -24439,6 +24616,7 @@ var operationRetryOn = map[string][]int{ "GetUpload": {429, 503}, "UpdateUpload": {429, 503}, "ListUploadVersions": {429, 503}, + "CreateUploadVersion": {429, 503}, "GetVault": {429, 503}, "UpdateVault": {429, 503}, "ListDocuments": {429, 503}, @@ -26282,6 +26460,11 @@ type ClientWithResponsesInterface interface { // ListUploadVersionsWithResponse request ListUploadVersionsWithResponse(ctx context.Context, accountId string, uploadId int64, reqEditors ...RequestEditorFn) (*ListUploadVersionsResponse, error) + // CreateUploadVersionWithBodyWithResponse request with any body + CreateUploadVersionWithBodyWithResponse(ctx context.Context, accountId string, uploadId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUploadVersionResponse, error) + + CreateUploadVersionWithResponse(ctx context.Context, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUploadVersionResponse, error) + // GetVaultWithResponse request GetVaultWithResponse(ctx context.Context, accountId string, vaultId int64, reqEditors ...RequestEditorFn) (*GetVaultResponse, error) @@ -26470,6 +26653,7 @@ type CreateAttachmentResponse struct { JSON422 *ValidationErrorResponseContent JSON429 *RateLimitErrorResponseContent JSON500 *InternalServerErrorResponseContent + JSON507 *StorageLimitErrorResponseContent } // Status returns HTTPResponse.Status @@ -28602,6 +28786,7 @@ type CreateCampfireUploadResponse struct { JSON422 *ValidationErrorResponseContent JSON429 *RateLimitErrorResponseContent JSON500 *InternalServerErrorResponseContent + JSON507 *StorageLimitErrorResponseContent } // Status returns HTTPResponse.Status @@ -34482,6 +34667,43 @@ func (r ListUploadVersionsResponse) ContentType() string { return "" } +type CreateUploadVersionResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CreateUploadVersionResponseContent + JSON401 *UnauthorizedErrorResponseContent + JSON403 *ForbiddenErrorResponseContent + JSON404 *NotFoundErrorResponseContent + JSON422 *ValidationErrorResponseContent + JSON429 *RateLimitErrorResponseContent + JSON500 *InternalServerErrorResponseContent + JSON507 *StorageLimitErrorResponseContent +} + +// Status returns HTTPResponse.Status +func (r CreateUploadVersionResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateUploadVersionResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateUploadVersionResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type GetVaultResponse struct { Body []byte HTTPResponse *http.Response @@ -34663,6 +34885,7 @@ type CreateUploadResponse struct { JSON422 *ValidationErrorResponseContent JSON429 *RateLimitErrorResponseContent JSON500 *InternalServerErrorResponseContent + JSON507 *StorageLimitErrorResponseContent } // Status returns HTTPResponse.Status @@ -37635,6 +37858,23 @@ func (c *ClientWithResponses) ListUploadVersionsWithResponse(ctx context.Context return ParseListUploadVersionsResponse(rsp) } +// CreateUploadVersionWithBodyWithResponse request with arbitrary body returning *CreateUploadVersionResponse +func (c *ClientWithResponses) CreateUploadVersionWithBodyWithResponse(ctx context.Context, accountId string, uploadId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUploadVersionResponse, error) { + rsp, err := c.CreateUploadVersionWithBody(ctx, accountId, uploadId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateUploadVersionResponse(rsp) +} + +func (c *ClientWithResponses) CreateUploadVersionWithResponse(ctx context.Context, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUploadVersionResponse, error) { + rsp, err := c.CreateUploadVersion(ctx, accountId, uploadId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateUploadVersionResponse(rsp) +} + // GetVaultWithResponse request returning *GetVaultResponse func (c *ClientWithResponses) GetVaultWithResponse(ctx context.Context, accountId string, vaultId int64, reqEditors ...RequestEditorFn) (*GetVaultResponse, error) { rsp, err := c.GetVault(ctx, accountId, vaultId, reqEditors...) @@ -38023,6 +38263,12 @@ func ParseCreateAttachmentResponse(rsp *http.Response) (*CreateAttachmentRespons response.JSON500 = &dest } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 507: + var dest StorageLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON507 = &dest + } + } return response, nil @@ -41289,6 +41535,12 @@ func ParseCreateCampfireUploadResponse(rsp *http.Response) (*CreateCampfireUploa response.JSON500 = &dest } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 507: + var dest StorageLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON507 = &dest + } + } return response, nil @@ -50155,6 +50407,74 @@ func ParseListUploadVersionsResponse(rsp *http.Response) (*ListUploadVersionsRes return response, nil } +// ParseCreateUploadVersionResponse parses an HTTP response from a CreateUploadVersionWithResponse call +func ParseCreateUploadVersionResponse(rsp *http.Response) (*CreateUploadVersionResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateUploadVersionResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CreateUploadVersionResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest UnauthorizedErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON401 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ForbiddenErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON403 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON404 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ValidationErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON422 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest RateLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON429 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON500 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 507: + var dest StorageLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON507 = &dest + } + + } + + return response, nil +} + // ParseGetVaultResponse parses an HTTP response from a GetVaultWithResponse call func ParseGetVaultResponse(rsp *http.Response) (*GetVaultResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -50468,6 +50788,12 @@ func ParseCreateUploadResponse(rsp *http.Response) (*CreateUploadResponse, error response.JSON500 = &dest } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 507: + var dest StorageLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON507 = &dest + } + } return response, nil diff --git a/kotlin/README.md b/kotlin/README.md index 6e56d230f1..e17a69bc12 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -659,6 +659,9 @@ try { is BasecampException.NotFound -> println("Not found: ${e.message}") is BasecampException.RateLimit -> println("Retry in ${e.retryAfterSeconds}s") is BasecampException.Validation -> println("Invalid input: ${e.message}") + is BasecampException.LimitExceeded -> + // 507. An account limit, not a transient failure — do not retry. + println("Limit reached: ${e.message}") is BasecampException.Ambiguous -> println("Ambiguous: ${e.message}") is BasecampException.Network -> println("Network error: ${e.message}") is BasecampException.Api -> println("Server error (${e.httpStatus}): ${e.message}") @@ -685,9 +688,10 @@ try { | `NotFound` | 404 | 2 | Resource not found | | `RateLimit` | 429 | 5 | Rate limit exceeded (retryable) | | `Network` | - | 6 | Network error (retryable) | -| `Api` | 5xx | 7 | Server error | +| `Api` | 500, 502, 503, 504, other 5xx | 7 | Server error | | `Ambiguous` | - | 8 | Multiple matches found | | `Validation` | 400, 422 | 9 | Invalid request data | +| `LimitExceeded` | 507 | 10 | Account limit reached (file storage, projects, webhooks) — never retryable | | `Usage` | - | 1 | Configuration or argument error | | `DiscoverySelection` | - | 7 or 9 | OAuth discovery selection failed (code derived from `reason`) | | `DeviceFlow` | - | 1, 3, 6, or 9 | Device authorization grant failed (code derived from `reason`) | 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 6eb03e0027..9ab6410a12 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 @@ -79,6 +79,35 @@ private fun summarizeProjects(projects: List): JsonElement = buildJsonO put("last_project_id", projects.lastOrNull()?.id ?: 0L) } +/** + * Flattens the versions array into top-level scalars. + * + * GET /uploads/{id}/versions.json returns an ARRAY and a responseBody path + * resolves as a top-level key only. Every value comes off the DECODED model, so + * this is a decode test of the retype that closes #649 and not a transport test + * — kotlinx.serialization rejects a body missing any non-nullable member. + */ +private fun summarizeUploadVersions(versions: List): JsonElement = buildJsonObject { + put("versions_count", versions.size) + put("current_count", versions.count { it.upload?.current == true }) + versions.firstOrNull()?.let { first -> + put("first_action", first.action) + first.upload?.let { file -> + put("first_filename", file.filename) + file.contentType?.let { put("first_content_type", it) } + file.byteSize?.let { put("first_byte_size", it) } + put("first_current", file.current) + } + } + versions.lastOrNull()?.let { last -> + put("last_action", last.action) + // A version whose recordable no longer resolves omits the upload object + // entirely — the optionality UploadVersion.upload declares. + put("last_has_upload", last.upload != null) + } +} + + fun main() { val testsDir = File("../conformance/tests") @@ -1399,6 +1428,40 @@ private suspend fun dispatchOperation(tc: TestCase, account: AccountClient): Dis DispatchResult() } + // Presence-bearing, like ReplaceScheduleEntry: a key the fixture omits + // stays null and `?.let` keeps it off the wire, so an unaddressed + // description carries forward while an explicit "" is sent and clears. + "CreateUploadVersion" -> { + val uploadId = tc.pathParams.longParam("uploadId") + val rb = tc.requestBody + account.uploads.createVersion(uploadId, CreateUploadVersionBody( + attachableSgid = tc.requestBody.stringParam("attachable_sgid"), + baseName = rb?.get("base_name")?.jsonPrimitive?.contentOrNull, + description = rb?.get("description")?.jsonPrimitive?.contentOrNull, + notify = rb?.get("notify")?.jsonPrimitive?.contentOrNull, + subscriptions = rb?.get("subscriptions")?.jsonArray + ?.map { element -> element.jsonPrimitive.long }, + )) + DispatchResult() + } + + "UpdateUpload" -> { + val uploadId = tc.pathParams.longParam("uploadId") + val rb = tc.requestBody + account.uploads.update(uploadId, UpdateUploadBody( + baseName = rb?.get("base_name")?.jsonPrimitive?.contentOrNull, + description = rb?.get("description")?.jsonPrimitive?.contentOrNull, + )) + DispatchResult() + } + + "ListUploadVersions" -> { + val uploadId = tc.pathParams.longParam("uploadId") + val result = account.uploads.listVersions(uploadId) + // ListResult delegates to List, so it IS the item list. + DispatchResult(resultJson = summarizeUploadVersions(result)) + } + "ListForwards" -> { val inboxId = tc.pathParams.longParam("inboxId") val result = account.forwards.list(inboxId) 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 bc09cf4323..58aed40ecb 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 @@ -49,7 +49,7 @@ val SERVICE_SPLITS: Map>> = mapOf( ), "Files" to mapOf( "Attachments" to listOf("CreateAttachment"), - "Uploads" to listOf("GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"), + "Uploads" to listOf("GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions", "CreateUploadVersion"), "Vaults" to listOf("GetVault", "UpdateVault", "ListVaults", "CreateVault"), "Documents" to listOf("GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"), "CloudFiles" to listOf("GetCloudFile", "CreateCloudFile", "UpdateCloudFile"), @@ -260,6 +260,7 @@ val METHOD_NAME_OVERRIDES = mapOf( "ListUploads" to "list", "CreateUpload" to "create", "ListUploadVersions" to "listVersions", + "CreateUploadVersion" to "createVersion", "GetMessage" to "get", "UpdateMessage" to "update", "CreateMessage" to "create", @@ -329,6 +330,7 @@ val TYPE_ALIASES = mapOf( "Vault" to "Vault", "Document" to "Document", "Upload" to "Upload", + "UploadVersion" to "UploadVersion", "Schedule" to "Schedule", "ScheduleEntry" to "ScheduleEntry", "Recording" to "Recording", diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt index 750b6b047c..6d4e07356f 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt @@ -119,6 +119,20 @@ sealed class BasecampException( val fieldErrors: Map>? = null, ) : BasecampException(message, CODE_VALIDATION, hint, httpStatus, false, requestId) + /** + * An account limit blocks the request (507) — file storage exhausted, or a + * webhook ceiling reached. + * + * Never retryable: no amount of backoff frees storage or raises a plan + * limit. Distinct from [Api] for exactly that reason, since a 507 would + * otherwise land there as a retryable 5xx. + */ + class LimitExceeded( + message: String = "Account limit reached", + hint: String? = null, + requestId: String? = null, + ) : BasecampException(message, CODE_LIMIT_EXCEEDED, hint, 507, false, requestId) + /** Ambiguous match error (multiple resources match a name/identifier). */ class Ambiguous( /** The type of resource that was ambiguous. */ @@ -204,6 +218,7 @@ sealed class BasecampException( const val CODE_VALIDATION = "validation" const val CODE_AMBIGUOUS = "ambiguous" const val CODE_USAGE = "usage" + const val CODE_LIMIT_EXCEEDED = "limit_exceeded" // RFC 8628 device-flow reasons (see [DeviceFlow]). const val DEVICE_ACCESS_DENIED = "access_denied" @@ -242,6 +257,7 @@ sealed class BasecampException( private const val EXIT_API = 7 private const val EXIT_AMBIGUOUS = 8 private const val EXIT_VALIDATION = 9 + private const val EXIT_LIMIT_EXCEEDED = 10 /** Maps an error code to a CLI exit code. */ fun exitCodeFor(code: String): Int = when (code) { @@ -254,6 +270,7 @@ sealed class BasecampException( CODE_API -> EXIT_API CODE_AMBIGUOUS -> EXIT_AMBIGUOUS CODE_VALIDATION -> EXIT_VALIDATION + CODE_LIMIT_EXCEEDED -> EXIT_LIMIT_EXCEEDED else -> EXIT_API } @@ -291,6 +308,10 @@ sealed class BasecampException( 404 -> NotFound(msg, hint, requestId) 429 -> RateLimit(retryAfterSeconds, msg, hint, requestId) 400, 422 -> Validation(msg, hint, httpStatus, requestId, fieldErrors) + // A 5xx status carrying a client fact: the account is out of + // storage, or at its webhook ceiling. Matched before the else + // arm, which would make it a retryable Api. + 507 -> LimitExceeded(msg, hint, requestId) else -> Api(msg, httpStatus, hint, httpStatus in 500..599, requestId) } } 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 dc3c881acf..9b059800a0 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 @@ -55,6 +55,7 @@ object Metadata { "CreateTodosetTodo" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "CreateTool" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "CreateUpload" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), + "CreateUploadVersion" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "CreateVault" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "CreateWebhook" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "CreateWormhole" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersion.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersion.kt new file mode 100644 index 0000000000..a5d60a4318 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersion.kt @@ -0,0 +1,24 @@ +package com.basecamp.sdk.generated.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * UploadVersion entity from the Basecamp API. + * + * @generated from OpenAPI spec — do not edit directly + */ +@Serializable +data class UploadVersion( + val id: Long, + @SerialName("recording_id") val recordingId: Long, + val action: String, + @SerialName("created_at") val createdAt: String, + val creator: Person, + val details: EventDetails? = null, + @SerialName("boosts_count") val boostsCount: Int? = null, + @SerialName("boosts_url") val boostsUrl: String? = null, + val upload: UploadVersionFile? = null +) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersionFile.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersionFile.kt new file mode 100644 index 0000000000..3a54a53ca5 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersionFile.kt @@ -0,0 +1,21 @@ +package com.basecamp.sdk.generated.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * UploadVersionFile entity from the Basecamp API. + * + * @generated from OpenAPI spec — do not edit directly + */ +@Serializable +data class UploadVersionFile( + val filename: String, + @SerialName("download_url") val downloadUrl: String, + @SerialName("app_download_url") val appDownloadUrl: String, + val current: Boolean, + @SerialName("content_type") val contentType: String? = null, + @SerialName("byte_size") val byteSize: Long? = null +) 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 00efb62643..0898a8f006 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 @@ -1165,6 +1165,15 @@ data class UpdateUploadBody( val baseName: String? = null ) +/** Request body for CreateUploadVersion. */ +data class CreateUploadVersionBody( + val attachableSgid: String, + val baseName: String? = null, + val description: String? = null, + val notify: String? = null, + val subscriptions: List? = null +) + /** Options for ListUploads. */ data class ListUploadsOptions( /** Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8. */ diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt index a7eddb76d5..99818d32a7 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt @@ -61,7 +61,7 @@ open class UploadsService(client: AccountClient) : BaseService(client) { * @param uploadId The upload ID * @param options Optional query parameters and pagination control */ - suspend fun listVersions(uploadId: Long, options: PaginationOptions? = null): ListResult { + suspend fun listVersions(uploadId: Long, options: PaginationOptions? = null): ListResult { val info = OperationInfo( service = "Uploads", operation = "ListUploadVersions", @@ -73,7 +73,34 @@ open class UploadsService(client: AccountClient) : BaseService(client) { return requestPaginated(info, options, { httpGet("/uploads/${uploadId}/versions.json", operationName = info.operation) }) { body -> - json.decodeFromString>(body) + json.decodeFromString>(body) + } + } + + /** + * Replace an upload's file with a new version + * @param uploadId The upload ID + * @param body Request body + */ + suspend fun createVersion(uploadId: Long, body: CreateUploadVersionBody): Upload { + val info = OperationInfo( + service = "Uploads", + operation = "CreateUploadVersion", + resourceType = "upload_version", + isMutation = true, + projectId = null, + resourceId = uploadId, + ) + return request(info, { + httpPost("/uploads/${uploadId}/versions.json", json.encodeToString(kotlinx.serialization.json.buildJsonObject { + put("attachable_sgid", kotlinx.serialization.json.JsonPrimitive(body.attachableSgid)) + body.baseName?.let { put("base_name", kotlinx.serialization.json.JsonPrimitive(it)) } + body.description?.let { put("description", kotlinx.serialization.json.JsonPrimitive(it)) } + body.notify?.let { put("notify", kotlinx.serialization.json.JsonPrimitive(it)) } + body.subscriptions?.let { put("subscriptions", kotlinx.serialization.json.JsonArray(it.map { kotlinx.serialization.json.JsonPrimitive(it) })) } + }), operationName = info.operation) + }) { body -> + json.decodeFromString(body) } } diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt index 258deaa39e..83730c857d 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt @@ -184,6 +184,7 @@ class ErrorTest { BasecampException.Api("error", 500), BasecampException.Ambiguous("project"), BasecampException.Validation("invalid"), + BasecampException.LimitExceeded(), BasecampException.Usage("bad arg"), BasecampException.DiscoverySelection("ambiguous_issuers", "ambiguous"), BasecampException.DeviceFlow(BasecampException.DEVICE_ACCESS_DENIED), @@ -200,6 +201,7 @@ class ErrorTest { is BasecampException.Api -> "api" is BasecampException.Ambiguous -> "ambiguous" is BasecampException.Validation -> "validation" + is BasecampException.LimitExceeded -> "limit_exceeded" is BasecampException.Usage -> "usage" is BasecampException.DiscoverySelection -> "discovery_selection" is BasecampException.DeviceFlow -> "device_flow" diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ProjectsServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ProjectsServiceTest.kt index f390680646..00445fe673 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ProjectsServiceTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ProjectsServiceTest.kt @@ -272,8 +272,9 @@ class ProjectsServiceTest { client.close() } - // The only behavioural evidence for ProjectLimitError. No SDK gives 507 a named - // class, so it lands in the generic Api arm carrying the status (SPEC.md §7). + // The only behavioural evidence for ProjectLimitError. A 507 is an account + // limit, so it maps to limit_exceeded and is NOT retryable — no backoff frees + // a project slot (SPEC.md §6, step 11). @Test fun unarchiveProjectAtProjectLimitThrows() = runTest { val client = mockClient { _ -> @@ -288,10 +289,10 @@ class ProjectsServiceTest { try { account.projects.unarchive(projectId = 42) assertTrue(false, "Should have thrown") - } catch (e: BasecampException.Api) { + } catch (e: BasecampException.LimitExceeded) { assertEquals(507, e.httpStatus) assertEquals("The project limit for this account has been reached.", e.message) - assertTrue(e.retryable, "Kotlin marks every unclassified 5xx retryable") + assertTrue(!e.retryable, "an account limit is never retryable") } client.close() diff --git a/openapi.json b/openapi.json index 45661b542e..bf59b74e58 100644 --- a/openapi.json +++ b/openapi.json @@ -476,6 +476,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -7312,6 +7322,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -25371,6 +25391,136 @@ 503 ] } + }, + "post": { + "description": "Replace an upload's file with a new version\n\nThe recording keeps its id, its URL and its comments; the previous file becomes a\npast version. Use this instead of CreateUpload when publishing a new release of the\nsame file, so its published link keeps working.", + "operationId": "CreateUploadVersion", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadVersionRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "uploadId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "CreateUploadVersion 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadVersionResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Files" + ], + "x-basecamp-retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } } }, "/{accountId}/vaults/{vaultId}": { @@ -25995,6 +26145,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -28974,6 +29134,40 @@ "CreateUploadResponseContent": { "$ref": "#/components/schemas/Upload" }, + "CreateUploadVersionRequestContent": { + "type": "object", + "properties": { + "attachable_sgid": { + "type": "string" + }, + "base_name": { + "type": "string", + "description": "Omit to keep the uploaded file's own name. Sending \"\" also keeps it." + }, + "description": { + "type": "string", + "description": "Presence-aware: omit to carry the previous version's description forward,\nsend \"\" to clear it, send a value to set it." + }, + "notify": { + "type": "string", + "description": "Who to notify: \"default\", \"everyone\", or \"custom\" (the people in subscriptions).\n\nOmit both this and subscriptions to notify nobody. A subscriptions array sent\nwithout notify is read as \"custom\"." + }, + "subscriptions": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "description": "People to notify about the replacement and subscribe to the upload." + } + }, + "required": [ + "attachable_sgid" + ] + }, + "CreateUploadVersionResponseContent": { + "$ref": "#/components/schemas/Upload" + }, "CreateVaultRequestContent": { "type": "object", "properties": { @@ -31188,7 +31382,7 @@ "ListUploadVersionsResponseContent": { "type": "array", "items": { - "$ref": "#/components/schemas/Upload" + "$ref": "#/components/schemas/UploadVersion" } }, "ListUploadsResponseContent": { @@ -33616,6 +33810,21 @@ "SetClientVisibilityResponseContent": { "$ref": "#/components/schemas/Recording" }, + "StorageLimitErrorResponseContent": { + "type": "object", + "description": "The account has reached its file storage limit.\n\nRaised by ResourceLimits#ensure_account_can_upload_files ahead of any operation\nthat stores new bytes. No retry can satisfy it: the account needs more storage,\nso this maps to `limit_exceeded` rather than a retryable server error.", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, "SubscribeResponseContent": { "$ref": "#/components/schemas/Subscription" }, @@ -35622,6 +35831,87 @@ "visible_to_clients" ] }, + "UploadVersion": { + "type": "object", + "description": "A version event for an upload, from GET /uploads/{id}/versions.json.\n\n`action` is one of `created`, `active` (the upload's publication) or `blob_changed`\n(a file replacement). To list the file's PAST versions, select entries that carry\nan `upload` whose `current` is false — the original file arrives as `created` or\n`active`, never `blob_changed`, so filtering on that action drops the original\nand keeps the current file instead. This is an Event plus the file it recorded, rendered by its own\npartial rather than the shared event one, so upload fields don't leak onto todo,\nmessage and card events.", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "recording_id": { + "type": "integer", + "format": "int64" + }, + "action": { + "type": "string" + }, + "details": { + "$ref": "#/components/schemas/EventDetails" + }, + "created_at": { + "type": "string", + "x-go-type": "time.Time", + "x-go-type-import": { + "path": "time" + } + }, + "creator": { + "$ref": "#/components/schemas/Person" + }, + "boosts_count": { + "type": "integer", + "format": "int32" + }, + "boosts_url": { + "type": "string" + }, + "upload": { + "$ref": "#/components/schemas/UploadVersionFile" + } + }, + "required": [ + "action", + "created_at", + "creator", + "id", + "recording_id" + ] + }, + "UploadVersionFile": { + "type": "object", + "description": "The file a version event recorded — a reduced projection, not an Upload.", + "properties": { + "content_type": { + "type": "string" + }, + "byte_size": { + "type": "integer", + "format": "int64" + }, + "filename": { + "type": "string" + }, + "download_url": { + "type": "string", + "description": "Fetches THIS version's bytes. The upload's own download_url always serves the\nlatest, which is the whole point of the feature.", + "x-basecamp-auth-routable-url": {} + }, + "app_download_url": { + "type": "string" + }, + "current": { + "type": "boolean", + "description": "True for the newest version *event*, and for exactly one element of any\nnon-empty response. The renderer computes it positionally — `event ==\n@events.first` over a reverse-chronological list — so it is a property of\nordering, not of what the upload currently points at, and it cannot be\nzero or plural.\n\nThat distinction is the whole caveat: `current` does NOT mean \"this is the\nfile the upload's own download_url serves\". A metadata-only PUT swaps in a\nrecordable carrying the same blob and emits no event, so afterwards no\nevent references the upload's current recordable — and this flag still\nmarks exactly one element, the newest event. bc3 pins that case by name in\n\"exactly one version is current after a metadata-only update\"." + } + }, + "required": [ + "app_download_url", + "current", + "download_url", + "filename" + ] + }, "ValidationErrorResponseContent": { "type": "object", "properties": { diff --git a/python/README.md b/python/README.md index 694b9e38e2..e6f319b769 100644 --- a/python/README.md +++ b/python/README.md @@ -626,6 +626,7 @@ All exceptions inherit from `BasecampError`: | `ApiError` | `api_error` | 5xx, other | Yes for 500/502/503/504; No otherwise | | `AmbiguousError` | `ambiguous` | - | No | | `ValidationError` | `validation` | 400, 422 | No | +| `LimitExceededError` | `limit_exceeded` | 507 | No | Every `BasecampError` provides: - `code` - `ErrorCode` enum value diff --git a/python/scripts/generate_services.py b/python/scripts/generate_services.py index 3d2d4eba2b..5b6aa79c41 100644 --- a/python/scripts/generate_services.py +++ b/python/scripts/generate_services.py @@ -66,7 +66,7 @@ }, "Files": { "Attachments": ["CreateAttachment"], - "Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"], + "Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions", "CreateUploadVersion"], "Vaults": ["GetVault", "UpdateVault", "ListVaults", "CreateVault"], "Documents": ["GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"], "CloudFiles": ["GetCloudFile", "CreateCloudFile", "UpdateCloudFile"], @@ -223,6 +223,7 @@ "ListUploads": "list", "CreateUpload": "create", "ListUploadVersions": "list_versions", + "CreateUploadVersion": "create_version", "GetMessage": "get", "UpdateMessage": "update", "CreateMessage": "create", diff --git a/python/src/basecamp/__init__.py b/python/src/basecamp/__init__.py index 738b9737cc..7815278306 100644 --- a/python/src/basecamp/__init__.py +++ b/python/src/basecamp/__init__.py @@ -20,6 +20,7 @@ ErrorCode, ExitCode, ForbiddenError, + LimitExceededError, NetworkError, NotFoundError, RateLimitError, @@ -43,6 +44,7 @@ "NetworkError", "ApiError", "AmbiguousError", + "LimitExceededError", "UsageError", "ErrorCode", "ExitCode", diff --git a/python/src/basecamp/errors.py b/python/src/basecamp/errors.py index 9dba575e6a..177646336f 100644 --- a/python/src/basecamp/errors.py +++ b/python/src/basecamp/errors.py @@ -16,6 +16,7 @@ class ErrorCode(StrEnum): API = "api_error" AMBIGUOUS = "ambiguous" VALIDATION = "validation" + LIMIT_EXCEEDED = "limit_exceeded" class ExitCode(IntEnum): @@ -28,6 +29,7 @@ class ExitCode(IntEnum): API = 7 AMBIGUOUS = 8 VALIDATION = 9 + LIMIT_EXCEEDED = 10 _EXIT_CODE_MAP = { @@ -40,6 +42,7 @@ class ExitCode(IntEnum): ErrorCode.API: ExitCode.API, ErrorCode.AMBIGUOUS: ExitCode.AMBIGUOUS, ErrorCode.VALIDATION: ExitCode.VALIDATION, + ErrorCode.LIMIT_EXCEEDED: ExitCode.LIMIT_EXCEEDED, } @@ -108,6 +111,18 @@ def __init__(self, message: str = "API error", *, retryable: bool = False, **kwa super().__init__(message, code=ErrorCode.API, retryable=retryable, **kwargs) +class LimitExceededError(BasecampError): + """An account limit blocks the request (HTTP 507). + + File storage exhausted, or a webhook ceiling reached. Never retryable: no + amount of backoff frees storage or raises a plan limit. That is why this is + not an ApiError, which a 507 would otherwise become via the 5xx catch-all. + """ + + def __init__(self, message: str = "Account limit reached", **kwargs: Any): + super().__init__(message, code=ErrorCode.LIMIT_EXCEEDED, retryable=False, **kwargs) + + class AmbiguousError(BasecampError): def __init__(self, message: str = "Ambiguous match", *, matches: list[Any] | None = None, **kwargs: Any): super().__init__(message, code=ErrorCode.AMBIGUOUS, **kwargs) @@ -239,6 +254,11 @@ def error_from_response(status: int, body: str | bytes | None, headers: dict[str # otherwise; truncated after flattening so the tail is capped too. message = f"{message} ({flat})" if message else flat err = ValidationError(_truncate(message or "Validation failed"), http_status=status, field_errors=field_errors) + elif status == 507: + # A 5xx status carrying a client fact: the account is out of storage, or + # at its webhook ceiling. Retrying cannot satisfy it, so this is decided + # before the 5xx arms below. + err = LimitExceededError(_truncate(message or "Account limit reached"), http_status=507) elif status == 500: err = ApiError("Server error (500)", retryable=True, http_status=500) elif status in (502, 503, 504): diff --git a/python/src/basecamp/generated/metadata.json b/python/src/basecamp/generated/metadata.json index ecaedd5af5..0d81effcf1 100644 --- a/python/src/basecamp/generated/metadata.json +++ b/python/src/basecamp/generated/metadata.json @@ -388,6 +388,17 @@ ] } }, + "CreateUploadVersion": { + "retry": { + "backoff": "exponential", + "base_delay_ms": 1000, + "max": 2, + "retry_on": [ + 429, + 503 + ] + } + }, "CreateVault": { "retry": { "backoff": "exponential", diff --git a/python/src/basecamp/generated/services/uploads.py b/python/src/basecamp/generated/services/uploads.py index b26df7492f..7207fe1d01 100644 --- a/python/src/basecamp/generated/services/uploads.py +++ b/python/src/basecamp/generated/services/uploads.py @@ -36,6 +36,30 @@ def list_versions(self, *, upload_id: int, max_items: int | None = None) -> List operation="ListUploadVersions", ) + def create_version( + self, + *, + upload_id: int, + attachable_sgid: str, + base_name: str | None = None, + description: str | None = None, + notify: str | None = None, + subscriptions: list[int] | None = None, + ) -> dict[str, Any]: + return self._request( + OperationInfo(service="uploads", operation="create_version", is_mutation=True, resource_id=upload_id), + "POST", + f"/uploads/{upload_id}/versions.json", + json_body=self._compact( + attachable_sgid=attachable_sgid, + base_name=base_name, + description=description, + notify=notify, + subscriptions=subscriptions, + ), + operation="CreateUploadVersion", + ) + def list(self, *, vault_id: int, page: int | None = None, max_items: int | None = None) -> ListResult: return self._request_paginated( OperationInfo(service="uploads", operation="list", is_mutation=False, resource_id=vault_id), @@ -98,6 +122,30 @@ async def list_versions(self, *, upload_id: int, max_items: int | None = None) - operation="ListUploadVersions", ) + async def create_version( + self, + *, + upload_id: int, + attachable_sgid: str, + base_name: str | None = None, + description: str | None = None, + notify: str | None = None, + subscriptions: list[int] | None = None, + ) -> dict[str, Any]: + return await self._request( + OperationInfo(service="uploads", operation="create_version", is_mutation=True, resource_id=upload_id), + "POST", + f"/uploads/{upload_id}/versions.json", + json_body=self._compact( + attachable_sgid=attachable_sgid, + base_name=base_name, + description=description, + notify=notify, + subscriptions=subscriptions, + ), + operation="CreateUploadVersion", + ) + async def list(self, *, vault_id: int, page: int | None = None, max_items: int | None = None) -> ListResult: return await self._request_paginated( OperationInfo(service="uploads", operation="list", is_mutation=False, resource_id=vault_id), diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index f3686e9022..f131b6cf57 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -628,6 +628,14 @@ class CreateUploadRequestContent(TypedDict): visible_to_clients: NotRequired[bool] +class CreateUploadVersionRequestContent(TypedDict): + attachable_sgid: str + base_name: NotRequired[str] + description: NotRequired[str] + notify: NotRequired[str] + subscriptions: NotRequired[list[int]] + + class CreateVaultRequestContent(TypedDict): title: str @@ -1654,6 +1662,11 @@ class SetClientVisibilityRequestContent(TypedDict): visible_to_clients: bool +class StorageLimitErrorResponseContent(TypedDict): + error: str + message: NotRequired[str] + + class Subscription(TypedDict): count: int subscribed: bool @@ -2163,6 +2176,27 @@ class Upload(TypedDict): width: NotRequired[int | float] +class UploadVersion(TypedDict): + action: str + boosts_count: NotRequired[int] + boosts_url: NotRequired[str] + created_at: str + creator: Person + details: NotRequired[EventDetails] + id: int + recording_id: int + upload: NotRequired[UploadVersionFile] + + +class UploadVersionFile(TypedDict): + app_download_url: str + byte_size: NotRequired[int] + content_type: NotRequired[str] + current: bool + download_url: str + filename: str + + class ValidationErrorResponseContent(TypedDict): error: str message: NotRequired[str] diff --git a/python/tests/services/test_projects.py b/python/tests/services/test_projects.py index 773fe1700b..71d4ff3bc7 100644 --- a/python/tests/services/test_projects.py +++ b/python/tests/services/test_projects.py @@ -6,7 +6,7 @@ from basecamp import AsyncClient from basecamp.client import Client -from basecamp.errors import ApiError, ErrorCode, ForbiddenError +from basecamp.errors import ErrorCode, ForbiddenError, LimitExceededError BASE = "https://3.basecampapi.com/12345" @@ -109,15 +109,14 @@ def test_archive_project_forbidden(self): assert excinfo.value.http_status == 403 - # The only behavioural evidence for ProjectLimitError. No SDK gives 507 a named - # class, so it falls into the generic api_error arm carrying the status - # (SPEC.md §7). + # The only behavioural evidence for ProjectLimitError. A 507 is an account + # limit, so it maps to limit_exceeded and is NOT retryable — no backoff frees + # a project slot (SPEC.md §6, step 11). # - # NOTE the deliberate `retryable is False`. Python's fallback arm builds a bare - # ApiError, whose default is retryable=False, while the other five SDKs mark - # every unclassified 5xx retryable. That is a pre-existing divergence from - # SPEC §7 (python/src/basecamp/errors.py) and is asserted here as-is rather - # than fixed in passing. + # The divergence this comment used to record is gone. Python's fallback arm + # produced retryable=False here while the other five marked every + # unclassified 5xx retryable; now all six classify 507 the same way, and + # False is the agreed answer rather than an accident of which arm caught it. @respx.mock def test_unarchive_project_at_project_limit(self): respx.put(f"{BASE}/projects/42/status/active.json").mock( @@ -125,12 +124,12 @@ def test_unarchive_project_at_project_limit(self): ) client, account = make_account() - with pytest.raises(ApiError) as excinfo: + with pytest.raises(LimitExceededError) as excinfo: account.projects.unarchive(project_id=42) client.close() assert excinfo.value.http_status == 507 - assert excinfo.value.code == ErrorCode.API + assert excinfo.value.code == ErrorCode.LIMIT_EXCEEDED assert excinfo.value.retryable is False @@ -174,9 +173,9 @@ async def test_unarchive_project_at_project_limit(self): ) account = AsyncClient(access_token="test-token").for_account("12345") - with pytest.raises(ApiError) as excinfo: + with pytest.raises(LimitExceededError) as excinfo: await account.projects.unarchive(project_id=42) assert excinfo.value.http_status == 507 - assert excinfo.value.code == ErrorCode.API + assert excinfo.value.code == ErrorCode.LIMIT_EXCEEDED assert excinfo.value.retryable is False diff --git a/python/tests/services/test_uploads.py b/python/tests/services/test_uploads.py index 1af3c9f281..2dc2814bda 100644 --- a/python/tests/services/test_uploads.py +++ b/python/tests/services/test_uploads.py @@ -10,7 +10,7 @@ import respx from basecamp import AsyncClient, Client -from basecamp.errors import UsageError +from basecamp.errors import LimitExceededError, UsageError _FIXTURES = Path(__file__).resolve().parents[3] / "spec" / "fixtures" @@ -215,3 +215,125 @@ def test_get_preserves_dimension_float_and_none(self): # NotRequired[Optional[int | float]] width/height type. assert attachments[1]["width"] is None assert attachments[1]["height"] is None + + +class TestListVersions: + """The endpoint returns EVENTS, not Uploads — the retype that closes #649.""" + + @respx.mock + def test_versions_carry_the_file_each_one_recorded(self): + versions = json.loads((_FIXTURES / "uploads" / "versions.json").read_text(encoding="utf-8")) + respx.get("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(200, json=versions) + ) + + c = Client(access_token="test-token") + result = c.for_account("12345").uploads.list_versions(upload_id=77) + + assert len(result) == 3 + assert result[0]["action"] == "blob_changed" + assert result[0]["upload"]["filename"] == "company-logo.png" + assert result[0]["upload"]["byte_size"] == 184829 + + @respx.mock + def test_exactly_one_version_is_current(self): + versions = json.loads((_FIXTURES / "uploads" / "versions.json").read_text(encoding="utf-8")) + respx.get("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(200, json=versions) + ) + + c = Client(access_token="test-token") + result = c.for_account("12345").uploads.list_versions(upload_id=77) + + assert sum(1 for v in result if v.get("upload", {}).get("current")) == 1 + assert result[0]["upload"]["current"] is True + + @respx.mock + def test_tolerates_a_version_whose_recordable_is_gone(self): + versions = json.loads((_FIXTURES / "uploads" / "versions.json").read_text(encoding="utf-8")) + respx.get("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(200, json=versions) + ) + + c = Client(access_token="test-token") + result = c.for_account("12345").uploads.list_versions(upload_id=77) + + assert result[2]["action"] == "created" + assert "upload" not in result[2] + + +class TestCreateVersion: + @respx.mock + def test_posts_the_attachable_sgid(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(201, json={"id": 77, "filename": "company-logo.png"}) + ) + + c = Client(access_token="test-token") + result = c.for_account("12345").uploads.create_version(upload_id=77, attachable_sgid="sgid-abc") + + assert result["id"] == 77 + assert json.loads(route.calls[0].request.content)["attachable_sgid"] == "sgid-abc" + + # Presence-aware: omitted carries the previous description forward, "" + # clears. _compact strips None only, so "" survives to the wire — which is + # exactly why "" is the SDK's clear spelling rather than None. + @respx.mock + def test_omits_an_unaddressed_description(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(201, json={"id": 77}) + ) + + c = Client(access_token="test-token") + c.for_account("12345").uploads.create_version(upload_id=77, attachable_sgid="sgid-abc") + + body = json.loads(route.calls[0].request.content) + assert "description" not in body + assert "base_name" not in body + + @respx.mock + def test_sends_an_explicit_blank_description_to_clear_it(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(201, json={"id": 77}) + ) + + c = Client(access_token="test-token") + c.for_account("12345").uploads.create_version(upload_id=77, attachable_sgid="sgid-abc", description="") + + body = json.loads(route.calls[0].request.content) + assert "description" in body + assert body["description"] == "" + + @respx.mock + def test_passes_notify_and_subscriptions(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(201, json={"id": 77}) + ) + + c = Client(access_token="test-token") + c.for_account("12345").uploads.create_version( + upload_id=77, attachable_sgid="sgid-abc", notify="custom", subscriptions=[1049715915] + ) + + body = json.loads(route.calls[0].request.content) + assert body["notify"] == "custom" + assert body["subscriptions"] == [1049715915] + + # A replacement copies bytes into a new blob and keeps the old one, so it + # always grows recorded storage. 507 is a limit, never a transient failure. + @respx.mock + def test_storage_limit_is_limit_exceeded_and_not_retried(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(507, json={"error": "The storage limit for this account has been reached."}) + ) + + c = Client(access_token="test-token") + with pytest.raises(LimitExceededError) as excinfo: + c.for_account("12345").uploads.create_version(upload_id=77, attachable_sgid="sgid-abc") + + err = excinfo.value + assert err.code == "limit_exceeded" + assert err.exit_code == 10 + assert err.retryable is False + assert "storage limit" in str(err) + assert route.call_count == 1 diff --git a/ruby/README.md b/ruby/README.md index fb22ece1b5..2984c6041e 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -480,6 +480,7 @@ end | `ValidationError` | Invalid request data (400, 422) | | `RateLimitError` | Rate limit exceeded (429) | | `NetworkError` | Connection failures | +| `LimitExceededError` | Account limit reached (507) — file storage, projects, webhooks | ### Validation Errors diff --git a/ruby/lib/basecamp.rb b/ruby/lib/basecamp.rb index 10ff69aef6..5e40077ae3 100644 --- a/ruby/lib/basecamp.rb +++ b/ruby/lib/basecamp.rb @@ -132,6 +132,10 @@ def self.error_from_response(status, body = nil, retry_after: nil) NotFoundError.new(message: message) when 429 RateLimitError.new(retry_after: retry_after) + when 507 + # Decided before the 5xx arms: a 507 is an account limit, not a + # transient server failure, and no retry can satisfy it. + LimitExceededError.new(Security.truncate(message)) when 500 ApiError.new("Server error (500)", http_status: 500, retryable: true) when 502, 503, 504 diff --git a/ruby/lib/basecamp/error.rb b/ruby/lib/basecamp/error.rb index b02ec16d70..0584624531 100644 --- a/ruby/lib/basecamp/error.rb +++ b/ruby/lib/basecamp/error.rb @@ -79,6 +79,7 @@ def self.exit_code_for(code) when ErrorCode::API then ExitCode::API when ErrorCode::AMBIGUOUS then ExitCode::AMBIGUOUS when ErrorCode::VALIDATION then ExitCode::VALIDATION + when ErrorCode::LIMIT_EXCEEDED then ExitCode::LIMIT_EXCEEDED else ExitCode::API end end diff --git a/ruby/lib/basecamp/error_code.rb b/ruby/lib/basecamp/error_code.rb index 6c66ca6cd1..367936ed49 100644 --- a/ruby/lib/basecamp/error_code.rb +++ b/ruby/lib/basecamp/error_code.rb @@ -12,5 +12,6 @@ module ErrorCode API = "api_error" AMBIGUOUS = "ambiguous" VALIDATION = "validation" + LIMIT_EXCEEDED = "limit_exceeded" end end diff --git a/ruby/lib/basecamp/exit_code.rb b/ruby/lib/basecamp/exit_code.rb index f52beb15a7..c15f27eae4 100644 --- a/ruby/lib/basecamp/exit_code.rb +++ b/ruby/lib/basecamp/exit_code.rb @@ -13,5 +13,6 @@ module ExitCode API = 7 AMBIGUOUS = 8 VALIDATION = 9 + LIMIT_EXCEEDED = 10 end end diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index f5c48ea12d..ab2c5f4894 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-06T00:45:34Z", + "generated": "2026-08-07T11:12:00Z", "operations": { "GetAccount": { "retry": { @@ -3148,6 +3148,17 @@ "maxPageSize": 50 } }, + "CreateUploadVersion": { + "retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + }, "GetVault": { "retry": { "maxAttempts": 3, diff --git a/ruby/lib/basecamp/generated/services/uploads_service.rb b/ruby/lib/basecamp/generated/services/uploads_service.rb index 5b3c3e50da..5ea59b4f6f 100644 --- a/ruby/lib/basecamp/generated/services/uploads_service.rb +++ b/ruby/lib/basecamp/generated/services/uploads_service.rb @@ -37,6 +37,24 @@ def list_versions(upload_id:, max_items: nil) end end + # Replace an upload's file with a new version + # @param upload_id [Integer] upload id ID + # @param attachable_sgid [String] attachable sgid + # @param base_name [String, nil] Omit to keep the uploaded file's own name. Sending "" also keeps it. + # @param description [String, nil] Presence-aware: omit to carry the previous version's description forward, + # send "" to clear it, send a value to set it. + # @param notify [String, nil] Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + # + # Omit both this and subscriptions to notify nobody. A subscriptions array sent + # without notify is read as "custom". + # @param subscriptions [Array, nil] People to notify about the replacement and subscribe to the upload. + # @return [Hash] response data + def create_version(upload_id:, attachable_sgid:, base_name: nil, description: nil, notify: nil, subscriptions: nil) + with_operation(service: "uploads", operation: "create_version", is_mutation: true, resource_id: upload_id) do + http_post("/uploads/#{upload_id}/versions.json", body: compact_params(attachable_sgid: attachable_sgid, base_name: base_name, description: description, notify: notify, subscriptions: subscriptions)).json + end + end + # List uploads in a vault # @param vault_id [Integer] vault id ID # @param page [Integer, nil] Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8. diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 67a35256d1..7cb463cc95 100644 --- a/ruby/lib/basecamp/generated/types.rb +++ b/ruby/lib/basecamp/generated/types.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Auto-generated from OpenAPI spec. Do not edit manually. -# Generated: 2026-08-06T00:45:34Z +# Generated: 2026-08-07T11:12:00Z require "json" require "time" @@ -5063,6 +5063,82 @@ def to_json(*args) end end + # UploadVersion + class UploadVersion + include TypeHelpers + attr_accessor :action, :created_at, :creator, :id, :recording_id, :boosts_count, :boosts_url, :details, :upload + + # @return [Array] + def self.required_fields + %i[action created_at creator id recording_id].freeze + end + + def initialize(data = {}) + @action = data["action"] + @created_at = parse_datetime(data["created_at"]) + @creator = parse_type(data["creator"], "Person") + @id = parse_integer(data["id"]) + @recording_id = parse_integer(data["recording_id"]) + @boosts_count = parse_integer(data["boosts_count"]) + @boosts_url = data["boosts_url"] + @details = parse_type(data["details"], "EventDetails") + @upload = parse_type(data["upload"], "UploadVersionFile") + end + + def to_h + { + "action" => @action, + "created_at" => @created_at, + "creator" => @creator, + "id" => @id, + "recording_id" => @recording_id, + "boosts_count" => @boosts_count, + "boosts_url" => @boosts_url, + "details" => @details, + "upload" => @upload, + }.compact + end + + def to_json(*args) + to_h.to_json(*args) + end + end + + # UploadVersionFile + class UploadVersionFile + include TypeHelpers + attr_accessor :app_download_url, :current, :download_url, :filename, :byte_size, :content_type + + # @return [Array] + def self.required_fields + %i[app_download_url current download_url filename].freeze + end + + def initialize(data = {}) + @app_download_url = data["app_download_url"] + @current = parse_boolean(data["current"]) + @download_url = data["download_url"] + @filename = data["filename"] + @byte_size = parse_integer(data["byte_size"]) + @content_type = data["content_type"] + end + + def to_h + { + "app_download_url" => @app_download_url, + "current" => @current, + "download_url" => @download_url, + "filename" => @filename, + "byte_size" => @byte_size, + "content_type" => @content_type, + }.compact + end + + def to_json(*args) + to_h.to_json(*args) + end + end + # Vault class Vault include TypeHelpers diff --git a/ruby/lib/basecamp/http.rb b/ruby/lib/basecamp/http.rb index 1b9276491c..84d686ac3f 100644 --- a/ruby/lib/basecamp/http.rb +++ b/ruby/lib/basecamp/http.rb @@ -632,6 +632,12 @@ def handle_error(error, refresh_on_401: true) Basecamp.compose_validation_message(Basecamp.parse_error_message(body), field_errors) || "Validation failed" ) Basecamp::ValidationError.new(message, http_status: status, field_errors: field_errors) + when 507 + # A 5xx status carrying a client fact: the account is out of storage, or + # at its webhook ceiling. Retrying cannot satisfy it, so this is decided + # before the 5xx arms below. + message = Security.truncate(Basecamp.parse_error_message(body) || "Account limit reached") + Basecamp::LimitExceededError.new(message) when 500 Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true) when 502, 503, 504 diff --git a/ruby/lib/basecamp/limit_exceeded_error.rb b/ruby/lib/basecamp/limit_exceeded_error.rb new file mode 100644 index 0000000000..cb045b57e4 --- /dev/null +++ b/ruby/lib/basecamp/limit_exceeded_error.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Basecamp + # Raised when an account limit blocks the request (HTTP 507) — file storage + # exhausted, or a webhook ceiling reached. + # + # Never retryable: no amount of backoff frees storage or raises a plan limit. + # That is the whole reason this is not an ApiError, which a 507 would + # otherwise become through the 5xx catch-all. + class LimitExceededError < Error + def initialize(message = "Account limit reached", hint: nil, cause: nil) + super( + code: ErrorCode::LIMIT_EXCEEDED, + message: message, + hint: hint, + http_status: 507, + retryable: false, + cause: cause + ) + end + end +end diff --git a/ruby/scripts/generate-services.rb b/ruby/scripts/generate-services.rb index cdf51e437c..71eb8733d0 100644 --- a/ruby/scripts/generate-services.rb +++ b/ruby/scripts/generate-services.rb @@ -65,7 +65,7 @@ class ServiceGenerator }, 'Files' => { 'Attachments' => %w[CreateAttachment], - 'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions], + 'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions CreateUploadVersion], 'Vaults' => %w[GetVault UpdateVault ListVaults CreateVault], 'Documents' => %w[GetDocument ReplaceDocument ListDocuments CreateDocument], 'CloudFiles' => %w[GetCloudFile CreateCloudFile UpdateCloudFile], @@ -217,6 +217,7 @@ class ServiceGenerator 'ListUploads' => 'list', 'CreateUpload' => 'create', 'ListUploadVersions' => 'list_versions', + 'CreateUploadVersion' => 'create_version', 'GetMessage' => 'get', 'UpdateMessage' => 'update', 'CreateMessage' => 'create', diff --git a/ruby/test/basecamp/services/projects_service_test.rb b/ruby/test/basecamp/services/projects_service_test.rb index fca39a9826..3b6b42d41e 100644 --- a/ruby/test/basecamp/services/projects_service_test.rb +++ b/ruby/test/basecamp/services/projects_service_test.rb @@ -113,8 +113,9 @@ def test_archive_project_forbidden assert_equal 403, error.http_status end - # The only behavioural evidence for ProjectLimitError. No SDK gives 507 a named - # class, so it lands in the generic ApiError arm carrying the status (SPEC.md §7). + # The only behavioural evidence for ProjectLimitError. A 507 is an account + # limit, so it maps to limit_exceeded and is NOT retryable — no backoff frees + # a project slot (SPEC.md §6, step 11). def test_unarchive_project_at_project_limit stub_put( "/12345/projects/123/status/active.json", @@ -122,12 +123,12 @@ def test_unarchive_project_at_project_limit status: 507 ) - error = assert_raises(Basecamp::ApiError) do + error = assert_raises(Basecamp::LimitExceededError) do @account.projects.unarchive(project_id: 123) end assert_equal 507, error.http_status - assert_equal Basecamp::ErrorCode::API, error.code - assert error.retryable, "Ruby's from_status marks every 5xx retryable" + assert_equal Basecamp::ErrorCode::LIMIT_EXCEEDED, error.code + assert_not error.retryable, "an account limit is never retryable" end end diff --git a/ruby/test/basecamp/services/uploads_service_test.rb b/ruby/test/basecamp/services/uploads_service_test.rb index 018d423049..70dffa4e12 100644 --- a/ruby/test/basecamp/services/uploads_service_test.rb +++ b/ruby/test/basecamp/services/uploads_service_test.rb @@ -69,14 +69,112 @@ def test_update assert_equal "Updated description", result["description"] end + # The endpoint returns EVENTS, not Uploads — the retype that closes #649. def test_list_versions - response = [ { "id" => 1, "version" => 1, "description_attachments" => [] }, { "id" => 2, "version" => 2, "description_attachments" => [] } ] + stub_request(:get, %r{https://3\.basecampapi\.com/12345/uploads/\d+/versions\.json}) + .to_return(status: 200, body: load_fixture("uploads/versions.json").to_json, + headers: { "Content-Type" => "application/json" }) + + result = @account.uploads.list_versions(upload_id: 2).to_a + + assert_equal 3, result.length + assert_equal "blob_changed", result.first["action"] + assert_equal "company-logo.png", result.first["upload"]["filename"] + assert_equal 184829, result.first["upload"]["byte_size"] + end + def test_list_versions_marks_exactly_one_current stub_request(:get, %r{https://3\.basecampapi\.com/12345/uploads/\d+/versions\.json}) - .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) + .to_return(status: 200, body: load_fixture("uploads/versions.json").to_json, + headers: { "Content-Type" => "application/json" }) result = @account.uploads.list_versions(upload_id: 2).to_a - assert_equal 2, result.length + + assert_equal 1, result.count { |v| v.dig("upload", "current") } + assert result.first.dig("upload", "current") + end + + # A version whose recordable no longer resolves omits the upload object + # entirely; the partial's `if upload = uploads[...]` is false. + def test_list_versions_tolerates_a_missing_recordable + stub_request(:get, %r{https://3\.basecampapi\.com/12345/uploads/\d+/versions\.json}) + .to_return(status: 200, body: load_fixture("uploads/versions.json").to_json, + headers: { "Content-Type" => "application/json" }) + + result = @account.uploads.list_versions(upload_id: 2).to_a + + assert_equal "created", result.last["action"] + assert_nil result.last["upload"] + end + + def test_create_version_posts_the_attachable_sgid + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 201, body: { "id" => 2, "filename" => "company-logo.png" }.to_json, + headers: { "Content-Type" => "application/json" }) + + result = @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc") + + assert_equal 2, result["id"] + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") do |req| + JSON.parse(req.body)["attachable_sgid"] == "sgid-abc" + end + end + + # Presence-aware: omitted carries the previous description forward, "" clears. + def test_create_version_omits_an_unaddressed_description + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 201, body: { "id" => 2 }.to_json, headers: { "Content-Type" => "application/json" }) + + @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc") + + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") do |req| + body = JSON.parse(req.body) + !body.key?("description") && !body.key?("base_name") + end + end + + def test_create_version_sends_an_explicit_blank_description_to_clear_it + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 201, body: { "id" => 2 }.to_json, headers: { "Content-Type" => "application/json" }) + + @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc", description: "") + + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") do |req| + body = JSON.parse(req.body) + body.key?("description") && body["description"] == "" + end + end + + def test_create_version_passes_notify_and_subscriptions + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 201, body: { "id" => 2 }.to_json, headers: { "Content-Type" => "application/json" }) + + @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc", + notify: "custom", subscriptions: [ 1049715915 ]) + + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") do |req| + body = JSON.parse(req.body) + body["notify"] == "custom" && body["subscriptions"] == [ 1049715915 ] + end + end + + # A replacement copies bytes into a new blob and keeps the old one, so it + # always grows recorded storage. 507 is a limit, never a transient failure. + def test_create_version_reports_a_storage_limit_as_limit_exceeded + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 507, + body: { "error" => "The storage limit for this account has been reached." }.to_json, + headers: { "Content-Type" => "application/json" }) + + error = assert_raises(Basecamp::LimitExceededError) do + @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc") + end + + assert_equal "limit_exceeded", error.code + assert_equal 10, error.exit_code + assert_not error.retryable? + assert_match(/storage limit/, error.message) + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json", times: 1) end def test_download_delegates_through_download_url diff --git a/scripts/check-grouped-client-coverage b/scripts/check-grouped-client-coverage index ce3f0edba7..f4ce97f119 100755 --- a/scripts/check-grouped-client-coverage +++ b/scripts/check-grouped-client-coverage @@ -61,7 +61,7 @@ GENERATED_PATH = ENV["GROUPED_CLIENT_GENERATED"] || File.join(ROOT, "go/pkg/gene # Extraction floors. Every input here is parsed with a regex, and a regex that # stops matching returns an empty set rather than an error — which reads as "all # clear" and is how a gate silently stops gating. A floor turns that into a -# failure. The numbers are deliberately far below the real values (249 operations, +# failure. The numbers are deliberately far below the real values (250 operations, # 63 arms, 15 services): they catch total extraction collapse, not drift. MIN_OPERATIONS = 200 MIN_ARMS = 40 diff --git a/spec/api-gaps/README.md b/spec/api-gaps/README.md index b020637c12..ff2b764add 100644 --- a/spec/api-gaps/README.md +++ b/spec/api-gaps/README.md @@ -52,7 +52,8 @@ making the absorption journey publicly auditable. | [todoset-direct-todo-create](todoset-direct-todo-create.md) | absorbed-in-sdk | post-train | medium | | [schedule-recurrence-writes](schedule-recurrence-writes.md) | addressed-in-bc3-pr-12359 | post-train | medium | | [dock-tool-create-contract](dock-tool-create-contract.md) | absorbed-in-sdk | launch | medium | -| [upload-new-version](upload-new-version.md) | addressed-in-bc3-pr-12555 | post-train | medium | +| [upload-new-version](upload-new-version.md) | absorbed-in-sdk | post-train | medium | +| [upload-create-subscriptions](upload-create-subscriptions.md) | partial-coverage | n/a | low | | [todolist-reposition](todolist-reposition.md) | absorbed-in-sdk | pre-BC5 | medium | | [rich-text-attachments-coverage](rich-text-attachments-coverage.md) | absorbed-in-sdk | n/a | medium | | [visible-to-clients-on-creates](visible-to-clients-on-creates.md) | absorbed-in-sdk | post-train | medium | @@ -97,13 +98,42 @@ making the absorption journey publicly auditable. > tracked in #12463) and the SDK's matching removal of `GetEverythingBoosts`; > its `no-json-contract` is literal — the feed has no JSON API today. > -> The provenance pin is `7fe1c63ab3` (2026-08-05). +> The provenance pin is `b5d8c9df8d` (2026-08-05). > That line is checked by `make doc-constants-check` and deliberately *not* > rewritten by `make sync-api-version`: this file is in > `spec/doc-constants.json` `.writerExcludes`, because the pin sentence heads > the range triage below and cannot advance without that triage advancing too. > The ranges themselves are settled history and stay unmarked. > +> The `7fe1c63ab3..b5d8c9df8d` range is **one commit**, and it is the companion +> to the absorption this repin carries: BC3 **#12565** (`b5d8c9df8d`) settles the +> input contract of the replacement endpoint #12555 shipped. It documents +> `notify` and `subscriptions` — `Subscribers#notify_param` defaults to +> `"custom"`, so an audience arrives either through `notify` naming a mode or +> through a bare `subscriptions` array, and the web form already relied on the +> second — and it removes `visible_to_clients` from the endpoint's reachable +> surface. That parameter never set the recording's visibility; it only widened +> `Subscribers`' audience, so a request could announce a client-invisible file to +> a project's clients. It also pins `""` as a description clear on both the +> replacement and `PUT /uploads/{id}.json`, which is the spelling the SDKs can +> express — five of six strip nulls structurally before the wire. +> +> Absorbed here as `CreateUploadVersion`, `UploadVersion` / `UploadVersionFile` +> and `StorageLimitError`, closing [`upload-new-version`](upload-new-version.md) +> and basecamp-sdk#649. The `POST /uploads/:id/versions` waivers the previous +> repin added to `spec/bc3-route-allowlist.yml` are **deleted** by that +> absorption, which is what the gate demands — a waiver matching nothing is a +> hard failure, not a shrug. +> +> One correction to the previous range's disposition, not a new finding: that +> triage recorded the 507 as needing "its own error shape", which it got +> (`StorageLimitError`, distinct from `ProjectLimitError` as required). What it +> did not record is that **neither** shape was classified correctly. SPEC §6 had +> no 507 step, so both fell through to `status >= 500` and surfaced as +> `api_error` with `retryable: true` — a plan limit reported as a transient +> server error. §6 now maps 507 to `limit_exceeded`, non-retryable, ahead of the +> 5xx catch-all, which fixes `ProjectLimitError` as well as the new shape. +> > The `4e34dc83eb..7fe1c63ab3` range (71 commits) contains exactly **six** > API-contract or API-documentation changes. Three touch `doc/api`; the other > three change the wire, or a payload's backing field, without a documentation or diff --git a/spec/api-gaps/upload-create-subscriptions.md b/spec/api-gaps/upload-create-subscriptions.md new file mode 100644 index 0000000000..fd7dfa367a --- /dev/null +++ b/spec/api-gaps/upload-create-subscriptions.md @@ -0,0 +1,101 @@ +--- +gap: upload-create-subscriptions +status: partial-coverage +detected: 2026-08-05 +sdk_demand: low +bc3_refs: + routes: + - POST /:account_id/vaults/:vault_id/uploads.json + - POST /:account_id/vaults/:vault_id/uploads/publish.json + controllers: + - app/controllers/uploads_controller.rb + - app/controllers/vaults/uploads_controller.rb + related_existing_api: + - CreateUpload + - CreateUploadVersion +--- + +# `CreateUploadInput.subscriptions` is modeled but never consumed + +## What's missing + +Nothing is missing from BC3 — the defect is on the SDK side. `CreateUploadInput` +models an input the endpoint ignores, so what's missing is either the server +behaviour the field promises or the field's removal. Details below. + +## What's wrong + +`CreateUploadInput` declares `subscriptions: PersonIdList` +(`spec/basecamp.smithy`), so every SDK offers it on create. The endpoint ignores +it. + +`CreateUpload` is `POST /{accountId}/vaults/{vaultId}/uploads.json`, which +`config/routes.rb` routes to `UploadsController#create`. That controller does +**not** include the `Subscribers` concern, and its `create` calls +`@bucket.record(@new_upload.recordable, parent:, status:, visible_to_clients:)` +— no subscribers argument anywhere in the path. + +`subscriptions` *is* consumed for uploads, but by a different controller on a +different route: `Vaults::UploadsController#publish` +(`POST /vaults/:vault_id/uploads/publish.json`), which passes +`subscribers: find_subscribers` into `Upload::Publisher.publish`. An API-created +upload never reaches it — `Upload::Creation#status` returns `active` for an +sgid-backed upload, so there is no draft left to publish. + +`doc/api/sections/uploads.md` is consistent with the code: the "Create an upload" +section documents `attachable_sgid`, `description`, `base_name` and +`visible_to_clients`, and never mentions `subscriptions`. + +## Why it matters + +This is the same class of defect as basecamp-sdk#649, in the structure right +next to the one that fixed it. A caller who passes `subscriptions` to +`uploads.create` gets no error and no subscribers — the field is accepted, +serialized, sent, and dropped. Silent no-ops are worse than absent features, +because nothing tells the caller to go looking. + +It is scoped `low` only because the workaround is easy once known +(`SubscriptionsService` after the create), not because the lie is mild. + +## Contrast with `CreateUploadVersion` + +`CreateUploadVersion` models `notify` and `subscriptions` and both are live — +`Uploads::VersionsController` includes `Subscribers`, and basecamp/bc3#12565 +documented and tested all four input shapes. So the two operations in the same +service now disagree about whether `subscriptions` does anything, which is +exactly the mixed-shape footgun to resolve rather than leave. + +## Suggested API shape + +Either the field goes, or BC3 grows the behaviour it names: + +1. **Remove `subscriptions` from `CreateUploadInput`.** Honest, and a breaking + change to the generated surface in six SDKs — needs its own `MIGRATING.md` + entry. Preferred. +2. **Make BC3 honor it** by including `Subscribers` in `UploadsController#create` + and threading `find_subscribers` through, matching the versions controller. + Turns the lie into a feature, and would make create consistent with + `CreateUploadVersion`, where both `notify` and `subscriptions` are live. + +## Implementation notes for BC3 + +Only needed if option 2 is chosen. `UploadsController#create` would include +`Subscribers` and pass `subscribers: find_subscribers` into the record call, and +`doc/api/sections/uploads.md` would gain `notify` and `subscriptions` bullets on +"Create an upload" mirroring the ones basecamp/bc3#12565 added to "Create an +upload version". Note the upload is already `active` at create for an +sgid-backed upload, so the publish path is not where this would live. + +If option 1 is chosen, BC3 needs no change at all — the docs are already correct. + +## SDK absorption plan when this lands + +Deliberately not bundled into the `CreateUploadVersion` absorption: removing an +existing input member is breaking, and belongs in a change whose title says so. + +- Option 1: drop the member from `CreateUploadInput`, regenerate, and add a + `MIGRATING.md` breaking entry alongside the other uploads-surface entries. + Check the CLI and MCP server for callers first — a silent no-op has no + compile-time users to find, so grep rather than trusting the build. +- Option 2: nothing to change in the SDK; the field starts working, and this + entry closes as `absorbed-in-sdk` with a test asserting subscribers land. diff --git a/spec/api-gaps/upload-new-version.md b/spec/api-gaps/upload-new-version.md index 7c9ce39c0c..59ee54f09c 100644 --- a/spec/api-gaps/upload-new-version.md +++ b/spec/api-gaps/upload-new-version.md @@ -1,9 +1,14 @@ --- gap: upload-new-version -status: addressed-in-bc3-pr-12555 +status: absorbed-in-sdk detected: 2026-07-22 sdk_demand: medium bc3_pr: 12555 +smithy_refs: + - CreateUploadVersion + - CreateUploadVersionInput + - UploadVersion + - UploadVersionFile bc3_refs: introduced_in: BC3 #12555, inside the range the SDK triaged when it registered this routes: @@ -19,34 +24,6 @@ bc3_refs: - ListUploadVersions --- -> **Status: shipped in BC3, not yet absorbed by the SDK.** BC3 **#12555** -> ("Expose upload file replacement over the API") added the write side this brief -> asked for. It chose the **second** of the two options proposed below — a -> dedicated `POST /uploads/:id/versions.json` returning **201**, drawn flat and -> bucket-scoped (`resources :versions, only: %i[ index create ], controller: -> "uploads/versions"`) — and **not** the `PUT /uploads/{id}.json` shape -> basecamp-cli#404 hypothesized and the analysis below disproved. The new version -> payload carries a `current` flag among its fields. -> -> It also answers **507** when the account is over its storage allowance. That -> 507 needs **its own error shape**: it is the *storage* limit ("The storage limit -> for this account has been reached.", -> `app/controllers/concerns/resource_limits.rb`), a different resource with a -> different message from the project limit. `ProjectLimitError` — added for -> `CreateProject`/`UnarchiveProject` in the absorption recorded in -> [`project-archive-unarchive.md`](project-archive-unarchive.md) — is **not** it -> and must not be reused. -> -> Absorption belongs to the `upload-versions-api` branch, not to the repin that -> registered this. Until that lands the SDK models **no** operation for these -> routes, which is why `spec/bc3-route-allowlist.yml` carries a -> `registry: spec/api-gaps/upload-new-version.md` disposition for -> `POST /uploads/:id/versions` — one entry, because direction 2 collapses the -> leading `/buckets/:id` and both documented spellings normalize to that key. -> -> Everything below predates #12555 and is preserved as the analysis that -> established what bc3 did *not* do; read it as history, not as current state. - # Upload a new version of an existing file (write side) ## What's missing @@ -121,15 +98,42 @@ reflect the new blob (`byte_size`, `content_type`, `filename`, `download_url`). ## SDK absorption plan when this lands -- Model the write operation in `spec/basecamp.smithy` (extend - `UpdateUploadInput` with `attachable_sgid`, or add a `CreateUploadVersion` - operation), then `make smithy-build` and regenerate. -- Map the new field/operation in `UploadsService` (`go/pkg/basecamp/vaults.go`) - and the peer SDKs; add `AttachableSGID` to `UpdateUploadRequest` only once the - server honors it. -- Add a canary fixture exercising a real file replacement and confirm the - version list grows, against a live account, before the CLI (basecamp-cli#404) - exposes an "upload new version" command. -- Replace the reflection guard - (`TestUpdateUploadRequest_HasNoFileReplacementField`) with a positive - assertion that the field is now sent. +**Done.** Landed in basecamp/bc3#12555 + #12565 and absorbed here; the plan below +is kept as the record of what was decided. + +BC3 took the **second** option in "Suggested API shape": a dedicated +`POST /uploads/{id}/versions.json`, shipped in basecamp/bc3#12555. `PUT +/uploads/{id}.json` was deliberately left alone, so the hypothesis +basecamp-cli#404 started from stays false — the update still permits only +`base_name` and `description`. + +That decision inverts one bullet of the old absorption plan. The reflection +guard `TestUpdateUploadRequest_HasNoFileReplacementField` is **not** replaced +with "the field is now sent"; it is still true and still worth holding, because +it now pins a design choice rather than a missing feature. It keeps its name, +gains a comment pointing at the sanctioned path, and is joined by a positive +counterpart asserting `CreateUploadVersionRequest` carries `AttachableSGID`. + +Absorbed as: + +- `CreateUploadVersion` — the write, tagged `Files`, grouped into `Uploads`. +- `UploadVersion` / `UploadVersionFile` — the read side. `ListUploadVersionsOutput` + previously declared `uploads: UploadList`, which was a typed lie: the endpoint + returns *events*, and 11 of `Upload`'s 14 `@required` members are absent from + every response. That is basecamp-sdk#649, fixed here rather than merely + corrected, because #12555 also added the nested `upload` object that gives the + version list a filename to report. +- `StorageLimitError` — the `507 Insufficient Storage` contract, which + `ensure_account_can_upload_files` also fronts on three operations the SDK + already modeled (`CreateUpload`, `CreateAttachment`, `CreateCampfireUpload`). + All four now declare it, and SPEC §6 maps 507 to `limit_exceeded` / + non-retryable instead of letting it fall through to the retryable 5xx + catch-all. + +Input contract settled in basecamp/bc3#12565: `notify` and `subscriptions` are +documented and tested, and `visible_to_clients` was removed from the endpoint's +reachable surface (it never set visibility — it only widened the notification +audience, and could announce a client-invisible file to a project's clients). + +Still open: the live-account canary, and basecamp-cli#404's "upload new version" +command. diff --git a/spec/api-provenance.json b/spec/api-provenance.json index cfd5e32b66..eb7a71835e 100644 --- a/spec/api-provenance.json +++ b/spec/api-provenance.json @@ -1,7 +1,7 @@ { "bc3": { "branch": "master", - "revision": "7fe1c63ab33be059605b58e3721cc5db02c9e59a", + "revision": "b5d8c9df8dd957e78bf2618807623d14b4704dc2", "date": "2026-08-05" }, "compatibility": { diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index 9f42028228..3fa094c59c 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -115,6 +115,7 @@ service Basecamp { CreateUpload, UpdateUpload, ListUploadVersions, + CreateUploadVersion, GetCloudFile, CreateCloudFile, UpdateCloudFile, @@ -476,6 +477,19 @@ structure WebhookLimitError { message: String } +/// The account has reached its file storage limit. +/// +/// Raised by ResourceLimits#ensure_account_can_upload_files ahead of any operation +/// that stores new bytes. No retry can satisfy it: the account needs more storage, +/// so this maps to `limit_exceeded` rather than a retryable server error. +@error("server") +@httpError(507) +structure StorageLimitError { + @required + error: String + message: String +} + /// The account has reached its project limit. Raised by CreateProject and by /// UnarchiveProject, both of which add to the active project count. @error("server") @@ -2653,7 +2667,7 @@ structure GetUploadOutput { operation CreateUpload { input: CreateUploadInput output: CreateUploadOutput - errors: [ValidationError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] + errors: [ValidationError, StorageLimitError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] } structure CreateUploadInput { @@ -2681,6 +2695,53 @@ structure CreateUploadOutput { upload: Upload } +/// Replace an upload's file with a new version +/// +/// The recording keeps its id, its URL and its comments; the previous file becomes a +/// past version. Use this instead of CreateUpload when publishing a new release of the +/// same file, so its published link keeps working. +@basecampRetry(maxAttempts: 2, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) +@http(method: "POST", uri: "/{accountId}/uploads/{uploadId}/versions.json", code: 201) +operation CreateUploadVersion { + input: CreateUploadVersionInput + output: CreateUploadVersionOutput + errors: [NotFoundError, ValidationError, StorageLimitError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] +} + +structure CreateUploadVersionInput { + @required + @httpLabel + accountId: AccountId + + @required + @httpLabel + uploadId: UploadId + + @required + attachable_sgid: AttachableSgid + + /// Omit to keep the uploaded file's own name. Sending "" also keeps it. + base_name: UploadBaseName + + /// Presence-aware: omit to carry the previous version's description forward, + /// send "" to clear it, send a value to set it. + description: UploadDescription + + /// Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + /// + /// Omit both this and subscriptions to notify nobody. A subscriptions array sent + /// without notify is read as "custom". + notify: String + + /// People to notify about the replacement and subscribe to the upload. + subscriptions: PersonIdList +} + +structure CreateUploadVersionOutput { + + upload: Upload +} + /// Update an existing upload @idempotent @basecampRetry(maxAttempts: 3, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) @@ -2738,7 +2799,7 @@ structure ListUploadVersionsInput { structure ListUploadVersionsOutput { - uploads: UploadList + versions: UploadVersionList } // ===== Cloud File Operations ===== @@ -3012,7 +3073,7 @@ structure UpdateGoogleDocumentOutput { operation CreateAttachment { input: CreateAttachmentInput output: CreateAttachmentOutput - errors: [ValidationError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] + errors: [ValidationError, StorageLimitError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] } structure CreateAttachmentInput { @@ -4529,7 +4590,7 @@ structure ListCampfireUploadsOutput { operation CreateCampfireUpload { input: CreateCampfireUploadInput output: CreateCampfireUploadOutput - errors: [ValidationError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] + errors: [ValidationError, StorageLimitError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] } structure CreateCampfireUploadInput { @@ -7202,6 +7263,71 @@ structure EventDetails { notified_recipient_ids: PersonIdList } +list UploadVersionList { + member: UploadVersion +} + +/// A version event for an upload, from GET /uploads/{id}/versions.json. +/// +/// `action` is one of `created`, `active` (the upload's publication) or `blob_changed` +/// (a file replacement). To list the file's PAST versions, select entries that carry +/// an `upload` whose `current` is false — the original file arrives as `created` or +/// `active`, never `blob_changed`, so filtering on that action drops the original +/// and keeps the current file instead. This is an Event plus the file it recorded, rendered by its own +/// partial rather than the shared event one, so upload fields don't leak onto todo, +/// message and card events. +structure UploadVersion { + @required + id: EventId + @required + recording_id: RecordingId + @required + action: String + details: EventDetails + @required + created_at: ISO8601Timestamp + @required + creator: Person + boosts_count: Integer + boosts_url: String + + /// The file this version recorded. Absent when the recordable no longer resolves. + upload: UploadVersionFile +} + +/// The file a version event recorded — a reduced projection, not an Upload. +structure UploadVersionFile { + content_type: String + byte_size: Long + + @required + filename: String + + /// Fetches THIS version's bytes. The upload's own download_url always serves the + /// latest, which is the whole point of the feature. + @required + @basecampAuthRoutableUrl + download_url: String + + @required + app_download_url: String + + /// True for the newest version *event*, and for exactly one element of any + /// non-empty response. The renderer computes it positionally — `event == + /// @events.first` over a reverse-chronological list — so it is a property of + /// ordering, not of what the upload currently points at, and it cannot be + /// zero or plural. + /// + /// That distinction is the whole caveat: `current` does NOT mean "this is the + /// file the upload's own download_url serves". A metadata-only PUT swaps in a + /// recordable carrying the same blob and emits no event, so afterwards no + /// event references the upload's current recordable — and this flag still + /// marks exactly one element, the newest event. bc3 pins that case by name in + /// "exactly one version is current after a metadata-only update". + @required + current: Boolean +} + // ===== Recording Shapes ===== @documentation("Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault") diff --git a/spec/bc3-route-allowlist.yml b/spec/bc3-route-allowlist.yml index e2d7ce7a60..b1cba7f340 100644 --- a/spec/bc3-route-allowlist.yml +++ b/spec/bc3-route-allowlist.yml @@ -235,34 +235,6 @@ bc3_routes_not_modeled: path: /dock/doors registry: spec/api-gaps/external-links-doors.md - # -- upload file replacement (BC3 #12555, arrived with the repin that registered it) -- - # - # Registered, not waived: bc3 now documents creating a new version of an - # upload, which closes the write side spec/api-gaps/upload-new-version.md has - # described since basecamp-cli#404 hypothesized the wrong shape for it. bc3 - # chose POST .../versions.json (201), not the PUT /uploads/{id}.json that - # brief disproved. - # - # ONE entry covers both spellings. bc3 documents the flat - # POST /uploads/:id/versions and the bucket-scoped - # POST /buckets/:id/uploads/:id/versions, and the gate's direction-2 failure - # named both — but direction 2 collapses a leading /buckets/:id, so the two - # rows normalize to the same key and the flat entry satisfies both. Adding a - # second entry for the bucket-scoped spelling is a HARD FAILURE ("matches - # nothing"), which is how this was established rather than assumed. - # - # Absorption is NOT this repin's job and is not implied by these entries: it - # belongs to the `upload-versions-api` branch, which also has to model the - # 507 this endpoint returns. That 507 is a **storage** limit ("The storage - # limit for this account has been reached.", app/controllers/concerns/ - # resource_limits.rb) and must get its own error shape — ProjectLimitError, - # added here for CreateProject and UnarchiveProject, is a different resource - # with a different message and must not be reused for it. - - - method: POST - path: /uploads/:id/versions - registry: spec/api-gaps/upload-new-version.md - # -- gauge needle show/update/destroy (#581) -- # # Not a coverage gap: bc3 draws these three actions twice, once nested under @@ -312,3 +284,4 @@ bc3_routes_not_modeled: path: /projects/:id/recordings/:id/timesheet/entries modeled_as: CreateTimesheetEntry routes_rb: "config/routes.rb:268 draws the flat /recordings/:id/timesheet/entries; :747 draws the project-scoped form, inside `resources :recordings, only: []` at :744, inside `resources :projects` at :676. Both name controller \"timesheets/entries\", action create. Beware the near-miss: :741 draws entries under the PROJECT timesheet at :740, which is /projects/:id/timesheet/entries and is `only: %i[ show edit update destroy ]` — no create, and a different parent. bc3's API tests cover THIS pair on both sides: \"create entry on project timesheet\" (test/api/timesheets/entries_controller_api_test.rb:11) hits the scoped form, \"create entry via flat recording route\" (:139) the flat one. Read at the pinned revision 2c0dafba13." + diff --git a/spec/bc3-routes.json b/spec/bc3-routes.json index 9c0839d9de..45dd7c3560 100644 --- a/spec/bc3-routes.json +++ b/spec/bc3-routes.json @@ -5,7 +5,7 @@ "source": { "repo": "basecamp/bc3", "branch": "master", - "revision": "7fe1c63ab33be059605b58e3721cc5db02c9e59a", + "revision": "b5d8c9df8dd957e78bf2618807623d14b4704dc2", "date": "2026-08-05", "extracted_from": [ "doc/api/sections/*.md" diff --git a/spec/doc-constants.json b/spec/doc-constants.json index 8bb480b722..dced718198 100644 --- a/spec/doc-constants.json +++ b/spec/doc-constants.json @@ -48,7 +48,7 @@ "unmarkedPinCitations": { "spec/api-gaps/README.md": { "count": 2, - "reason": "the two ways a range triage names its own endpoint, which is by definition the pin that repin set: the 4e34dc83eb..7fe1c63ab3 range itself, and BC3 #12566 cited as the commit that shipped the note documenting a project's status as read-only on update. Both are bound to that triage and stay true after the next repin. Note the count survived a repin WITHIN this triage without changing meaning: the earlier a26c2e479f endpoint and its #12555 citation stopped being checked the moment the pin advanced past them, and the two citations granted here are the new endpoint's. The pin sentence is separately marked and is the only class-A claim in the file" + "reason": "the two ways a range triage names its own endpoint, which is by definition the pin that repin set: the 7fe1c63ab3..b5d8c9df8d range itself, and BC3 #12565 cited as the commit that settled the upload replacement's input contract. Both are bound to that triage and stay true after the next repin. The count has now survived two repins without changing meaning, which is the point of stating it as a count rather than a list: each repin's two citations stop being checked the moment the pin advances past them, and the two granted here are the new endpoint's. The pin sentence is separately marked and is the only class-A claim in the file" } } } diff --git a/spec/fixtures/manifest.yaml b/spec/fixtures/manifest.yaml index 1e4d12a99e..d8b89fc854 100644 --- a/spec/fixtures/manifest.yaml +++ b/spec/fixtures/manifest.yaml @@ -119,6 +119,31 @@ targets: fixture: uploads/get.json pointer: "" schema: Upload + + # GET /uploads/{id}/versions.json returns EVENTS, not Uploads — the reason + # ListUploadVersionsOutput was retyped (basecamp-sdk#649). Built from + # app/views/api/uploads/versions/_version.json.jbuilder over + # recordings/events/_event.json.jbuilder. + # + # Element 0 is the blob_changed replacement and the only one with + # "current": true — the renderer passes current_event: @events.first, so + # exactly one element per response carries it. Element 2 has NO upload object + # at all: its recordable no longer resolves, so the partial's + # `if upload = uploads[event.recordable_gid]` is false. That element pins the + # optionality and is deliberately not the one covering UploadVersionFile, + # since the concrete-instance rule needs a non-null representative. + - id: upload-version-0 + fixture: uploads/versions.json + pointer: "/0" + schema: UploadVersion + - id: upload-version-file-0 + fixture: uploads/versions.json + pointer: "/0/upload" + schema: UploadVersionFile + - id: upload-version-recordable-gone + fixture: uploads/versions.json + pointer: "/2" + schema: UploadVersion - id: cloud-file-get fixture: cloud_files/get.json pointer: "" @@ -271,6 +296,8 @@ covered_schemas: Card: [card-get] Todolist: [todolist-get, todolist-group-get] Upload: [upload-get] + UploadVersion: [upload-version-0, upload-version-recordable-gone] + UploadVersionFile: [upload-version-file-0] CloudFile: [cloud-file-get] GoogleDocument: [google-document-get] ScheduleEntry: [schedule-entry-get] diff --git a/spec/fixtures/uploads/versions.json b/spec/fixtures/uploads/versions.json new file mode 100644 index 0000000000..40075f78ea --- /dev/null +++ b/spec/fixtures/uploads/versions.json @@ -0,0 +1,109 @@ +[ + { + "id": 1069479501, + "recording_id": 1069479400, + "action": "blob_changed", + "details": {}, + "created_at": "2022-12-04T16:41:12.114Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "boosts_count": 2, + "boosts_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479400/events/1069479501/boosts.json", + "upload": { + "content_type": "image/png", + "byte_size": 184829, + "filename": "company-logo.png", + "download_url": "https://3.basecampapi.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479501/download/company-logo.png", + "app_download_url": "https://storage.3.basecamp.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479501/download/company-logo.png", + "current": true + } + }, + { + "id": 1069479500, + "recording_id": 1069479400, + "action": "active", + "details": {}, + "created_at": "2022-11-28T09:02:44.301Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd1e798e7c0e2b40cd88ed93c0b8", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkz4=--e609ef146e39f9ca5e4bb7f8bf7b7fecfff6e302/avatar?v=1", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + }, + "upload": { + "content_type": "image/png", + "byte_size": 172294, + "filename": "company-logo.png", + "download_url": "https://3.basecampapi.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479500/download/company-logo.png", + "app_download_url": "https://storage.3.basecamp.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479500/download/company-logo.png", + "current": false + } + }, + { + "id": 1069479499, + "recording_id": 1069479400, + "action": "created", + "details": {}, + "created_at": "2022-11-22T08:23:58.523Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd1e798e7c0e2b40cd88ed93c0b8", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkz4=--e609ef146e39f9ca5e4bb7f8bf7b7fecfff6e302/avatar?v=1", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + } + } +] diff --git a/spec/overlays/tags.smithy b/spec/overlays/tags.smithy index 076002a4ae..4280ca83fc 100644 --- a/spec/overlays/tags.smithy +++ b/spec/overlays/tags.smithy @@ -64,6 +64,7 @@ apply GetUpload @tags(["Files"]) apply CreateUpload @tags(["Files"]) apply UpdateUpload @tags(["Files"]) apply ListUploadVersions @tags(["Files"]) +apply CreateUploadVersion @tags(["Files"]) apply GetCloudFile @tags(["Files"]) apply CreateCloudFile @tags(["Files"]) apply UpdateCloudFile @tags(["Files"]) diff --git a/swift/README.md b/swift/README.md index 799c1f6b38..caea10972e 100644 --- a/swift/README.md +++ b/swift/README.md @@ -361,6 +361,9 @@ do { fieldErrors?.forEach { field, messages in messages.forEach { print(" \(field) \($0)") } } + case .limitExceeded(let message, _, _): + // 507. An account limit, not a transient failure — do not retry. + print("Limit reached: \(message)") case .ambiguous(let resource, _, _): print("Ambiguous \(resource)") case .usage(let message, _): @@ -385,9 +388,10 @@ do { | `.notFound` | 404 | 2 | Resource not found | | `.rateLimit` | 429 | 5 | Rate limit exceeded (retryable) | | `.network` | - | 6 | Network error (retryable) | -| `.api` | 5xx | 7 | Server error | +| `.api` | 500, 502, 503, 504, other 5xx | 7 | Server error | | `.ambiguous` | - | 8 | Multiple matches found | | `.validation` | 400, 422 | 9 | Invalid request data | +| `.limitExceeded` | 507 | 10 | Account limit reached (file storage, projects, webhooks) — never retryable | | `.usage` | - | 1 | Configuration or argument error | ### Validation Errors diff --git a/swift/Sources/Basecamp/BasecampError.swift b/swift/Sources/Basecamp/BasecampError.swift index 86d56a38cf..f08cf9b646 100644 --- a/swift/Sources/Basecamp/BasecampError.swift +++ b/swift/Sources/Basecamp/BasecampError.swift @@ -50,6 +50,13 @@ public enum BasecampError: Error, Sendable, LocalizedError { message: String, httpStatus: Int, hint: String?, requestId: String?, fieldErrors: [String: [String]]?) + /// An account limit blocks the request (HTTP 507) — file storage + /// exhausted, or a webhook ceiling reached. Never retryable: no amount of + /// backoff frees storage or raises a plan limit. Distinct from `.api` for + /// exactly that reason, since a 507 would otherwise land there as a + /// retryable 5xx. + case limitExceeded(message: String, hint: String?, requestId: String?) + /// Multiple matches found for a name or identifier. case ambiguous(resource: String, matches: [String], hint: String?) @@ -64,6 +71,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .rateLimit: true case .network: true case .api(_, let status, _, _): status.map { $0 >= 500 } ?? false + case .limitExceeded: false case .ambiguous: false default: false } @@ -78,6 +86,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .rateLimit: 429 case .validation(_, let status, _, _, _): status case .api(_, let status, _, _): status + case .limitExceeded: 507 case .ambiguous: nil case .network: nil case .usage: nil @@ -96,6 +105,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .api: 7 case .ambiguous: 8 case .validation: 9 + case .limitExceeded: 10 } } @@ -108,6 +118,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .rateLimit(_, _, let hint, _): hint case .network: "Check your network connection" case .api(_, _, let hint, _): hint + case .limitExceeded(_, let hint, _): hint case .ambiguous(_, _, let hint): hint case .validation(_, _, let hint, _, _): hint case .usage(_, let hint): hint @@ -123,6 +134,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .rateLimit(let msg, _, _, _): msg case .network(let msg, _): msg case .api(let msg, _, _, _): msg + case .limitExceeded(let msg, _, _): msg case .ambiguous(let resource, _, _): "Ambiguous \(resource)" case .validation(let msg, _, _, _, _): msg case .usage(let msg, _): msg @@ -137,6 +149,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .notFound(_, _, let id): id case .rateLimit(_, _, _, let id): id case .api(_, _, _, let id): id + case .limitExceeded(_, _, let id): id case .ambiguous: nil case .validation(_, _, _, let id, _): id case .network: nil @@ -209,6 +222,11 @@ public enum BasecampError: Error, Sendable, LocalizedError { message: validationMessage, httpStatus: status, hint: hint, requestId: requestId, fieldErrors: fieldErrors ) + case 507: + // A 5xx status carrying a client fact: the account is out of + // storage, or at its webhook ceiling. Decided before the default + // arm, which would make it a retryable .api. + return .limitExceeded(message: message, hint: hint, requestId: requestId) default: return .api( message: message, httpStatus: status, diff --git a/swift/Sources/Basecamp/Generated/Metadata.swift b/swift/Sources/Basecamp/Generated/Metadata.swift index 3cfcfc60ab..4418087745 100644 --- a/swift/Sources/Basecamp/Generated/Metadata.swift +++ b/swift/Sources/Basecamp/Generated/Metadata.swift @@ -38,6 +38,7 @@ enum Metadata { "CreateTodosetTodo": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateTool": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateUpload": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), + "CreateUploadVersion": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateVault": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateWebhook": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateWormhole": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), diff --git a/swift/Sources/Basecamp/Generated/Models/CreateUploadVersionRequest.swift b/swift/Sources/Basecamp/Generated/Models/CreateUploadVersionRequest.swift new file mode 100644 index 0000000000..7c10429800 --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/CreateUploadVersionRequest.swift @@ -0,0 +1,24 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct CreateUploadVersionRequest: Codable, Sendable { + public let attachableSgid: String + public var baseName: String? + public var description: String? + public var notify: String? + public var subscriptions: [Int]? + + public init( + attachableSgid: String, + baseName: String? = nil, + description: String? = nil, + notify: String? = nil, + subscriptions: [Int]? = nil + ) { + self.attachableSgid = attachableSgid + self.baseName = baseName + self.description = description + self.notify = notify + self.subscriptions = subscriptions + } +} diff --git a/swift/Sources/Basecamp/Generated/Models/UploadVersion.swift b/swift/Sources/Basecamp/Generated/Models/UploadVersion.swift new file mode 100644 index 0000000000..fc2a61dbee --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/UploadVersion.swift @@ -0,0 +1,36 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct UploadVersion: Codable, Sendable { + public let action: String + public let createdAt: String + public let creator: Person + public let id: Int + public let recordingId: Int + public var boostsCount: Int32? + public var boostsUrl: String? + public var details: EventDetails? + public var upload: UploadVersionFile? + + public init( + action: String, + createdAt: String, + creator: Person, + id: Int, + recordingId: Int, + boostsCount: Int32? = nil, + boostsUrl: String? = nil, + details: EventDetails? = nil, + upload: UploadVersionFile? = nil + ) { + self.action = action + self.createdAt = createdAt + self.creator = creator + self.id = id + self.recordingId = recordingId + self.boostsCount = boostsCount + self.boostsUrl = boostsUrl + self.details = details + self.upload = upload + } +} diff --git a/swift/Sources/Basecamp/Generated/Models/UploadVersionFile.swift b/swift/Sources/Basecamp/Generated/Models/UploadVersionFile.swift new file mode 100644 index 0000000000..8b8c8d53f2 --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/UploadVersionFile.swift @@ -0,0 +1,27 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct UploadVersionFile: Codable, Sendable { + public let appDownloadUrl: String + public let current: Bool + public let downloadUrl: String + public let filename: String + public var byteSize: Int? + public var contentType: String? + + public init( + appDownloadUrl: String, + current: Bool, + downloadUrl: String, + filename: String, + byteSize: Int? = nil, + contentType: String? = nil + ) { + self.appDownloadUrl = appDownloadUrl + self.current = current + self.downloadUrl = downloadUrl + self.filename = filename + self.byteSize = byteSize + self.contentType = contentType + } +} diff --git a/swift/Sources/Basecamp/Generated/Services/UploadsService.swift b/swift/Sources/Basecamp/Generated/Services/UploadsService.swift index d012767e65..80f65248fe 100644 --- a/swift/Sources/Basecamp/Generated/Services/UploadsService.swift +++ b/swift/Sources/Basecamp/Generated/Services/UploadsService.swift @@ -32,6 +32,16 @@ public final class UploadsService: BaseService, @unchecked Sendable { ) } + public func createVersion(uploadId: Int, req: CreateUploadVersionRequest) async throws -> Upload { + return try await request( + OperationInfo(service: "Uploads", operation: "CreateUploadVersion", resourceType: "upload_version", isMutation: true, resourceId: uploadId), + method: "POST", + path: "/uploads/\(uploadId)/versions.json", + body: req, + retryConfig: Metadata.retryConfig(for: "CreateUploadVersion") + ) + } + public func get(uploadId: Int) async throws -> Upload { return try await request( OperationInfo(service: "Uploads", operation: "GetUpload", resourceType: "upload", isMutation: false, resourceId: uploadId), @@ -41,7 +51,7 @@ public final class UploadsService: BaseService, @unchecked Sendable { ) } - public func listVersions(uploadId: Int, options: ListVersionsUploadOptions? = nil) async throws -> ListResult { + public func listVersions(uploadId: Int, options: ListVersionsUploadOptions? = nil) async throws -> ListResult { return try await requestPaginated( OperationInfo(service: "Uploads", operation: "ListUploadVersions", resourceType: "upload_version", isMutation: false, resourceId: uploadId), path: "/uploads/\(uploadId)/versions.json", diff --git a/swift/Sources/BasecampGenerator/MethodNaming.swift b/swift/Sources/BasecampGenerator/MethodNaming.swift index 5dfc4cb511..861fcf8bbd 100644 --- a/swift/Sources/BasecampGenerator/MethodNaming.swift +++ b/swift/Sources/BasecampGenerator/MethodNaming.swift @@ -124,6 +124,7 @@ let methodNameOverrides: [String: String] = [ "ListUploads": "list", "CreateUpload": "create", "ListUploadVersions": "listVersions", + "CreateUploadVersion": "createVersion", "GetMessage": "get", "UpdateMessage": "update", "CreateMessage": "create", diff --git a/swift/Sources/BasecampGenerator/ServiceGrouper.swift b/swift/Sources/BasecampGenerator/ServiceGrouper.swift index 88601489c8..f52c39b58a 100644 --- a/swift/Sources/BasecampGenerator/ServiceGrouper.swift +++ b/swift/Sources/BasecampGenerator/ServiceGrouper.swift @@ -48,7 +48,7 @@ let serviceSplits: [String: [String: [String]]] = [ ], "Files": [ "Attachments": ["CreateAttachment"], - "Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"], + "Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions", "CreateUploadVersion"], "Vaults": ["GetVault", "UpdateVault", "ListVaults", "CreateVault"], "Documents": ["GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"], "CloudFiles": ["GetCloudFile", "CreateCloudFile", "UpdateCloudFile"], diff --git a/typescript/README.md b/typescript/README.md index 27daa4e3e5..1cd8d29771 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -634,9 +634,10 @@ try { | `not_found` | 404 | 2 | Resource not found | | `rate_limit` | 429 | 5 | Rate limit exceeded (retryable) | | `network` | - | 6 | Network error (retryable) | -| `api_error` | 5xx | 7 | Server error | +| `api_error` | 500, 502, 503, 504, other 5xx | 7 | Server error | | `ambiguous` | - | 8 | Multiple matches found | | `validation` | 400, 422 | 9 | Invalid request data | +| `limit_exceeded` | 507 | 10 | Account limit reached (file storage, projects, webhooks) — never retryable | | `usage` | - | 1 | Configuration or argument error | ### Validation Errors diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts index 0924ad046a..f96d7e5733 100644 --- a/typescript/scripts/generate-services.ts +++ b/typescript/scripts/generate-services.ts @@ -208,7 +208,7 @@ const SERVICE_SPLITS: Record> = { }, Files: { Attachments: ["CreateAttachment"], - Uploads: ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"], + Uploads: ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions", "CreateUploadVersion"], Vaults: ["GetVault", "UpdateVault", "ListVaults", "CreateVault"], Documents: ["GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"], CloudFiles: ["GetCloudFile", "CreateCloudFile", "UpdateCloudFile"], @@ -416,6 +416,7 @@ const METHOD_NAME_OVERRIDES: Record = { ListUploads: "list", CreateUpload: "create", ListUploadVersions: "listVersions", + CreateUploadVersion: "createVersion", GetMessage: "get", UpdateMessage: "update", CreateMessage: "create", @@ -474,6 +475,7 @@ const TYPE_ALIASES: Record Vault: ["Vault", "entity"], Document: ["Document", "entity"], Upload: ["Upload", "entity"], + UploadVersion: ["UploadVersion", "entity"], CloudFile: ["CloudFile", "entity"], GoogleDocument: ["GoogleDocument", "entity"], Schedule: ["Schedule", "entity"], diff --git a/typescript/src/errors.ts b/typescript/src/errors.ts index f02585ddfd..6d974b2c96 100644 --- a/typescript/src/errors.ts +++ b/typescript/src/errors.ts @@ -51,7 +51,8 @@ export type ErrorCode = | "ambiguous" | "network" | "api_error" - | "usage"; + | "usage" + | "limit_exceeded"; /** * Options for creating a BasecampError. @@ -87,6 +88,7 @@ const EXIT_CODES: Record = { api_error: 7, // API error ambiguous: 8, // Multiple matches found validation: 9, // Validation error (HTTP 400/422) + limit_exceeded: 10, // Account limit reached (HTTP 507) }; /** @@ -363,6 +365,16 @@ export function errorFromParsedBody( case 400: case 422: return new BasecampError("validation", message, { httpStatus, hint, requestId, fieldErrors }); + case 507: + // A 5xx status carrying a client fact: the account is out of storage, or + // at its webhook ceiling. Retrying cannot satisfy it, so this must be + // decided before the 5xx catch-all below. + return new BasecampError("limit_exceeded", message, { + httpStatus, + retryable: false, + hint, + requestId, + }); default: // 5xx errors are retryable const retryable = httpStatus >= 500 && httpStatus < 600; diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 8b990a6430..74f766e1aa 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-06T00:45:32.690Z", + "generated": "2026-08-07T11:11:59.965Z", "operations": { "GetAccount": { "retry": { @@ -3185,6 +3185,17 @@ const metadata: MetadataOutput = { "maxPageSize": 50 } }, + "CreateUploadVersion": { + "retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + }, "GetVault": { "retry": { "maxAttempts": 3, diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index eee27ac01e..b56c44aef5 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -417,6 +417,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -6571,6 +6581,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -22663,6 +22683,125 @@ 503 ] } + }, + "post": { + "description": "Replace an upload's file with a new version\n\nThe recording keeps its id, its URL and its comments; the previous file becomes a\npast version. Use this instead of CreateUpload when publishing a new release of the\nsame file, so its published link keeps working.", + "operationId": "CreateUploadVersion", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadVersionRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "uploadId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "CreateUploadVersion 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadVersionResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Files" + ], + "x-basecamp-retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } } }, "/vaults/{vaultId}": { @@ -23221,6 +23360,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -26145,6 +26294,40 @@ "CreateUploadResponseContent": { "$ref": "#/components/schemas/Upload" }, + "CreateUploadVersionRequestContent": { + "type": "object", + "properties": { + "attachable_sgid": { + "type": "string" + }, + "base_name": { + "type": "string", + "description": "Omit to keep the uploaded file's own name. Sending \"\" also keeps it." + }, + "description": { + "type": "string", + "description": "Presence-aware: omit to carry the previous version's description forward,\nsend \"\" to clear it, send a value to set it." + }, + "notify": { + "type": "string", + "description": "Who to notify: \"default\", \"everyone\", or \"custom\" (the people in subscriptions).\n\nOmit both this and subscriptions to notify nobody. A subscriptions array sent\nwithout notify is read as \"custom\"." + }, + "subscriptions": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "description": "People to notify about the replacement and subscribe to the upload." + } + }, + "required": [ + "attachable_sgid" + ] + }, + "CreateUploadVersionResponseContent": { + "$ref": "#/components/schemas/Upload" + }, "CreateVaultRequestContent": { "type": "object", "properties": { @@ -28359,7 +28542,7 @@ "ListUploadVersionsResponseContent": { "type": "array", "items": { - "$ref": "#/components/schemas/Upload" + "$ref": "#/components/schemas/UploadVersion" } }, "ListUploadsResponseContent": { @@ -30787,6 +30970,21 @@ "SetClientVisibilityResponseContent": { "$ref": "#/components/schemas/Recording" }, + "StorageLimitErrorResponseContent": { + "type": "object", + "description": "The account has reached its file storage limit.\n\nRaised by ResourceLimits#ensure_account_can_upload_files ahead of any operation\nthat stores new bytes. No retry can satisfy it: the account needs more storage,\nso this maps to `limit_exceeded` rather than a retryable server error.", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, "SubscribeResponseContent": { "$ref": "#/components/schemas/Subscription" }, @@ -32793,6 +32991,87 @@ "visible_to_clients" ] }, + "UploadVersion": { + "type": "object", + "description": "A version event for an upload, from GET /uploads/{id}/versions.json.\n\n`action` is one of `created`, `active` (the upload's publication) or `blob_changed`\n(a file replacement). To list the file's PAST versions, select entries that carry\nan `upload` whose `current` is false — the original file arrives as `created` or\n`active`, never `blob_changed`, so filtering on that action drops the original\nand keeps the current file instead. This is an Event plus the file it recorded, rendered by its own\npartial rather than the shared event one, so upload fields don't leak onto todo,\nmessage and card events.", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "recording_id": { + "type": "integer", + "format": "int64" + }, + "action": { + "type": "string" + }, + "details": { + "$ref": "#/components/schemas/EventDetails" + }, + "created_at": { + "type": "string", + "x-go-type": "time.Time", + "x-go-type-import": { + "path": "time" + } + }, + "creator": { + "$ref": "#/components/schemas/Person" + }, + "boosts_count": { + "type": "integer", + "format": "int32" + }, + "boosts_url": { + "type": "string" + }, + "upload": { + "$ref": "#/components/schemas/UploadVersionFile" + } + }, + "required": [ + "action", + "created_at", + "creator", + "id", + "recording_id" + ] + }, + "UploadVersionFile": { + "type": "object", + "description": "The file a version event recorded — a reduced projection, not an Upload.", + "properties": { + "content_type": { + "type": "string" + }, + "byte_size": { + "type": "integer", + "format": "int64" + }, + "filename": { + "type": "string" + }, + "download_url": { + "type": "string", + "description": "Fetches THIS version's bytes. The upload's own download_url always serves the\nlatest, which is the whole point of the feature.", + "x-basecamp-auth-routable-url": {} + }, + "app_download_url": { + "type": "string" + }, + "current": { + "type": "boolean", + "description": "True for the newest version *event*, and for exactly one element of any\nnon-empty response. The renderer computes it positionally — `event ==\n@events.first` over a reverse-chronological list — so it is a property of\nordering, not of what the upload currently points at, and it cannot be\nzero or plural.\n\nThat distinction is the whole caveat: `current` does NOT mean \"this is the\nfile the upload's own download_url serves\". A metadata-only PUT swaps in a\nrecordable carrying the same blob and emits no event, so afterwards no\nevent references the upload's current recordable — and this flag still\nmarks exactly one element, the newest event. bc3 pins that case by name in\n\"exactly one version is current after a metadata-only update\"." + } + }, + "required": [ + "app_download_url", + "current", + "download_url", + "filename" + ] + }, "ValidationErrorResponseContent": { "type": "object", "properties": { diff --git a/typescript/src/generated/path-mapping.ts b/typescript/src/generated/path-mapping.ts index 815be35016..94c32c0180 100644 --- a/typescript/src/generated/path-mapping.ts +++ b/typescript/src/generated/path-mapping.ts @@ -193,6 +193,7 @@ export const PATH_TO_OPERATION: Record = { "GET:/{accountId}/uploads/{uploadId}": "GetUpload", "PUT:/{accountId}/uploads/{uploadId}": "UpdateUpload", "GET:/{accountId}/uploads/{uploadId}/versions.json": "ListUploadVersions", + "POST:/{accountId}/uploads/{uploadId}/versions.json": "CreateUploadVersion", "GET:/{accountId}/vaults/{vaultId}": "GetVault", "PUT:/{accountId}/vaults/{vaultId}": "UpdateVault", "GET:/{accountId}/vaults/{vaultId}/documents.json": "ListDocuments", diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 979f049db1..460b4aaaf0 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -3307,7 +3307,14 @@ export interface paths { */ get: operations["ListUploadVersions"]; put?: never; - post?: never; + /** + * @description Replace an upload's file with a new version + * + * The recording keeps its id, its URL and its comments; the previous file becomes a + * past version. Use this instead of CreateUpload when publishing a new release of the + * same file, so its published link keeps working. + */ + post: operations["CreateUploadVersion"]; delete?: never; options?: never; head?: never; @@ -4214,6 +4221,26 @@ export interface components { visible_to_clients?: boolean; }; CreateUploadResponseContent: components["schemas"]["Upload"]; + CreateUploadVersionRequestContent: { + attachable_sgid: string; + /** @description Omit to keep the uploaded file's own name. Sending "" also keeps it. */ + base_name?: string; + /** + * @description Presence-aware: omit to carry the previous version's description forward, + * send "" to clear it, send a value to set it. + */ + description?: string; + /** + * @description Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + * + * Omit both this and subscriptions to notify nobody. A subscriptions array sent + * without notify is read as "custom". + */ + notify?: string; + /** @description People to notify about the replacement and subscribe to the upload. */ + subscriptions?: number[]; + }; + CreateUploadVersionResponseContent: components["schemas"]["Upload"]; CreateVaultRequestContent: { title: string; }; @@ -4915,7 +4942,7 @@ export interface components { ListTodolistGroupsResponseContent: components["schemas"]["Todolist"][]; ListTodolistsResponseContent: components["schemas"]["Todolist"][]; ListTodosResponseContent: components["schemas"]["Todo"][]; - ListUploadVersionsResponseContent: components["schemas"]["Upload"][]; + ListUploadVersionsResponseContent: components["schemas"]["UploadVersion"][]; ListUploadsResponseContent: components["schemas"]["Upload"][]; ListVaultsResponseContent: components["schemas"]["Vault"][]; ListWebhooksResponseContent: components["schemas"]["Webhook"][]; @@ -5896,6 +5923,17 @@ export interface components { visible_to_clients: boolean; }; SetClientVisibilityResponseContent: components["schemas"]["Recording"]; + /** + * @description The account has reached its file storage limit. + * + * Raised by ResourceLimits#ensure_account_can_upload_files ahead of any operation + * that stores new bytes. No retry can satisfy it: the account needs more storage, + * so this maps to `limit_exceeded` rather than a retryable server error. + */ + StorageLimitErrorResponseContent: { + error: string; + message?: string; + }; SubscribeResponseContent: components["schemas"]["Subscription"]; Subscription: { subscribed: boolean; @@ -6796,6 +6834,59 @@ export interface components { boosts_count?: number; boosts_url?: string; }; + /** + * @description A version event for an upload, from GET /uploads/{id}/versions.json. + * + * `action` is one of `created`, `active` (the upload's publication) or `blob_changed` + * (a file replacement). To list the file's PAST versions, select entries that carry + * an `upload` whose `current` is false — the original file arrives as `created` or + * `active`, never `blob_changed`, so filtering on that action drops the original + * and keeps the current file instead. This is an Event plus the file it recorded, rendered by its own + * partial rather than the shared event one, so upload fields don't leak onto todo, + * message and card events. + */ + UploadVersion: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + recording_id: number; + action: string; + details?: components["schemas"]["EventDetails"]; + created_at: string; + creator: components["schemas"]["Person"]; + /** Format: int32 */ + boosts_count?: number; + boosts_url?: string; + upload?: components["schemas"]["UploadVersionFile"]; + }; + /** @description The file a version event recorded — a reduced projection, not an Upload. */ + UploadVersionFile: { + content_type?: string; + /** Format: int64 */ + byte_size?: number; + filename: string; + /** + * @description Fetches THIS version's bytes. The upload's own download_url always serves the + * latest, which is the whole point of the feature. + */ + download_url: string; + app_download_url: string; + /** + * @description True for the newest version *event*, and for exactly one element of any + * non-empty response. The renderer computes it positionally — `event == + * @events.first` over a reverse-chronological list — so it is a property of + * ordering, not of what the upload currently points at, and it cannot be + * zero or plural. + * + * That distinction is the whole caveat: `current` does NOT mean "this is the + * file the upload's own download_url serves". A metadata-only PUT swaps in a + * recordable carrying the same blob and emits no event, so afterwards no + * event references the upload's current recordable — and this flag still + * marks exactly one element, the newest event. bc3 pins that case by name in + * "exactly one version is current after a metadata-only update". + */ + current: boolean; + }; ValidationErrorResponseContent: { error: string; message?: string; @@ -7259,6 +7350,15 @@ export interface operations { "application/json": components["schemas"]["InternalServerErrorResponseContent"]; }; }; + /** @description StorageLimitError 507 response */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageLimitErrorResponseContent"]; + }; + }; }; }; GetBoost: { @@ -11362,6 +11462,15 @@ export interface operations { "application/json": components["schemas"]["InternalServerErrorResponseContent"]; }; }; + /** @description StorageLimitError 507 response */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageLimitErrorResponseContent"]; + }; + }; }; }; GetEverythingCheckins: { @@ -22280,6 +22389,95 @@ export interface operations { }; }; }; + CreateUploadVersion: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateUploadVersionRequestContent"]; + }; + }; + responses: { + /** @description CreateUploadVersion 201 response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateUploadVersionResponseContent"]; + }; + }; + /** @description UnauthorizedError 401 response */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedErrorResponseContent"]; + }; + }; + /** @description ForbiddenError 403 response */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ForbiddenErrorResponseContent"]; + }; + }; + /** @description NotFoundError 404 response */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NotFoundErrorResponseContent"]; + }; + }; + /** @description ValidationError 422 response */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidationErrorResponseContent"]; + }; + }; + /** @description RateLimitError 429 response */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RateLimitErrorResponseContent"]; + }; + }; + /** @description InternalServerError 500 response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalServerErrorResponseContent"]; + }; + }; + /** @description StorageLimitError 507 response */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageLimitErrorResponseContent"]; + }; + }; + }; + }; GetVault: { parameters: { query?: never; @@ -22671,6 +22869,15 @@ export interface operations { "application/json": components["schemas"]["InternalServerErrorResponseContent"]; }; }; + /** @description StorageLimitError 507 response */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageLimitErrorResponseContent"]; + }; + }; }; }; ListVaults: { diff --git a/typescript/src/generated/services/uploads.ts b/typescript/src/generated/services/uploads.ts index 6324a47454..1e28b9c961 100644 --- a/typescript/src/generated/services/uploads.ts +++ b/typescript/src/generated/services/uploads.ts @@ -16,6 +16,8 @@ import { Errors } from "../../errors.js"; /** Upload entity from the Basecamp API. */ export type Upload = components["schemas"]["Upload"]; +/** UploadVersion entity from the Basecamp API. */ +export type UploadVersion = components["schemas"]["UploadVersion"]; /** * Request parameters for update. @@ -33,6 +35,26 @@ export interface UpdateUploadRequest { export interface ListVersionsUploadOptions extends PaginationOptions { } +/** + * Request parameters for createVersion. + */ +export interface CreateVersionUploadRequest { + /** Attachable sgid */ + attachableSgid: string; + /** Omit to keep the uploaded file's own name. Sending "" also keeps it. */ + baseName?: string; + /** Presence-aware: omit to carry the previous version's description forward, +send "" to clear it, send a value to set it. */ + description?: string; + /** Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + +Omit both this and subscriptions to notify nobody. A subscriptions array sent +without notify is read as "custom". */ + notify?: string; + /** People to notify about the replacement and subscribe to the upload. */ + subscriptions?: number[]; +} + /** * Options for list. */ @@ -136,14 +158,14 @@ export class UploadsService extends BaseService { * List versions of an upload * @param uploadId - The upload ID * @param options - Optional query parameters - * @returns All Upload across all pages, with .meta.totalCount + * @returns All UploadVersion across all pages, with .meta.totalCount * * @example * ```ts * const result = await client.uploads.listVersions(123); * ``` */ - async listVersions(uploadId: number, options?: ListVersionsUploadOptions): Promise> { + async listVersions(uploadId: number, options?: ListVersionsUploadOptions): Promise> { return this.requestPaginated( { service: "Uploads", @@ -162,6 +184,47 @@ export class UploadsService extends BaseService { ); } + /** + * Replace an upload's file with a new version + * @param uploadId - The upload ID + * @param req - Upload_version creation parameters + * @returns The Upload + * @throws {BasecampError} If required fields are missing or invalid + * + * @example + * ```ts + * const result = await client.uploads.createVersion(123, { attachableSgid: "example" }); + * ``` + */ + async createVersion(uploadId: number, req: CreateVersionUploadRequest): Promise { + if (!req.attachableSgid) { + throw Errors.validation("Attachable sgid is required"); + } + const response = await this.request( + { + service: "Uploads", + operation: "CreateUploadVersion", + resourceType: "upload_version", + isMutation: true, + resourceId: uploadId, + }, + () => + this.client.POST("/uploads/{uploadId}/versions.json", { + params: { + path: { uploadId }, + }, + body: { + attachable_sgid: req.attachableSgid, + base_name: req.baseName, + description: req.description, + notify: req.notify, + subscriptions: req.subscriptions, + }, + }) + ); + return response; + } + /** * List uploads in a vault * @param vaultId - The vault ID diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 4757278f84..bd16e2fdaa 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -385,8 +385,10 @@ export { export { UploadsService } from "./services/uploads-extensions.js"; export { type Upload, + type UploadVersion, type CreateUploadRequest, type UpdateUploadRequest, + type CreateVersionUploadRequest, } from "./generated/services/uploads.js"; // Schedule & Time services diff --git a/typescript/tests/services/projects.test.ts b/typescript/tests/services/projects.test.ts index 5b9c014698..7b6f9950fe 100644 --- a/typescript/tests/services/projects.test.ts +++ b/typescript/tests/services/projects.test.ts @@ -175,10 +175,10 @@ describe("ProjectsService", () => { await expect(client.projects.unarchive(42)).resolves.toBeUndefined(); }); - // The only behavioural evidence for ProjectLimitError. No SDK gives 507 a - // named class, so it surfaces as a generic api_error carrying the status - // (SPEC.md §7). - it("should surface the 507 project limit as a generic api_error", async () => { + // The only behavioural evidence for ProjectLimitError. A 507 is an account + // limit, so it maps to limit_exceeded and is NOT retryable — no backoff + // frees a project slot (SPEC.md §6, step 11). + it("should surface the 507 project limit as a non-retryable limit_exceeded", async () => { server.use( http.put(`${BASE_URL}/projects/42/status/active.json`, () => { return HttpResponse.json( @@ -189,8 +189,9 @@ describe("ProjectsService", () => { ); await expect(client.projects.unarchive(42)).rejects.toMatchObject({ - code: "api_error", + code: "limit_exceeded", httpStatus: 507, + retryable: false, }); }); }); diff --git a/typescript/tests/services/uploads.test.ts b/typescript/tests/services/uploads.test.ts index b4bb4b731f..edb9ece23c 100644 --- a/typescript/tests/services/uploads.test.ts +++ b/typescript/tests/services/uploads.test.ts @@ -10,6 +10,8 @@ import { http, HttpResponse } from "msw"; import { server } from "../setup.js"; import { BasecampError } from "../../src/errors.js"; import { createBasecampClient } from "../../src/client.js"; +// Sourced from the shared, coverage-guarded fixture (spec/fixtures/manifest.yaml) +import versionsFixture from "../../../spec/fixtures/uploads/versions.json"; const BASE_URL = "https://3.basecampapi.com/12345"; @@ -255,37 +257,82 @@ describe("UploadsService", () => { }); describe("listVersions", () => { - it("should return upload versions", async () => { - const uploads = [ - { - id: 7001, - filename: "file_v3.pdf", - created_at: "2024-03-15T10:00:00Z", - description_attachments: [], - }, - { - id: 7001, - filename: "file_v2.pdf", - created_at: "2024-02-10T10:00:00Z", - description_attachments: [], - }, - { - id: 7001, - filename: "file_v1.pdf", - created_at: "2024-01-05T10:00:00Z", - description_attachments: [], - }, - ]; - + // The endpoint returns EVENTS, not Uploads — the retype that closes #649. + it("should return upload versions carrying the file each one recorded", async () => { server.use( http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { - return HttpResponse.json(uploads); + return HttpResponse.json(versionsFixture); }), ); const result = await service.listVersions(7001); expect(result).toHaveLength(3); + expect(result[0].action).toBe("blob_changed"); + expect(result[0].upload?.filename).toBe("company-logo.png"); + expect(result[0].upload?.content_type).toBe("image/png"); + expect(result[0].upload?.byte_size).toBe(184829); + }); + + it("marks exactly one version current", async () => { + server.use( + http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { + return HttpResponse.json(versionsFixture); + }), + ); + + const result = await service.listVersions(7001); + + expect(result.filter((v) => v.upload?.current)).toHaveLength(1); + expect(result[0].upload?.current).toBe(true); + }); + + // The per-version URL serves THAT version's bytes; the upload's own always + // serves the latest, which is the whole point of the feature. + it("gives each version its own download URL", async () => { + server.use( + http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { + return HttpResponse.json(versionsFixture); + }), + ); + + const result = await service.listVersions(7001); + + expect(result[0].upload?.download_url).not.toBe(result[1].upload?.download_url); + expect(result[0].upload?.download_url).toContain("/versions/1069479501/"); + }); + + // Selecting past versions by action is the tempting shortcut and the wrong + // one: the original file arrives as `created`/`active`, so `blob_changed` + // drops it and keeps the CURRENT file instead. Select on current === false. + it("selects past versions by current, not by action", async () => { + server.use( + http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { + return HttpResponse.json(versionsFixture); + }), + ); + + const result = await service.listVersions(7001); + + const past = result.filter((v) => v.upload && v.upload.current === false); + expect(past.map((v) => v.action)).toEqual(["active"]); + + // The shortcut returns the current file and none of the history. + const byAction = result.filter((v) => v.action === "blob_changed"); + expect(byAction.map((v) => v.upload?.current)).toEqual([true]); + }); + + it("tolerates a version whose recordable no longer resolves", async () => { + server.use( + http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { + return HttpResponse.json(versionsFixture); + }), + ); + + const result = await service.listVersions(7001); + + expect(result[2].action).toBe("created"); + expect(result[2].upload).toBeUndefined(); }); it("should return empty array when no versions", async () => { @@ -301,6 +348,113 @@ describe("UploadsService", () => { }); }); + describe("createVersion", () => { + it("posts the attachable sgid and returns the updated upload", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json( + { id: 7001, filename: "company-logo.png", description_attachments: [] }, + { status: 201 }, + ); + }), + ); + + const result = await service.createVersion(7001, { attachableSgid: "sgid-abc" }); + + expect(result.id).toBe(7001); + expect(capturedBody?.attachable_sgid).toBe("sgid-abc"); + }); + + // Presence-aware: omitted carries the previous description forward, "" + // clears. Both spellings have to be distinguishable on the wire. + it("omits description when it is not addressed", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ id: 7001, description_attachments: [] }, { status: 201 }); + }), + ); + + await service.createVersion(7001, { attachableSgid: "sgid-abc" }); + + expect(capturedBody).not.toHaveProperty("description"); + expect(capturedBody).not.toHaveProperty("base_name"); + }); + + it("sends an explicit empty description to clear it", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ id: 7001, description_attachments: [] }, { status: 201 }); + }), + ); + + await service.createVersion(7001, { attachableSgid: "sgid-abc", description: "" }); + + expect(capturedBody).toHaveProperty("description"); + expect(capturedBody?.description).toBe(""); + }); + + it("passes notify and subscriptions through", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ id: 7001, description_attachments: [] }, { status: 201 }); + }), + ); + + await service.createVersion(7001, { + attachableSgid: "sgid-abc", + notify: "custom", + subscriptions: [1049715915, 1049715916], + }); + + expect(capturedBody?.notify).toBe("custom"); + expect(capturedBody?.subscriptions).toEqual([1049715915, 1049715916]); + }); + + it("rejects a missing attachable sgid before hitting the wire", async () => { + await expect(service.createVersion(7001, { attachableSgid: "" })).rejects.toThrow( + BasecampError, + ); + }); + + // A replacement copies bytes into a new blob and keeps the old one, so it + // always grows recorded storage. 507 is a limit, never a transient failure. + it("reports a storage limit as limit_exceeded and does not retry", async () => { + let requestCount = 0; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, () => { + requestCount += 1; + return HttpResponse.json( + { error: "The storage limit for this account has been reached." }, + { status: 507 }, + ); + }), + ); + + const error = await service + .createVersion(7001, { attachableSgid: "sgid-abc" }) + .catch((e) => e as BasecampError); + + expect(error).toBeInstanceOf(BasecampError); + expect(error.code).toBe("limit_exceeded"); + expect(error.retryable).toBe(false); + expect(error.message).toContain("storage limit"); + expect(requestCount).toBe(1); + }); + }); + // Note: trash() is on RecordingsService, not UploadsService (spec-conformant) // Use client.recordings.trash(uploadId) instead