diff --git a/conformance/runner/go/main.go b/conformance/runner/go/main.go index d06d35fa9..8439f9077 100644 --- a/conformance/runner/go/main.go +++ b/conformance/runner/go/main.go @@ -538,6 +538,22 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T _, err := account.Todos().Update(ctx, todoID, req) return operationResult{err: err} + case "UpdateScheduleEntry": + // Participants are presence-bearing: absent means "leave them alone" + // (BC3 preserves only because the key is missing), an empty non-nil + // slice means "remove everyone". + entryID := getInt64Param(tc.PathParams, "entryId") + req := &basecamp.UpdateScheduleEntryRequest{ + Summary: getStringParam(tc.RequestBody, "summary"), + StartsAt: getStringParam(tc.RequestBody, "starts_at"), + EndsAt: getStringParam(tc.RequestBody, "ends_at"), + } + if ids, ok := getInt64SliceParam(tc.RequestBody, "participant_ids"); ok { + req.ParticipantIDs = ids + } + _, err := account.Schedules().UpdateEntry(ctx, entryID, req) + return operationResult{err: err} + case "UpdateCard": // Merge-safe composite: GET then PUT, resending the fetched due_on. _, err := account.Cards().Update(ctx, getInt64Param(tc.PathParams, "cardId"), cardUpdateRequest(tc.RequestBody)) diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index 7a98cd503..73e3259f1 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -26,6 +26,7 @@ # Wire keys for todo write operations; identical to the Python kwarg / # edit-attribute names, so fixtures map onto the SDK surface directly. _TODO_WRITE_FIELDS = ("content", "description", "assignee_ids", "completion_subscriber_ids", "due_on", "starts_on", "notify") +_SCHEDULE_ENTRY_WRITE_FIELDS = ("summary", "starts_at", "ends_at", "description", "participant_ids", "all_day", "notify") _CARD_WRITE_FIELDS = ("title", "content", "due_on", "assignee_ids") # Sentinel distinguishing "key absent from the JSON body" from a present None. @@ -102,6 +103,13 @@ def __call__(self, operation: str, *, path_params: dict, query_params: dict, bod todo_id=path_params["todoId"], **{k: body[k] for k in _TODO_WRITE_FIELDS if k in body}, ) + case "UpdateScheduleEntry": + # Only pass keys the fixture carries: _compact strips None, so + # an absent participant_ids stays off the wire while [] survives. + return self._account.schedules.update_entry( + entry_id=path_params["entryId"], + **{k: body[k] for k in _SCHEDULE_ENTRY_WRITE_FIELDS if k in body}, + ) case "UpdateCard": # Merge-safe composite: GET then PUT, resending the fetched due_on. return self._account.cards.update( diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index 1385821d4..94ba320cb 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -156,6 +156,10 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "") todo_id: path_params["todoId"], **todo_write_kwargs(body) ) + when "UpdateScheduleEntry" + # Only pass keys the fixture carries: compact_params strips nil, so an + # absent participant_ids stays off the wire while [] survives. + @account.schedules.update_entry(entry_id: path_params["entryId"], **schedule_entry_write_kwargs(body)) when "UpdateCard" # Merge-safe composite: GET then PUT, resending the fetched due_on. @account.cards.update(card_id: path_params["cardId"], **card_write_kwargs(body)) @@ -217,6 +221,13 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "") content description assignee_ids completion_subscriber_ids due_on starts_on notify ].freeze + SCHEDULE_ENTRY_WRITE_KEYS = %w[summary starts_at ends_at description participant_ids all_day notify].freeze + + def schedule_entry_write_kwargs(body) + SCHEDULE_ENTRY_WRITE_KEYS.select { |key| (body || {}).key?(key) } \ + .to_h { |key| [key.to_sym, body[key]] } + end + CARD_WRITE_KEYS = %w[title content due_on assignee_ids].freeze def card_write_kwargs(body) diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 4f19df9fc..6e87dcccd 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -234,6 +234,17 @@ async function executeOperation( await client.todos.update(Number(params.todoId), mapTodoWireFields(body)); break; + case "UpdateScheduleEntry": + // Only spread participantIds when the fixture carries the key: an + // absent key must not become [] or null on the wire. + await client.schedules.updateEntry(Number(params.entryId), { + ...(body.summary !== undefined ? { summary: String(body.summary) } : {}), + ...(body.participant_ids !== undefined + ? { participantIds: body.participant_ids as number[] } + : {}), + }); + break; + case "UpdateCard": // Merge-safe composite: GET then PUT, resending the fetched due_on. await client.cards.update(Number(params.cardId), { diff --git a/conformance/tests/schedule_entries_write.json b/conformance/tests/schedule_entries_write.json new file mode 100644 index 000000000..3e66ba708 --- /dev/null +++ b/conformance/tests/schedule_entries_write.json @@ -0,0 +1,192 @@ +[ + { + "name": "update-omits-participant-ids: an unaddressed participant list stays off the wire", + "description": "BC3 preserves the entry's participants only because the key is absent \u2014 until basecamp/bc3#12425 the controller called replace_participants unconditionally, so an omitted key wiped every participant and notified them. The SDK's job is narrow but load-bearing: when the caller says nothing about participants, participant_ids must not appear in the body at all. A compactor that emitted null, or a default that emitted [], would clear the list on the server.", + "operation": "UpdateScheduleEntry", + "method": "PUT", + "path": "/schedule_entries/{entryId}", + "pathParams": { + "entryId": 1069479523 + }, + "requestBody": { + "summary": "Team Meeting" + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479523, + "status": "active", + "visible_to_clients": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "title": "Team Meeting", + "inherits_status": true, + "type": "Schedule::Entry", + "url": "https://3.basecampapi.com/999/buckets/1/schedule_entries/1069479523.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedule_entries/1069479523", + "summary": "Team Meeting", + "all_day": false, + "starts_at": "2026-06-05T06:00:00Z", + "ends_at": "2026-06-05T08:30:00Z", + "participants": [ + { + "id": 1049715914, + "name": "Victor Cooper" + }, + { + "id": 1049715915, + "name": "Annie Bryan" + } + ], + "description_attachments": [], + "parent": { + "id": 1069479521, + "title": "Schedule", + "type": "Schedule", + "url": "https://3.basecampapi.com/999/buckets/1/schedules/1069479521.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedules/1069479521" + }, + "bucket": { + "id": 1, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715914, + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestMethod", + "expected": "PUT", + "index": 0 + }, + { + "type": "requestBody", + "path": "summary", + "expected": "Team Meeting", + "index": 0 + }, + { + "type": "requestBodyAbsent", + "path": "participant_ids", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "schedule-entries", + "participants", + "omission" + ] + }, + { + "name": "update-empty-participant-ids: an explicit empty list reaches the wire", + "description": "The counterpart to omission: passing an explicitly empty list means \"remove everyone\" and must be sent as participant_ids: []. Ruby's compact_params and Python's _compact strip nil/None but not empty collections, so this distinguishes \"not addressed\" from \"addressed and cleared\" \u2014 the whole point of the BC3 guard.", + "operation": "UpdateScheduleEntry", + "method": "PUT", + "path": "/schedule_entries/{entryId}", + "pathParams": { + "entryId": 1069479523 + }, + "requestBody": { + "summary": "Team Meeting", + "participant_ids": [] + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479523, + "status": "active", + "visible_to_clients": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "title": "Team Meeting", + "inherits_status": true, + "type": "Schedule::Entry", + "url": "https://3.basecampapi.com/999/buckets/1/schedule_entries/1069479523.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedule_entries/1069479523", + "summary": "Team Meeting", + "all_day": false, + "starts_at": "2026-06-05T06:00:00Z", + "ends_at": "2026-06-05T08:30:00Z", + "participants": [ + { + "id": 1049715914, + "name": "Victor Cooper" + }, + { + "id": 1049715915, + "name": "Annie Bryan" + } + ], + "description_attachments": [], + "parent": { + "id": 1069479521, + "title": "Schedule", + "type": "Schedule", + "url": "https://3.basecampapi.com/999/buckets/1/schedules/1069479521.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedules/1069479521" + }, + "bucket": { + "id": 1, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715914, + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestMethod", + "expected": "PUT", + "index": 0 + }, + { + "type": "requestBody", + "path": "participant_ids", + "expected": [], + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "schedule-entries", + "participants", + "clear" + ] + } +] diff --git a/go/pkg/basecamp/api-provenance.json b/go/pkg/basecamp/api-provenance.json index 83295cf85..b6ce27f98 100644 --- a/go/pkg/basecamp/api-provenance.json +++ b/go/pkg/basecamp/api-provenance.json @@ -1,8 +1,8 @@ { "bc3": { "branch": "master", - "revision": "c308693171680e8ec9d3ae9e68181ef2ea80f077", - "date": "2026-07-26" + "revision": "dffa7e11b3337b17454d9a82301be3e94226a858", + "date": "2026-07-28" }, "compatibility": { "bc3-four": { diff --git a/go/pkg/basecamp/version.go b/go/pkg/basecamp/version.go index 2aa6e4af6..7d6cd140a 100644 --- a/go/pkg/basecamp/version.go +++ b/go/pkg/basecamp/version.go @@ -4,4 +4,4 @@ package basecamp const Version = "0.9.0" // APIVersion is the Basecamp API version this SDK targets. -const APIVersion = "2026-07-26" +const APIVersion = "2026-07-28" diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index b596c64d3..1144f1072 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -2953,10 +2953,20 @@ type UpdateQuestionResponseContent = Question // UpdateScheduleEntryRequestContent defines model for UpdateScheduleEntryRequestContent. type UpdateScheduleEntryRequestContent struct { - AllDay *bool `json:"all_day,omitempty"` - Description string `json:"description,omitempty"` - EndsAt time.Time `json:"ends_at,omitempty"` - Notify *bool `json:"notify,omitempty"` + AllDay *bool `json:"all_day,omitempty"` + Description string `json:"description,omitempty"` + EndsAt time.Time `json:"ends_at,omitempty"` + Notify *bool `json:"notify,omitempty"` + + // ParticipantIds Replaces the entry's participants. + // + // Omitting this member preserves the current participants; sending an empty + // array clears them. That guarantee is BC3-side and recent: until + // basecamp/bc3#12425, `Schedules::EntriesController#update` called + // `replace_participants` unconditionally, so any update omitting the key — + // including the shape in BC3's own "Update a schedule entry" doc example — + // silently removed every participant and notified each one. The controller + // now guards on the request actually addressing participants. ParticipantIds []int64 `json:"participant_ids,omitempty"` StartsAt time.Time `json:"starts_at,omitempty"` Summary string `json:"summary,omitempty"` diff --git a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt index 675b0f3d3..c1da394e7 100644 --- a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt +++ b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt @@ -690,6 +690,20 @@ private suspend fun dispatchOperation(tc: TestCase, account: AccountClient): Dis DispatchResult() } + // Participants are presence-bearing: an absent key must not become an + // empty list on the wire, or BC3 clears the participants. + "UpdateScheduleEntry" -> { + val entryId = tc.pathParams.longParam("entryId") + val rb = tc.requestBody + account.schedules.updateEntry(entryId, UpdateScheduleEntryBody( + summary = rb?.get("summary")?.jsonPrimitive?.contentOrNull, + startsAt = rb?.get("starts_at")?.jsonPrimitive?.contentOrNull, + endsAt = rb?.get("ends_at")?.jsonPrimitive?.contentOrNull, + participantIds = rb?.get("participant_ids")?.jsonArray?.map { it.jsonPrimitive.long }, + )) + DispatchResult() + } + // Merge-safe composite: GET then PUT, resending the fetched due_on. "UpdateCard" -> { val cardId = tc.pathParams.longParam("cardId") diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt index f1d1bfc01..f6198abf9 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt @@ -29,7 +29,7 @@ data class BasecampConfig( ) { companion object { const val VERSION = "0.9.0" - const val API_VERSION = "2026-07-26" + const val API_VERSION = "2026-07-28" const val DEFAULT_BASE_URL = "https://3.basecampapi.com" const val DEFAULT_USER_AGENT = "basecamp-sdk-kotlin/$VERSION (api:$API_VERSION)" const val DEFAULT_MAX_RETRIES = 3 diff --git a/openapi.json b/openapi.json index 37f71dc66..c779765cd 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Basecamp", - "version": "2026-07-26", + "version": "2026-07-28", "description": "Basecamp API", "contact": { "name": "Basecamp", @@ -31069,7 +31069,8 @@ "items": { "type": "integer", "format": "int64" - } + }, + "description": "Replaces the entry's participants.\n\nOmitting this member preserves the current participants; sending an empty\narray clears them. That guarantee is BC3-side and recent: until\nbasecamp/bc3#12425, `Schedules::EntriesController#update` called\n`replace_participants` unconditionally, so any update omitting the key —\nincluding the shape in BC3's own \"Update a schedule entry\" doc example —\nsilently removed every participant and notified each one. The controller\nnow guards on the request actually addressing participants." }, "all_day": { "type": "boolean", diff --git a/python/src/basecamp/_version.py b/python/src/basecamp/_version.py index f0db63589..5985be1b4 100644 --- a/python/src/basecamp/_version.py +++ b/python/src/basecamp/_version.py @@ -1,2 +1,2 @@ VERSION = "0.9.0" -API_VERSION = "2026-07-26" +API_VERSION = "2026-07-28" diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 470b54131..e4b92c8cc 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-28T07:10:09Z", + "generated": "2026-07-28T21:34:14Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/services/schedules_service.rb b/ruby/lib/basecamp/generated/services/schedules_service.rb index c9e77a000..6d81442bf 100644 --- a/ruby/lib/basecamp/generated/services/schedules_service.rb +++ b/ruby/lib/basecamp/generated/services/schedules_service.rb @@ -22,7 +22,15 @@ def get_entry(entry_id:) # @param starts_at [String, nil] starts at (RFC3339 (e.g., 2024-12-15T09:00:00Z)) # @param ends_at [String, nil] ends at (RFC3339 (e.g., 2024-12-15T09:00:00Z)) # @param description [String, nil] description - # @param participant_ids [Array, nil] participant ids + # @param participant_ids [Array, nil] Replaces the entry's participants. + # + # Omitting this member preserves the current participants; sending an empty + # array clears them. That guarantee is BC3-side and recent: until + # basecamp/bc3#12425, `Schedules::EntriesController#update` called + # `replace_participants` unconditionally, so any update omitting the key — + # including the shape in BC3's own "Update a schedule entry" doc example — + # silently removed every participant and notified each one. The controller + # now guards on the request actually addressing participants. # @param all_day [Boolean, nil] all day # @param notify [Boolean, nil] notify # @return [Hash] response data diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 03757bc3b..ebea1db13 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-28T07:10:09Z +# Generated: 2026-07-28T21:34:14Z require "json" require "time" diff --git a/ruby/lib/basecamp/version.rb b/ruby/lib/basecamp/version.rb index 72f4347de..a96aa3ee3 100644 --- a/ruby/lib/basecamp/version.rb +++ b/ruby/lib/basecamp/version.rb @@ -2,5 +2,5 @@ module Basecamp VERSION = "0.9.0" - API_VERSION = "2026-07-26" + API_VERSION = "2026-07-28" end diff --git a/spec/api-gaps/everything-tasks-aggregate.md b/spec/api-gaps/everything-tasks-aggregate.md new file mode 100644 index 000000000..5a9f9bda8 --- /dev/null +++ b/spec/api-gaps/everything-tasks-aggregate.md @@ -0,0 +1,100 @@ +--- +gap: everything-tasks-aggregate +status: confirmed-not-api-resource +detected: 2026-07-28 +sdk_demand: none +bc3_refs: + introduced_in: five + bc3_plan_section: "Not in API scope — web-only 'All tasks' page (bc3 e4bf93c3c2 + f697d8604d @ dffa7e11b3); HTML/Turbo Frame controllers, no respond_to :json, no jbuilder, no doc/api section" + routes: + - "GET /:account_id/tasks → everything/assignments#index (HTML only)" + - "GET /:account_id/tasks/results → everything/assignments/results#index (Turbo Frame, HTML only)" + controllers: + - "Everything::AssignmentsController" + - "Everything::Assignments::ResultsController" + related_existing_api: + - "GetEverythingOpenTodos / GetEverythingOverdueTodos / … (the modeled Everything JSON aggregates)" + - "GetMyAssignments / GetMyDueAssignments / GetMyCompletedAssignments (my/assignments.json — unchanged)" +--- + +# Everything "tasks" — a web view, not an API resource + +## What's missing + +Nothing. This entry exists to record a **negative** result, so the same routes +don't get re-flagged at the next provenance sync. + +BC3 gained routes under the `scope module: :everything` block — the same block +that produces the modeled `/todos/open.json` and `/cards/completed.json`: + +```ruby +get "tasks" => "assignments#index", as: :assignments +namespace :assignments, path: "tasks" do + get "results" => "results#index" +end +``` + +Sitting in that block makes them *look* like siblings of the JSON aggregates. +They are not. At the pinned `dffa7e11b3` both controllers are HTML-only: + +```ruby +class Everything::AssignmentsController < ApplicationController + include Everything::Assignments::Filters + def index + end +end + +class Everything::Assignments::ResultsController < ApplicationController + include Assignments::SlicedBuckets, Everything::Assignments::Filters + layout "turbo_rails/frame" + def index + @assignments = Everything::Assignments.new(Current.person, **assignment_filter_params) + @page = sliced_buckets @assignments.recordings, buckets: @assignments.sorted_buckets, viewer: Current.person + end +end +``` + +- no `respond_to`, no JSON branch; +- every view under `app/views/everything/assignments/` is `.html.erb`; +- no `.json.jbuilder` template anywhere; +- `results` declares `layout "turbo_rails/frame"` — it exists to serve a Turbo + Frame for the filter UI; +- nothing in `doc/api/`. + +**A Rails route that tolerates a `.json` suffix does not create a JSON +representation.** Requesting these with `.json` gets the HTML template or a +missing-template error, not a payload. + +## Why it matters + +Only as a guard against a false positive. The provenance-drift triage from +`c308693171` to `dffa7e11b3` surfaced two commits — `e4bf93c3c2` added +`everything/assignments`, `f697d8604d` renamed it to `everything/tasks` — and +the naming makes them read as an unmodeled API aggregate. They are the +web-only "All tasks" page. + +**This is also not a break of `my/assignments`.** The commit subject "Move +/assignments to /tasks" reads alarming against an SDK that ships +`GetMyAssignments`, but the moved routes are the Everything ones. The SDK's +assignment operations use `/:account_id/my/assignments.json` and its +`completed`/`due` variants, which live under a different scope and are +untouched across the whole drift range — `git diff c308693171..dffa7e11b3 -- +config/routes.rb` contains only the two `tasks` additions. + +## Suggested API shape + +None. Deliberately not proposed: there is no captured response to model, and +sketching one would invite modeling a payload that does not exist. If an +account-wide assignments JSON aggregate is ever wanted, it needs a BC3-side +JSON representation first — at which point this entry should be reopened as a +real gap with a shape read off an actual response. + +## Implementation notes for BC3 + +None required. The routes serve the web UI as intended. + +## SDK absorption plan when this lands + +Not applicable — there is nothing to absorb. Reopen this entry only if BC3 adds +a JSON representation for these controllers; until then the correct SDK action +is to do nothing. diff --git a/spec/api-provenance.json b/spec/api-provenance.json index 83295cf85..b6ce27f98 100644 --- a/spec/api-provenance.json +++ b/spec/api-provenance.json @@ -1,8 +1,8 @@ { "bc3": { "branch": "master", - "revision": "c308693171680e8ec9d3ae9e68181ef2ea80f077", - "date": "2026-07-26" + "revision": "dffa7e11b3337b17454d9a82301be3e94226a858", + "date": "2026-07-28" }, "compatibility": { "bc3-four": { diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index c55a68634..70f39cc35 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -51,7 +51,7 @@ use basecamp.traits#basecampAuthRoutableUrl /// Basecamp API @restJson1 service Basecamp { - version: "2026-07-26" + version: "2026-07-28" rename: { "smithy.api#Document": "JsonDocument" } @@ -2718,6 +2718,15 @@ structure UpdateScheduleEntryInput { starts_at: ISO8601Timestamp ends_at: ISO8601Timestamp description: ScheduleEntryDescription + /// Replaces the entry's participants. + /// + /// Omitting this member preserves the current participants; sending an empty + /// array clears them. That guarantee is BC3-side and recent: until + /// basecamp/bc3#12425, `Schedules::EntriesController#update` called + /// `replace_participants` unconditionally, so any update omitting the key — + /// including the shape in BC3's own "Update a schedule entry" doc example — + /// silently removed every participant and notified each one. The controller + /// now guards on the request actually addressing participants. participant_ids: PersonIdList all_day: Boolean notify: Boolean diff --git a/swift/Sources/Basecamp/BasecampConfig.swift b/swift/Sources/Basecamp/BasecampConfig.swift index 48ffe464e..7ce0d3897 100644 --- a/swift/Sources/Basecamp/BasecampConfig.swift +++ b/swift/Sources/Basecamp/BasecampConfig.swift @@ -27,7 +27,7 @@ public struct BasecampConfig: Sendable { public static let version = "0.9.0" /// Basecamp API version this SDK targets. - public static let apiVersion = "2026-07-26" + public static let apiVersion = "2026-07-28" /// Default User-Agent header value. public static let defaultUserAgent = "basecamp-sdk-swift/\(version) (api:\(apiVersion))" diff --git a/swift/Tests/BasecampTests/ScheduleEntryParticipantsTests.swift b/swift/Tests/BasecampTests/ScheduleEntryParticipantsTests.swift new file mode 100644 index 000000000..017f380bc --- /dev/null +++ b/swift/Tests/BasecampTests/ScheduleEntryParticipantsTests.swift @@ -0,0 +1,141 @@ +import XCTest +@testable import Basecamp + +/// Swift mirror of `conformance/tests/schedule_entries_write.json`. +/// +/// Swift is not part of `make conformance`, so these carry the same two +/// assertions the five executable runners make. +/// +/// The contract is BC3-side and recent. Until basecamp/bc3#12425, +/// `Schedules::EntriesController#update` called `replace_participants` +/// unconditionally, so an update omitting `participant_ids` removed every +/// participant and notified each one — including the shape in BC3's own +/// "Update a schedule entry" doc example. The controller now guards on the +/// request actually addressing participants, which makes the SDK's job narrow +/// but load-bearing: an unaddressed list must stay *off the wire entirely*. +/// Emitting `null`, or defaulting to `[]`, would clear it on the server. +private final class ScheduleRequestLog: @unchecked Sendable { + private let lock = NSLock() + private var _putBody: [String: Any]? + + var putBody: [String: Any]? { lock.withLock { _putBody } } + + func record(_ request: URLRequest) { + guard request.httpMethod == "PUT" else { return } + lock.withLock { + if let data = request.httpBody ?? request.scheduleBodyStreamData() { + _putBody = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + } + } +} + +extension URLRequest { + fileprivate func scheduleBodyStreamData() -> Data? { + guard let stream = httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } +} + +private func scheduleEntryJSON(id: Int = 1069479523) -> [String: Any] { + [ + "id": id, + "status": "active", + "visible_to_clients": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "title": "Team Meeting", + "inherits_status": true, + "type": "Schedule::Entry", + "url": "https://3.basecampapi.com/999/buckets/1/schedule_entries/\(id).json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedule_entries/\(id)", + "summary": "Team Meeting", + "all_day": false, + "starts_at": "2026-06-05T06:00:00Z", + "ends_at": "2026-06-05T08:30:00Z", + "description_attachments": [] as [Any], + "participants": [ + ["id": 1049715914, "name": "Victor Cooper"], + ["id": 1049715915, "name": "Annie Bryan"], + ], + "parent": [ + "id": 1069479521, + "title": "Schedule", + "type": "Schedule", + "url": "https://3.basecampapi.com/999/buckets/1/schedules/1069479521.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedules/1069479521", + ], + "bucket": ["id": 1, "name": "The Leto Laptop", "type": "Project"], + "creator": [ + "id": 1049715914, + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + ], + ] +} + +final class ScheduleEntryParticipantsTests: XCTestCase { + private func makeClient(log: ScheduleRequestLog) throws -> AccountClient { + let data = try JSONSerialization.data(withJSONObject: scheduleEntryJSON()) + let transport = MockTransport { request in + log.record(request) + return ( + data, + makeHTTPResponse( + url: request.url!.absoluteString, + statusCode: 200, + headers: ["Content-Type": "application/json"] + ) + ) + } + return makeTestAccountClient(transport: transport) + } + + func testOmittedParticipantIdsStayOffTheWire() async throws { + let log = ScheduleRequestLog() + let account = try makeClient(log: log) + + _ = try await account.schedules.updateEntry( + entryId: 1069479523, + req: UpdateScheduleEntryRequest(summary: "Team Meeting") + ) + + let body = try XCTUnwrap(log.putBody) + XCTAssertEqual(body["summary"] as? String, "Team Meeting") + XCTAssertNil( + body["participant_ids"], + "an unaddressed participant list must not reach the wire — BC3 preserves participants only because the key is absent" + ) + XCTAssertFalse(body.keys.contains("participant_ids")) + } + + func testExplicitEmptyParticipantIdsReachTheWire() async throws { + let log = ScheduleRequestLog() + let account = try makeClient(log: log) + + _ = try await account.schedules.updateEntry( + entryId: 1069479523, + req: UpdateScheduleEntryRequest(participantIds: [], summary: "Team Meeting") + ) + + let body = try XCTUnwrap(log.putBody) + let ids = try XCTUnwrap( + body["participant_ids"] as? [Any], + "an explicitly empty list means 'remove everyone' and must be sent" + ) + XCTAssertTrue(ids.isEmpty) + } +} diff --git a/typescript/src/client.ts b/typescript/src/client.ts index d44965d05..676afd8f5 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -233,7 +233,7 @@ export interface BasecampClientOptions { } export const VERSION = "0.9.0"; -export const API_VERSION = "2026-07-26"; +export const API_VERSION = "2026-07-28"; const DEFAULT_USER_AGENT = `basecamp-sdk-ts/${VERSION} (api:${API_VERSION})`; /** diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 682354f42..819575b7e 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-28T07:10:08.052Z", + "generated": "2026-07-28T21:34:13.284Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 13d9cf347..502d0d67f 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Basecamp", - "version": "2026-07-26", + "version": "2026-07-28", "description": "Basecamp API", "contact": { "name": "Basecamp", @@ -28493,7 +28493,8 @@ "items": { "type": "integer", "format": "int64" - } + }, + "description": "Replaces the entry's participants.\n\nOmitting this member preserves the current participants; sending an empty\narray clears them. That guarantee is BC3-side and recent: until\nbasecamp/bc3#12425, `Schedules::EntriesController#update` called\n`replace_participants` unconditionally, so any update omitting the key —\nincluding the shape in BC3's own \"Update a schedule entry\" doc example —\nsilently removed every participant and notified each one. The controller\nnow guards on the request actually addressing participants." }, "all_day": { "type": "boolean", diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 731eca8fd..2ed943328 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -5382,6 +5382,17 @@ export interface components { starts_at?: string; ends_at?: string; description?: string; + /** + * @description Replaces the entry's participants. + * + * Omitting this member preserves the current participants; sending an empty + * array clears them. That guarantee is BC3-side and recent: until + * basecamp/bc3#12425, `Schedules::EntriesController#update` called + * `replace_participants` unconditionally, so any update omitting the key — + * including the shape in BC3's own "Update a schedule entry" doc example — + * silently removed every participant and notified each one. The controller + * now guards on the request actually addressing participants. + */ participant_ids?: number[]; all_day?: boolean; notify?: boolean; diff --git a/typescript/src/generated/services/schedules.ts b/typescript/src/generated/services/schedules.ts index 3b464cff2..a4c293b6d 100644 --- a/typescript/src/generated/services/schedules.ts +++ b/typescript/src/generated/services/schedules.ts @@ -31,7 +31,15 @@ export interface UpdateEntryScheduleRequest { endsAt?: string; /** Rich text description (HTML) */ description?: string; - /** Participant ids */ + /** Replaces the entry's participants. + +Omitting this member preserves the current participants; sending an empty +array clears them. That guarantee is BC3-side and recent: until +basecamp/bc3#12425, `Schedules::EntriesController#update` called +`replace_participants` unconditionally, so any update omitting the key — +including the shape in BC3's own "Update a schedule entry" doc example — +silently removed every participant and notified each one. The controller +now guards on the request actually addressing participants. */ participantIds?: number[]; /** All day */ allDay?: boolean;