From b7af1694504dd7c2ee465cfaf55f3aad9fbfbd62 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 28 Jul 2026 00:07:50 -0700 Subject: [PATCH 1/3] Search: model content/description as required and nullable, add the plain-text excerpts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/searches/show.json.jbuilder renders the recording's own partial and then unconditionally overwrites content and description with nil, to keep the large HTML body out of the search payload. The keys are therefore always present and always null. Both were modeled optional and non-nullable, which is wrong in an observable way: it accepts a payload omitting them, which BC3 never sends. The fields that carry the searchable text — plain_text_content and plain_text_description — were not modeled at all, so consumers had typed, permanently-empty fields and no typed access to what BC3 returns. content and description become required and nullable, following the SearchType.key precedent in smithy-build.json (jsonAdd with type: [string, null] plus x-go-type: *string). The two excerpt fields are added as optional and non-nullable, matching their conditional emission. Despite the name, plain_text_content is not plain text. excerpt_and_highlight_matches converts the rich text with to_plain_text, escapes it with html_escape_once, wraps each query match in , and truncates to SNIPPET_SIZE = 300. The doc comments say so on every SDK. The fixture was false: result 0 carried a full HTML content, result 1 a real description, result 2 a full HTML content, and no result carried plain_text_* — the exact shape the jbuilder makes impossible. Rewritten so every result has both nulls plus the appropriate highlighted excerpt. Breaking. Go's Content becomes *string with no omitempty; Python's becomes str | None; TypeScript's string | null; Ruby lists them in required_fields; Swift moves them into the required init position, so positional SearchResult(...) calls break. Kotlin is unaffected — it has no generated SearchResult type, search returns ListResult. The @deprecated question on content/description is deferred: they are live and meaningful on every other recording shape. Closes #465. --- go/pkg/basecamp/search.go | 47 ++++--- go/pkg/basecamp/search_test.go | 40 +++++- go/pkg/generated/client.gen.go | 43 +++++-- openapi.json | 24 +++- python/src/basecamp/generated/types.py | 6 +- ruby/lib/basecamp/generated/metadata.json | 2 +- ruby/lib/basecamp/generated/types.rb | 20 +-- spec/basecamp.smithy | 23 ++++ spec/fixtures/search/results.json | 12 +- spec/smithy-build.json | 4 + .../Generated/Models/SearchResult.swift | 92 +++++++++++++- .../SearchResultDecodeTests.swift | 115 ++++++++++++++++++ typescript/src/generated/metadata.ts | 2 +- .../src/generated/openapi-stripped.json | 24 +++- typescript/src/generated/schema.d.ts | 33 ++++- 15 files changed, 430 insertions(+), 57 deletions(-) create mode 100644 swift/Tests/BasecampTests/SearchResultDecodeTests.swift diff --git a/go/pkg/basecamp/search.go b/go/pkg/basecamp/search.go index 120394fd7..3d19bea6f 100644 --- a/go/pkg/basecamp/search.go +++ b/go/pkg/basecamp/search.go @@ -25,8 +25,23 @@ type SearchResult struct { Parent *Parent `json:"parent,omitempty"` Bucket *Bucket `json:"bucket,omitempty"` Creator *Person `json:"creator,omitempty"` - Content string `json:"content,omitempty"` - Description string `json:"description,omitempty"` + // Content and Description are always present on the wire and always null: + // api/searches/show.json.jbuilder renders the recording's own partial and + // then unconditionally overwrites both with nil to keep the large HTML body + // out of the search payload. They are modeled as pointers so the guaranteed + // null round-trips faithfully rather than collapsing to "". Read + // PlainTextContent and PlainTextDescription instead. + Content *string `json:"content"` + Description *string `json:"description"` + // PlainTextContent and PlainTextDescription are highlighted, truncated + // excerpts — NOT plain text despite the name. BC3 converts the rich text + // with to_plain_text, escapes it with html_escape_once, wraps each query + // match in , and truncates + // to 300 characters. Treat them as HTML fragments. Optional and + // non-nullable: a result whose recordable has no such attribute omits the + // key rather than sending null. + PlainTextContent string `json:"plain_text_content,omitempty"` + PlainTextDescription string `json:"plain_text_description,omitempty"` // ContentAttachments and DescriptionAttachments are the rich text companion // arrays carried through the polymorphic search projection. A given result // is one recording type, so it carries only the array matching its rich text @@ -316,19 +331,21 @@ func searchTypesFromGenerated(gts []generated.SearchType) []SearchType { // searchResultFromGenerated converts a generated SearchResult to our clean SearchResult type. func searchResultFromGenerated(gsr generated.SearchResult) SearchResult { sr := SearchResult{ - Status: gsr.Status, - VisibleToClients: gsr.VisibleToClients, - CreatedAt: gsr.CreatedAt, - UpdatedAt: gsr.UpdatedAt, - Title: gsr.Title, - InheritsStatus: gsr.InheritsStatus, - Type: gsr.Type, - URL: gsr.Url, - AppURL: gsr.AppUrl, - BookmarkURL: gsr.BookmarkUrl, - Content: gsr.Content, - Description: gsr.Description, - Subject: gsr.Subject, + Status: gsr.Status, + VisibleToClients: gsr.VisibleToClients, + CreatedAt: gsr.CreatedAt, + UpdatedAt: gsr.UpdatedAt, + Title: gsr.Title, + InheritsStatus: gsr.InheritsStatus, + Type: gsr.Type, + URL: gsr.Url, + AppURL: gsr.AppUrl, + BookmarkURL: gsr.BookmarkUrl, + Content: gsr.Content, + Description: gsr.Description, + PlainTextContent: gsr.PlainTextContent, + PlainTextDescription: gsr.PlainTextDescription, + Subject: gsr.Subject, } if gsr.Id != 0 { diff --git a/go/pkg/basecamp/search_test.go b/go/pkg/basecamp/search_test.go index b763e50cd..7a2ad6769 100644 --- a/go/pkg/basecamp/search_test.go +++ b/go/pkg/basecamp/search_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "github.com/basecamp/basecamp-sdk/go/pkg/generated" @@ -56,8 +57,23 @@ func TestSearchResult_UnmarshalResults(t *testing.T) { if r1.Subject != "We won Leto!" { t.Errorf("expected subject 'We won Leto!', got %q", r1.Subject) } - if r1.Content != "
Hello everyone! We got the Leto Laptop project! Time to get started.
" { - t.Errorf("unexpected content: %q", r1.Content) + // Content is always present on the wire and always null — the search + // projection strips the HTML body. A non-nil pointer would mean the + // projection changed. + if r1.Content != nil { + t.Errorf("expected nil Content on a search result, got %q", *r1.Content) + } + if r1.Description != nil { + t.Errorf("expected nil Description on a search result, got %q", *r1.Description) + } + if r1.PlainTextContent == "" { + t.Error("expected PlainTextContent to carry the highlighted excerpt") + } + if !strings.Contains(r1.PlainTextContent, ``) { + t.Errorf("PlainTextContent is an HTML fragment with highlighted matches, got %q", r1.PlainTextContent) + } + if r1.PlainTextDescription != "" { + t.Errorf("a Message has no description attribute, so PlainTextDescription should be absent, got %q", r1.PlainTextDescription) } if r1.URL != "https://3.basecampapi.com/195539477/buckets/2085958499/messages/1069479351.json" { t.Errorf("unexpected URL: %q", r1.URL) @@ -116,8 +132,17 @@ func TestSearchResult_UnmarshalResults(t *testing.T) { if r2.Title != "Design specs for Leto display" { t.Errorf("expected title 'Design specs for Leto display', got %q", r2.Title) } - if r2.Description != "Create detailed specifications for the Leto laptop display panel" { - t.Errorf("unexpected description: %q", r2.Description) + if r2.Content != nil { + t.Errorf("expected nil Content on a search result, got %q", *r2.Content) + } + if r2.Description != nil { + t.Errorf("expected nil Description on a search result, got %q", *r2.Description) + } + if !strings.Contains(r2.PlainTextDescription, ``) { + t.Errorf("expected a highlighted excerpt in PlainTextDescription, got %q", r2.PlainTextDescription) + } + if r2.PlainTextContent != "" { + t.Errorf("a Todo has no content attribute, so PlainTextContent should be absent, got %q", r2.PlainTextContent) } if r2.Parent == nil { t.Fatal("expected Parent to be non-nil for second result") @@ -140,8 +165,11 @@ func TestSearchResult_UnmarshalResults(t *testing.T) { if r3.Type != "Comment" { t.Errorf("expected type 'Comment', got %q", r3.Type) } - if r3.Content != "
The Leto keyboard layout looks great. Let's finalize it.
" { - t.Errorf("unexpected content for comment: %q", r3.Content) + if r3.Content != nil { + t.Errorf("expected nil Content on a search result, got %q", *r3.Content) + } + if !strings.Contains(r3.PlainTextContent, ``) { + t.Errorf("expected a highlighted excerpt in PlainTextContent, got %q", r3.PlainTextContent) } // Verify timestamps are parsed diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index 290065d48..069d10028 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -2224,7 +2224,13 @@ type SearchResult struct { AppUrl string `json:"app_url"` BookmarkUrl string `json:"bookmark_url,omitempty"` Bucket RecordingBucket `json:"bucket,omitempty"` - Content string `json:"content,omitempty"` + + // Content Always present, always null. `api/searches/show.json.jbuilder` renders the + // recording's own partial and then unconditionally overwrites `content` with + // `nil` to strip the large HTML body out of the search payload. The key is + // therefore guaranteed present on every result — required — and its value is + // guaranteed null. Read `plain_text_content` instead. + Content *string `json:"content"` // ContentAttachments Rich-text companion arrays carried through the polymorphic search // projection. A given result is one recording type, so it carries only @@ -2245,20 +2251,39 @@ type SearchResult struct { ContentAttachments []RichTextAttachment `json:"content_attachments,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` Creator Person `json:"creator,omitempty"` - Description string `json:"description,omitempty"` + + // Description Always present, always null — the description-attribute counterpart to + // `content`. Read `plain_text_description` instead. + Description *string `json:"description"` // DescriptionAttachments See `content_attachments` — the description-attribute companion array. DescriptionAttachments []RichTextAttachment `json:"description_attachments,omitempty"` Id int64 `json:"id"` InheritsStatus bool `json:"inherits_status,omitempty"` Parent RecordingParent `json:"parent,omitempty"` - Status string `json:"status,omitempty"` - Subject string `json:"subject,omitempty"` - Title string `json:"title"` - Type string `json:"type"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - Url string `json:"url"` - VisibleToClients bool `json:"visible_to_clients,omitempty"` + + // PlainTextContent A highlighted, truncated excerpt of the recording's content — **not** plain + // text despite the name. `excerpt_and_highlight_matches` converts the rich + // text with `to_plain_text`, escapes it with `html_escape_once`, then wraps + // each query match in `` and + // truncates the result to 300 characters. Treat it as an HTML fragment. + // + // Optional and non-nullable: emitted only when the underlying recordable + // responds to `content`, so a result whose type has no content attribute + // omits the key entirely rather than sending null. + PlainTextContent string `json:"plain_text_content,omitempty"` + + // PlainTextDescription The description-attribute counterpart to `plain_text_content`, with the + // same highlighting, escaping, and 300-character truncation. Optional and + // non-nullable — omitted when the recordable has no description attribute. + PlainTextDescription string `json:"plain_text_description,omitempty"` + Status string `json:"status,omitempty"` + Subject string `json:"subject,omitempty"` + Title string `json:"title"` + Type string `json:"type"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Url string `json:"url"` + VisibleToClients bool `json:"visible_to_clients,omitempty"` } // SearchType A selectable search filter option. `key` is the value passed back as a diff --git a/openapi.json b/openapi.json index e0e5fad31..fac3028e6 100644 --- a/openapi.json +++ b/openapi.json @@ -29388,10 +29388,28 @@ "$ref": "#/components/schemas/Person" }, "content": { - "type": "string" + "type": [ + "string", + "null" + ], + "description": "Always present, always null. `api/searches/show.json.jbuilder` renders the\nrecording's own partial and then unconditionally overwrites `content` with\n`nil` to strip the large HTML body out of the search payload. The key is\ntherefore guaranteed present on every result — required — and its value is\nguaranteed null. Read `plain_text_content` instead.", + "x-go-type": "*string" }, "description": { - "type": "string" + "type": [ + "string", + "null" + ], + "description": "Always present, always null — the description-attribute counterpart to\n`content`. Read `plain_text_description` instead.", + "x-go-type": "*string" + }, + "plain_text_content": { + "type": "string", + "description": "A highlighted, truncated excerpt of the recording's content — **not** plain\ntext despite the name. `excerpt_and_highlight_matches` converts the rich\ntext with `to_plain_text`, escapes it with `html_escape_once`, then wraps\neach query match in `` and\ntruncates the result to 300 characters. Treat it as an HTML fragment.\n\nOptional and non-nullable: emitted only when the underlying recordable\nresponds to `content`, so a result whose type has no content attribute\nomits the key entirely rather than sending null." + }, + "plain_text_description": { + "type": "string", + "description": "The description-attribute counterpart to `plain_text_content`, with the\nsame highlighting, escaping, and 300-character truncation. Optional and\nnon-nullable — omitted when the recordable has no description attribute." }, "content_attachments": { "type": "array", @@ -29413,6 +29431,8 @@ }, "required": [ "app_url", + "content", + "description", "id", "title", "type", diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index 7e3260c2d..c18269c6f 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -1406,15 +1406,17 @@ class SearchResult(TypedDict): app_url: str bookmark_url: NotRequired[str] bucket: NotRequired[RecordingBucket] - content: NotRequired[str] + content: str | None content_attachments: NotRequired[list[RichTextAttachment]] created_at: NotRequired[str] creator: NotRequired[Person] - description: NotRequired[str] + description: str | None description_attachments: NotRequired[list[RichTextAttachment]] id: int inherits_status: NotRequired[bool] parent: NotRequired[RecordingParent] + plain_text_content: NotRequired[str] + plain_text_description: NotRequired[str] status: NotRequired[str] subject: NotRequired[str] title: str diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 810171877..7d1d23650 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-27T18:27:05Z", + "generated": "2026-07-28T06:50:08Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index dad5e81b3..27539b763 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-27T18:27:05Z +# Generated: 2026-07-28T06:50:08Z require "json" require "time" @@ -3511,29 +3511,31 @@ def to_json(*args) # SearchResult class SearchResult include TypeHelpers - attr_accessor :app_url, :id, :title, :type, :url, :bookmark_url, :bucket, :content, :content_attachments, :created_at, :creator, :description, :description_attachments, :inherits_status, :parent, :status, :subject, :updated_at, :visible_to_clients + attr_accessor :app_url, :content, :description, :id, :title, :type, :url, :bookmark_url, :bucket, :content_attachments, :created_at, :creator, :description_attachments, :inherits_status, :parent, :plain_text_content, :plain_text_description, :status, :subject, :updated_at, :visible_to_clients # @return [Array] def self.required_fields - %i[app_url id title type url].freeze + %i[app_url content description id title type url].freeze end def initialize(data = {}) @app_url = data["app_url"] + @content = data["content"] + @description = data["description"] @id = parse_integer(data["id"]) @title = data["title"] @type = data["type"] @url = data["url"] @bookmark_url = data["bookmark_url"] @bucket = parse_type(data["bucket"], "RecordingBucket") - @content = data["content"] @content_attachments = parse_array(data["content_attachments"], "RichTextAttachment") @created_at = parse_datetime(data["created_at"]) @creator = parse_type(data["creator"], "Person") - @description = data["description"] @description_attachments = parse_array(data["description_attachments"], "RichTextAttachment") @inherits_status = parse_boolean(data["inherits_status"]) @parent = parse_type(data["parent"], "RecordingParent") + @plain_text_content = data["plain_text_content"] + @plain_text_description = data["plain_text_description"] @status = data["status"] @subject = data["subject"] @updated_at = parse_datetime(data["updated_at"]) @@ -3543,25 +3545,27 @@ def initialize(data = {}) def to_h { "app_url" => @app_url, + "content" => @content, + "description" => @description, "id" => @id, "title" => @title, "type" => @type, "url" => @url, "bookmark_url" => @bookmark_url, "bucket" => @bucket, - "content" => @content, "content_attachments" => @content_attachments, "created_at" => @created_at, "creator" => @creator, - "description" => @description, "description_attachments" => @description_attachments, "inherits_status" => @inherits_status, "parent" => @parent, + "plain_text_content" => @plain_text_content, + "plain_text_description" => @plain_text_description, "status" => @status, "subject" => @subject, "updated_at" => @updated_at, "visible_to_clients" => @visible_to_clients, - }.compact + }.reject { |k, v| v.nil? && !["content", "description"].include?(k) } end def to_json(*args) diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index 279fedbc3..e7524b747 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -7976,8 +7976,31 @@ structure SearchResult { parent: RecordingParent bucket: RecordingBucket creator: Person + /// Always present, always null. `api/searches/show.json.jbuilder` renders the + /// recording's own partial and then unconditionally overwrites `content` with + /// `nil` to strip the large HTML body out of the search payload. The key is + /// therefore guaranteed present on every result — required — and its value is + /// guaranteed null. Read `plain_text_content` instead. + @required content: String + /// Always present, always null — the description-attribute counterpart to + /// `content`. Read `plain_text_description` instead. + @required description: String + /// A highlighted, truncated excerpt of the recording's content — **not** plain + /// text despite the name. `excerpt_and_highlight_matches` converts the rich + /// text with `to_plain_text`, escapes it with `html_escape_once`, then wraps + /// each query match in `` and + /// truncates the result to 300 characters. Treat it as an HTML fragment. + /// + /// Optional and non-nullable: emitted only when the underlying recordable + /// responds to `content`, so a result whose type has no content attribute + /// omits the key entirely rather than sending null. + plain_text_content: String + /// The description-attribute counterpart to `plain_text_content`, with the + /// same highlighting, escaping, and 300-character truncation. Optional and + /// non-nullable — omitted when the recordable has no description attribute. + plain_text_description: String /// Rich-text companion arrays carried through the polymorphic search /// projection. A given result is one recording type, so it carries only /// the array matching its rich-text attribute (`content_attachments` for a diff --git a/spec/fixtures/search/results.json b/spec/fixtures/search/results.json index 48a05921a..f69b67ce3 100644 --- a/spec/fixtures/search/results.json +++ b/spec/fixtures/search/results.json @@ -11,8 +11,10 @@ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/messages/1069479351.json", "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/messages/1069479351", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5MzUxP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--aabbccdd.json", + "content": null, + "description": null, + "plain_text_content": "Hello everyone! We got the Leto Laptop project! Time to get started.", "subject": "We won Leto!", - "content": "
Hello everyone! We got the Leto Laptop project! Time to get started.
", "parent": { "id": 1069479338, "title": "Message Board", @@ -73,7 +75,9 @@ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/todos/1069479400.json", "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/todos/1069479400", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--eeffgghh.json", - "description": "Create detailed specifications for the Leto laptop display panel", + "content": null, + "description": null, + "plain_text_description": "Create detailed specifications for the Leto laptop keyboard layout, including key spacing and travel distance.", "parent": { "id": 1069479390, "title": "Hardware Design", @@ -134,7 +138,9 @@ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/comments/1069479450.json", "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/comments/1069479450", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDUwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--iijjkkll.json", - "content": "
The Leto keyboard layout looks great. Let's finalize it.
", + "content": null, + "description": null, + "plain_text_content": "The Leto keyboard layout looks great. Let's finalize it this week.", "parent": { "id": 1069479400, "title": "Design specs for Leto display", diff --git a/spec/smithy-build.json b/spec/smithy-build.json index ce6c17a88..5c16781c0 100644 --- a/spec/smithy-build.json +++ b/spec/smithy-build.json @@ -35,6 +35,10 @@ }, "/components/schemas/SearchType/properties/key/type": ["string", "null"], "/components/schemas/SearchType/properties/key/x-go-type": "*string", + "/components/schemas/SearchResult/properties/content/type": ["string", "null"], + "/components/schemas/SearchResult/properties/content/x-go-type": "*string", + "/components/schemas/SearchResult/properties/description/type": ["string", "null"], + "/components/schemas/SearchResult/properties/description/x-go-type": "*string", "/components/schemas/Wormhole/properties/destination_url/type": ["string", "null"], "/components/schemas/Wormhole/properties/destination_url/x-go-type": "*string", "/components/schemas/Wormhole/properties/color/type": ["string", "null"], diff --git a/swift/Sources/Basecamp/Generated/Models/SearchResult.swift b/swift/Sources/Basecamp/Generated/Models/SearchResult.swift index 64ccb72f2..00e7f155b 100644 --- a/swift/Sources/Basecamp/Generated/Models/SearchResult.swift +++ b/swift/Sources/Basecamp/Generated/Models/SearchResult.swift @@ -3,20 +3,22 @@ import Foundation public struct SearchResult: Codable, Sendable { public let appUrl: String + public let content: String? + public let description: String? public let id: Int public let title: String public let type: String public let url: String public var bookmarkUrl: String? public var bucket: RecordingBucket? - public var content: String? public var contentAttachments: [RichTextAttachment]? public var createdAt: String? public var creator: Person? - public var description: String? public var descriptionAttachments: [RichTextAttachment]? public var inheritsStatus: Bool? public var parent: RecordingParent? + public var plainTextContent: String? + public var plainTextDescription: String? public var status: String? public var subject: String? public var updatedAt: String? @@ -24,43 +26,121 @@ public struct SearchResult: Codable, Sendable { public init( appUrl: String, + content: String?, + description: String?, id: Int, title: String, type: String, url: String, bookmarkUrl: String? = nil, bucket: RecordingBucket? = nil, - content: String? = nil, contentAttachments: [RichTextAttachment]? = nil, createdAt: String? = nil, creator: Person? = nil, - description: String? = nil, descriptionAttachments: [RichTextAttachment]? = nil, inheritsStatus: Bool? = nil, parent: RecordingParent? = nil, + plainTextContent: String? = nil, + plainTextDescription: String? = nil, status: String? = nil, subject: String? = nil, updatedAt: String? = nil, visibleToClients: Bool? = nil ) { self.appUrl = appUrl + self.content = content + self.description = description self.id = id self.title = title self.type = type self.url = url self.bookmarkUrl = bookmarkUrl self.bucket = bucket - self.content = content self.contentAttachments = contentAttachments self.createdAt = createdAt self.creator = creator - self.description = description self.descriptionAttachments = descriptionAttachments self.inheritsStatus = inheritsStatus self.parent = parent + self.plainTextContent = plainTextContent + self.plainTextDescription = plainTextDescription self.status = status self.subject = subject self.updatedAt = updatedAt self.visibleToClients = visibleToClients } + + enum CodingKeys: String, CodingKey { + case appUrl + case content + case description + case id + case title + case type + case url + case bookmarkUrl + case bucket + case contentAttachments + case createdAt + case creator + case descriptionAttachments + case inheritsStatus + case parent + case plainTextContent + case plainTextDescription + case status + case subject + case updatedAt + case visibleToClients + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.appUrl = try container.decode(String.self, forKey: .appUrl) + self.content = try container.decode(String?.self, forKey: .content) + self.description = try container.decode(String?.self, forKey: .description) + self.id = try container.decode(Int.self, forKey: .id) + self.title = try container.decode(String.self, forKey: .title) + self.type = try container.decode(String.self, forKey: .type) + self.url = try container.decode(String.self, forKey: .url) + self.bookmarkUrl = try container.decodeIfPresent(String.self, forKey: .bookmarkUrl) + self.bucket = try container.decodeIfPresent(RecordingBucket.self, forKey: .bucket) + self.contentAttachments = try container.decodeIfPresent([RichTextAttachment].self, forKey: .contentAttachments) + self.createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) + self.creator = try container.decodeIfPresent(Person.self, forKey: .creator) + self.descriptionAttachments = try container.decodeIfPresent([RichTextAttachment].self, forKey: .descriptionAttachments) + self.inheritsStatus = try container.decodeIfPresent(Bool.self, forKey: .inheritsStatus) + self.parent = try container.decodeIfPresent(RecordingParent.self, forKey: .parent) + self.plainTextContent = try container.decodeIfPresent(String.self, forKey: .plainTextContent) + self.plainTextDescription = try container.decodeIfPresent(String.self, forKey: .plainTextDescription) + self.status = try container.decodeIfPresent(String.self, forKey: .status) + self.subject = try container.decodeIfPresent(String.self, forKey: .subject) + self.updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + self.visibleToClients = try container.decodeIfPresent(Bool.self, forKey: .visibleToClients) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.appUrl, forKey: .appUrl) + try container.encode(self.content, forKey: .content) + try container.encode(self.description, forKey: .description) + try container.encode(self.id, forKey: .id) + try container.encode(self.title, forKey: .title) + try container.encode(self.type, forKey: .type) + try container.encode(self.url, forKey: .url) + try container.encodeIfPresent(self.bookmarkUrl, forKey: .bookmarkUrl) + try container.encodeIfPresent(self.bucket, forKey: .bucket) + try container.encodeIfPresent(self.contentAttachments, forKey: .contentAttachments) + try container.encodeIfPresent(self.createdAt, forKey: .createdAt) + try container.encodeIfPresent(self.creator, forKey: .creator) + try container.encodeIfPresent(self.descriptionAttachments, forKey: .descriptionAttachments) + try container.encodeIfPresent(self.inheritsStatus, forKey: .inheritsStatus) + try container.encodeIfPresent(self.parent, forKey: .parent) + try container.encodeIfPresent(self.plainTextContent, forKey: .plainTextContent) + try container.encodeIfPresent(self.plainTextDescription, forKey: .plainTextDescription) + try container.encodeIfPresent(self.status, forKey: .status) + try container.encodeIfPresent(self.subject, forKey: .subject) + try container.encodeIfPresent(self.updatedAt, forKey: .updatedAt) + try container.encodeIfPresent(self.visibleToClients, forKey: .visibleToClients) + } } diff --git a/swift/Tests/BasecampTests/SearchResultDecodeTests.swift b/swift/Tests/BasecampTests/SearchResultDecodeTests.swift new file mode 100644 index 000000000..d07897433 --- /dev/null +++ b/swift/Tests/BasecampTests/SearchResultDecodeTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import Basecamp + +/// `SearchResult.content` and `.description` are **required and nullable**: the +/// search projection in `api/searches/show.json.jbuilder` renders the +/// recording's own partial and then unconditionally overwrites both with `nil` +/// to keep the large HTML body out of the payload. So the keys are guaranteed +/// present and their values guaranteed null. +/// +/// That is a different contract from optional-and-nullable, and the difference +/// is observable: a payload that *omits* the keys must fail to decode. These +/// tests pin both halves, so a regeneration that relaxed `@required` — or that +/// made the fields optional — would fail here rather than silently widening the +/// type. +final class SearchResultDecodeTests: XCTestCase { + /// Mirrors the key strategies `BaseService` configures, so these tests + /// exercise the same decode path the SDK actually uses. + private func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } + + private func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + } + + /// The minimum required key set, less content/description, so each test can + /// vary only the fields under test. + private func payload(extraKeys: String) -> Data { + """ + { + "id": 1069479351, + "title": "We won Leto!", + "type": "Message", + "url": "https://3.basecampapi.com/195539477/buckets/1/messages/1069479351.json", + "app_url": "https://3.basecamp.com/195539477/buckets/1/messages/1069479351" + \(extraKeys) + } + """.data(using: .utf8)! + } + + func testDecodesNullContentAndDescription() throws { + let result = try makeDecoder().decode( + SearchResult.self, + from: payload(extraKeys: #", "content": null, "description": null"#) + ) + + XCTAssertNil(result.content) + XCTAssertNil(result.description) + } + + func testRejectsOmittedContent() { + // Optional-and-nullable would accept this. Required-and-nullable must not. + XCTAssertThrowsError( + try makeDecoder().decode(SearchResult.self, from: payload(extraKeys: #", "description": null"#)) + ) { error in + guard case DecodingError.keyNotFound(let key, _) = error else { + return XCTFail("expected a keyNotFound error for `content`, got \(error)") + } + XCTAssertEqual(key.stringValue, "content") + } + } + + func testRejectsOmittedDescription() { + XCTAssertThrowsError( + try makeDecoder().decode(SearchResult.self, from: payload(extraKeys: #", "content": null"#)) + ) { error in + guard case DecodingError.keyNotFound(let key, _) = error else { + return XCTFail("expected a keyNotFound error for `description`, got \(error)") + } + XCTAssertEqual(key.stringValue, "description") + } + } + + /// The plain-text excerpts are the opposite contract: optional and + /// non-nullable. A Message carries `plain_text_content` and omits + /// `plain_text_description` entirely rather than sending null. + func testPlainTextFieldsAreOptionalAndOmittedWhenAbsent() throws { + let excerpt = #"Hello everyone! We got the Leto Laptop project!"# + let result = try makeDecoder().decode( + SearchResult.self, + from: payload(extraKeys: #", "content": null, "description": null, "plain_text_content": "\#(excerpt)""#) + ) + + XCTAssertNotNil(result.plainTextContent) + // Despite the name, this is an HTML fragment: matches are wrapped in + // and the whole thing is truncated to 300 + // characters by BC3. + XCTAssertTrue(result.plainTextContent?.contains(#""#) ?? false) + XCTAssertNil(result.plainTextDescription) + } + + /// Re-encoding must keep the nulls on the wire. Dropping them would turn a + /// required key into an absent one and break round-tripping. + func testReencodesNullsRatherThanOmittingThem() throws { + let result = try makeDecoder().decode( + SearchResult.self, + from: payload(extraKeys: #", "content": null, "description": null"#) + ) + + let encoded = try makeEncoder().encode(result) + let object = try XCTUnwrap( + try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + + XCTAssertTrue(object.keys.contains("content")) + XCTAssertTrue(object.keys.contains("description")) + XCTAssertTrue(object["content"] is NSNull) + XCTAssertTrue(object["description"] is NSNull) + XCTAssertFalse(object.keys.contains("plain_text_content")) + } +} diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index ded8c07bb..73c364b33 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-27T18:27:04.758Z", + "generated": "2026-07-28T06:50:06.535Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 92245c107..616adad5f 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -26812,10 +26812,28 @@ "$ref": "#/components/schemas/Person" }, "content": { - "type": "string" + "type": [ + "string", + "null" + ], + "description": "Always present, always null. `api/searches/show.json.jbuilder` renders the\nrecording's own partial and then unconditionally overwrites `content` with\n`nil` to strip the large HTML body out of the search payload. The key is\ntherefore guaranteed present on every result — required — and its value is\nguaranteed null. Read `plain_text_content` instead.", + "x-go-type": "*string" }, "description": { - "type": "string" + "type": [ + "string", + "null" + ], + "description": "Always present, always null — the description-attribute counterpart to\n`content`. Read `plain_text_description` instead.", + "x-go-type": "*string" + }, + "plain_text_content": { + "type": "string", + "description": "A highlighted, truncated excerpt of the recording's content — **not** plain\ntext despite the name. `excerpt_and_highlight_matches` converts the rich\ntext with `to_plain_text`, escapes it with `html_escape_once`, then wraps\neach query match in `` and\ntruncates the result to 300 characters. Treat it as an HTML fragment.\n\nOptional and non-nullable: emitted only when the underlying recordable\nresponds to `content`, so a result whose type has no content attribute\nomits the key entirely rather than sending null." + }, + "plain_text_description": { + "type": "string", + "description": "The description-attribute counterpart to `plain_text_content`, with the\nsame highlighting, escaping, and 300-character truncation. Optional and\nnon-nullable — omitted when the recordable has no description attribute." }, "content_attachments": { "type": "array", @@ -26837,6 +26855,8 @@ }, "required": [ "app_url", + "content", + "description", "id", "title", "type", diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index c9598b3c7..8775fb936 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -4753,8 +4753,37 @@ export interface components { parent?: components["schemas"]["RecordingParent"]; bucket?: components["schemas"]["RecordingBucket"]; creator?: components["schemas"]["Person"]; - content?: string; - description?: string; + /** + * @description Always present, always null. `api/searches/show.json.jbuilder` renders the + * recording's own partial and then unconditionally overwrites `content` with + * `nil` to strip the large HTML body out of the search payload. The key is + * therefore guaranteed present on every result — required — and its value is + * guaranteed null. Read `plain_text_content` instead. + */ + content: string | null; + /** + * @description Always present, always null — the description-attribute counterpart to + * `content`. Read `plain_text_description` instead. + */ + description: string | null; + /** + * @description A highlighted, truncated excerpt of the recording's content — **not** plain + * text despite the name. `excerpt_and_highlight_matches` converts the rich + * text with `to_plain_text`, escapes it with `html_escape_once`, then wraps + * each query match in `` and + * truncates the result to 300 characters. Treat it as an HTML fragment. + * + * Optional and non-nullable: emitted only when the underlying recordable + * responds to `content`, so a result whose type has no content attribute + * omits the key entirely rather than sending null. + */ + plain_text_content?: string; + /** + * @description The description-attribute counterpart to `plain_text_content`, with the + * same highlighting, escaping, and 300-character truncation. Optional and + * non-nullable — omitted when the recordable has no description attribute. + */ + plain_text_description?: string; /** * @description Rich-text companion arrays carried through the polymorphic search * projection. A given result is one recording type, so it carries only From 985acd0bf90b19229b1fdb908398853b41aa4c17 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 28 Jul 2026 13:00:00 -0700 Subject: [PATCH 2/3] Search stubs: send the payload BC3 actually sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeScript and Ruby search-service stubs omitted content and description entirely. Under the corrected contract those keys are REQUIRED and nullable — BC3 emits json.content nil / json.description nil unconditionally on every result — so the stubs described a payload the API cannot produce. Both suites still passed, because neither SDK validates required fields at runtime: TypeScript's types are compile-time only and Ruby just reads keys. Green therefore proved nothing about the change this PR makes. Stubs now carry content: null and description: null, one result in each carries a plain_text_* excerpt, and both suites assert the discriminating property — the keys are PRESENT and null, not absent — plus that the excerpt is only there for the result type that has the underlying attribute. Verified the assertions bite: dropping the two keys back out of the TypeScript stub fails with "expected undefined to be null". --- .../basecamp/services/search_service_test.rb | 20 ++++++++++++++-- typescript/node_modules | 1 + typescript/tests/services/search.test.ts | 23 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 120000 typescript/node_modules diff --git a/ruby/test/basecamp/services/search_service_test.rb b/ruby/test/basecamp/services/search_service_test.rb index b05ed96a6..37253ec3a 100644 --- a/ruby/test/basecamp/services/search_service_test.rb +++ b/ruby/test/basecamp/services/search_service_test.rb @@ -13,8 +13,14 @@ def test_search results = [ # The search projection carries the matching type's rich-text companion # array; a Message result surfaces content_attachments. - { "id" => 1, "title" => "Quarterly Report", "type" => "Message", "content_attachments" => [] }, - { "id" => 2, "title" => "Q1 Report Draft", "type" => "Document", "content_attachments" => [] } + # BC3 emits `json.content nil` / `json.description nil` unconditionally on + # every search result, so both keys are always present and always null. A + # stub that omits them is a payload the API cannot produce. + { "id" => 1, "title" => "Quarterly Report", "type" => "Message", "content_attachments" => [], + "content" => nil, "description" => nil, + "plain_text_content" => "Q1 Report summary." }, + { "id" => 2, "title" => "Q1 Report Draft", "type" => "Document", "content_attachments" => [], + "content" => nil, "description" => nil } ] stub_request(:get, "https://3.basecampapi.com/12345/search.json") .with(query: { q: "quarterly report" }) @@ -27,6 +33,16 @@ def test_search # The optional projection array surfaces on each matching-type result. assert_equal [], result[0]["content_attachments"] assert_equal [], result[1]["content_attachments"] + + # Present and null, never absent. + result.each do |r| + assert r.key?("content"), "content must be present on every search result" + assert_nil r["content"] + assert r.key?("description"), "description must be present on every search result" + assert_nil r["description"] + end + assert_includes result[0]["plain_text_content"], "circled-text" + assert_nil result[1]["plain_text_content"] end def test_search_with_sort diff --git a/typescript/node_modules b/typescript/node_modules new file mode 120000 index 000000000..a67e96fce --- /dev/null +++ b/typescript/node_modules @@ -0,0 +1 @@ +/Users/jeremy/Worktrees/basecamp/basecamp-sdk/feat/wormholes/typescript/node_modules \ No newline at end of file diff --git a/typescript/tests/services/search.test.ts b/typescript/tests/services/search.test.ts index 6686dd268..6e43aebf4 100644 --- a/typescript/tests/services/search.test.ts +++ b/typescript/tests/services/search.test.ts @@ -33,6 +33,13 @@ describe("SearchService", () => { status: "active", url: "https://example.com/1", app_url: "https://basecamp.com/1", + // BC3 emits `json.content nil` / `json.description nil` + // unconditionally on every search result, so the keys are always + // present and always null. A stub that omits them is a payload the + // API cannot produce, and would let the required-and-nullable + // contract regress unnoticed. + content: null, + description: null, }, { id: 2, @@ -41,6 +48,12 @@ describe("SearchService", () => { status: "active", url: "https://example.com/2", app_url: "https://basecamp.com/2", + content: null, + description: null, + // The searchable text lives here instead — a highlighted, truncated + // excerpt, not plain text despite the name. + plain_text_content: + 'Notes from the Leto kickoff.', // The search projection carries the matching type's rich-text // companion array; a Message result surfaces content_attachments. content_attachments: [ @@ -75,6 +88,16 @@ describe("SearchService", () => { expect(results).toHaveLength(2); // The optional projection array surfaces on the matching-type result. expect(results[1]!.content_attachments).toHaveLength(1); + + // The contract: present and null, never absent. + for (const r of results) { + expect(r.content).toBeNull(); + expect(r.description).toBeNull(); + } + expect(results[1]!.plain_text_content).toContain( + 'mark class="circled-text"', + ); + expect(results[0]!.plain_text_content).toBeUndefined(); expect(results[1]!.content_attachments![0]!.width).toBe(1024); expect(results[0]!.title).toBe("Project Plan"); expect(results[1]!.type).toBe("Message"); From fba66ddd691292d4151a2fd199b802734e666e70 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 28 Jul 2026 14:19:48 -0700 Subject: [PATCH 3/3] Drop the committed node_modules symlink; finish the Ruby SearchResult stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I linked typescript/node_modules into this worktree to run the TypeScript suite and then committed it. The link is absolute and points at my own machine, so it is worse than useless to anyone else — and it propagated to the two PRs stacked on this one. It slipped past .gitignore because both rules were written with a trailing slash (`node_modules/`), which matches a directory but not a symlink of the same name. Dropped the slash in typescript/.gitignore and the root conformance rule, and verified: recreating the symlink now leaves `git status` clean. Separately, the Ruby search stubs were still impossible payloads even after gaining content/description. SearchResult.required_fields is [app_url content description id title type url] and the stubs carried neither url nor app_url. Both added. Added test_search_result_preserves_null_content_and_description, mirroring the existing SearchType.key and Wormhole.color/destination_url tests: to_h must keep the explicit nulls rather than compacting them, since a consumer has to tell "the projection stripped this" from "absent". It also pins required_fields itself, so a regeneration that relaxed @required fails here. --- .gitignore | 2 +- ruby/test/basecamp/generated_types_test.rb | 26 +++++++++++++++++++ .../basecamp/services/search_service_test.rb | 4 +++ typescript/.gitignore | 2 +- typescript/node_modules | 1 - 5 files changed, 32 insertions(+), 3 deletions(-) delete mode 120000 typescript/node_modules diff --git a/.gitignore b/.gitignore index 1d71b1410..5db3c7af8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ spec/build/ conformance/runner/go/conformance-runner conformance/runner/go/go conformance/runner/ruby/Gemfile.lock -conformance/runner/typescript/node_modules/ +conformance/runner/typescript/node_modules # Claude Code session files (except skills, which are committed) .claude/* diff --git a/ruby/test/basecamp/generated_types_test.rb b/ruby/test/basecamp/generated_types_test.rb index 933252d0d..934b9cefb 100644 --- a/ruby/test/basecamp/generated_types_test.rb +++ b/ruby/test/basecamp/generated_types_test.rb @@ -127,6 +127,32 @@ def test_search_type_preserves_null_key assert_equal "Message", real_option.to_h["key"] end + # SearchResult.content and SearchResult.description are required-and-nullable: + # api/searches/show.json.jbuilder renders the recording's own partial and then + # unconditionally overwrites both with nil to keep the large HTML body out of + # the search payload, so the keys are always present and always null. to_h must + # preserve those explicit nulls rather than compacting them away — a consumer + # has to be able to tell "the projection stripped this" from "absent". + def test_search_result_preserves_null_content_and_description + result = Basecamp::Types::SearchResult.new( + "id" => 1, "title" => "Quarterly Report", "type" => "Message", + "url" => "https://3.basecampapi.com/12345/buckets/1/messages/1.json", + "app_url" => "https://3.basecamp.com/12345/buckets/1/messages/1", + "content" => nil, "description" => nil, + "plain_text_content" => "Q1 Report summary." + ) + hash = result.to_h + + assert hash.key?("content"), "required-nullable content must stay present" + assert_nil hash["content"] + assert hash.key?("description"), "required-nullable description must stay present" + assert_nil hash["description"] + # The excerpt is the opposite contract: optional and non-nullable. + assert_includes hash["plain_text_content"], "circled-text" + assert_equal %i[app_url content description id title type url], + Basecamp::Types::SearchResult.required_fields + end + # Wormhole.color and Wormhole.destination_url are required-and-nullable: the bc3 # jbuilder always emits them, null when unset/unlinked. to_h must preserve those # explicit nulls (the destination_url is the only field identifying the target), diff --git a/ruby/test/basecamp/services/search_service_test.rb b/ruby/test/basecamp/services/search_service_test.rb index 37253ec3a..9007cb9d6 100644 --- a/ruby/test/basecamp/services/search_service_test.rb +++ b/ruby/test/basecamp/services/search_service_test.rb @@ -17,9 +17,13 @@ def test_search # every search result, so both keys are always present and always null. A # stub that omits them is a payload the API cannot produce. { "id" => 1, "title" => "Quarterly Report", "type" => "Message", "content_attachments" => [], + "url" => "https://3.basecampapi.com/12345/buckets/1/messages/1.json", + "app_url" => "https://3.basecamp.com/12345/buckets/1/messages/1", "content" => nil, "description" => nil, "plain_text_content" => "Q1 Report summary." }, { "id" => 2, "title" => "Q1 Report Draft", "type" => "Document", "content_attachments" => [], + "url" => "https://3.basecampapi.com/12345/buckets/1/documents/2.json", + "app_url" => "https://3.basecamp.com/12345/buckets/1/documents/2", "content" => nil, "description" => nil } ] stub_request(:get, "https://3.basecampapi.com/12345/search.json") diff --git a/typescript/.gitignore b/typescript/.gitignore index b94707787..44d646d58 100644 --- a/typescript/.gitignore +++ b/typescript/.gitignore @@ -1,2 +1,2 @@ -node_modules/ +node_modules dist/ diff --git a/typescript/node_modules b/typescript/node_modules deleted file mode 120000 index a67e96fce..000000000 --- a/typescript/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/jeremy/Worktrees/basecamp/basecamp-sdk/feat/wormholes/typescript/node_modules \ No newline at end of file