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/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/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 b05ed96a6..9007cb9d6 100644 --- a/ruby/test/basecamp/services/search_service_test.rb +++ b/ruby/test/basecamp/services/search_service_test.rb @@ -13,8 +13,18 @@ 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" => [], + "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") .with(query: { q: "quarterly report" }) @@ -27,6 +37,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/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/.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/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 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");