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/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]` --- 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..34f5c1c68 100644 --- a/go/pkg/basecamp/campfires.go +++ b/go/pkg/basecamp/campfires.go @@ -482,6 +482,44 @@ func (s *CampfiresService) CreateLine(ctx context.Context, campfireID int64, con return &line, nil } +// UpdateLine updates the content of an existing line (message) in a campfire. +// 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) (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 content == "" { + err = ErrUsage("campfire line content is required") + return err + } + + body := generated.UpdateCampfireLineJSONRequestBody{ + Content: content, + } + + 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..20edc28ea 100644 --- a/go/pkg/basecamp/campfires_test.go +++ b/go/pkg/basecamp/campfires_test.go @@ -663,6 +663,54 @@ 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_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, 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) + }) + + 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 len(receivedBody) != 1 { + t.Errorf("expected body to contain only content, got %v", receivedBody) + } +} + 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..25d196385 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 The new line content, interpreted as rich text (HTML) + Content string `json:"content"` +} + // 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..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 @@ -52,6 +52,11 @@ data class CreateCampfireLineBody( val contentType: String? = null ) +/** Request body for UpdateCampfireLine. */ +data class UpdateCampfireLineBody( + val content: String +) + /** 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..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 @@ -233,7 +233,29 @@ class CampfiresService(client: AccountClient) : BaseService(client) { } /** - * Delete a 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 + */ + 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)) + }), operationName = info.operation) + }) { Unit } + } + + /** + * 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/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() + } +} diff --git a/openapi.json b/openapi.json index 4d06d53de..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": [ { @@ -4702,6 +4702,131 @@ 503 ] } + }, + "put": { + "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": { + "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,18 @@ "UpdateAccountNameResponseContent": { "$ref": "#/components/schemas/Account" }, + "UpdateCampfireLineRequestContent": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The new line content, interpreted as rich text (HTML)" + } + }, + "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..5e8ac1dec 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) -> 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), + 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,15 @@ 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) -> 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), + 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..4508acb44 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -1503,6 +1503,10 @@ class UpdateAccountNameRequestContent(TypedDict): name: str +class UpdateCampfireLineRequestContent(TypedDict): + content: str + + class UpdateCardColumnRequestContent(TypedDict): description: NotRequired[str] title: NotRequired[str] 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() diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 4711538f3..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:19:36Z", + "generated": "2026-07-22T08:33:01Z", "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..ed7836d06 100644 --- a/ruby/lib/basecamp/generated/services/campfires_service.rb +++ b/ruby/lib/basecamp/generated/services/campfires_service.rb @@ -112,7 +112,19 @@ def get_line(campfire_id:, line_id:) end end - # Delete a 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] The new line content, interpreted as rich text (HTML) + # @return [void] + 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)) + nil + end + end + + # 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 bd7a5b455..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:19:36Z +# Generated: 2026-07-22T08:33:01Z 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..951533d85 100644 --- a/ruby/test/basecamp/services/campfires_service_test.rb +++ b/ruby/test/basecamp/services/campfires_service_test.rb @@ -141,6 +141,28 @@ 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_validation_error + stub_put("/12345/chats/200/lines/300", + response_body: { "error" => "Unprocessable" }, status: 422) + + assert_raises(Basecamp::ValidationError) do + @account.campfires.update_line(campfire_id: 200, line_id: 300, content: "Edited") + end + end + def test_delete_line # Generated service: /lines/{id} without .json stub_delete("/12345/chats/200/lines/300") 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 25620dc75..edb6aa73a 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -135,6 +135,7 @@ service Basecamp { ListCampfireLines, GetCampfireLine, CreateCampfireLine, + UpdateCampfireLine, DeleteCampfireLine, ListCampfireUploads, CreateCampfireUpload, @@ -3364,7 +3365,42 @@ structure CreateCampfireLineOutput { line: CampfireLine } -/// Delete a 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) +@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 + + /// The new line content, interpreted as rich text (HTML) + @required + content: String +} + +structure UpdateCampfireLineOutput {} + +/// 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/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..a15e691fc --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/UpdateCampfireLineRequest.swift @@ -0,0 +1,10 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct UpdateCampfireLineRequest: Codable, Sendable { + public let content: String + + public init(content: String) { + self.content = content + } +} 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/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")) + } } 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..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:19:34.802Z", + "generated": "2026-07-22T08:32:59.805Z", "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..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": [ { @@ -4189,6 +4189,120 @@ 503 ] } + }, + "put": { + "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": { + "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,18 @@ "UpdateAccountNameResponseContent": { "$ref": "#/components/schemas/Account" }, + "UpdateCampfireLineRequestContent": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The new line content, interpreted as rich text (HTML)" + } + }, + "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..9e91b896a 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -539,9 +539,18 @@ export interface paths { }; /** @description Get a campfire line by ID */ get: operations["GetCampfireLine"]; - put?: never; + /** + * @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 */ + /** + * @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; @@ -4299,6 +4308,10 @@ export interface components { name: string; }; UpdateAccountNameResponseContent: components["schemas"]["Account"]; + UpdateCampfireLineRequestContent: { + /** @description The new line content, interpreted as rich text (HTML) */ + content: string; + }; UpdateCardColumnRequestContent: { title?: string; description?: string; @@ -7509,6 +7522,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..a49cf3285 100644 --- a/typescript/src/generated/services/campfires.ts +++ b/typescript/src/generated/services/campfires.ts @@ -73,6 +73,14 @@ export interface CreateLineCampfireRequest { contentType?: string; } +/** + * Request parameters for updateLine. + */ +export interface UpdateLineCampfireRequest { + /** The new line content, interpreted as rich text (HTML) */ + content: string; +} + /** * Options for listUploads. */ @@ -420,7 +428,44 @@ export class CampfiresService extends BaseService { } /** - * Delete a 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 + * @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, + }, + }) + ); + } + + /** + * 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 diff --git a/typescript/tests/services/campfires.test.ts b/typescript/tests/services/campfires.test.ts index 0c60d9db4..da50c55a3 100644 --- a/typescript/tests/services/campfires.test.ts +++ b/typescript/tests/services/campfires.test.ts @@ -188,6 +188,42 @@ 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).toEqual({ content: "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(); + }); + + 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", () => { it("should delete a line", async () => { server.use(