From cae67859dadcfd95a48bd017ff530b995e70f7e0 Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Mon, 4 May 2026 17:38:19 +0800 Subject: [PATCH 1/7] feat(campfires): add UpdateCampfireLine operation Adds an UpdateCampfireLine operation across all six SDKs, generated from the Smithy spec. Maps PUT /chats/{campfireId}/lines/{lineId}, which the Basecamp API accepts but the spec did not previously expose. - Smithy: new operation with Campfire tag, registered in service operations - Generated clients regenerated for Go, TypeScript, Ruby, Python, Swift, Kotlin - Service generator mappings (TS, Ruby, Python, Swift, Kotlin) updated to rename UpdateCampfireLine -> updateLine / update_line - Go service wrapper UpdateLine returns the re-fetched line (API responds 204) - Go, Ruby, TypeScript tests cover happy path and validation - AGENTS.md operation count refreshed (175 -> 204) Verified: PUT /chats/{c}/lines/{l} with {content,content_type} returns 204 against a real Basecamp account; the line content updates as expected. --- AGENTS.md | 4 +- behavior-model.json | 12 + go/README.md | 2 +- go/pkg/basecamp/campfires.go | 57 +++++ go/pkg/basecamp/campfires_test.go | 86 +++++++ go/pkg/basecamp/url-routes.json | 3 +- go/pkg/generated/client.gen.go | 212 ++++++++++++++++++ .../com/basecamp/sdk/generator/Config.kt | 3 +- .../com/basecamp/sdk/generated/Metadata.kt | 1 + .../basecamp/sdk/generated/services/Types.kt | 6 + .../sdk/generated/services/campfires.kt | 23 ++ openapi.json | 139 ++++++++++++ python/scripts/generate_services.py | 3 +- python/src/basecamp/generated/metadata.json | 12 + .../basecamp/generated/services/campfires.py | 20 ++ python/src/basecamp/generated/types.py | 5 + ruby/lib/basecamp/generated/metadata.json | 16 +- .../generated/services/campfires_service.rb | 13 ++ ruby/lib/basecamp/generated/types.rb | 2 +- ruby/scripts/generate-services.rb | 3 +- .../services/campfires_service_test.rb | 26 +++ spec/basecamp.smithy | 33 +++ spec/overlays/tags.smithy | 1 + .../Sources/Basecamp/Generated/Metadata.swift | 1 + .../Models/UpdateCampfireLineRequest.swift | 12 + .../Generated/Services/CampfiresService.swift | 10 + .../BasecampGenerator/MethodNaming.swift | 1 + .../BasecampGenerator/ServiceGrouper.swift | 2 +- typescript/README.md | 2 +- typescript/scripts/generate-services.ts | 3 +- typescript/src/generated/metadata.ts | 16 +- .../src/generated/openapi-stripped.json | 128 +++++++++++ typescript/src/generated/path-mapping.ts | 1 + typescript/src/generated/schema.d.ts | 86 ++++++- .../src/generated/services/campfires.ts | 48 ++++ typescript/tests/services/campfires.test.ts | 22 ++ 36 files changed, 1000 insertions(+), 14 deletions(-) create mode 100644 swift/Sources/Basecamp/Generated/Models/UpdateCampfireLineRequest.swift diff --git a/AGENTS.md b/AGENTS.md index 9b980fb40..cf031e34e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ | Component | Status | Details | |-----------|--------|---------| -| **Smithy Spec** | 175 operations | Single source of truth for all APIs | +| **Smithy Spec** | 204 operations | Single source of truth for all APIs | | **Go SDK** | Production-ready | Full generated client + service wrappers | | **TypeScript SDK** | Production-ready | 37 generated services, openapi-fetch based | | **Ruby SDK** | Production-ready | 37 generated services | @@ -31,7 +31,7 @@ Smithy Spec → OpenAPI → Generated Client → Service Layer → User | **Kotlin** | Ktor via `BaseService` | `sdk/src/commonMain/kotlin/.../generated/services/*.kt` | | **Python** | httpx via `HttpClient` | `src/basecamp/generated/services/*.py` | -All 175 operations across 38+ services are generated. Hand-written code is limited to infrastructure: +All 204 operations across 38+ services are generated. Hand-written code is limited to infrastructure: | Purpose | TypeScript | Ruby | Swift | Kotlin | Python | |---------|-----------|------|-------|--------|--------| diff --git a/behavior-model.json b/behavior-model.json index bccd0a3cc..9cadc4f2e 100644 --- a/behavior-model.json +++ b/behavior-model.json @@ -2257,6 +2257,18 @@ ] } }, + "UpdateCampfireLine": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "UpdateCard": { "idempotent": true, "retry": { diff --git a/go/README.md b/go/README.md index e2797879f..5109067c8 100644 --- a/go/README.md +++ b/go/README.md @@ -228,7 +228,7 @@ cfg, err := basecamp.LoadConfig("/path/to/config.json") | `MessageBoards()` | Get | | `MessageTypes()` | List, Get, Create, Update, Destroy | | `Comments()` | List, Get, Create, Update, Trash | -| `Campfires()` | List, Get, ListLines, GetLine, CreateLine, DeleteLine, Chatbot CRUD | +| `Campfires()` | List, Get, ListLines, GetLine, CreateLine, UpdateLine, DeleteLine, Chatbot CRUD | | `Forwards()` | List, Get | ### Scheduling diff --git a/go/pkg/basecamp/campfires.go b/go/pkg/basecamp/campfires.go index fe77f4bb7..7171eddce 100644 --- a/go/pkg/basecamp/campfires.go +++ b/go/pkg/basecamp/campfires.go @@ -482,6 +482,63 @@ func (s *CampfiresService) CreateLine(ctx context.Context, campfireID int64, con return &line, nil } +// UpdateLineOptions specifies optional parameters for updating a campfire line. +type UpdateLineOptions struct { + // ContentType is "text/plain" or "text/html". If empty, the API defaults to plain text. + ContentType string +} + +// UpdateLine updates the content of an existing line (message) in a campfire. +// opts is optional; pass an UpdateLineOptions to set content_type (text/html or text/plain). +// The API returns 204 No Content on success; if the caller needs the updated +// representation, follow up with GetLine. Mirrors DeleteLine in returning only +// an error so a transient post-mutation read can't make a successful update +// appear to fail. +func (s *CampfiresService) UpdateLine(ctx context.Context, campfireID, lineID int64, content string, opts ...*UpdateLineOptions) (err error) { + op := OperationInfo{ + Service: "Campfires", Operation: "UpdateLine", + ResourceType: "campfire_line", IsMutation: true, + ResourceID: lineID, + } + if gater, ok := s.client.parent.hooks.(GatingHooks); ok { + if ctx, err = gater.OnOperationGate(ctx, op); err != nil { + return + } + } + start := time.Now() + ctx = s.client.parent.hooks.OnOperationStart(ctx, op) + defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }() + + if len(opts) > 1 { + err = ErrUsage("UpdateLine accepts at most one UpdateLineOptions argument") + return err + } + + if content == "" { + err = ErrUsage("campfire line content is required") + return err + } + + body := generated.UpdateCampfireLineJSONRequestBody{ + Content: content, + } + if len(opts) > 0 && opts[0] != nil && opts[0].ContentType != "" { + switch opts[0].ContentType { + case LineContentTypePlain, LineContentTypeHTML: + body.ContentType = opts[0].ContentType + default: + err = ErrUsage("content_type must be \"text/plain\" or \"text/html\"") + return err + } + } + + resp, err := s.client.parent.gen.UpdateCampfireLineWithResponse(ctx, s.client.accountID, campfireID, lineID, body) + if err != nil { + return err + } + return checkResponse(resp.HTTPResponse, resp.Body) +} + // DeleteLine deletes a line (message) from a campfire. func (s *CampfiresService) DeleteLine(ctx context.Context, campfireID, lineID int64) (err error) { op := OperationInfo{ diff --git a/go/pkg/basecamp/campfires_test.go b/go/pkg/basecamp/campfires_test.go index 153e65653..e5cf765f2 100644 --- a/go/pkg/basecamp/campfires_test.go +++ b/go/pkg/basecamp/campfires_test.go @@ -663,6 +663,92 @@ func TestCreateLine_PlainOption_Service(t *testing.T) { } } +func TestUpdateLine_EmptyContent(t *testing.T) { + svc := testCampfiresServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("server should not be called when content is empty") + }) + if err := svc.UpdateLine(context.Background(), 200, 1069479350, ""); err == nil { + t.Fatalf("expected error for empty content") + } +} + +func TestUpdateLine_MultipleOptions(t *testing.T) { + svc := testCampfiresServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("server should not be called when multiple options are provided") + }) + err := svc.UpdateLine(context.Background(), 200, 1069479350, "x", + &UpdateLineOptions{ContentType: LineContentTypeHTML}, + &UpdateLineOptions{ContentType: LineContentTypePlain}) + if err == nil { + t.Fatalf("expected error for multiple options") + } +} + +func TestUpdateLine_InvalidContentType(t *testing.T) { + svc := testCampfiresServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("server should not be called when content_type is invalid") + }) + err := svc.UpdateLine(context.Background(), 200, 1069479350, "x", + &UpdateLineOptions{ContentType: "application/xml"}) + if err == nil { + t.Fatalf("expected error for invalid content_type") + } +} + +func TestUpdateLine_NoOptions_Service(t *testing.T) { + var receivedBody map[string]any + var receivedMethod, receivedPath string + svc := testCampfiresServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Errorf("expected PUT only, got %s", r.Method) + return + } + receivedMethod = r.Method + receivedPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedBody) + w.WriteHeader(204) + }) + + if err := svc.UpdateLine(context.Background(), 200, 1069479350, "Edited!"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if receivedMethod != "PUT" { + t.Errorf("expected PUT, got %s", receivedMethod) + } + if receivedPath != "/99999/chats/200/lines/1069479350" { + t.Errorf("unexpected path: %s", receivedPath) + } + if receivedBody["content"] != "Edited!" { + t.Errorf("expected content 'Edited!', got %v", receivedBody["content"]) + } + if _, exists := receivedBody["content_type"]; exists { + t.Errorf("content_type should be absent with no options, got %v", receivedBody["content_type"]) + } +} + +func TestUpdateLine_HTMLOption_Service(t *testing.T) { + var receivedBody map[string]any + svc := testCampfiresServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Errorf("expected PUT only, got %s", r.Method) + return + } + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedBody) + w.WriteHeader(204) + }) + + err := svc.UpdateLine(context.Background(), 200, 1069479350, "Hi", + &UpdateLineOptions{ContentType: LineContentTypeHTML}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if receivedBody["content_type"] != "text/html" { + t.Errorf("expected content_type 'text/html', got %v", receivedBody["content_type"]) + } +} + func TestChatbot_UnmarshalList(t *testing.T) { data := loadCampfiresFixture(t, "chatbots_list.json") diff --git a/go/pkg/basecamp/url-routes.json b/go/pkg/basecamp/url-routes.json index e71cbf8eb..e701a2012 100644 --- a/go/pkg/basecamp/url-routes.json +++ b/go/pkg/basecamp/url-routes.json @@ -488,7 +488,8 @@ "resource": "Campfire", "operations": { "DELETE": "DeleteCampfireLine", - "GET": "GetCampfireLine" + "GET": "GetCampfireLine", + "PUT": "UpdateCampfireLine" }, "params": { "accountId": { diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index 6c6beddd5..4cca3b0a2 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -2208,6 +2208,12 @@ type UpdateAccountNameRequestContent struct { // UpdateAccountNameResponseContent defines model for UpdateAccountNameResponseContent. type UpdateAccountNameResponseContent = Account +// UpdateCampfireLineRequestContent defines model for UpdateCampfireLineRequestContent. +type UpdateCampfireLineRequestContent struct { + Content string `json:"content"` + ContentType string `json:"content_type,omitempty"` +} + // UpdateCardColumnRequestContent defines model for UpdateCardColumnRequestContent. type UpdateCardColumnRequestContent struct { Description string `json:"description,omitempty"` @@ -2872,6 +2878,9 @@ type UpdateChatbotJSONRequestBody = UpdateChatbotRequestContent // CreateCampfireLineJSONRequestBody defines body for CreateCampfireLine for application/json ContentType. type CreateCampfireLineJSONRequestBody = CreateCampfireLineRequestContent +// UpdateCampfireLineJSONRequestBody defines body for UpdateCampfireLine for application/json ContentType. +type UpdateCampfireLineJSONRequestBody = UpdateCampfireLineRequestContent + // UpdateCommentJSONRequestBody defines body for UpdateComment for application/json ContentType. type UpdateCommentJSONRequestBody = UpdateCommentRequestContent @@ -3491,6 +3500,11 @@ type ClientInterface interface { // GetCampfireLine request GetCampfireLine(ctx context.Context, accountId string, campfireId int64, lineId int64, reqEditors ...RequestEditorFn) (*http.Response, error) + // UpdateCampfireLineWithBody request with any body + UpdateCampfireLineWithBody(ctx context.Context, accountId string, campfireId int64, lineId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + UpdateCampfireLine(ctx context.Context, accountId string, campfireId int64, lineId int64, body UpdateCampfireLineJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListCampfireUploads request ListCampfireUploads(ctx context.Context, accountId string, campfireId int64, params *ListCampfireUploadsParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -4821,6 +4835,24 @@ func (c *Client) GetCampfireLine(ctx context.Context, accountId string, campfire } +// UpdateCampfireLineWithBody is marked as idempotent and will be retried on transient failures. + +func (c *Client) UpdateCampfireLineWithBody(ctx context.Context, accountId string, campfireId int64, lineId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + + return c.doWithRetry(ctx, func() (*http.Request, error) { + return NewUpdateCampfireLineRequestWithBody(c.Server, accountId, campfireId, lineId, contentType, body) + }, true, "UpdateCampfireLine", reqEditors...) + +} + +func (c *Client) UpdateCampfireLine(ctx context.Context, accountId string, campfireId int64, lineId int64, body UpdateCampfireLineJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + + return c.doWithRetry(ctx, func() (*http.Request, error) { + return NewUpdateCampfireLineRequest(c.Server, accountId, campfireId, lineId, body) + }, true, "UpdateCampfireLine", reqEditors...) + +} + // ListCampfireUploads is marked as idempotent and will be retried on transient failures. func (c *Client) ListCampfireUploads(ctx context.Context, accountId string, campfireId int64, params *ListCampfireUploadsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -9268,6 +9300,67 @@ func NewGetCampfireLineRequest(server string, accountId string, campfireId int64 return req, nil } +// NewUpdateCampfireLineRequest calls the generic UpdateCampfireLine builder with application/json body +func NewUpdateCampfireLineRequest(server string, accountId string, campfireId int64, lineId int64, body UpdateCampfireLineJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewUpdateCampfireLineRequestWithBody(server, accountId, campfireId, lineId, "application/json", bodyReader) +} + +// NewUpdateCampfireLineRequestWithBody generates requests for UpdateCampfireLine with any type of body +func NewUpdateCampfireLineRequestWithBody(server string, accountId string, campfireId int64, lineId int64, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "accountId", runtime.ParamLocationPath, accountId) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "campfireId", runtime.ParamLocationPath, campfireId) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithLocation("simple", false, "lineId", runtime.ParamLocationPath, lineId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/%s/chats/%s/lines/%s", pathParam0, pathParam1, pathParam2) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListCampfireUploadsRequest generates requests for ListCampfireUploads func NewListCampfireUploadsRequest(server string, accountId string, campfireId int64, params *ListCampfireUploadsParams) (*http.Request, error) { var err error @@ -17026,6 +17119,7 @@ var operationMetadata = map[string]OperationMetadata{ "CreateCampfireLine": {Idempotent: false, HasSensitiveParams: false}, "DeleteCampfireLine": {Idempotent: true, HasSensitiveParams: false}, "GetCampfireLine": {Idempotent: true, HasSensitiveParams: false}, + "UpdateCampfireLine": {Idempotent: true, HasSensitiveParams: false}, "ListCampfireUploads": {Idempotent: true, HasSensitiveParams: false}, "CreateCampfireUpload": {Idempotent: false, HasSensitiveParams: false}, "ListPingablePeople": {Idempotent: true, HasSensitiveParams: false}, @@ -18294,6 +18388,11 @@ type ClientWithResponsesInterface interface { // GetCampfireLineWithResponse request GetCampfireLineWithResponse(ctx context.Context, accountId string, campfireId int64, lineId int64, reqEditors ...RequestEditorFn) (*GetCampfireLineResponse, error) + // UpdateCampfireLineWithBodyWithResponse request with any body + UpdateCampfireLineWithBodyWithResponse(ctx context.Context, accountId string, campfireId int64, lineId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateCampfireLineResponse, error) + + UpdateCampfireLineWithResponse(ctx context.Context, accountId string, campfireId int64, lineId int64, body UpdateCampfireLineJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateCampfireLineResponse, error) + // ListCampfireUploadsWithResponse request ListCampfireUploadsWithResponse(ctx context.Context, accountId string, campfireId int64, params *ListCampfireUploadsParams, reqEditors ...RequestEditorFn) (*ListCampfireUploadsResponse, error) @@ -20446,6 +20545,41 @@ func (r GetCampfireLineResponse) ContentType() string { return "" } +type UpdateCampfireLineResponse struct { + Body []byte + HTTPResponse *http.Response + JSON401 *UnauthorizedErrorResponseContent + JSON403 *ForbiddenErrorResponseContent + JSON404 *NotFoundErrorResponseContent + JSON422 *ValidationErrorResponseContent + JSON429 *RateLimitErrorResponseContent + JSON500 *InternalServerErrorResponseContent +} + +// Status returns HTTPResponse.Status +func (r UpdateCampfireLineResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UpdateCampfireLineResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r UpdateCampfireLineResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ListCampfireUploadsResponse struct { Body []byte HTTPResponse *http.Response @@ -26386,6 +26520,23 @@ func (c *ClientWithResponses) GetCampfireLineWithResponse(ctx context.Context, a return ParseGetCampfireLineResponse(rsp) } +// UpdateCampfireLineWithBodyWithResponse request with arbitrary body returning *UpdateCampfireLineResponse +func (c *ClientWithResponses) UpdateCampfireLineWithBodyWithResponse(ctx context.Context, accountId string, campfireId int64, lineId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateCampfireLineResponse, error) { + rsp, err := c.UpdateCampfireLineWithBody(ctx, accountId, campfireId, lineId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateCampfireLineResponse(rsp) +} + +func (c *ClientWithResponses) UpdateCampfireLineWithResponse(ctx context.Context, accountId string, campfireId int64, lineId int64, body UpdateCampfireLineJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateCampfireLineResponse, error) { + rsp, err := c.UpdateCampfireLine(ctx, accountId, campfireId, lineId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateCampfireLineResponse(rsp) +} + // ListCampfireUploadsWithResponse request returning *ListCampfireUploadsResponse func (c *ClientWithResponses) ListCampfireUploadsWithResponse(ctx context.Context, accountId string, campfireId int64, params *ListCampfireUploadsParams, reqEditors ...RequestEditorFn) (*ListCampfireUploadsResponse, error) { rsp, err := c.ListCampfireUploads(ctx, accountId, campfireId, params, reqEditors...) @@ -30789,6 +30940,67 @@ func ParseGetCampfireLineResponse(rsp *http.Response) (*GetCampfireLineResponse, return response, nil } +// ParseUpdateCampfireLineResponse parses an HTTP response from a UpdateCampfireLineWithResponse call +func ParseUpdateCampfireLineResponse(rsp *http.Response) (*UpdateCampfireLineResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UpdateCampfireLineResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest UnauthorizedErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ForbiddenErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ValidationErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest RateLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseListCampfireUploadsResponse parses an HTTP response from a ListCampfireUploadsWithResponse call func ParseListCampfireUploadsResponse(rsp *http.Response) (*ListCampfireUploadsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt index a16bd9122..e75d4c920 100644 --- a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt +++ b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt @@ -28,7 +28,7 @@ val SERVICE_SPLITS: Map>> = mapOf( "Campfires" to listOf( "GetCampfire", "ListCampfires", "ListChatbots", "CreateChatbot", "GetChatbot", "UpdateChatbot", "DeleteChatbot", - "ListCampfireLines", "CreateCampfireLine", "GetCampfireLine", "DeleteCampfireLine", + "ListCampfireLines", "CreateCampfireLine", "GetCampfireLine", "UpdateCampfireLine", "DeleteCampfireLine", "ListCampfireUploads", "CreateCampfireUpload", ), ), @@ -219,6 +219,7 @@ val METHOD_NAME_OVERRIDES = mapOf( "ListCampfireLines" to "listLines", "CreateCampfireLine" to "createLine", "GetCampfireLine" to "getLine", + "UpdateCampfireLine" to "updateLine", "DeleteCampfireLine" to "deleteLine", "ListCampfireUploads" to "listUploads", "CreateCampfireUpload" to "createUpload", diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt index c6c8b7287..1d3f230e1 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt @@ -196,6 +196,7 @@ object Metadata { "UpdateAccountLogo" to OperationConfig(true, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "UpdateAccountName" to OperationConfig(true, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "UpdateAnswer" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), + "UpdateCampfireLine" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "UpdateCard" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "UpdateCardColumn" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "UpdateCardStep" to OperationConfig(true, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt index 8bba466e5..819734208 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt @@ -52,6 +52,12 @@ data class CreateCampfireLineBody( val contentType: String? = null ) +/** Request body for UpdateCampfireLine. */ +data class UpdateCampfireLineBody( + val content: String, + val contentType: String? = null +) + /** Options for ListCampfireUploads. */ data class ListCampfireUploadsOptions( val sort: String? = null, diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt index 53bdcd59f..88d3cdf3b 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt @@ -232,6 +232,29 @@ class CampfiresService(client: AccountClient) : BaseService(client) { } } + /** + * Update an existing campfire line + * @param campfireId The campfire ID + * @param lineId The line ID + * @param body Request body + */ + suspend fun updateLine(campfireId: Long, lineId: Long, body: UpdateCampfireLineBody): Unit { + val info = OperationInfo( + service = "Campfires", + operation = "UpdateCampfireLine", + resourceType = "campfire_line", + isMutation = true, + projectId = null, + resourceId = lineId, + ) + request(info, { + httpPut("/chats/${campfireId}/lines/${lineId}", json.encodeToString(kotlinx.serialization.json.buildJsonObject { + put("content", kotlinx.serialization.json.JsonPrimitive(body.content)) + body.contentType?.let { put("content_type", kotlinx.serialization.json.JsonPrimitive(it)) } + }), operationName = info.operation) + }) { Unit } + } + /** * Delete a campfire line * @param campfireId The campfire ID diff --git a/openapi.json b/openapi.json index 4d06d53de..81416f9a5 100644 --- a/openapi.json +++ b/openapi.json @@ -4702,6 +4702,131 @@ 503 ] } + }, + "put": { + "description": "Update an existing campfire line", + "operationId": "UpdateCampfireLine", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCampfireLineRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "campfireId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + }, + { + "name": "lineId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "UpdateCampfireLine 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Campfire" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } } }, "/{accountId}/chats/{campfireId}/uploads.json": { @@ -27080,6 +27205,20 @@ "UpdateAccountNameResponseContent": { "$ref": "#/components/schemas/Account" }, + "UpdateCampfireLineRequestContent": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "content_type": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, "UpdateCardColumnRequestContent": { "type": "object", "properties": { diff --git a/python/scripts/generate_services.py b/python/scripts/generate_services.py index 9ff8c6afe..3f6613f97 100644 --- a/python/scripts/generate_services.py +++ b/python/scripts/generate_services.py @@ -41,7 +41,7 @@ "Campfires": [ "GetCampfire", "ListCampfires", "ListChatbots", "CreateChatbot", "GetChatbot", "UpdateChatbot", "DeleteChatbot", - "ListCampfireLines", "CreateCampfireLine", "GetCampfireLine", "DeleteCampfireLine", + "ListCampfireLines", "CreateCampfireLine", "GetCampfireLine", "UpdateCampfireLine", "DeleteCampfireLine", "ListCampfireUploads", "CreateCampfireUpload", ], }, @@ -189,6 +189,7 @@ "ListCampfireLines": "list_lines", "CreateCampfireLine": "create_line", "GetCampfireLine": "get_line", + "UpdateCampfireLine": "update_line", "DeleteCampfireLine": "delete_line", "ListCampfireUploads": "list_uploads", "CreateCampfireUpload": "create_upload", diff --git a/python/src/basecamp/generated/metadata.json b/python/src/basecamp/generated/metadata.json index 552d6f164..c20465f10 100644 --- a/python/src/basecamp/generated/metadata.json +++ b/python/src/basecamp/generated/metadata.json @@ -1973,6 +1973,18 @@ ] } }, + "UpdateCampfireLine": { + "idempotent": true, + "retry": { + "backoff": "exponential", + "base_delay_ms": 1000, + "max": 3, + "retry_on": [ + 429, + 503 + ] + } + }, "UpdateCard": { "idempotent": true, "retry": { diff --git a/python/src/basecamp/generated/services/campfires.py b/python/src/basecamp/generated/services/campfires.py index d8ae194b7..abbdb4389 100644 --- a/python/src/basecamp/generated/services/campfires.py +++ b/python/src/basecamp/generated/services/campfires.py @@ -87,6 +87,15 @@ def get_line(self, *, campfire_id: int, line_id: int) -> dict[str, Any]: f"/chats/{campfire_id}/lines/{line_id}", ) + def update_line(self, *, campfire_id: int, line_id: int, content: str, content_type: str | None = None) -> None: + self._request_void( + OperationInfo(service="campfires", operation="update_line", is_mutation=True, resource_id=line_id), + "PUT", + f"/chats/{campfire_id}/lines/{line_id}", + json_body=self._compact(content=content, content_type=content_type), + operation="UpdateCampfireLine", + ) + def delete_line(self, *, campfire_id: int, line_id: int) -> None: self._request_void( OperationInfo(service="campfires", operation="delete_line", is_mutation=True, resource_id=line_id), @@ -194,6 +203,17 @@ async def get_line(self, *, campfire_id: int, line_id: int) -> dict[str, Any]: f"/chats/{campfire_id}/lines/{line_id}", ) + async def update_line( + self, *, campfire_id: int, line_id: int, content: str, content_type: str | None = None + ) -> None: + await self._request_void( + OperationInfo(service="campfires", operation="update_line", is_mutation=True, resource_id=line_id), + "PUT", + f"/chats/{campfire_id}/lines/{line_id}", + json_body=self._compact(content=content, content_type=content_type), + operation="UpdateCampfireLine", + ) + async def delete_line(self, *, campfire_id: int, line_id: int) -> None: await self._request_void( OperationInfo(service="campfires", operation="delete_line", is_mutation=True, resource_id=line_id), diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index f04e165e8..60b23b1c9 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -1503,6 +1503,11 @@ class UpdateAccountNameRequestContent(TypedDict): name: str +class UpdateCampfireLineRequestContent(TypedDict): + content: str + content_type: NotRequired[str] + + class UpdateCardColumnRequestContent(TypedDict): description: NotRequired[str] title: NotRequired[str] diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 4711538f3..54a379579 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-22T08:19:36Z", + "generated": "2026-07-22T08:30:29Z", "operations": { "GetAccount": { "retry": { @@ -576,6 +576,20 @@ ] } }, + "UpdateCampfireLine": { + "retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + }, + "idempotent": { + "natural": true + } + }, "DeleteCampfireLine": { "retry": { "maxAttempts": 3, diff --git a/ruby/lib/basecamp/generated/services/campfires_service.rb b/ruby/lib/basecamp/generated/services/campfires_service.rb index bce963280..927efa0b6 100644 --- a/ruby/lib/basecamp/generated/services/campfires_service.rb +++ b/ruby/lib/basecamp/generated/services/campfires_service.rb @@ -112,6 +112,19 @@ def get_line(campfire_id:, line_id:) end end + # Update an existing campfire line + # @param campfire_id [Integer] campfire id ID + # @param line_id [Integer] line id ID + # @param content [String] content + # @param content_type [String, nil] content type + # @return [void] + def update_line(campfire_id:, line_id:, content:, content_type: nil) + with_operation(service: "campfires", operation: "update_line", is_mutation: true, resource_id: line_id) do + http_put("/chats/#{campfire_id}/lines/#{line_id}", body: compact_params(content: content, content_type: content_type)) + nil + end + end + # Delete a campfire line # @param campfire_id [Integer] campfire id ID # @param line_id [Integer] line id ID diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index bd7a5b455..d73506dd8 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-22T08:19:36Z +# Generated: 2026-07-22T08:30:29Z require "json" require "time" diff --git a/ruby/scripts/generate-services.rb b/ruby/scripts/generate-services.rb index 42795fa26..ccb220400 100644 --- a/ruby/scripts/generate-services.rb +++ b/ruby/scripts/generate-services.rb @@ -45,7 +45,7 @@ class ServiceGenerator 'Campfires' => %w[ GetCampfire ListCampfires ListChatbots CreateChatbot GetChatbot UpdateChatbot DeleteChatbot - ListCampfireLines CreateCampfireLine GetCampfireLine DeleteCampfireLine + ListCampfireLines CreateCampfireLine GetCampfireLine UpdateCampfireLine DeleteCampfireLine ListCampfireUploads CreateCampfireUpload ] }, @@ -190,6 +190,7 @@ class ServiceGenerator 'ListCampfireLines' => 'list_lines', 'CreateCampfireLine' => 'create_line', 'GetCampfireLine' => 'get_line', + 'UpdateCampfireLine' => 'update_line', 'DeleteCampfireLine' => 'delete_line', 'ListCampfireUploads' => 'list_uploads', 'CreateCampfireUpload' => 'create_upload', diff --git a/ruby/test/basecamp/services/campfires_service_test.rb b/ruby/test/basecamp/services/campfires_service_test.rb index 95ccbe643..49b9b1e2e 100644 --- a/ruby/test/basecamp/services/campfires_service_test.rb +++ b/ruby/test/basecamp/services/campfires_service_test.rb @@ -141,6 +141,32 @@ def test_create_line_with_content_type assert_equal 998, line["id"] end + def test_update_line + # Generated service: /lines/{id} without .json + stub_put("/12345/chats/200/lines/300", response_body: "", status: 204) + + result = @account.campfires.update_line( + campfire_id: 200, + line_id: 300, + content: "Edited message" + ) + + assert_nil result + end + + def test_update_line_with_content_type + stub_put("/12345/chats/200/lines/300", response_body: "", status: 204) + + result = @account.campfires.update_line( + campfire_id: 200, + line_id: 300, + content: "Edited", + content_type: "text/html" + ) + + assert_nil result + end + def test_delete_line # Generated service: /lines/{id} without .json stub_delete("/12345/chats/200/lines/300") diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index 25620dc75..a9958af11 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -135,6 +135,7 @@ service Basecamp { ListCampfireLines, GetCampfireLine, CreateCampfireLine, + UpdateCampfireLine, DeleteCampfireLine, ListCampfireUploads, CreateCampfireUpload, @@ -3364,6 +3365,38 @@ structure CreateCampfireLineOutput { line: CampfireLine } +/// Update an existing campfire line +@idempotent +@basecampRetry(maxAttempts: 3, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) +@basecampIdempotent(natural: true) +@http(method: "PUT", uri: "/{accountId}/chats/{campfireId}/lines/{lineId}", code: 204) +operation UpdateCampfireLine { + input: UpdateCampfireLineInput + output: UpdateCampfireLineOutput + errors: [NotFoundError, ValidationError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] +} + +structure UpdateCampfireLineInput { + @required + @httpLabel + accountId: AccountId + + @required + @httpLabel + campfireId: CampfireId + + @required + @httpLabel + lineId: CampfireLineId + + @required + content: String + + content_type: String +} + +structure UpdateCampfireLineOutput {} + /// Delete a campfire line @idempotent @basecampRetry(maxAttempts: 3, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) diff --git a/spec/overlays/tags.smithy b/spec/overlays/tags.smithy index 5281fb81f..5d426261e 100644 --- a/spec/overlays/tags.smithy +++ b/spec/overlays/tags.smithy @@ -84,6 +84,7 @@ apply GetCampfire @tags(["Campfire"]) apply ListCampfireLines @tags(["Campfire"]) apply GetCampfireLine @tags(["Campfire"]) apply CreateCampfireLine @tags(["Campfire"]) +apply UpdateCampfireLine @tags(["Campfire"]) apply DeleteCampfireLine @tags(["Campfire"]) apply ListCampfireUploads @tags(["Campfire"]) apply CreateCampfireUpload @tags(["Campfire"]) diff --git a/swift/Sources/Basecamp/Generated/Metadata.swift b/swift/Sources/Basecamp/Generated/Metadata.swift index 6810da77f..929b0094f 100644 --- a/swift/Sources/Basecamp/Generated/Metadata.swift +++ b/swift/Sources/Basecamp/Generated/Metadata.swift @@ -179,6 +179,7 @@ enum Metadata { "UpdateAccountLogo": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "UpdateAccountName": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "UpdateAnswer": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), + "UpdateCampfireLine": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "UpdateCard": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "UpdateCardColumn": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "UpdateCardStep": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), diff --git a/swift/Sources/Basecamp/Generated/Models/UpdateCampfireLineRequest.swift b/swift/Sources/Basecamp/Generated/Models/UpdateCampfireLineRequest.swift new file mode 100644 index 000000000..bf798c0c1 --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/UpdateCampfireLineRequest.swift @@ -0,0 +1,12 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct UpdateCampfireLineRequest: Codable, Sendable { + public let content: String + public var contentType: String? + + public init(content: String, contentType: String? = nil) { + self.content = content + self.contentType = contentType + } +} diff --git a/swift/Sources/Basecamp/Generated/Services/CampfiresService.swift b/swift/Sources/Basecamp/Generated/Services/CampfiresService.swift index 8c8c41a80..4571dd249 100644 --- a/swift/Sources/Basecamp/Generated/Services/CampfiresService.swift +++ b/swift/Sources/Basecamp/Generated/Services/CampfiresService.swift @@ -173,6 +173,16 @@ public final class CampfiresService: BaseService, @unchecked Sendable { ) } + public func updateLine(campfireId: Int, lineId: Int, req: UpdateCampfireLineRequest) async throws { + try await requestVoid( + OperationInfo(service: "Campfires", operation: "UpdateCampfireLine", resourceType: "campfire_line", isMutation: true, resourceId: lineId), + method: "PUT", + path: "/chats/\(campfireId)/lines/\(lineId)", + body: req, + retryConfig: Metadata.retryConfig(for: "UpdateCampfireLine") + ) + } + public func updateChatbot(campfireId: Int, chatbotId: Int, req: UpdateChatbotRequest) async throws -> Chatbot { return try await request( OperationInfo(service: "Campfires", operation: "UpdateChatbot", resourceType: "chatbot", isMutation: true, resourceId: chatbotId), diff --git a/swift/Sources/BasecampGenerator/MethodNaming.swift b/swift/Sources/BasecampGenerator/MethodNaming.swift index dbd585d25..7a355ca22 100644 --- a/swift/Sources/BasecampGenerator/MethodNaming.swift +++ b/swift/Sources/BasecampGenerator/MethodNaming.swift @@ -91,6 +91,7 @@ let methodNameOverrides: [String: String] = [ "ListCampfireLines": "listLines", "CreateCampfireLine": "createLine", "GetCampfireLine": "getLine", + "UpdateCampfireLine": "updateLine", "DeleteCampfireLine": "deleteLine", "ListCampfireUploads": "listUploads", "CreateCampfireUpload": "createUpload", diff --git a/swift/Sources/BasecampGenerator/ServiceGrouper.swift b/swift/Sources/BasecampGenerator/ServiceGrouper.swift index 9e810802f..c8b503c45 100644 --- a/swift/Sources/BasecampGenerator/ServiceGrouper.swift +++ b/swift/Sources/BasecampGenerator/ServiceGrouper.swift @@ -27,7 +27,7 @@ let serviceSplits: [String: [String: [String]]] = [ "Campfires": [ "GetCampfire", "ListCampfires", "ListChatbots", "CreateChatbot", "GetChatbot", "UpdateChatbot", "DeleteChatbot", - "ListCampfireLines", "CreateCampfireLine", "GetCampfireLine", "DeleteCampfireLine", + "ListCampfireLines", "CreateCampfireLine", "GetCampfireLine", "UpdateCampfireLine", "DeleteCampfireLine", "ListCampfireUploads", "CreateCampfireUpload", ], ], diff --git a/typescript/README.md b/typescript/README.md index e2b1460a1..8036f00f7 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -257,7 +257,7 @@ The SDK provides typed services for the complete Basecamp API: | `messageBoards` | get | | `messageTypes` | list, get, create, update, delete | | `comments` | list, get, create, update | -| `campfires` | list, get, listLines, getLine, createLine, deleteLine | +| `campfires` | list, get, listLines, getLine, createLine, updateLine, deleteLine | ### Card Tables (Kanban) diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts index b8d96c429..5fac70907 100644 --- a/typescript/scripts/generate-services.ts +++ b/typescript/scripts/generate-services.ts @@ -175,7 +175,7 @@ const SERVICE_SPLITS: Record> = { Campfires: [ "GetCampfire", "ListCampfires", "ListChatbots", "CreateChatbot", "GetChatbot", "UpdateChatbot", "DeleteChatbot", - "ListCampfireLines", "CreateCampfireLine", "GetCampfireLine", "DeleteCampfireLine", + "ListCampfireLines", "CreateCampfireLine", "GetCampfireLine", "UpdateCampfireLine", "DeleteCampfireLine", "ListCampfireUploads", "CreateCampfireUpload", ], }, @@ -353,6 +353,7 @@ const METHOD_NAME_OVERRIDES: Record = { ListCampfireLines: "listLines", CreateCampfireLine: "createLine", GetCampfireLine: "getLine", + UpdateCampfireLine: "updateLine", DeleteCampfireLine: "deleteLine", ListCampfireUploads: "listUploads", CreateCampfireUpload: "createUpload", diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index adbe9bfaf..519224ed0 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-22T08:19:34.802Z", + "generated": "2026-07-22T08:30:27.785Z", "operations": { "GetAccount": { "retry": { @@ -612,6 +612,20 @@ const metadata: MetadataOutput = { ] } }, + "UpdateCampfireLine": { + "retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + }, + "idempotent": { + "natural": true + } + }, "DeleteCampfireLine": { "retry": { "maxAttempts": 3, diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index c334f3d08..a15cf8f60 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -4189,6 +4189,120 @@ 503 ] } + }, + "put": { + "description": "Update an existing campfire line", + "operationId": "UpdateCampfireLine", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCampfireLineRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "campfireId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + }, + { + "name": "lineId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "UpdateCampfireLine 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Campfire" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } } }, "/chats/{campfireId}/uploads.json": { @@ -24757,6 +24871,20 @@ "UpdateAccountNameResponseContent": { "$ref": "#/components/schemas/Account" }, + "UpdateCampfireLineRequestContent": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "content_type": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, "UpdateCardColumnRequestContent": { "type": "object", "properties": { diff --git a/typescript/src/generated/path-mapping.ts b/typescript/src/generated/path-mapping.ts index 7e83d0528..dae63eae4 100644 --- a/typescript/src/generated/path-mapping.ts +++ b/typescript/src/generated/path-mapping.ts @@ -52,6 +52,7 @@ export const PATH_TO_OPERATION: Record = { "POST:/{accountId}/chats/{campfireId}/lines.json": "CreateCampfireLine", "DELETE:/{accountId}/chats/{campfireId}/lines/{lineId}": "DeleteCampfireLine", "GET:/{accountId}/chats/{campfireId}/lines/{lineId}": "GetCampfireLine", + "PUT:/{accountId}/chats/{campfireId}/lines/{lineId}": "UpdateCampfireLine", "GET:/{accountId}/chats/{campfireId}/uploads.json": "ListCampfireUploads", "POST:/{accountId}/chats/{campfireId}/uploads.json": "CreateCampfireUpload", "GET:/{accountId}/client/approvals.json": "ListClientApprovals", diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index ae432dbb4..cfd8c6dee 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -539,7 +539,8 @@ export interface paths { }; /** @description Get a campfire line by ID */ get: operations["GetCampfireLine"]; - put?: never; + /** @description Update an existing campfire line */ + put: operations["UpdateCampfireLine"]; post?: never; /** @description Delete a campfire line */ delete: operations["DeleteCampfireLine"]; @@ -4299,6 +4300,10 @@ export interface components { name: string; }; UpdateAccountNameResponseContent: components["schemas"]["Account"]; + UpdateCampfireLineRequestContent: { + content: string; + content_type?: string; + }; UpdateCardColumnRequestContent: { title?: string; description?: string; @@ -7509,6 +7514,85 @@ export interface operations { }; }; }; + UpdateCampfireLine: { + parameters: { + query?: never; + header?: never; + path: { + campfireId: number; + lineId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateCampfireLineRequestContent"]; + }; + }; + responses: { + /** @description UpdateCampfireLine 204 response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description UnauthorizedError 401 response */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedErrorResponseContent"]; + }; + }; + /** @description ForbiddenError 403 response */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ForbiddenErrorResponseContent"]; + }; + }; + /** @description NotFoundError 404 response */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NotFoundErrorResponseContent"]; + }; + }; + /** @description ValidationError 422 response */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidationErrorResponseContent"]; + }; + }; + /** @description RateLimitError 429 response */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RateLimitErrorResponseContent"]; + }; + }; + /** @description InternalServerError 500 response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalServerErrorResponseContent"]; + }; + }; + }; + }; DeleteCampfireLine: { parameters: { query?: never; diff --git a/typescript/src/generated/services/campfires.ts b/typescript/src/generated/services/campfires.ts index 4c7212a51..427457779 100644 --- a/typescript/src/generated/services/campfires.ts +++ b/typescript/src/generated/services/campfires.ts @@ -73,6 +73,16 @@ export interface CreateLineCampfireRequest { contentType?: string; } +/** + * Request parameters for updateLine. + */ +export interface UpdateLineCampfireRequest { + /** Text content */ + content: string; + /** Content type */ + contentType?: string; +} + /** * Options for listUploads. */ @@ -419,6 +429,44 @@ export class CampfiresService extends BaseService { return response; } + /** + * Update an existing campfire line + * @param campfireId - The campfire ID + * @param lineId - The line ID + * @param req - Campfire_line update parameters + * @returns void + * @throws {BasecampError} If the resource is not found or fields are invalid + * + * @example + * ```ts + * await client.campfires.updateLine(123, 123, { content: "Hello world" }); + * ``` + */ + async updateLine(campfireId: number, lineId: number, req: UpdateLineCampfireRequest): Promise { + if (!req.content) { + throw Errors.validation("Content is required"); + } + await this.request( + { + service: "Campfires", + operation: "UpdateCampfireLine", + resourceType: "campfire_line", + isMutation: true, + resourceId: lineId, + }, + () => + this.client.PUT("/chats/{campfireId}/lines/{lineId}", { + params: { + path: { campfireId, lineId }, + }, + body: { + content: req.content, + content_type: req.contentType, + }, + }) + ); + } + /** * Delete a campfire line * @param campfireId - The campfire ID diff --git a/typescript/tests/services/campfires.test.ts b/typescript/tests/services/campfires.test.ts index 0c60d9db4..1bdf3d371 100644 --- a/typescript/tests/services/campfires.test.ts +++ b/typescript/tests/services/campfires.test.ts @@ -188,6 +188,28 @@ describe("CampfiresService", () => { }); }); + describe("updateLine", () => { + it("should update a line", async () => { + server.use( + http.put(`${BASE_URL}/chats/42/lines/10`, async ({ request }) => { + const body = (await request.json()) as Record; + expect(body.content).toBe("Edited!"); + return new HttpResponse(null, { status: 204 }); + }) + ); + + await expect( + client.campfires.updateLine(42, 10, { content: "Edited!" }) + ).resolves.toBeUndefined(); + }); + + it("should reject empty content", async () => { + await expect( + client.campfires.updateLine(42, 10, { content: "" }) + ).rejects.toThrow(); + }); + }); + describe("deleteLine", () => { it("should delete a line", async () => { server.use( From 9e1380a1a56c777e4afc6998ee34d4a5fbf6edcc Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 21 Jul 2026 23:11:06 -0700 Subject: [PATCH 2/7] fix(campfires): align UpdateCampfireLine with server semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server ignores content_type on line updates: Chats::LinesController#update strips it (update_params = line_params.except(:content_type)) and coerces every edited line to rich text via becomes(Chat::Lines::RichText). Only the line's creator may edit, and only text/rich-text lines are editable (Person::Ability#can_edit_chat_line?). Publicly documented in doc/api/sections/campfires.md ("Update a Campfire line", bc3-api af143f25; registered as the campfire-line-edit api-gap by #390). Drop the dead content_type parameter from UpdateCampfireLineInput and regenerate all six SDKs; document the rich-text coercion and creator-only constraint on the operation. Simplify the Go wrapper to UpdateLine(ctx, campfireID, lineID, content) — no options struct — matching DeleteLine's shape. The operation is unreleased, so no compatibility concern. Endpoint semantics were verified against bc3 through 13c84145 via a full drift audit of e52453ff..13c84145; the provenance pin itself advanced separately on main (#390, to ba105ba7), so this commit no longer touches it. Test adjustments: drop the Go option-validation tests with the options struct, assert the update body carries only content; drop the Ruby content_type variant; add 422 mapping coverage for update_line in Ruby and TypeScript. --- go/pkg/basecamp/campfires.go | 25 +------- go/pkg/basecamp/campfires_test.go | 62 ++++--------------- go/pkg/generated/client.gen.go | 4 +- .../basecamp/sdk/generated/services/Types.kt | 3 +- .../sdk/generated/services/campfires.kt | 3 +- openapi.json | 8 +-- .../basecamp/generated/services/campfires.py | 10 ++- python/src/basecamp/generated/types.py | 1 - ruby/lib/basecamp/generated/metadata.json | 2 +- .../generated/services/campfires_service.rb | 9 ++- ruby/lib/basecamp/generated/types.rb | 2 +- .../services/campfires_service_test.rb | 16 ++--- spec/basecamp.smithy | 8 ++- .../Models/UpdateCampfireLineRequest.swift | 4 +- typescript/src/generated/metadata.ts | 2 +- .../src/generated/openapi-stripped.json | 8 +-- typescript/src/generated/schema.d.ts | 9 ++- .../src/generated/services/campfires.ts | 7 +-- typescript/tests/services/campfires.test.ts | 16 ++++- 19 files changed, 72 insertions(+), 127 deletions(-) diff --git a/go/pkg/basecamp/campfires.go b/go/pkg/basecamp/campfires.go index 7171eddce..34f5c1c68 100644 --- a/go/pkg/basecamp/campfires.go +++ b/go/pkg/basecamp/campfires.go @@ -482,19 +482,14 @@ func (s *CampfiresService) CreateLine(ctx context.Context, campfireID int64, con return &line, nil } -// UpdateLineOptions specifies optional parameters for updating a campfire line. -type UpdateLineOptions struct { - // ContentType is "text/plain" or "text/html". If empty, the API defaults to plain text. - ContentType string -} - // UpdateLine updates the content of an existing line (message) in a campfire. -// opts is optional; pass an UpdateLineOptions to set content_type (text/html or text/plain). +// The content is always treated as rich text (HTML) — the server coerces every +// edited line to rich text — and only the line's creator may edit it. // The API returns 204 No Content on success; if the caller needs the updated // representation, follow up with GetLine. Mirrors DeleteLine in returning only // an error so a transient post-mutation read can't make a successful update // appear to fail. -func (s *CampfiresService) UpdateLine(ctx context.Context, campfireID, lineID int64, content string, opts ...*UpdateLineOptions) (err error) { +func (s *CampfiresService) UpdateLine(ctx context.Context, campfireID, lineID int64, content string) (err error) { op := OperationInfo{ Service: "Campfires", Operation: "UpdateLine", ResourceType: "campfire_line", IsMutation: true, @@ -509,11 +504,6 @@ func (s *CampfiresService) UpdateLine(ctx context.Context, campfireID, lineID in ctx = s.client.parent.hooks.OnOperationStart(ctx, op) defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }() - if len(opts) > 1 { - err = ErrUsage("UpdateLine accepts at most one UpdateLineOptions argument") - return err - } - if content == "" { err = ErrUsage("campfire line content is required") return err @@ -522,15 +512,6 @@ func (s *CampfiresService) UpdateLine(ctx context.Context, campfireID, lineID in body := generated.UpdateCampfireLineJSONRequestBody{ Content: content, } - if len(opts) > 0 && opts[0] != nil && opts[0].ContentType != "" { - switch opts[0].ContentType { - case LineContentTypePlain, LineContentTypeHTML: - body.ContentType = opts[0].ContentType - default: - err = ErrUsage("content_type must be \"text/plain\" or \"text/html\"") - return err - } - } resp, err := s.client.parent.gen.UpdateCampfireLineWithResponse(ctx, s.client.accountID, campfireID, lineID, body) if err != nil { diff --git a/go/pkg/basecamp/campfires_test.go b/go/pkg/basecamp/campfires_test.go index e5cf765f2..20edc28ea 100644 --- a/go/pkg/basecamp/campfires_test.go +++ b/go/pkg/basecamp/campfires_test.go @@ -672,30 +672,7 @@ func TestUpdateLine_EmptyContent(t *testing.T) { } } -func TestUpdateLine_MultipleOptions(t *testing.T) { - svc := testCampfiresServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Errorf("server should not be called when multiple options are provided") - }) - err := svc.UpdateLine(context.Background(), 200, 1069479350, "x", - &UpdateLineOptions{ContentType: LineContentTypeHTML}, - &UpdateLineOptions{ContentType: LineContentTypePlain}) - if err == nil { - t.Fatalf("expected error for multiple options") - } -} - -func TestUpdateLine_InvalidContentType(t *testing.T) { - svc := testCampfiresServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Errorf("server should not be called when content_type is invalid") - }) - err := svc.UpdateLine(context.Background(), 200, 1069479350, "x", - &UpdateLineOptions{ContentType: "application/xml"}) - if err == nil { - t.Fatalf("expected error for invalid content_type") - } -} - -func TestUpdateLine_NoOptions_Service(t *testing.T) { +func TestUpdateLine_Service(t *testing.T) { var receivedBody map[string]any var receivedMethod, receivedPath string svc := testCampfiresServer(t, func(w http.ResponseWriter, r *http.Request) { @@ -705,8 +682,15 @@ func TestUpdateLine_NoOptions_Service(t *testing.T) { } receivedMethod = r.Method receivedPath = r.URL.Path - body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("failed to read request body: %v", err) + return + } + if err := json.Unmarshal(body, &receivedBody); err != nil { + t.Errorf("failed to unmarshal request body: %v", err) + return + } w.WriteHeader(204) }) @@ -722,30 +706,8 @@ func TestUpdateLine_NoOptions_Service(t *testing.T) { if receivedBody["content"] != "Edited!" { t.Errorf("expected content 'Edited!', got %v", receivedBody["content"]) } - if _, exists := receivedBody["content_type"]; exists { - t.Errorf("content_type should be absent with no options, got %v", receivedBody["content_type"]) - } -} - -func TestUpdateLine_HTMLOption_Service(t *testing.T) { - var receivedBody map[string]any - svc := testCampfiresServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "PUT" { - t.Errorf("expected PUT only, got %s", r.Method) - return - } - body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) - w.WriteHeader(204) - }) - - err := svc.UpdateLine(context.Background(), 200, 1069479350, "Hi", - &UpdateLineOptions{ContentType: LineContentTypeHTML}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if receivedBody["content_type"] != "text/html" { - t.Errorf("expected content_type 'text/html', got %v", receivedBody["content_type"]) + if len(receivedBody) != 1 { + t.Errorf("expected body to contain only content, got %v", receivedBody) } } diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index 4cca3b0a2..25d196385 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -2210,8 +2210,8 @@ type UpdateAccountNameResponseContent = Account // UpdateCampfireLineRequestContent defines model for UpdateCampfireLineRequestContent. type UpdateCampfireLineRequestContent struct { - Content string `json:"content"` - ContentType string `json:"content_type,omitempty"` + // Content The new line content, interpreted as rich text (HTML) + Content string `json:"content"` } // UpdateCardColumnRequestContent defines model for UpdateCardColumnRequestContent. diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt index 819734208..7faa35609 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt @@ -54,8 +54,7 @@ data class CreateCampfireLineBody( /** Request body for UpdateCampfireLine. */ data class UpdateCampfireLineBody( - val content: String, - val contentType: String? = null + val content: String ) /** Options for ListCampfireUploads. */ diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt index 88d3cdf3b..6dc212b4f 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt @@ -233,7 +233,7 @@ class CampfiresService(client: AccountClient) : BaseService(client) { } /** - * Update an existing campfire line + * Update an existing campfire line; the content is always treated as rich text (HTML). * @param campfireId The campfire ID * @param lineId The line ID * @param body Request body @@ -250,7 +250,6 @@ class CampfiresService(client: AccountClient) : BaseService(client) { request(info, { httpPut("/chats/${campfireId}/lines/${lineId}", json.encodeToString(kotlinx.serialization.json.buildJsonObject { put("content", kotlinx.serialization.json.JsonPrimitive(body.content)) - body.contentType?.let { put("content_type", kotlinx.serialization.json.JsonPrimitive(it)) } }), operationName = info.operation) }) { Unit } } diff --git a/openapi.json b/openapi.json index 81416f9a5..34e8e5be0 100644 --- a/openapi.json +++ b/openapi.json @@ -4704,7 +4704,7 @@ } }, "put": { - "description": "Update an existing campfire line", + "description": "Update an existing campfire line; the content is always treated as rich text (HTML).\nThe server coerces every edited line to rich text and ignores any content\ntype hint. Only the line's creator may edit it, and only text and\nrich-text lines are editable.", "operationId": "UpdateCampfireLine", "requestBody": { "content": { @@ -27209,10 +27209,8 @@ "type": "object", "properties": { "content": { - "type": "string" - }, - "content_type": { - "type": "string" + "type": "string", + "description": "The new line content, interpreted as rich text (HTML)" } }, "required": [ diff --git a/python/src/basecamp/generated/services/campfires.py b/python/src/basecamp/generated/services/campfires.py index abbdb4389..5e8ac1dec 100644 --- a/python/src/basecamp/generated/services/campfires.py +++ b/python/src/basecamp/generated/services/campfires.py @@ -87,12 +87,12 @@ def get_line(self, *, campfire_id: int, line_id: int) -> dict[str, Any]: f"/chats/{campfire_id}/lines/{line_id}", ) - def update_line(self, *, campfire_id: int, line_id: int, content: str, content_type: str | None = None) -> None: + def update_line(self, *, campfire_id: int, line_id: int, content: str) -> None: self._request_void( OperationInfo(service="campfires", operation="update_line", is_mutation=True, resource_id=line_id), "PUT", f"/chats/{campfire_id}/lines/{line_id}", - json_body=self._compact(content=content, content_type=content_type), + json_body=self._compact(content=content), operation="UpdateCampfireLine", ) @@ -203,14 +203,12 @@ async def get_line(self, *, campfire_id: int, line_id: int) -> dict[str, Any]: f"/chats/{campfire_id}/lines/{line_id}", ) - async def update_line( - self, *, campfire_id: int, line_id: int, content: str, content_type: str | None = None - ) -> None: + async def update_line(self, *, campfire_id: int, line_id: int, content: str) -> None: await self._request_void( OperationInfo(service="campfires", operation="update_line", is_mutation=True, resource_id=line_id), "PUT", f"/chats/{campfire_id}/lines/{line_id}", - json_body=self._compact(content=content, content_type=content_type), + json_body=self._compact(content=content), operation="UpdateCampfireLine", ) diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index 60b23b1c9..4508acb44 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -1505,7 +1505,6 @@ class UpdateAccountNameRequestContent(TypedDict): class UpdateCampfireLineRequestContent(TypedDict): content: str - content_type: NotRequired[str] class UpdateCardColumnRequestContent(TypedDict): diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 54a379579..f90e0eabd 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-22T08:30:29Z", + "generated": "2026-07-22T08:31:46Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/services/campfires_service.rb b/ruby/lib/basecamp/generated/services/campfires_service.rb index 927efa0b6..736086fb8 100644 --- a/ruby/lib/basecamp/generated/services/campfires_service.rb +++ b/ruby/lib/basecamp/generated/services/campfires_service.rb @@ -112,15 +112,14 @@ def get_line(campfire_id:, line_id:) end end - # Update an existing campfire line + # Update an existing campfire line; the content is always treated as rich text (HTML). # @param campfire_id [Integer] campfire id ID # @param line_id [Integer] line id ID - # @param content [String] content - # @param content_type [String, nil] content type + # @param content [String] The new line content, interpreted as rich text (HTML) # @return [void] - def update_line(campfire_id:, line_id:, content:, content_type: nil) + def update_line(campfire_id:, line_id:, content:) with_operation(service: "campfires", operation: "update_line", is_mutation: true, resource_id: line_id) do - http_put("/chats/#{campfire_id}/lines/#{line_id}", body: compact_params(content: content, content_type: content_type)) + http_put("/chats/#{campfire_id}/lines/#{line_id}", body: compact_params(content: content)) nil end end diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index d73506dd8..e4fee50d3 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-22T08:30:29Z +# Generated: 2026-07-22T08:31:46Z require "json" require "time" diff --git a/ruby/test/basecamp/services/campfires_service_test.rb b/ruby/test/basecamp/services/campfires_service_test.rb index 49b9b1e2e..951533d85 100644 --- a/ruby/test/basecamp/services/campfires_service_test.rb +++ b/ruby/test/basecamp/services/campfires_service_test.rb @@ -154,17 +154,13 @@ def test_update_line assert_nil result end - def test_update_line_with_content_type - stub_put("/12345/chats/200/lines/300", response_body: "", status: 204) - - result = @account.campfires.update_line( - campfire_id: 200, - line_id: 300, - content: "Edited", - content_type: "text/html" - ) + def test_update_line_validation_error + stub_put("/12345/chats/200/lines/300", + response_body: { "error" => "Unprocessable" }, status: 422) - assert_nil result + assert_raises(Basecamp::ValidationError) do + @account.campfires.update_line(campfire_id: 200, line_id: 300, content: "Edited") + end end def test_delete_line diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index a9958af11..ffdb008a3 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -3365,7 +3365,10 @@ structure CreateCampfireLineOutput { line: CampfireLine } -/// Update an existing campfire line +/// Update an existing campfire line; the content is always treated as rich text (HTML). +/// The server coerces every edited line to rich text and ignores any content +/// type hint. Only the line's creator may edit it, and only text and +/// rich-text lines are editable. @idempotent @basecampRetry(maxAttempts: 3, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) @basecampIdempotent(natural: true) @@ -3389,10 +3392,9 @@ structure UpdateCampfireLineInput { @httpLabel lineId: CampfireLineId + /// The new line content, interpreted as rich text (HTML) @required content: String - - content_type: String } structure UpdateCampfireLineOutput {} diff --git a/swift/Sources/Basecamp/Generated/Models/UpdateCampfireLineRequest.swift b/swift/Sources/Basecamp/Generated/Models/UpdateCampfireLineRequest.swift index bf798c0c1..a15e691fc 100644 --- a/swift/Sources/Basecamp/Generated/Models/UpdateCampfireLineRequest.swift +++ b/swift/Sources/Basecamp/Generated/Models/UpdateCampfireLineRequest.swift @@ -3,10 +3,8 @@ import Foundation public struct UpdateCampfireLineRequest: Codable, Sendable { public let content: String - public var contentType: String? - public init(content: String, contentType: String? = nil) { + public init(content: String) { self.content = content - self.contentType = contentType } } diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 519224ed0..126bda9e6 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-22T08:30:27.785Z", + "generated": "2026-07-22T08:31:45.743Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index a15cf8f60..8ab66990d 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -4191,7 +4191,7 @@ } }, "put": { - "description": "Update an existing campfire line", + "description": "Update an existing campfire line; the content is always treated as rich text (HTML).\nThe server coerces every edited line to rich text and ignores any content\ntype hint. Only the line's creator may edit it, and only text and\nrich-text lines are editable.", "operationId": "UpdateCampfireLine", "requestBody": { "content": { @@ -24875,10 +24875,8 @@ "type": "object", "properties": { "content": { - "type": "string" - }, - "content_type": { - "type": "string" + "type": "string", + "description": "The new line content, interpreted as rich text (HTML)" } }, "required": [ diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index cfd8c6dee..0211b5532 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -539,7 +539,12 @@ export interface paths { }; /** @description Get a campfire line by ID */ get: operations["GetCampfireLine"]; - /** @description Update an existing campfire line */ + /** + * @description Update an existing campfire line; the content is always treated as rich text (HTML). + * The server coerces every edited line to rich text and ignores any content + * type hint. Only the line's creator may edit it, and only text and + * rich-text lines are editable. + */ put: operations["UpdateCampfireLine"]; post?: never; /** @description Delete a campfire line */ @@ -4301,8 +4306,8 @@ export interface components { }; UpdateAccountNameResponseContent: components["schemas"]["Account"]; UpdateCampfireLineRequestContent: { + /** @description The new line content, interpreted as rich text (HTML) */ content: string; - content_type?: string; }; UpdateCardColumnRequestContent: { title?: string; diff --git a/typescript/src/generated/services/campfires.ts b/typescript/src/generated/services/campfires.ts index 427457779..282c9e8c2 100644 --- a/typescript/src/generated/services/campfires.ts +++ b/typescript/src/generated/services/campfires.ts @@ -77,10 +77,8 @@ export interface CreateLineCampfireRequest { * Request parameters for updateLine. */ export interface UpdateLineCampfireRequest { - /** Text content */ + /** The new line content, interpreted as rich text (HTML) */ content: string; - /** Content type */ - contentType?: string; } /** @@ -430,7 +428,7 @@ export class CampfiresService extends BaseService { } /** - * Update an existing campfire line + * Update an existing campfire line; the content is always treated as rich text (HTML). * @param campfireId - The campfire ID * @param lineId - The line ID * @param req - Campfire_line update parameters @@ -461,7 +459,6 @@ export class CampfiresService extends BaseService { }, body: { content: req.content, - content_type: req.contentType, }, }) ); diff --git a/typescript/tests/services/campfires.test.ts b/typescript/tests/services/campfires.test.ts index 1bdf3d371..da50c55a3 100644 --- a/typescript/tests/services/campfires.test.ts +++ b/typescript/tests/services/campfires.test.ts @@ -193,7 +193,7 @@ describe("CampfiresService", () => { server.use( http.put(`${BASE_URL}/chats/42/lines/10`, async ({ request }) => { const body = (await request.json()) as Record; - expect(body.content).toBe("Edited!"); + expect(body).toEqual({ content: "Edited!" }); return new HttpResponse(null, { status: 204 }); }) ); @@ -208,6 +208,20 @@ describe("CampfiresService", () => { client.campfires.updateLine(42, 10, { content: "" }) ).rejects.toThrow(); }); + + it("should surface 422 as BasecampError", async () => { + server.use( + http.put(`${BASE_URL}/chats/42/lines/10`, () => { + return HttpResponse.json({ error: "Unprocessable" }, { status: 422 }); + }) + ); + + const error = await client.campfires + .updateLine(42, 10, { content: "Edited!" }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(BasecampError); + expect((error as BasecampError).httpStatus).toBe(422); + }); }); describe("deleteLine", () => { From 6ddb60d408581d5c197ac4ad0a12e2845aec6815 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 21 Jul 2026 23:12:06 -0700 Subject: [PATCH 3/7] test(py): cover campfire line operations Backfill service tests for create_line/get_line/update_line/delete_line. update_line gets happy-path (204 -> None, body carries only content) and 422 ValidationError cases in both the sync and async clients. --- .../tests/services/test_campfires_service.py | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 python/tests/services/test_campfires_service.py diff --git a/python/tests/services/test_campfires_service.py b/python/tests/services/test_campfires_service.py new file mode 100644 index 000000000..5a380fb8b --- /dev/null +++ b/python/tests/services/test_campfires_service.py @@ -0,0 +1,115 @@ +"""Tests for campfire line operations (sync + async).""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from basecamp import AsyncClient, Client +from basecamp.errors import ValidationError + + +def _line(line_id: int = 300, content: str = "Hello everyone!") -> dict: + return { + "id": line_id, + "status": "active", + "type": "Chat::Lines::Text", + "content": content, + } + + +class TestSyncCampfireLines: + @respx.mock + def test_create_line(self): + route = respx.post("https://3.basecampapi.com/12345/chats/200/lines.json").mock( + return_value=httpx.Response(201, json=_line(999, "New message")) + ) + + c = Client(access_token="test-token") + line = c.for_account("12345").campfires.create_line(campfire_id=200, content="New message") + c.close() + + assert route.called + assert line["id"] == 999 + assert line["content"] == "New message" + + @respx.mock + def test_get_line(self): + route = respx.get("https://3.basecampapi.com/12345/chats/200/lines/300").mock( + return_value=httpx.Response(200, json=_line()) + ) + + c = Client(access_token="test-token") + line = c.for_account("12345").campfires.get_line(campfire_id=200, line_id=300) + c.close() + + assert route.called + assert line["id"] == 300 + + @respx.mock + def test_update_line(self): + route = respx.put("https://3.basecampapi.com/12345/chats/200/lines/300").mock(return_value=httpx.Response(204)) + + c = Client(access_token="test-token") + result = c.for_account("12345").campfires.update_line(campfire_id=200, line_id=300, content="Edited!") + c.close() + + assert result is None + assert route.called + body = json.loads(route.calls.last.request.content) + assert body == {"content": "Edited!"} + + @respx.mock + def test_update_line_validation_error(self): + respx.put("https://3.basecampapi.com/12345/chats/200/lines/300").mock( + return_value=httpx.Response(422, json={"error": "Unprocessable"}) + ) + + c = Client(access_token="test-token") + with pytest.raises(ValidationError): + c.for_account("12345").campfires.update_line(campfire_id=200, line_id=300, content="Edited!") + c.close() + + @respx.mock + def test_delete_line(self): + route = respx.delete("https://3.basecampapi.com/12345/chats/200/lines/300").mock( + return_value=httpx.Response(204) + ) + + c = Client(access_token="test-token") + result = c.for_account("12345").campfires.delete_line(campfire_id=200, line_id=300) + c.close() + + assert result is None + assert route.called + + +class TestAsyncCampfireLines: + @pytest.mark.asyncio + @respx.mock + async def test_update_line(self): + route = respx.put("https://3.basecampapi.com/12345/chats/200/lines/300").mock(return_value=httpx.Response(204)) + + c = AsyncClient(access_token="test-token") + result = await c.for_account("12345").campfires.update_line(campfire_id=200, line_id=300, content="Edited!") + await c.close() + + assert result is None + assert route.called + body = json.loads(route.calls.last.request.content) + assert body == {"content": "Edited!"} + + @pytest.mark.asyncio + @respx.mock + async def test_update_line_validation_error(self): + respx.put("https://3.basecampapi.com/12345/chats/200/lines/300").mock( + return_value=httpx.Response(422, json={"error": "Unprocessable"}) + ) + + c = AsyncClient(access_token="test-token") + with pytest.raises(ValidationError): + await c.for_account("12345").campfires.update_line(campfire_id=200, line_id=300, content="Edited!") + await c.close() From d9edb1ce3cad3ccc7362b583f03c2ee3e4822584 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 21 Jul 2026 23:17:16 -0700 Subject: [PATCH 4/7] test(kt): cover campfire line operations Backfill CampfiresServiceTest: createLine/getLine/deleteLine happy paths, updateLine PUT with a content-only body, and 422 -> Validation mapping. --- .../com/basecamp/sdk/CampfiresServiceTest.kt | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/CampfiresServiceTest.kt diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/CampfiresServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/CampfiresServiceTest.kt new file mode 100644 index 000000000..618257dec --- /dev/null +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/CampfiresServiceTest.kt @@ -0,0 +1,176 @@ +package com.basecamp.sdk + +import com.basecamp.sdk.generated.campfires +import com.basecamp.sdk.generated.services.CreateCampfireLineBody +import com.basecamp.sdk.generated.services.UpdateCampfireLineBody +import io.ktor.client.engine.mock.* +import io.ktor.http.* +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CampfiresServiceTest { + + private val json = Json { ignoreUnknownKeys = true } + + private fun mockClient(handler: MockRequestHandler): BasecampClient { + val engine = MockEngine(handler) + return testBasecampClient { + accessToken("test-token") + this.engine = engine + } + } + + private fun lineJson(id: Long, content: String) = """{ + "id": $id, + "status": "active", + "visible_to_clients": false, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", + "title": "Test line", + "inherits_status": true, + "type": "Chat::Lines::Text", + "url": "https://3.basecampapi.com/12345/buckets/1/chats/42/lines/$id.json", + "app_url": "https://3.basecamp.com/12345/buckets/1/chats/42/lines/$id", + "content": "$content", + "parent": {"id": 42, "title": "Campfire", "type": "Chat::Transcript", "url": "https://3.basecampapi.com/12345/buckets/1/chats/42.json", "app_url": "https://3.basecamp.com/12345/buckets/1/chats/42"}, + "bucket": {"id": 1, "name": "Project", "type": "Project"}, + "creator": {"id": 1, "name": "Test User", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z"} + }""" + + @Test + fun createLine() = runTest { + var capturedBody: String? = null + + val client = mockClient { request -> + assertEquals(HttpMethod.Post, request.method) + assertTrue(request.url.encodedPath.contains("/chats/42/lines.json")) + assertEquals("Bearer test-token", request.headers["Authorization"]) + capturedBody = request.body.toByteArray().decodeToString() + + respond( + content = lineJson(300, "Hello everyone!"), + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + + val account = client.forAccount("12345") + val line = account.campfires.createLine( + campfireId = 42, + body = CreateCampfireLineBody(content = "Hello everyone!"), + ) + + assertEquals(300L, line.id) + assertEquals("Hello everyone!", line.content) + + val bodyJson = json.parseToJsonElement(capturedBody!!).jsonObject + assertEquals("Hello everyone!", bodyJson["content"]!!.jsonPrimitive.content) + + client.close() + } + + @Test + fun getLine() = runTest { + val client = mockClient { request -> + assertTrue(request.url.encodedPath.contains("/chats/42/lines/300")) + + respond( + content = lineJson(300, "Hello everyone!"), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + + val account = client.forAccount("12345") + val line = account.campfires.getLine(campfireId = 42, lineId = 300) + + assertEquals(300L, line.id) + assertEquals("Chat::Lines::Text", line.type) + + client.close() + } + + @Test + fun updateLine() = runTest { + var capturedMethod: HttpMethod? = null + var capturedBody: String? = null + + val client = mockClient { request -> + capturedMethod = request.method + capturedBody = request.body.toByteArray().decodeToString() + assertTrue(request.url.encodedPath.contains("/chats/42/lines/300")) + + respond( + content = "", + status = HttpStatusCode.NoContent, + ) + } + + val account = client.forAccount("12345") + account.campfires.updateLine( + campfireId = 42, + lineId = 300, + body = UpdateCampfireLineBody(content = "Edited!"), + ) + + assertEquals(HttpMethod.Put, capturedMethod) + + val bodyJson = json.parseToJsonElement(capturedBody!!).jsonObject + assertEquals("Edited!", bodyJson["content"]!!.jsonPrimitive.content) + assertEquals(setOf("content"), bodyJson.keys) + + client.close() + } + + @Test + fun updateLineValidationThrows() = runTest { + val client = mockClient { _ -> + respond( + content = """{"error": "Unprocessable"}""", + status = HttpStatusCode.UnprocessableEntity, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + + val account = client.forAccount("12345") + try { + account.campfires.updateLine( + campfireId = 42, + lineId = 300, + body = UpdateCampfireLineBody(content = "Edited!"), + ) + assertTrue(false, "Should have thrown") + } catch (e: BasecampException.Validation) { + assertEquals("Unprocessable", e.message) + } + + client.close() + } + + @Test + fun deleteLine() = runTest { + var capturedMethod: HttpMethod? = null + + val client = mockClient { request -> + capturedMethod = request.method + assertTrue(request.url.encodedPath.contains("/chats/42/lines/300")) + + respond( + content = "", + status = HttpStatusCode.NoContent, + ) + } + + val account = client.forAccount("12345") + account.campfires.deleteLine(campfireId = 42, lineId = 300) + + assertEquals(HttpMethod.Delete, capturedMethod) + + client.close() + } +} From 9fa98105b89538aebcf04ecdb1c37f6f930d4c53 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 21 Jul 2026 23:18:51 -0700 Subject: [PATCH 5/7] test(swift): cover campfire line operations Backfill GeneratedServiceTests: createLine/getLine/deleteLine happy paths, updateLine PUT with a content-only body, and 422 -> .validation mapping. --- .../BasecampTests/GeneratedServiceTests.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/swift/Tests/BasecampTests/GeneratedServiceTests.swift b/swift/Tests/BasecampTests/GeneratedServiceTests.swift index 7ab6146fd..d6c0c80d1 100644 --- a/swift/Tests/BasecampTests/GeneratedServiceTests.swift +++ b/swift/Tests/BasecampTests/GeneratedServiceTests.swift @@ -439,4 +439,94 @@ final class GeneratedServiceTests: XCTestCase { XCTAssertEqual(sentJSON["tool_type"] as? String, "Message::Board") XCTAssertNil(sentJSON["title"]) } + + // MARK: - Campfire line operations + + private func campfireLineJSON(id: Int, content: String) -> [String: Any] { + [ + "id": id, "content": content, + "app_url": "https://3.basecamp.com/1/buckets/1/chats/42/lines/\(id)", + "url": "https://3.basecampapi.com/1/buckets/1/chats/42/lines/\(id).json", + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + "status": "active", "title": "Test line", "type": "Chat::Lines::Text", + "inherits_status": true, "visible_to_clients": false, + "bucket": ["id": 1, "name": "Project", "type": "Project"] as [String: Any], + "creator": ["id": 1, "name": "Test User"] as [String: Any], + "parent": ["id": 42, "title": "Campfire", "type": "Chat::Transcript", + "app_url": "https://3.basecamp.com/1/buckets/1/chats/42", + "url": "https://3.basecampapi.com/1/buckets/1/chats/42.json"] as [String: Any], + ] + } + + func testCampfiresServiceCreateLine() async throws { + let data = try JSONSerialization.data(withJSONObject: campfireLineJSON(id: 300, content: "Hello everyone!")) + let transport = MockTransport(statusCode: 201, data: data) + let account = makeTestAccountClient(transport: transport) + + let req = CreateCampfireLineRequest(content: "Hello everyone!") + let line = try await account.campfires.createLine(campfireId: 42, req: req) + + XCTAssertEqual(line.id, 300) + XCTAssertEqual(line.content, "Hello everyone!") + XCTAssertEqual(transport.lastRequest!.request.httpMethod, "POST") + XCTAssertTrue(transport.lastRequest!.request.url!.absoluteString.hasSuffix("/chats/42/lines.json")) + } + + func testCampfiresServiceGetLine() async throws { + let data = try JSONSerialization.data(withJSONObject: campfireLineJSON(id: 300, content: "Hello everyone!")) + let transport = MockTransport(statusCode: 200, data: data) + let account = makeTestAccountClient(transport: transport) + + let line = try await account.campfires.getLine(campfireId: 42, lineId: 300) + + XCTAssertEqual(line.id, 300) + XCTAssertEqual(line.type, "Chat::Lines::Text") + XCTAssertTrue(transport.lastRequest!.request.url!.absoluteString.hasSuffix("/chats/42/lines/300")) + } + + func testCampfiresServiceUpdateLineSendsPUT() async throws { + let transport = MockTransport(statusCode: 204, data: Data()) + let account = makeTestAccountClient(transport: transport) + + let req = UpdateCampfireLineRequest(content: "Edited!") + try await account.campfires.updateLine(campfireId: 42, lineId: 300, req: req) + + let sent = transport.lastRequest!.request + XCTAssertEqual(sent.httpMethod, "PUT") + XCTAssertTrue(sent.url!.absoluteString.hasSuffix("/chats/42/lines/300")) + + let sentJSON = try JSONSerialization.jsonObject(with: sent.httpBody!) as! [String: Any] + XCTAssertEqual(sentJSON["content"] as? String, "Edited!") + XCTAssertEqual(sentJSON.count, 1, "Body should carry only content") + } + + func testCampfiresServiceUpdateLine422MapsToValidation() async throws { + let errorBody = try JSONSerialization.data(withJSONObject: ["error": "Unprocessable"]) + let transport = MockTransport(statusCode: 422, data: errorBody) + let account = makeTestAccountClient(transport: transport) + + do { + let req = UpdateCampfireLineRequest(content: "Edited!") + try await account.campfires.updateLine(campfireId: 42, lineId: 300, req: req) + XCTFail("Expected 422 error") + } catch let error as BasecampError { + if case .validation(let message, let status, _, _) = error { + XCTAssertEqual(status, 422) + XCTAssertEqual(message, "Unprocessable") + } else { + XCTFail("Expected .validation error, got \(error)") + } + } + } + + func testCampfiresServiceDeleteLine() async throws { + let transport = MockTransport(statusCode: 204, data: Data()) + let account = makeTestAccountClient(transport: transport) + + try await account.campfires.deleteLine(campfireId: 42, lineId: 300) + + let sent = transport.lastRequest!.request + XCTAssertEqual(sent.httpMethod, "DELETE") + XCTAssertTrue(sent.url!.absoluteString.hasSuffix("/chats/42/lines/300")) + } } From 3eb13a5fdbcea12f08a48f522875d52ac4508983 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 21 Jul 2026 23:19:26 -0700 Subject: [PATCH 6/7] docs: refresh SPEC.md operation counts (181 -> 204) behavior-model.json now carries 204 operations (66 idempotent, 138 not); the retry-pattern claims (three (max, base_delay_ms) shapes, all retry_on [429, 503]) were re-verified against the model and still hold. --- SPEC.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index b058c03eb..4d9074e9e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -496,7 +496,7 @@ END ### behavior-model.json Retry Patterns -All 181 operations in `behavior-model.json` use `retry_on: [429, 503]`. Three `(max, base_delay_ms)` patterns exist: +All 204 operations in `behavior-model.json` use `retry_on: [429, 503]`. Three `(max, base_delay_ms)` patterns exist: - `(2, 1000)` — most create operations - `(3, 1000)` — most read/update/delete operations - `(3, 2000)` — `CreateAttachment`, `CreateCampfireUpload` (file uploads) @@ -1504,9 +1504,9 @@ Every operation has a `retry` block, including non-idempotent POSTs. For non-ide ### Operation Counts -- Total operations: 181 -- Idempotent: 55 (flagged with `idempotent: true`) -- Non-idempotent: 126 (no `idempotent` field, or not present) +- Total operations: 204 +- Idempotent: 66 (flagged with `idempotent: true`) +- Non-idempotent: 138 (no `idempotent` field, or not present) - All operations use `retry_on: [429, 503]` --- From 4de762409940e1c981dd77573099b3e7fe062d45 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 22 Jul 2026 01:20:52 -0700 Subject: [PATCH 7/7] spec(campfires): absorb campfire-line-edit gap; document delete permissions Flip spec/api-gaps/campfire-line-edit.md to absorbed-in-sdk with the UpdateCampfireLine Smithy ref, per the absorption plan #390 registered. While absorbing, refresh DeleteCampfireLine's doc comment with the newly documented creator-or-admin / 403 contract from the same section of doc/api/sections/campfires.md. --- .../sdk/generated/services/campfires.kt | 2 +- openapi.json | 2 +- ruby/lib/basecamp/generated/metadata.json | 2 +- .../generated/services/campfires_service.rb | 2 +- ruby/lib/basecamp/generated/types.rb | 2 +- spec/api-gaps/README.md | 2 +- spec/api-gaps/campfire-line-edit.md | 21 ++++++++++--------- spec/basecamp.smithy | 3 ++- typescript/src/generated/metadata.ts | 2 +- .../src/generated/openapi-stripped.json | 2 +- typescript/src/generated/schema.d.ts | 5 ++++- .../src/generated/services/campfires.ts | 2 +- 12 files changed, 26 insertions(+), 21 deletions(-) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt index 6dc212b4f..2a88f1914 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/campfires.kt @@ -255,7 +255,7 @@ class CampfiresService(client: AccountClient) : BaseService(client) { } /** - * Delete a campfire line + * Delete a campfire line; allowed for the line's creator or an admin. * @param campfireId The campfire ID * @param lineId The line ID */ diff --git a/openapi.json b/openapi.json index 34e8e5be0..e652090cc 100644 --- a/openapi.json +++ b/openapi.json @@ -4510,7 +4510,7 @@ }, "/{accountId}/chats/{campfireId}/lines/{lineId}": { "delete": { - "description": "Delete a campfire line", + "description": "Delete a campfire line; allowed for the line's creator or an admin.\nThe API responds 403 Forbidden otherwise.", "operationId": "DeleteCampfireLine", "parameters": [ { diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index f90e0eabd..7219fbd09 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-22T08:31:46Z", + "generated": "2026-07-22T08:33:01Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/services/campfires_service.rb b/ruby/lib/basecamp/generated/services/campfires_service.rb index 736086fb8..ed7836d06 100644 --- a/ruby/lib/basecamp/generated/services/campfires_service.rb +++ b/ruby/lib/basecamp/generated/services/campfires_service.rb @@ -124,7 +124,7 @@ def update_line(campfire_id:, line_id:, content:) end end - # Delete a campfire line + # Delete a campfire line; allowed for the line's creator or an admin. # @param campfire_id [Integer] campfire id ID # @param line_id [Integer] line id ID # @return [void] diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index e4fee50d3..427e2ad66 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-22T08:31:46Z +# Generated: 2026-07-22T08:33:01Z require "json" require "time" diff --git a/spec/api-gaps/README.md b/spec/api-gaps/README.md index 73a428374..f4075ac3f 100644 --- a/spec/api-gaps/README.md +++ b/spec/api-gaps/README.md @@ -48,7 +48,7 @@ making the absorption journey publicly auditable. | [recording-bubbleupable-field](recording-bubbleupable-field.md) | no-json-contract | 3e | low | | [todoset-completed-list-visibility](todoset-completed-list-visibility.md) | ambiguous | 3a | low | | [memories-emptied-regression](memories-emptied-regression.md) | addressed-in-bc3-pr-11628 | launch | high | -| [campfire-line-edit](campfire-line-edit.md) | addressed-in-bc3-pr-12359 | post-train | medium | +| [campfire-line-edit](campfire-line-edit.md) | absorbed-in-sdk | post-train | medium | | [todoset-direct-todo-create](todoset-direct-todo-create.md) | addressed-in-bc3-pr-12359 | post-train | medium | | [schedule-recurrence-writes](schedule-recurrence-writes.md) | addressed-in-bc3-pr-12359 | post-train | medium | diff --git a/spec/api-gaps/campfire-line-edit.md b/spec/api-gaps/campfire-line-edit.md index a97c2ff81..30114ae3d 100644 --- a/spec/api-gaps/campfire-line-edit.md +++ b/spec/api-gaps/campfire-line-edit.md @@ -1,9 +1,11 @@ --- gap: campfire-line-edit -status: addressed-in-bc3-pr-12359 +status: absorbed-in-sdk detected: 2026-07-22 sdk_demand: medium bc3_pr: 12359 +smithy_refs: + - "UpdateCampfireLine (spec/basecamp.smithy:3376)" bc3_refs: introduced_in: five routes: @@ -64,12 +66,11 @@ edit; creator-or-admin delete). ## SDK absorption plan when this lands -- **SDK PR #295 (open) already models `UpdateCampfireLine`** and is the - likely absorbing PR; the §Q absorption queue's PR-2 build-ahead pair is - the fallback vehicle if #295 stalls. -- Status flips to `absorbed-in-sdk` with the absorbing PR (which adds the - Smithy refs). -- While absorbing, refresh `DeleteCampfireLine`'s doc comment with the - creator-or-admin / 403 language from the same doc section. -- Pairwise check: route 404s on BC4 (no line editing), succeeds on BC5 — - additive-only, no invariant violation. +Done — absorbed by **SDK PR #295** (`UpdateCampfireLine`): required `content` body +member, 204/no output, `ForbiddenError` in the error list, and the rich-text +coercion + creator-only constraints in the operation doc. The same PR +refreshed `DeleteCampfireLine`'s doc comment with the creator-or-admin / 403 +language from the same doc section. + +Pairwise check: route 404s on BC4 (no line editing), succeeds on BC5 — +additive-only, no invariant violation. diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index ffdb008a3..edb6aa73a 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -3399,7 +3399,8 @@ structure UpdateCampfireLineInput { structure UpdateCampfireLineOutput {} -/// Delete a campfire line +/// Delete a campfire line; allowed for the line's creator or an admin. +/// The API responds 403 Forbidden otherwise. @idempotent @basecampRetry(maxAttempts: 3, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) @basecampIdempotent(natural: true) diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 126bda9e6..8824e42f1 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-22T08:31:45.743Z", + "generated": "2026-07-22T08:32:59.805Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 8ab66990d..e1eef0652 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -4019,7 +4019,7 @@ }, "/chats/{campfireId}/lines/{lineId}": { "delete": { - "description": "Delete a campfire line", + "description": "Delete a campfire line; allowed for the line's creator or an admin.\nThe API responds 403 Forbidden otherwise.", "operationId": "DeleteCampfireLine", "parameters": [ { diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 0211b5532..9e91b896a 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -547,7 +547,10 @@ export interface paths { */ put: operations["UpdateCampfireLine"]; post?: never; - /** @description Delete a campfire line */ + /** + * @description Delete a campfire line; allowed for the line's creator or an admin. + * The API responds 403 Forbidden otherwise. + */ delete: operations["DeleteCampfireLine"]; options?: never; head?: never; diff --git a/typescript/src/generated/services/campfires.ts b/typescript/src/generated/services/campfires.ts index 282c9e8c2..a49cf3285 100644 --- a/typescript/src/generated/services/campfires.ts +++ b/typescript/src/generated/services/campfires.ts @@ -465,7 +465,7 @@ export class CampfiresService extends BaseService { } /** - * Delete a campfire line + * Delete a campfire line; allowed for the line's creator or an admin. * @param campfireId - The campfire ID * @param lineId - The line ID * @returns void