From 6947813f474dcdd9a1a86e1190c33fc6562b2201 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 22:25:40 -0700 Subject: [PATCH] Absorb the external-links (doors) list surface; defer create/image/composite (bc3 #12375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bc3 #12375 documents the external-link (historically "door") resource. A pre-implementation spike scoped this to the cleanly-absorbable surface: Absorbed: - Make Door reachable through the EXISTING polymorphic ListRecordings query (GET /projects/recordings.json?type=Door) rather than a new operation: ListRecordings already carries a type query and Recording.url. Add Door to the RecordingType documented enum and optional position/description/service (DoorService struct) to the shared Recording projection. - Go wrapper: RecordingTypeDoor constant, clean DoorService type, door fields on Recording, mapper wiring. Runtime decode tests prove the full door shape (external url + service struct + description + position) decodes through the projection, and that a non-door recording leaves the door fields empty. - Get/rename/trash already reuse GetTool/UpdateTool/DeleteTool (no work). - type=Door ListRecordings canary entry (validates statically; live dormant). Deferred as residual gaps (entry is partial-coverage, not absorbed-in-sdk): - Create (POST .../dock/doors.json) returns 302 + empty body redirecting cross-origin. The spike found the six SDK transports handle this inconsistently — Go/TS/Kotlin/Swift follow the redirect; Python/Ruby do not but face empty-body decode — so modeling "return only the 302/empty" response needs a cross-cutting per-operation transport change, out of scope for an additive absorption. - image thumbnail is multipart-only; unmodeled. - The SPEC §18 create-and-discover composite depends on a shippable create. Additive only (Door enum + optional Recording fields); no operation change. --- .../runner/typescript/live-dispatch.ts | 26 ++++++ conformance/tests/live-my-surface.json | 14 ++++ go/pkg/basecamp/recordings.go | 40 ++++++++- go/pkg/basecamp/recordings_test.go | 81 +++++++++++++++++++ go/pkg/generated/client.gen.go | 50 ++++++++++-- .../sdk/generated/models/DoorService.kt | 20 +++++ .../sdk/generated/models/Recording.kt | 5 +- .../sdk/generated/services/recordings.kt | 2 +- openapi.json | 43 +++++++++- python/src/basecamp/generated/types.py | 11 +++ ruby/lib/basecamp/generated/metadata.json | 2 +- .../generated/services/recordings_service.rb | 2 +- ruby/lib/basecamp/generated/types.rb | 38 ++++++++- spec/api-gaps/README.md | 2 +- spec/api-gaps/external-links-doors.md | 66 +++++++++++---- spec/basecamp.smithy | 34 +++++++- .../Generated/Models/DoorService.swift | 10 +++ .../Basecamp/Generated/Models/Recording.swift | 9 +++ typescript/src/generated/metadata.ts | 2 +- .../src/generated/openapi-stripped.json | 43 +++++++++- typescript/src/generated/schema.d.ts | 36 ++++++++- .../src/generated/services/recordings.ts | 4 +- 22 files changed, 494 insertions(+), 46 deletions(-) create mode 100644 kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/DoorService.kt create mode 100644 swift/Sources/Basecamp/Generated/Models/DoorService.swift diff --git a/conformance/runner/typescript/live-dispatch.ts b/conformance/runner/typescript/live-dispatch.ts index 46c993066..102b9b457 100644 --- a/conformance/runner/typescript/live-dispatch.ts +++ b/conformance/runner/typescript/live-dispatch.ts @@ -81,6 +81,32 @@ export const LIVE_OPERATIONS: Record = { }, }, + ListRecordings: { + fixtures: [], + call: async (ctx) => { + // Backs the type=Door external-links canary; validates the door shape + // (external url + service + description) against the Recording schema. + const result = await ctx.client.recordings.list("Door"); + return { resolvedIds: {}, result }; + }, + }, + + GetProgressReport: { + fixtures: [], + call: async (ctx) => { + const result = await ctx.client.reports.progress(); + return { resolvedIds: {}, result }; + }, + }, + + GetBubbleUps: { + fixtures: [], + call: async (ctx) => { + const result = await ctx.client.myNotifications.bubbleUps(); + return { resolvedIds: {}, result }; + }, + }, + GetMyProfile: { fixtures: [], call: async (ctx) => { diff --git a/conformance/tests/live-my-surface.json b/conformance/tests/live-my-surface.json index 88696f0ab..6625fd6de 100644 --- a/conformance/tests/live-my-surface.json +++ b/conformance/tests/live-my-surface.json @@ -182,5 +182,19 @@ { "type": "liveCallSucceeds" }, { "type": "liveSchemaValidate" } ] + }, + { + "mode": "live", + "name": "ListRecordings type=Door decodes the external-link (door) shape", + "description": "DECODING coverage for the absorbed external-links/doors list surface (spec/api-gaps/external-links-doors.md, bc3 #12375). Drives the type=Door recordings query and validates each recording against the Recording schema, including the door-specific url/service/description/position fields. Live-dormant: validates statically until credentials are provisioned. Create/image/composite remain residual gaps (see the entry).", + "operation": "ListRecordings", + "method": "GET", + "path": "/projects/recordings.json", + "queryParams": { "type": "Door" }, + "tags": ["live", "read-only", "bc5-additive"], + "liveAssertions": [ + { "type": "liveCallSucceeds" }, + { "type": "liveSchemaValidate" } + ] } ] diff --git a/go/pkg/basecamp/recordings.go b/go/pkg/basecamp/recordings.go index 788e905bb..2bc802848 100644 --- a/go/pkg/basecamp/recordings.go +++ b/go/pkg/basecamp/recordings.go @@ -17,6 +17,7 @@ type RecordingType string const ( RecordingTypeComment RecordingType = "Comment" RecordingTypeDocument RecordingType = "Document" + RecordingTypeDoor RecordingType = "Door" RecordingTypeKanbanCard RecordingType = "Kanban::Card" RecordingTypeKanbanStep RecordingType = "Kanban::Step" RecordingTypeMessage RecordingType = "Message" @@ -61,9 +62,28 @@ type Recording struct { CommentsCount int `json:"comments_count,omitempty"` CommentsURL string `json:"comments_url,omitempty"` SubscriptionURL string `json:"subscription_url,omitempty"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` + // Position, Description, and Service are door-specific (external-link) + // fields, populated only on Door recordings returned by the type=Door + // recordings query (the only endpoint that returns the full door shape). + // See spec/api-gaps/external-links-doors.md. + Position int32 `json:"position,omitempty"` + Description string `json:"description,omitempty"` + Service *DoorService `json:"service,omitempty"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` +} + +// DoorService describes the recognized external service backing an external +// link (Door recording): its display name, a canonical example URL, a short +// code (or "other" for a generic link), the URL patterns Basecamp recognizes, +// and human supporting text. +type DoorService struct { + Name string `json:"name,omitempty"` + ExampleURL string `json:"example_url,omitempty"` + Code string `json:"code,omitempty"` + ValidPatterns []string `json:"valid_patterns,omitempty"` + SupportingText string `json:"supporting_text,omitempty"` } // DefaultRecordingLimit is the default number of recordings to return when no limit is specified. @@ -425,5 +445,19 @@ func recordingFromGenerated(gr generated.Recording) Recording { r.ContentAttachments = richTextAttachmentsPtrFromGenerated(gr.ContentAttachments) r.DescriptionAttachments = richTextAttachmentsPtrFromGenerated(gr.DescriptionAttachments) + // Door-specific fields (populated only for type=Door recordings). + r.Position = gr.Position + r.Description = gr.Description + if gr.Service.Name != "" || gr.Service.Code != "" || gr.Service.ExampleUrl != "" || + gr.Service.SupportingText != "" || len(gr.Service.ValidPatterns) > 0 { + r.Service = &DoorService{ + Name: gr.Service.Name, + ExampleURL: gr.Service.ExampleUrl, + Code: gr.Service.Code, + ValidPatterns: append([]string(nil), gr.Service.ValidPatterns...), + SupportingText: gr.Service.SupportingText, + } + } + return r } diff --git a/go/pkg/basecamp/recordings_test.go b/go/pkg/basecamp/recordings_test.go index ffae57cb5..6e4db16f6 100644 --- a/go/pkg/basecamp/recordings_test.go +++ b/go/pkg/basecamp/recordings_test.go @@ -268,6 +268,7 @@ func TestRecordingType_Constants(t *testing.T) { }{ {RecordingTypeComment, "Comment"}, {RecordingTypeDocument, "Document"}, + {RecordingTypeDoor, "Door"}, {RecordingTypeKanbanCard, "Kanban::Card"}, {RecordingTypeKanbanStep, "Kanban::Step"}, {RecordingTypeMessage, "Message"}, @@ -286,6 +287,86 @@ func TestRecordingType_Constants(t *testing.T) { } } +// TestRecording_UnmarshalDoor verifies that a type=Door recording (external +// link) decodes with the full door shape — the outside url, the service struct, +// the description, and the position — through the shared Recording projection. +func TestRecording_UnmarshalDoor(t *testing.T) { + data := `[ + { + "id": 1069480290, + "status": "active", + "visible_to_clients": false, + "created_at": "2026-07-22T15:51:54.872Z", + "updated_at": "2026-07-22T15:51:54.886Z", + "title": "Design system", + "inherits_status": true, + "type": "Door", + "url": "https://www.figma.com/file/abc123/Design-system", + "app_url": "https://3.basecampapi.com/195539477/buckets/2085958504/dock/doors/1069480290", + "position": 8, + "bucket": {"id": 2085958504, "name": "The Leto Laptop", "type": "Project"}, + "creator": {"id": 1049715913, "name": "Victor Cooper"}, + "service": { + "name": "Figma", + "example_url": "https://www.figma.com/file/aGVsbG8gZmlnbWEgZmlsZQ", + "code": "figma", + "valid_patterns": ["(.*?\\.)?figma\\.com(\\/.*)?"], + "supporting_text": "a file or project on Figma" + }, + "description": "
Shared Figma workspace
" + } + ]` + + var recordings []Recording + if err := json.Unmarshal([]byte(data), &recordings); err != nil { + t.Fatalf("failed to unmarshal door recording: %v", err) + } + if len(recordings) != 1 { + t.Fatalf("expected 1 recording, got %d", len(recordings)) + } + d := recordings[0] + if d.Type != "Door" { + t.Errorf("expected type Door, got %q", d.Type) + } + if d.URL != "https://www.figma.com/file/abc123/Design-system" { + t.Errorf("expected external url, got %q", d.URL) + } + if d.Position != 8 { + t.Errorf("expected position 8, got %d", d.Position) + } + if d.Description != "
Shared Figma workspace
" { + t.Errorf("unexpected description: %q", d.Description) + } + if d.Service == nil { + t.Fatal("expected service struct to be non-nil for a Door") + } + if d.Service.Name != "Figma" || d.Service.Code != "figma" { + t.Errorf("unexpected service name/code: %q/%q", d.Service.Name, d.Service.Code) + } + if len(d.Service.ValidPatterns) != 1 || d.Service.ValidPatterns[0] == "" { + t.Errorf("expected 1 valid_pattern, got %v", d.Service.ValidPatterns) + } + if d.Service.SupportingText != "a file or project on Figma" { + t.Errorf("unexpected supporting_text: %q", d.Service.SupportingText) + } +} + +// TestRecording_UnmarshalNonDoorOmitsDoorFields verifies a non-door recording +// leaves the door-specific fields empty (they are optional). +func TestRecording_UnmarshalNonDoorOmitsDoorFields(t *testing.T) { + data := `{"id": 1, "type": "Message", "title": "Hi", "url": "https://x/1.json"}` + var r Recording + if err := json.Unmarshal([]byte(data), &r); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if r.Service != nil { + t.Errorf("expected nil service for non-door, got %+v", r.Service) + } + if r.Description != "" || r.Position != 0 { + t.Errorf("expected empty door fields, got description=%q position=%d", r.Description, r.Position) + } +} + func TestRecordingsListOptions_BuildsQueryParams(t *testing.T) { // This is a structural test to ensure the options fields exist opts := RecordingsListOptions{ diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index 8a901f112..dcd031d89 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -806,6 +806,18 @@ type Document struct { VisibleToClients bool `json:"visible_to_clients"` } +// DoorService Metadata describing the recognized external service backing an external link +// (`Door` recording): its display name, a canonical example URL, a short code, +// the URL patterns Basecamp recognizes for it, and human supporting text. `code` +// is `other` for a generic link. +type DoorService struct { + Code string `json:"code,omitempty"` + ExampleUrl string `json:"example_url,omitempty"` + Name string `json:"name,omitempty"` + SupportingText string `json:"supporting_text,omitempty"` + ValidPatterns []string `json:"valid_patterns,omitempty"` +} + // EnableCardColumnOnHoldResponseContent defines model for EnableCardColumnOnHoldResponseContent. type EnableCardColumnOnHoldResponseContent = CardColumn @@ -1828,18 +1840,40 @@ type Recording struct { CreatedAt time.Time `json:"created_at"` Creator Person `json:"creator"` + // Description Rich-text (HTML) description shown beneath an external link. Present only + // on `Door` recordings returned by the `type=Door` recordings query (the only + // endpoint that returns the full door shape). The external destination + // address is `url` (not this field); `app_url` is the Basecamp redirector. + // See `spec/api-gaps/external-links-doors.md`. + Description string `json:"description,omitempty"` + // DescriptionAttachments See `content_attachments` — the description-attribute companion array. DescriptionAttachments []RichTextAttachment `json:"description_attachments,omitempty"` Id int64 `json:"id"` InheritsStatus bool `json:"inherits_status"` Parent RecordingParent `json:"parent"` - Status string `json:"status"` - SubscriptionUrl string `json:"subscription_url,omitempty"` - Title string `json:"title"` - Type string `json:"type"` - UpdatedAt time.Time `json:"updated_at"` - Url string `json:"url"` - VisibleToClients bool `json:"visible_to_clients"` + + // Position Ordinal position within the project's External links section. Present on + // `Door` (external-link) recordings. + Position int32 `json:"position,omitempty"` + + // Service Metadata describing the recognized external service backing an external link + // (`Door` recording): its display name, a canonical example URL, a short code, + // the URL patterns Basecamp recognizes for it, and human supporting text. `code` + // is `other` for a generic link. + Service DoorService `json:"service,omitempty"` + Status string `json:"status"` + SubscriptionUrl string `json:"subscription_url,omitempty"` + Title string `json:"title"` + Type string `json:"type"` + UpdatedAt time.Time `json:"updated_at"` + + // Url API URL of the recording. Exception: in the `type=Door` (external-link) + // projection, `url` is the door's **external destination address** (e.g. the + // Figma/Dropbox URL) and `app_url` is the Basecamp redirector — see the + // door-specific `service`/`description` fields below. + Url string `json:"url"` + VisibleToClients bool `json:"visible_to_clients"` } // RecordingBucket defines model for RecordingBucket. @@ -3053,7 +3087,7 @@ type ListProjectsParams struct { // ListRecordingsParams defines parameters for ListRecordings. type ListRecordingsParams struct { - // Type Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault + // Type Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault Type string `form:"type" json:"type"` Bucket string `form:"bucket,omitempty" json:"bucket,omitempty"` diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/DoorService.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/DoorService.kt new file mode 100644 index 000000000..aca860d91 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/DoorService.kt @@ -0,0 +1,20 @@ +package com.basecamp.sdk.generated.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * DoorService entity from the Basecamp API. + * + * @generated from OpenAPI spec — do not edit directly + */ +@Serializable +data class DoorService( + val name: String? = null, + @SerialName("example_url") val exampleUrl: String? = null, + val code: String? = null, + @SerialName("valid_patterns") val validPatterns: List = emptyList(), + @SerialName("supporting_text") val supportingText: String? = null +) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt index 6b9a71ef1..51ace91ee 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt @@ -31,5 +31,8 @@ data class Recording( @SerialName("description_attachments") val descriptionAttachments: List? = null, @SerialName("comments_count") val commentsCount: Int = 0, @SerialName("comments_url") val commentsUrl: String? = null, - @SerialName("subscription_url") val subscriptionUrl: String? = null + @SerialName("subscription_url") val subscriptionUrl: String? = null, + val position: Int = 0, + val description: String? = null, + val service: DoorService? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/recordings.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/recordings.kt index 3ebfab40f..d57ab4f90 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/recordings.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/recordings.kt @@ -14,7 +14,7 @@ class RecordingsService(client: AccountClient) : BaseService(client) { /** * List recordings of a given type across projects - * @param type Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault + * @param type Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault * @param options Optional query parameters and pagination control */ suspend fun list(type: String, options: ListRecordingsOptions? = null): ListResult { diff --git a/openapi.json b/openapi.json index 23e43ad57..5ce6eafed 100644 --- a/openapi.json +++ b/openapi.json @@ -10253,10 +10253,10 @@ { "name": "type", "in": "query", - "description": "Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault", + "description": "Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault", "schema": { "type": "string", - "description": "Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault" + "description": "Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault" }, "required": true, "examples": { @@ -23844,6 +23844,30 @@ "visible_to_clients" ] }, + "DoorService": { + "type": "object", + "description": "Metadata describing the recognized external service backing an external link\n(`Door` recording): its display name, a canonical example URL, a short code,\nthe URL patterns Basecamp recognizes for it, and human supporting text. `code`\nis `other` for a generic link.", + "properties": { + "name": { + "type": "string" + }, + "example_url": { + "type": "string" + }, + "code": { + "type": "string" + }, + "valid_patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "supporting_text": { + "type": "string" + } + } + }, "EnableCardColumnOnHoldResponseContent": { "$ref": "#/components/schemas/CardColumn" }, @@ -26591,7 +26615,8 @@ "type": "string" }, "url": { - "type": "string" + "type": "string", + "description": "API URL of the recording. Exception: in the `type=Door` (external-link)\nprojection, `url` is the door's **external destination address** (e.g. the\nFigma/Dropbox URL) and `app_url` is the Basecamp redirector — see the\ndoor-specific `service`/`description` fields below." }, "app_url": { "type": "string" @@ -26626,6 +26651,18 @@ "subscription_url": { "type": "string" }, + "position": { + "type": "integer", + "description": "Ordinal position within the project's External links section. Present on\n`Door` (external-link) recordings.", + "format": "int32" + }, + "description": { + "type": "string", + "description": "Rich-text (HTML) description shown beneath an external link. Present only\non `Door` recordings returned by the `type=Door` recordings query (the only\nendpoint that returns the full door shape). The external destination\naddress is `url` (not this field); `app_url` is the Basecamp redirector.\nSee `spec/api-gaps/external-links-doors.md`." + }, + "service": { + "$ref": "#/components/schemas/DoorService" + }, "parent": { "$ref": "#/components/schemas/RecordingParent" }, diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index 83f3aecff..e7560b0c8 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -584,6 +584,14 @@ class Document(TypedDict): visible_to_clients: bool +class DoorService(TypedDict): + code: NotRequired[str] + example_url: NotRequired[str] + name: NotRequired[str] + supporting_text: NotRequired[str] + valid_patterns: NotRequired[list[str]] + + class EnableOutOfOfficeRequestContent(TypedDict): out_of_office: OutOfOfficePayload @@ -1174,10 +1182,13 @@ class Recording(TypedDict): content_attachments: NotRequired[list[RichTextAttachment]] created_at: str creator: Person + description: NotRequired[str] description_attachments: NotRequired[list[RichTextAttachment]] id: int inherits_status: bool parent: RecordingParent + position: NotRequired[int] + service: NotRequired[DoorService] status: str subscription_url: NotRequired[str] title: str diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 99069d09e..4773e17cd 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-07-25T07:14:10Z", + "generated": "2026-07-25T07:27:08Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/services/recordings_service.rb b/ruby/lib/basecamp/generated/services/recordings_service.rb index 4f80558b2..4b72ca6e9 100644 --- a/ruby/lib/basecamp/generated/services/recordings_service.rb +++ b/ruby/lib/basecamp/generated/services/recordings_service.rb @@ -8,7 +8,7 @@ module Services class RecordingsService < BaseService # List recordings of a given type across projects - # @param type [String] Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault + # @param type [String] Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault # @param bucket [String, nil] bucket # @param status [String, nil] active|archived|trashed # @param sort [String, nil] created_at|updated_at diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 56c26cbec..442c7c6ca 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-07-25T07:14:10Z +# Generated: 2026-07-25T07:27:08Z require "json" require "time" @@ -1269,6 +1269,34 @@ def to_json(*args) end end + # DoorService + class DoorService + include TypeHelpers + attr_accessor :code, :example_url, :name, :supporting_text, :valid_patterns + + def initialize(data = {}) + @code = data["code"] + @example_url = data["example_url"] + @name = data["name"] + @supporting_text = data["supporting_text"] + @valid_patterns = data["valid_patterns"] + end + + def to_h + { + "code" => @code, + "example_url" => @example_url, + "name" => @name, + "supporting_text" => @supporting_text, + "valid_patterns" => @valid_patterns, + }.compact + end + + def to_json(*args) + to_h.to_json(*args) + end + end + # Event class Event include TypeHelpers @@ -2900,7 +2928,7 @@ def to_json(*args) # Recording class Recording include TypeHelpers - attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :comments_count, :comments_url, :content, :content_attachments, :description_attachments, :subscription_url + attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :comments_count, :comments_url, :content, :content_attachments, :description, :description_attachments, :position, :service, :subscription_url # @return [Array] def self.required_fields @@ -2926,7 +2954,10 @@ def initialize(data = {}) @comments_url = data["comments_url"] @content = data["content"] @content_attachments = parse_array(data["content_attachments"], "RichTextAttachment") + @description = data["description"] @description_attachments = parse_array(data["description_attachments"], "RichTextAttachment") + @position = parse_integer(data["position"]) + @service = parse_type(data["service"], "DoorService") @subscription_url = data["subscription_url"] end @@ -2950,7 +2981,10 @@ def to_h "comments_url" => @comments_url, "content" => @content, "content_attachments" => @content_attachments, + "description" => @description, "description_attachments" => @description_attachments, + "position" => @position, + "service" => @service, "subscription_url" => @subscription_url, }.compact end diff --git a/spec/api-gaps/README.md b/spec/api-gaps/README.md index 2f04d986b..a1a2c884d 100644 --- a/spec/api-gaps/README.md +++ b/spec/api-gaps/README.md @@ -56,7 +56,7 @@ making the absorption journey publicly auditable. | [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 | -| [external-links-doors](external-links-doors.md) | addressed-in-bc3-pr-12375 | post-train | low | +| [external-links-doors](external-links-doors.md) | partial-coverage | post-train | low | | [dock-tool-visible-to-clients](dock-tool-visible-to-clients.md) | absorbed-in-sdk | post-train | low | | [card-table-wormholes](card-table-wormholes.md) | absorbed-in-sdk | post-train | medium | | [bubble-ups-surface](bubble-ups-surface.md) | absorbed-in-sdk | launch | high | diff --git a/spec/api-gaps/external-links-doors.md b/spec/api-gaps/external-links-doors.md index 1bce9bd89..422132314 100644 --- a/spec/api-gaps/external-links-doors.md +++ b/spec/api-gaps/external-links-doors.md @@ -1,9 +1,13 @@ --- gap: external-links-doors -status: addressed-in-bc3-pr-12375 +status: partial-coverage detected: 2026-07-22 sdk_demand: low bc3_pr: 12375 +smithy_refs: + - "RecordingType Door enum value (spec/basecamp.smithy:6063)" + - "Recording.position/description/service (spec/basecamp.smithy:6078)" + - "DoorService (spec/basecamp.smithy:6142)" bc3_refs: routes: - GET /:account_id/projects/recordings.json?type=Door @@ -87,22 +91,50 @@ the contract is now documented and must be tracked to keep detection honest. ## SDK absorption plan when this lands -- Model the door **list** as a `type=Door`-scoped recordings query (or a - dedicated `ListExternalLinks` operation) returning the full door shape - (`url`, `service`, `description`). -- Model **create** carefully: the 302/no-JSON-body contract does not fit the - standard "create returns the resource" mold — the operation returns no - usable body, so the SDK surface must not promise one. The wire operation - should model only the 302/empty response; a create-and-discover convenience - (create, then `type=Door` list to find the new door's id) is a **separately - sanctioned composite** — it needs explicit composite treatment, conformance - coverage, and a defined answer for ambiguous concurrent/duplicate creates, - not something the plain create operation implies. -- Get/rename/trash reuse the existing dock-tool GetTool/UpdateTool/DeleteTool - operations. -- Add `Door` to the `RecordingType` documented enum. +**Partially absorbed** (basecamp-sdk PR-4 of the post-#401 follow-up program). +A pre-implementation spike resolved the three blockers and scoped this PR to the +cleanly-absorbable surface; the redirect-dependent create and the multipart +image are recorded as residual gaps below. + +**Absorbed in this PR:** + +- The door **list** is the existing `type=Door`-scoped `ListRecordings` query — + **not** a new operation. `ListRecordings` already carries a `type` query and a + `Recording.url`; the full door shape is reached by adding `Door` to the + `RecordingType` documented enum and adding optional `position`, `description`, + and a `service` struct (`DoorService`) to the shared `Recording` projection. + A runtime decode test proves the door shape (external `url` + `service` + + `description`) decodes through the projection. +- Get/rename/trash reuse the existing `GetTool` / `UpdateTool` / `DeleteTool` + operations — no new work. +- A `type=Door` recordings canary entry validates statically (live dormant). + +**Residual gaps (why this entry is `partial-coverage`, not +`absorbed-in-sdk`):** + +- **Create (`POST /buckets/:b/dock/doors.json`) — deferred.** The endpoint + returns **302 with an empty body** (no ID), redirecting cross-origin to the + web project page. The spike found the six SDK transports handle this + inconsistently: Go, TypeScript (fetch default), Kotlin (Ktor default), and + Swift (URLSession default) **follow** the redirect (landing on the web page, + after cross-origin auth stripping); Python (`follow_redirects=False`) and Ruby + (Faraday, no follow middleware) do **not** follow but then face empty-body + decode, and each classifies a bare 302 differently. Modeling "return only the + 302/empty response" would require coordinated per-operation redirect + suppression + 302-as-success handling across all six generated transports — a + cross-cutting transport change, not an additive absorption. Deferred to a + dedicated PR. +- **`image` thumbnail (multipart) — unmodeled.** `door[image]` is + `multipart/form-data` only; not modeled. Independent of the create-transport + question, this alone keeps the entry from honestly flipping to + `absorbed-in-sdk`. +- **Create-and-discover composite (SPEC §18) — deferred.** It depends on a + shippable `CreateExternalLink`, so it is out of scope until create lands. +- **No JSON update path** for `url`/`service`/`description` (PUT → 406): never + modeled, by contract. ## Compatibility -New documented resource surface; no change to existing modeled operations -beyond the additive `Door` enum value on `ListRecordings` output type. +Additive only: the `Door` enum value plus optional `position`/`description`/ +`service` fields on the shared `Recording` output type. No change to existing +modeled operations; no new operation. diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index b7b74e1c8..87b9130ae 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -6220,7 +6220,7 @@ structure EventDetails { // ===== Recording Shapes ===== -@documentation("Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault") +@documentation("Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault") string RecordingType @documentation("active|archived|trashed") @@ -6253,6 +6253,10 @@ structure Recording { inherits_status: Boolean @required type: String + /// API URL of the recording. Exception: in the `type=Door` (external-link) + /// projection, `url` is the door's **external destination address** (e.g. the + /// Figma/Dropbox URL) and `app_url` is the Basecamp redirector — see the + /// door-specific `service`/`description` fields below. @required url: String @required @@ -6272,6 +6276,22 @@ structure Recording { comments_count: Integer comments_url: String subscription_url: String + + /// Ordinal position within the project's External links section. Present on + /// `Door` (external-link) recordings. + position: Integer + + /// Rich-text (HTML) description shown beneath an external link. Present only + /// on `Door` recordings returned by the `type=Door` recordings query (the only + /// endpoint that returns the full door shape). The external destination + /// address is `url` (not this field); `app_url` is the Basecamp redirector. + /// See `spec/api-gaps/external-links-doors.md`. + description: String + + /// Metadata about the recognized external service the link's `url` points to + /// (Figma, Dropbox, GitHub, …). Present only on `Door` recordings. + service: DoorService + @required parent: RecordingParent @required @@ -6280,6 +6300,18 @@ structure Recording { creator: Person } +/// Metadata describing the recognized external service backing an external link +/// (`Door` recording): its display name, a canonical example URL, a short code, +/// the URL patterns Basecamp recognizes for it, and human supporting text. `code` +/// is `other` for a generic link. +structure DoorService { + name: String + example_url: String + code: String + valid_patterns: StringList + supporting_text: String +} + // ============================================================================= // BATCH 9 - Checkins (Questionnaires, Questions, Answers) // ============================================================================= diff --git a/swift/Sources/Basecamp/Generated/Models/DoorService.swift b/swift/Sources/Basecamp/Generated/Models/DoorService.swift new file mode 100644 index 000000000..39379de58 --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/DoorService.swift @@ -0,0 +1,10 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct DoorService: Codable, Sendable { + public var code: String? + public var exampleUrl: String? + public var name: String? + public var supportingText: String? + public var validPatterns: [String]? +} diff --git a/swift/Sources/Basecamp/Generated/Models/Recording.swift b/swift/Sources/Basecamp/Generated/Models/Recording.swift index 3be14f030..4e4ff307e 100644 --- a/swift/Sources/Basecamp/Generated/Models/Recording.swift +++ b/swift/Sources/Basecamp/Generated/Models/Recording.swift @@ -20,7 +20,10 @@ public struct Recording: Codable, Sendable { public var commentsUrl: String? public var content: String? public var contentAttachments: [RichTextAttachment]? + public var description: String? public var descriptionAttachments: [RichTextAttachment]? + public var position: Int32? + public var service: DoorService? public var subscriptionUrl: String? public init( @@ -42,7 +45,10 @@ public struct Recording: Codable, Sendable { commentsUrl: String? = nil, content: String? = nil, contentAttachments: [RichTextAttachment]? = nil, + description: String? = nil, descriptionAttachments: [RichTextAttachment]? = nil, + position: Int32? = nil, + service: DoorService? = nil, subscriptionUrl: String? = nil ) { self.appUrl = appUrl @@ -63,7 +69,10 @@ public struct Recording: Codable, Sendable { self.commentsUrl = commentsUrl self.content = content self.contentAttachments = contentAttachments + self.description = description self.descriptionAttachments = descriptionAttachments + self.position = position + self.service = service self.subscriptionUrl = subscriptionUrl } } diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 3f0637e55..c3c389834 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-07-25T07:14:09.617Z", + "generated": "2026-07-25T07:27:07.621Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 16459ccb7..1ad909623 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -9083,10 +9083,10 @@ { "name": "type", "in": "query", - "description": "Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault", + "description": "Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault", "schema": { "type": "string", - "description": "Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault" + "description": "Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault" }, "required": true, "examples": { @@ -21455,6 +21455,30 @@ "visible_to_clients" ] }, + "DoorService": { + "type": "object", + "description": "Metadata describing the recognized external service backing an external link\n(`Door` recording): its display name, a canonical example URL, a short code,\nthe URL patterns Basecamp recognizes for it, and human supporting text. `code`\nis `other` for a generic link.", + "properties": { + "name": { + "type": "string" + }, + "example_url": { + "type": "string" + }, + "code": { + "type": "string" + }, + "valid_patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "supporting_text": { + "type": "string" + } + } + }, "EnableCardColumnOnHoldResponseContent": { "$ref": "#/components/schemas/CardColumn" }, @@ -24202,7 +24226,8 @@ "type": "string" }, "url": { - "type": "string" + "type": "string", + "description": "API URL of the recording. Exception: in the `type=Door` (external-link)\nprojection, `url` is the door's **external destination address** (e.g. the\nFigma/Dropbox URL) and `app_url` is the Basecamp redirector — see the\ndoor-specific `service`/`description` fields below." }, "app_url": { "type": "string" @@ -24237,6 +24262,18 @@ "subscription_url": { "type": "string" }, + "position": { + "type": "integer", + "description": "Ordinal position within the project's External links section. Present on\n`Door` (external-link) recordings.", + "format": "int32" + }, + "description": { + "type": "string", + "description": "Rich-text (HTML) description shown beneath an external link. Present only\non `Door` recordings returned by the `type=Door` recordings query (the only\nendpoint that returns the full door shape). The external destination\naddress is `url` (not this field); `app_url` is the Basecamp redirector.\nSee `spec/api-gaps/external-links-doors.md`." + }, + "service": { + "$ref": "#/components/schemas/DoorService" + }, "parent": { "$ref": "#/components/schemas/RecordingParent" }, diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index f85c30618..a4cfa1cf4 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -3241,6 +3241,19 @@ export interface components { boosts_count?: number; boosts_url?: string; }; + /** + * @description Metadata describing the recognized external service backing an external link + * (`Door` recording): its display name, a canonical example URL, a short code, + * the URL patterns Basecamp recognizes for it, and human supporting text. `code` + * is `other` for a generic link. + */ + DoorService: { + name?: string; + example_url?: string; + code?: string; + valid_patterns?: string[]; + supporting_text?: string; + }; EnableCardColumnOnHoldResponseContent: components["schemas"]["CardColumn"]; EnableOutOfOfficeRequestContent: { out_of_office: components["schemas"]["OutOfOfficePayload"]; @@ -4035,6 +4048,12 @@ export interface components { title: string; inherits_status: boolean; type: string; + /** + * @description API URL of the recording. Exception: in the `type=Door` (external-link) + * projection, `url` is the door's **external destination address** (e.g. the + * Figma/Dropbox URL) and `app_url` is the Basecamp redirector — see the + * door-specific `service`/`description` fields below. + */ url: string; app_url: string; bookmark_url?: string; @@ -4055,6 +4074,21 @@ export interface components { comments_count?: number; comments_url?: string; subscription_url?: string; + /** + * Format: int32 + * @description Ordinal position within the project's External links section. Present on + * `Door` (external-link) recordings. + */ + position?: number; + /** + * @description Rich-text (HTML) description shown beneath an external link. Present only + * on `Door` recordings returned by the `type=Door` recordings query (the only + * endpoint that returns the full door shape). The external destination + * address is `url` (not this field); `app_url` is the Basecamp redirector. + * See `spec/api-gaps/external-links-doors.md`. + */ + description?: string; + service?: components["schemas"]["DoorService"]; parent: components["schemas"]["RecordingParent"]; bucket: components["schemas"]["RecordingBucket"]; creator: components["schemas"]["Person"]; @@ -11428,7 +11462,7 @@ export interface operations { ListRecordings: { parameters: { query: { - /** @description Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault */ + /** @description Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault */ type: string; bucket?: string; /** @description active|archived|trashed */ diff --git a/typescript/src/generated/services/recordings.ts b/typescript/src/generated/services/recordings.ts index a2407cb32..07a240a12 100644 --- a/typescript/src/generated/services/recordings.ts +++ b/typescript/src/generated/services/recordings.ts @@ -42,7 +42,7 @@ export class RecordingsService extends BaseService { /** * List recordings of a given type across projects - * @param type - Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault + * @param type - Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault * @param options - Optional query parameters * @returns All Recording across all pages, with .meta.totalCount * @@ -54,7 +54,7 @@ export class RecordingsService extends BaseService { * const filtered = await client.recordings.list("type", { bucket: [123] }); * ``` */ - async list(type: "Comment" | "Document" | "Kanban::Card" | "Kanban::Step" | "Message" | "Question::Answer" | "Schedule::Entry" | "Todo" | "Todolist" | "Upload" | "Vault", options?: ListRecordingOptions): Promise> { + async list(type: "Comment" | "Document" | "Door" | "Kanban::Card" | "Kanban::Step" | "Message" | "Question::Answer" | "Schedule::Entry" | "Todo" | "Todolist" | "Upload" | "Vault", options?: ListRecordingOptions): Promise> { return this.requestPaginated( { service: "Recordings",