Add the Embeds service: create and get an embed
Server: https://github.com/basecamp/bc3/pull/13962 (branch api-create-embeds)
CLI: https://github.com/basecamp/basecamp-cli/pull/927
The operations
A new Embeds service in all seven SDKs. An agent can now create an embed for a URL, such as a YouTube video or a Dash chart, and put it in rich text as <bc-attachment sgid="…"></bc-attachment>.
CreateEmbedcoversPOST /{accountId}/embeds.jsonwith{"url": …}. It is find-or-create and answers200either way. A new embed comes back withstatus: "pending"; a URL Basecamp already has an embed for comes back with that embed in whatever status it has. (bc3 kept200for new embeds so the editor's own request is answered exactly as before.)GetEmbedcoversGET /{accountId}/embeds/{sgid}. An sgid that names no embed answers404.Embedalways carriesurl,status(pending,activeornot_found) andsgid. An active embed addstype,content_type,image_url(nullable) and, when the provider gave HTML, sanitizedcontent. Those four keys are absent, not null, while the embed is pending or not found.- A URL no oEmbed provider matches answers
422 {"error": "Basecamp can't embed this URL. Link to it instead."}and maps tovalidationwith the server's message. Past 100 new embeds an hour, creating another answers429 {"error": "Too many new embeds. Try again later."}with noRetry-Afterand maps torate_limit. Asking for an embed Basecamp already has still works. CreateEmbedis@basecampIdempotent(natural: true): a repeated create finds the embed the first one made and creates nothing, so a retry after a lost response or a503is safe. It declaresretryOn: [503], though. Its 429 is a verdict for the rest of the hour, not throttling, so the 429 surfaces on the first attempt. This followsUpdateProjectClientAccess's seat-limit 429. SPEC §7 Gate 3, SECURITY.md and that operation's Smithy doc now name both exceptions. It also matters for basecamp-cli: its resilience hook sets a CLI-wide block on a headerless 429 from an operation that retries on 429, and hitting the embed limit must not block the post the embed was for.
Usage: create the embed, then get it about once a second while its status is pending. When it is active, put the <bc-attachment> in the rich text. When it is not_found, keep the link.
Go:
func (s *EmbedsService) Create(ctx context.Context, url string) (*Embed, error)
func (s *EmbedsService) Get(ctx context.Context, sgid string) (*Embed, error)
client.Embeds() returns the service. Its hooks see OperationInfo{Service: "Embeds", Operation: "CreateEmbed"/"GetEmbed"}, the full operationId SPEC §12 calls for, so generated.GetOperationRetryOn(op.Operation) finds the operation's policy. Embed has plain string fields, with omitempty on the four optional ones, the way Ping.ImageURL does it. An empty url or sgid is a usage error, raised before any request. A 422 or 429 comes back as *basecamp.Error with HTTPStatus, Code (validation / rate_limit) and the server's error text in Message.
Escaping string path parameters
An sgid is a Rails signed global id. Its strict base64 can contain / (about 45% of embed ids in a sample I generated), and the ?expires_in in the payload adds more. Go (url.PathEscape), TypeScript (openapi-fetch) and Rust (Route::fill) already percent-encoded path parameters. Ruby, Python, Kotlin and Swift interpolated them raw, so a GET for such an sgid would have gone to /embeds/<part>/<part>. Those four generators now send every string-typed path parameter through a path_segment / _path_segment / pathSegment helper on the hand-written base service: CGI.escapeURIComponent, urllib.parse.quote(safe=""), Ktor's encodeURLPathPart, or an unreserved-only CharacterSet. The one other string path parameter, GetScheduleEntryOccurrence's date, is regenerated through the helper too. A YYYY-MM-DD date encodes to itself, so its wire form is unchanged.
The URL routers had the opposite gap. Go's Router.MatchAPI and Rust's Route::recognize returned a captured segment still percent-encoded, so a matched /embeds/a%2Fb.json gave sgid a%2Fb, which Get then escaped again. Both now percent-decode each capture after matching. The whole path is never decoded, so %2F stays inside its segment, and + stays literal. Round-trip tests cover it in both languages.
Provenance
bc3#13962 is open and unmerged, so spec/api-provenance.json stays where it is. Nothing is pinned to a branch commit. As with Pings ahead of bc3 #13943, the two routes are carried by temporary sdk_routes_absent_from_bc3_docs entries in spec/bc3-route-allowlist.yml, which cite the branch's routes, views and tests. spec/api-gaps/embeds.md registers the contract as absorbed-in-sdk. The repin past #13962 will find the entries stale, and they get deleted then. Until bc3#13962 deploys, the production API host answers create with 404 and get with 500.
Tests
- Unit tests in all seven SDKs (Go, TypeScript, Ruby, Python, Rust
tests/embeds.rsplusservices.rs, KotlinEmbedsServiceTest, SwiftEmbedsServiceTests). They cover a create returning a new pending embed and one returning an existing active embed, the url-only body, absent and null optional fields, a 422 with its message, a 404, and the sgid sent as one percent-encoded segment. A 429 maps torate_limitin every SDK's tests, and Go, TypeScript, Python, Kotlin and Rust also show it arrives on the first attempt with retry enabled. Go also checks the empty-argument usage errors, that a 503 is retried, thatGetOperationRetryOn("CreateEmbed")excludes 429, and that hooks seeCreateEmbed/GetEmbed. I mutation-checked the escape tests in Ruby and Python: with escaping removed they fail. - Shared fixtures
spec/fixtures/embeds/{pending,active}.json, registered in the fixture manifest.Embedis a covered schema. conformance/tests/embeds.json(pending create, existing active create, 422 with message, 429 on one request, get, 404), withCreateEmbed/GetEmbeddispatch in all seven runners.- Gates: operation counts (285), service count (57), idempotency parity (99/236), Rust route-table counts, Go grouped-client inventory, route parity, api-gaps validation, and the SPEC §19 / Appendix D fixture rows.
scripts/check-operation-assignment-parityread Swift interpolations with\\\([^)]*\), which stops at the first), so\(pathSegment(sgid))read as an unknown path. It now allows one level of nested parentheses. Its self-test's synthetic Swift renderer now emitspathSegment(…)for the string path params openapi.json declares, the way the generator does, so the positive control fails under the old regex.
make check ends "All checks passed" locally on Linux. One local caveat: lint-actions ran with actionlint's shellcheck integration off, because the local shellcheck (0.9.0) flags an SC2002 style nit in .github/workflows/security.yml, a file this PR doesn't touch; zizmor ran normally. CI runs its own. Swift's build, tests and conformance runner are macOS-only and are skipped here (the Swift SDK does not build on Linux: CFAbsoluteTime), so the Swift test and runner dispatch rest on CI. The Swift generator itself ran in a swift:6.2 container, and swift-check-drift passed.
Found, not changed: Ruby builds every 429 as RateLimitError with the fixed message "Rate limit exceeded" (its 403 does the same with "Access denied"), so the server's error text is not on the error there. SPEC §6 leaves 429 message parsing [static], so the conformance 429 case asserts code, status and a single request, not the message. The other SDKs' unit tests check the message.
The breaking label is there because API Compatibility counts methods added to the generated ClientInterface as incompatible, as it did on #1015 and #1016.
Not done: the comment ledger at the top of scripts/check-idempotency-parity gets no new paragraph for this +1/+2. I bumped the counts without adding comment lines. This PR adds no code comments.