diff --git a/SPEC.md b/SPEC.md index eb3983992..5da385146 100644 --- a/SPEC.md +++ b/SPEC.md @@ -782,7 +782,7 @@ RECORD OperationInfo operation : String -- full operationId, e.g., "ListProjects", "GetTodo", "CreateProject" resource_type : String -- e.g., "todo", "project" is_mutation : Boolean -- true for POST, PUT, DELETE - project_id : Integer? -- if operation is project-scoped (Go omits this field) + project_id : Integer? -- if operation is project-scoped resource_id : Integer? -- if operation targets a specific resource END ``` diff --git a/go/pkg/basecamp/cards.go b/go/pkg/basecamp/cards.go index 4d519d9ea..01cbfd7fa 100644 --- a/go/pkg/basecamp/cards.go +++ b/go/pkg/basecamp/cards.go @@ -760,6 +760,7 @@ func (s *CardColumnsService) SetColor(ctx context.Context, bucketID, columnID in op := OperationInfo{ Service: "CardColumns", Operation: "SetColor", ResourceType: "card_column", IsMutation: true, + ProjectID: bucketID, ResourceID: columnID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { @@ -802,6 +803,7 @@ func (s *CardColumnsService) EnableOnHold(ctx context.Context, bucketID, columnI op := OperationInfo{ Service: "CardColumns", Operation: "EnableOnHold", ResourceType: "card_column", IsMutation: true, + ProjectID: bucketID, ResourceID: columnID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { @@ -835,6 +837,7 @@ func (s *CardColumnsService) DisableOnHold(ctx context.Context, bucketID, column op := OperationInfo{ Service: "CardColumns", Operation: "DisableOnHold", ResourceType: "card_column", IsMutation: true, + ProjectID: bucketID, ResourceID: columnID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { diff --git a/go/pkg/basecamp/gauges.go b/go/pkg/basecamp/gauges.go index 3171cfb74..5888f3caf 100644 --- a/go/pkg/basecamp/gauges.go +++ b/go/pkg/basecamp/gauges.go @@ -167,6 +167,7 @@ func (s *GaugesService) ListNeedles(ctx context.Context, projectID int64) (resul op := OperationInfo{ Service: "Gauges", Operation: "ListNeedles", ResourceType: "gauge_needle", IsMutation: false, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -243,6 +244,7 @@ func (s *GaugesService) CreateNeedle(ctx context.Context, projectID int64, req * op := OperationInfo{ Service: "Gauges", Operation: "CreateNeedle", ResourceType: "gauge_needle", IsMutation: true, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -357,6 +359,7 @@ func (s *GaugesService) Toggle(ctx context.Context, projectID int64, enabled boo op := OperationInfo{ Service: "Gauges", Operation: "Toggle", ResourceType: "gauge", IsMutation: true, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/observability.go b/go/pkg/basecamp/observability.go index b80da0acc..2860b7393 100644 --- a/go/pkg/basecamp/observability.go +++ b/go/pkg/basecamp/observability.go @@ -70,6 +70,8 @@ type OperationInfo struct { ResourceType string // IsMutation indicates if this operation modifies state. IsMutation bool + // ProjectID is the project/bucket ID if applicable. + ProjectID int64 // ResourceID is the specific resource ID if applicable. ResourceID int64 } diff --git a/go/pkg/basecamp/operation_scope_test.go b/go/pkg/basecamp/operation_scope_test.go new file mode 100644 index 000000000..c9737e979 --- /dev/null +++ b/go/pkg/basecamp/operation_scope_test.go @@ -0,0 +1,131 @@ +package basecamp + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// TestOperationProjectResourceScope pins the {ProjectID, ResourceID} split that +// OnOperationStart observes for every operation whose path is project- or +// bucket-scoped. It drives real service calls through a capturing hook so a +// forgotten ProjectID assignment (or a stale ResourceID) is caught mechanically +// — `make check` alone cannot guarantee this. The server returns a trivial body +// because the assertions only inspect the operation metadata captured before the +// request is issued, not the decoded response. +func TestOperationProjectResourceScope(t *testing.T) { + const ( + accountID = "5245563" + bucketID = int64(2085958499) + columnID = int64(1101) + cardTableID = int64(2202) + wormholeID = int64(3303) + projectID = int64(4404) + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer server.Close() + + tests := []struct { + name string + invoke func(ctx context.Context, ac *AccountClient) + wantProjectID int64 + wantResourceID int64 + }{ + // Group 1 — project scope (bucket) plus a deeper resource id that is kept. + {"CardColumns.SetColor", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.CardColumns().SetColor(ctx, bucketID, columnID, "white") + }, bucketID, columnID}, + {"CardColumns.EnableOnHold", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.CardColumns().EnableOnHold(ctx, bucketID, columnID) + }, bucketID, columnID}, + {"CardColumns.DisableOnHold", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.CardColumns().DisableOnHold(ctx, bucketID, columnID) + }, bucketID, columnID}, + {"Wormholes.Create", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Wormholes().Create(ctx, bucketID, cardTableID, 9001) + }, bucketID, cardTableID}, + {"Wormholes.Update", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Wormholes().Update(ctx, bucketID, wormholeID, 9001) + }, bucketID, wormholeID}, + {"Wormholes.Delete", func(ctx context.Context, ac *AccountClient) { + _ = ac.Wormholes().Delete(ctx, bucketID, wormholeID) + }, bucketID, wormholeID}, + + // Group 2 — project scope only; no deeper resource id. + {"Gauges.ListNeedles", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Gauges().ListNeedles(ctx, projectID) + }, projectID, 0}, + {"Gauges.CreateNeedle", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Gauges().CreateNeedle(ctx, projectID, &CreateGaugeNeedleRequest{}) + }, projectID, 0}, + {"Gauges.Toggle", func(ctx context.Context, ac *AccountClient) { + _ = ac.Gauges().Toggle(ctx, projectID, true) + }, projectID, 0}, + {"People.ListProjectPeople", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.People().ListProjectPeople(ctx, projectID, nil) + }, projectID, 0}, + {"People.UpdateProjectAccess", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.People().UpdateProjectAccess(ctx, projectID, &UpdateProjectAccessRequest{}) + }, projectID, 0}, + {"Timesheet.ProjectReport", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Timesheet().ProjectReport(ctx, projectID, nil) + }, projectID, 0}, + {"Timeline.ProjectTimeline", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Timeline().ProjectTimeline(ctx, projectID, nil) + }, projectID, 0}, + {"Tools.Create", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Tools().Create(ctx, bucketID, "Message::Board", nil) + }, bucketID, 0}, + {"Webhooks.List", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Webhooks().List(ctx, bucketID, &WebhookListOptions{Page: 1}) + }, bucketID, 0}, + {"Webhooks.Create", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Webhooks().Create(ctx, bucketID, &CreateWebhookRequest{}) + }, bucketID, 0}, + {"Projects.Get", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Projects().Get(ctx, projectID) + }, projectID, 0}, + {"Projects.Update", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Projects().Update(ctx, projectID, &UpdateProjectRequest{}) + }, projectID, 0}, + {"Projects.Trash", func(ctx context.Context, ac *AccountClient) { + _ = ac.Projects().Trash(ctx, projectID) + }, projectID, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.BaseURL = server.URL + + var capturedOp OperationInfo + var captured bool + hooks := &testHooks{ + onOperationStart: func(ctx context.Context, op OperationInfo) context.Context { + capturedOp = op + captured = true + return ctx + }, + } + client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}, WithHooks(hooks)) + + tt.invoke(context.Background(), client.ForAccount(accountID)) + + if !captured { + t.Fatalf("%s: OnOperationStart never fired", tt.name) + } + if capturedOp.ProjectID != tt.wantProjectID { + t.Errorf("%s: ProjectID = %d, want %d", tt.name, capturedOp.ProjectID, tt.wantProjectID) + } + if capturedOp.ResourceID != tt.wantResourceID { + t.Errorf("%s: ResourceID = %d, want %d", tt.name, capturedOp.ResourceID, tt.wantResourceID) + } + }) + } +} diff --git a/go/pkg/basecamp/otel/otel.go b/go/pkg/basecamp/otel/otel.go index d82bbea65..59e97b952 100644 --- a/go/pkg/basecamp/otel/otel.go +++ b/go/pkg/basecamp/otel/otel.go @@ -149,6 +149,10 @@ func (h *Hooks) OnOperationStart(ctx context.Context, op basecamp.OperationInfo) ), ) + if op.ProjectID != 0 { + span.SetAttributes(attribute.Int64("basecamp.project_id", op.ProjectID)) + } + if op.ResourceID != 0 { span.SetAttributes(attribute.Int64("basecamp.resource_id", op.ResourceID)) } diff --git a/go/pkg/basecamp/otel/otel_test.go b/go/pkg/basecamp/otel/otel_test.go index aa7348811..a1856f7b5 100644 --- a/go/pkg/basecamp/otel/otel_test.go +++ b/go/pkg/basecamp/otel/otel_test.go @@ -55,6 +55,7 @@ func TestOnOperationStartEnd(t *testing.T) { Operation: "Complete", ResourceType: "todo", IsMutation: true, + ProjectID: 789, ResourceID: 456, } @@ -93,6 +94,9 @@ func TestOnOperationStartEnd(t *testing.T) { if attrs["basecamp.is_mutation"] != true { t.Errorf("expected basecamp.is_mutation=true, got %v", attrs["basecamp.is_mutation"]) } + if attrs["basecamp.project_id"] != int64(789) { + t.Errorf("expected basecamp.project_id=789, got %v", attrs["basecamp.project_id"]) + } if attrs["basecamp.resource_id"] != int64(456) { t.Errorf("expected basecamp.resource_id=456, got %v", attrs["basecamp.resource_id"]) } diff --git a/go/pkg/basecamp/people.go b/go/pkg/basecamp/people.go index 4cb2ddf68..0b99629af 100644 --- a/go/pkg/basecamp/people.go +++ b/go/pkg/basecamp/people.go @@ -313,6 +313,7 @@ func (s *PeopleService) ListProjectPeople(ctx context.Context, projectID int64, op := OperationInfo{ Service: "People", Operation: "ListProjectPeople", ResourceType: "person", IsMutation: false, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -459,6 +460,7 @@ func (s *PeopleService) UpdateProjectAccess(ctx context.Context, projectID int64 op := OperationInfo{ Service: "People", Operation: "UpdateProjectAccess", ResourceType: "person", IsMutation: true, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/projects.go b/go/pkg/basecamp/projects.go index b195fc2a7..bcec161dd 100644 --- a/go/pkg/basecamp/projects.go +++ b/go/pkg/basecamp/projects.go @@ -212,7 +212,7 @@ func (s *ProjectsService) Get(ctx context.Context, id int64) (result *Project, e op := OperationInfo{ Service: "Projects", Operation: "Get", ResourceType: "project", IsMutation: false, - ResourceID: id, + ProjectID: id, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -287,7 +287,7 @@ func (s *ProjectsService) Update(ctx context.Context, id int64, req *UpdateProje op := OperationInfo{ Service: "Projects", Operation: "Update", ResourceType: "project", IsMutation: true, - ResourceID: id, + ProjectID: id, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -351,7 +351,7 @@ func (s *ProjectsService) Trash(ctx context.Context, id int64) (err error) { op := OperationInfo{ Service: "Projects", Operation: "Trash", ResourceType: "project", IsMutation: true, - ResourceID: id, + ProjectID: id, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/timeline.go b/go/pkg/basecamp/timeline.go index e79d39673..4426502eb 100644 --- a/go/pkg/basecamp/timeline.go +++ b/go/pkg/basecamp/timeline.go @@ -152,6 +152,7 @@ func (s *TimelineService) ProjectTimeline(ctx context.Context, projectID int64, op := OperationInfo{ Service: "Timeline", Operation: "ProjectTimeline", ResourceType: "timeline_event", IsMutation: false, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/timesheet.go b/go/pkg/basecamp/timesheet.go index 9bfaa1661..2e7f218f6 100644 --- a/go/pkg/basecamp/timesheet.go +++ b/go/pkg/basecamp/timesheet.go @@ -152,6 +152,7 @@ func (s *TimesheetService) ProjectReport(ctx context.Context, projectID int64, o op := OperationInfo{ Service: "Timesheet", Operation: "ProjectReport", ResourceType: "timesheet_entry", IsMutation: false, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/tools.go b/go/pkg/basecamp/tools.go index 5ccd82599..916babf52 100644 --- a/go/pkg/basecamp/tools.go +++ b/go/pkg/basecamp/tools.go @@ -87,7 +87,7 @@ func (s *ToolsService) Create(ctx context.Context, bucketID int64, toolType stri op := OperationInfo{ Service: "Tools", Operation: "Create", ResourceType: "tool", IsMutation: true, - ResourceID: bucketID, + ProjectID: bucketID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/tools_test.go b/go/pkg/basecamp/tools_test.go index c8feb0146..113fb7c00 100644 --- a/go/pkg/basecamp/tools_test.go +++ b/go/pkg/basecamp/tools_test.go @@ -135,8 +135,11 @@ func TestToolsServiceCreatePostsToBucketDock(t *testing.T) { if err != nil { t.Fatalf("Create() error = %v; request path = %s; want bucket %d dock tools endpoint", err, capturedPath, bucketID) } - if capturedOp.ResourceID != bucketID { - t.Fatalf("Create() operation ResourceID = %d, want destination bucket %d", capturedOp.ResourceID, bucketID) + if capturedOp.ProjectID != bucketID { + t.Fatalf("Create() operation ProjectID = %d, want destination bucket %d", capturedOp.ProjectID, bucketID) + } + if capturedOp.ResourceID != 0 { + t.Fatalf("Create() operation ResourceID = %d, want 0 (bucket is project scope, not a resource)", capturedOp.ResourceID) } } diff --git a/go/pkg/basecamp/webhooks.go b/go/pkg/basecamp/webhooks.go index 56fa0ea69..8bfa80ed2 100644 --- a/go/pkg/basecamp/webhooks.go +++ b/go/pkg/basecamp/webhooks.go @@ -104,6 +104,7 @@ func (s *WebhooksService) List(ctx context.Context, bucketID int64, opts *Webhoo op := OperationInfo{ Service: "Webhooks", Operation: "List", ResourceType: "webhook", IsMutation: false, + ProjectID: bucketID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -209,6 +210,7 @@ func (s *WebhooksService) Create(ctx context.Context, bucketID int64, req *Creat op := OperationInfo{ Service: "Webhooks", Operation: "Create", ResourceType: "webhook", IsMutation: true, + ProjectID: bucketID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/wormholes.go b/go/pkg/basecamp/wormholes.go index 02c4e7676..19e1a4502 100644 --- a/go/pkg/basecamp/wormholes.go +++ b/go/pkg/basecamp/wormholes.go @@ -62,6 +62,7 @@ func (s *WormholesService) Create(ctx context.Context, bucketID, cardTableID, de op := OperationInfo{ Service: "Wormholes", Operation: "Create", ResourceType: "wormhole", IsMutation: true, + ProjectID: bucketID, ResourceID: cardTableID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { @@ -114,6 +115,7 @@ func (s *WormholesService) Update(ctx context.Context, bucketID, wormholeID, des op := OperationInfo{ Service: "Wormholes", Operation: "Update", ResourceType: "wormhole", IsMutation: true, + ProjectID: bucketID, ResourceID: wormholeID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { @@ -163,6 +165,7 @@ func (s *WormholesService) Delete(ctx context.Context, bucketID, wormholeID int6 op := OperationInfo{ Service: "Wormholes", Operation: "Delete", ResourceType: "wormhole", IsMutation: true, + ProjectID: bucketID, ResourceID: wormholeID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { diff --git a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ServiceEmitter.kt b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ServiceEmitter.kt index 3c9942b3f..44913d66f 100644 --- a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ServiceEmitter.kt +++ b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ServiceEmitter.kt @@ -106,9 +106,9 @@ class ServiceEmitter(private val api: OpenApiParser) { sb.appendLine(" suspend fun ${op.methodName}($params): $returnType {") // Build OperationInfo - val projectParam = op.pathParams.find { it.name == "projectId" } - val resourceParam = op.pathParams.findLast { it.name != "projectId" && it.name.endsWith("Id") } - val projectArg = if (projectParam != null) "projectId" else "null" + val projectParam = op.pathParams.find { it.name == "projectId" || it.name == "bucketId" } + val resourceParam = op.pathParams.findLast { it.name != "projectId" && it.name != "bucketId" && it.name.endsWith("Id") } + val projectArg = if (projectParam != null) projectParam.name.snakeToCamelCase() else "null" val resourceArg = if (resourceParam != null) resourceParam.name.snakeToCamelCase() else "null" sb.appendLine(" val info = OperationInfo(") diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/card-columns.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/card-columns.kt index 6af1a060f..6522908ab 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/card-columns.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/card-columns.kt @@ -24,7 +24,7 @@ class CardColumnsService(client: AccountClient) : BaseService(client) { operation = "SetCardColumnColor", resourceType = "card_column_color", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = columnId, ) return request(info, { @@ -47,7 +47,7 @@ class CardColumnsService(client: AccountClient) : BaseService(client) { operation = "EnableCardColumnOnHold", resourceType = "card_column_on_hold", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = columnId, ) return request(info, { @@ -68,7 +68,7 @@ class CardColumnsService(client: AccountClient) : BaseService(client) { operation = "DisableCardColumnOnHold", resourceType = "card_column_on_hold", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = columnId, ) return request(info, { diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt index 4915b49ad..f3663956a 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt @@ -23,8 +23,8 @@ class ToolsService(client: AccountClient) : BaseService(client) { operation = "CreateTool", resourceType = "tool", isMutation = true, - projectId = null, - resourceId = bucketId, + projectId = bucketId, + resourceId = null, ) return request(info, { httpPost("/buckets/${bucketId}/dock/tools.json", json.encodeToString(kotlinx.serialization.json.buildJsonObject { diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/webhooks.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/webhooks.kt index 315b96970..ae698c14f 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/webhooks.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/webhooks.kt @@ -23,8 +23,8 @@ class WebhooksService(client: AccountClient) : BaseService(client) { operation = "ListWebhooks", resourceType = "webhook", isMutation = false, - projectId = null, - resourceId = bucketId, + projectId = bucketId, + resourceId = null, ) return requestPaginated(info, options, { httpGet("/buckets/${bucketId}/webhooks.json", operationName = info.operation) @@ -44,8 +44,8 @@ class WebhooksService(client: AccountClient) : BaseService(client) { operation = "CreateWebhook", resourceType = "webhook", isMutation = true, - projectId = null, - resourceId = bucketId, + projectId = bucketId, + resourceId = null, ) return request(info, { httpPost("/buckets/${bucketId}/webhooks.json", json.encodeToString(kotlinx.serialization.json.buildJsonObject { diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/wormholes.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/wormholes.kt index 7e49e4345..c69fa43a7 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/wormholes.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/wormholes.kt @@ -24,7 +24,7 @@ class WormholesService(client: AccountClient) : BaseService(client) { operation = "UpdateWormhole", resourceType = "wormhole", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = wormholeId, ) return request(info, { @@ -47,7 +47,7 @@ class WormholesService(client: AccountClient) : BaseService(client) { operation = "DeleteWormhole", resourceType = "wormhole", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = wormholeId, ) request(info, { @@ -67,7 +67,7 @@ class WormholesService(client: AccountClient) : BaseService(client) { operation = "CreateWormhole", resourceType = "wormhole", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = cardTableId, ) return request(info, { diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WebhooksServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WebhooksServiceTest.kt index 33373d5a0..d545f4e20 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WebhooksServiceTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WebhooksServiceTest.kt @@ -12,6 +12,8 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class WebhooksServiceTest { @@ -70,6 +72,39 @@ class WebhooksServiceTest { client.close() } + @Test + fun listEmitsBucketProjectScopeWithNullResourceId() = runTest { + var capturedInfo: OperationInfo? = null + val hooks = object : BasecampHooks { + override fun onOperationStart(info: OperationInfo) { + if (info.operation == "ListWebhooks") capturedInfo = info + } + } + val engine = MockEngine { _ -> + respond( + content = """[${webhookJson(10, "https://example.com/hook1")}]""", + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType to listOf(ContentType.Application.Json.toString()), + "X-Total-Count" to listOf("1"), + ), + ) + } + val client = testBasecampClient { + accessToken("test-token") + this.engine = engine + this.hooks = hooks + } + + client.forAccount("12345").webhooks.list(bucketId = 1) + + assertNotNull(capturedInfo) + assertEquals(1L, capturedInfo!!.projectId) + assertNull(capturedInfo!!.resourceId) + + client.close() + } + @Test fun getWebhook() = runTest { val client = mockClient { request -> diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt index b9e589f23..18d6c1e4f 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt @@ -82,6 +82,40 @@ class WormholesServiceTest { client.close() } + @Test + fun createEmitsBucketProjectScopeWithCardTableResourceId() = runTest { + var capturedInfo: OperationInfo? = null + val hooks = object : BasecampHooks { + override fun onOperationStart(info: OperationInfo) { + if (info.operation == "CreateWormhole") capturedInfo = info + } + } + val engine = MockEngine { _ -> + respond( + content = wormholeJson(99), + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + val client = testBasecampClient { + accessToken("test-token") + this.engine = engine + this.hooks = hooks + } + + client.forAccount("12345").wormholes.create( + bucketId = 2085958499, + cardTableId = 1069479345, + body = CreateWormholeBody(destinationRecordingId = 1069479500), + ) + + assertNotNull(capturedInfo) + assertEquals(2085958499L, capturedInfo!!.projectId) + assertEquals(1069479345L, capturedInfo!!.resourceId) + + client.close() + } + @Test fun createValidationErrorAtLimitThrows() = runTest { val client = mockClient { _ -> diff --git a/python/scripts/generate_services.py b/python/scripts/generate_services.py index 203c2ead1..6c77c8cf5 100644 --- a/python/scripts/generate_services.py +++ b/python/scripts/generate_services.py @@ -585,9 +585,9 @@ def build_info_kwargs(op: dict, service_name: str) -> str: f'is_mutation={op["is_mutation"]}', ] - project_param = next((p for p in op["path_params"] if p["name"] == "projectId"), None) + project_param = next((p for p in op["path_params"] if p["name"] in ("projectId", "bucketId")), None) resource_param = next( - (p for p in reversed(op["path_params"]) if p["name"] != "projectId"), + (p for p in reversed(op["path_params"]) if p["name"] not in ("projectId", "bucketId")), None, ) diff --git a/python/src/basecamp/generated/services/card_columns.py b/python/src/basecamp/generated/services/card_columns.py index cece5c47a..c0f476f49 100644 --- a/python/src/basecamp/generated/services/card_columns.py +++ b/python/src/basecamp/generated/services/card_columns.py @@ -13,7 +13,13 @@ class CardColumnsService(BaseService): def set_color(self, *, bucket_id: int, column_id: int, color: str) -> dict[str, Any]: return self._request( - OperationInfo(service="cardcolumns", operation="set_color", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="set_color", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "PUT", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/color.json", json_body=self._compact(color=color), @@ -22,7 +28,13 @@ def set_color(self, *, bucket_id: int, column_id: int, color: str) -> dict[str, def enable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: return self._request( - OperationInfo(service="cardcolumns", operation="enable_on_hold", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="enable_on_hold", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "POST", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/on_hold.json", operation="EnableCardColumnOnHold", @@ -30,7 +42,13 @@ def enable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: def disable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: return self._request( - OperationInfo(service="cardcolumns", operation="disable_on_hold", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="disable_on_hold", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "DELETE", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/on_hold.json", operation="DisableCardColumnOnHold", @@ -94,7 +112,13 @@ def move(self, *, card_table_id: int, source_id: int, target_id: int, position: class AsyncCardColumnsService(AsyncBaseService): async def set_color(self, *, bucket_id: int, column_id: int, color: str) -> dict[str, Any]: return await self._request( - OperationInfo(service="cardcolumns", operation="set_color", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="set_color", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "PUT", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/color.json", json_body=self._compact(color=color), @@ -103,7 +127,13 @@ async def set_color(self, *, bucket_id: int, column_id: int, color: str) -> dict async def enable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: return await self._request( - OperationInfo(service="cardcolumns", operation="enable_on_hold", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="enable_on_hold", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "POST", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/on_hold.json", operation="EnableCardColumnOnHold", @@ -111,7 +141,13 @@ async def enable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, A async def disable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: return await self._request( - OperationInfo(service="cardcolumns", operation="disable_on_hold", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="disable_on_hold", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "DELETE", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/on_hold.json", operation="DisableCardColumnOnHold", diff --git a/python/src/basecamp/generated/services/tools.py b/python/src/basecamp/generated/services/tools.py index 043776305..4c60e2e72 100644 --- a/python/src/basecamp/generated/services/tools.py +++ b/python/src/basecamp/generated/services/tools.py @@ -13,7 +13,7 @@ class ToolsService(BaseService): def create(self, *, bucket_id: int, tool_type: str, title: str | None = None) -> dict[str, Any]: return self._request( - OperationInfo(service="tools", operation="create", is_mutation=True, resource_id=bucket_id), + OperationInfo(service="tools", operation="create", is_mutation=True, project_id=bucket_id), "POST", f"/buckets/{bucket_id}/dock/tools.json", json_body=self._compact(tool_type=tool_type, title=title), @@ -73,7 +73,7 @@ def disable(self, *, tool_id: int) -> None: class AsyncToolsService(AsyncBaseService): async def create(self, *, bucket_id: int, tool_type: str, title: str | None = None) -> dict[str, Any]: return await self._request( - OperationInfo(service="tools", operation="create", is_mutation=True, resource_id=bucket_id), + OperationInfo(service="tools", operation="create", is_mutation=True, project_id=bucket_id), "POST", f"/buckets/{bucket_id}/dock/tools.json", json_body=self._compact(tool_type=tool_type, title=title), diff --git a/python/src/basecamp/generated/services/webhooks_service.py b/python/src/basecamp/generated/services/webhooks_service.py index 73ab4f86f..7f846ea8b 100644 --- a/python/src/basecamp/generated/services/webhooks_service.py +++ b/python/src/basecamp/generated/services/webhooks_service.py @@ -13,7 +13,7 @@ class WebhooksService(BaseService): def list(self, *, bucket_id: int) -> ListResult: return self._request_paginated( - OperationInfo(service="webhooks", operation="list", is_mutation=False, resource_id=bucket_id), + OperationInfo(service="webhooks", operation="list", is_mutation=False, project_id=bucket_id), f"/buckets/{bucket_id}/webhooks.json", ) @@ -21,7 +21,7 @@ def create( self, *, bucket_id: int, payload_url: str, types: list[str], active: bool | None = None ) -> dict[str, Any]: return self._request( - OperationInfo(service="webhooks", operation="create", is_mutation=True, resource_id=bucket_id), + OperationInfo(service="webhooks", operation="create", is_mutation=True, project_id=bucket_id), "POST", f"/buckets/{bucket_id}/webhooks.json", json_body=self._compact(payload_url=payload_url, types=types, active=active), @@ -63,7 +63,7 @@ def delete(self, *, webhook_id: int) -> None: class AsyncWebhooksService(AsyncBaseService): async def list(self, *, bucket_id: int) -> ListResult: return await self._request_paginated( - OperationInfo(service="webhooks", operation="list", is_mutation=False, resource_id=bucket_id), + OperationInfo(service="webhooks", operation="list", is_mutation=False, project_id=bucket_id), f"/buckets/{bucket_id}/webhooks.json", ) @@ -71,7 +71,7 @@ async def create( self, *, bucket_id: int, payload_url: str, types: list[str], active: bool | None = None ) -> dict[str, Any]: return await self._request( - OperationInfo(service="webhooks", operation="create", is_mutation=True, resource_id=bucket_id), + OperationInfo(service="webhooks", operation="create", is_mutation=True, project_id=bucket_id), "POST", f"/buckets/{bucket_id}/webhooks.json", json_body=self._compact(payload_url=payload_url, types=types, active=active), diff --git a/python/src/basecamp/generated/services/wormholes.py b/python/src/basecamp/generated/services/wormholes.py index 0ab7daa71..80c48d305 100644 --- a/python/src/basecamp/generated/services/wormholes.py +++ b/python/src/basecamp/generated/services/wormholes.py @@ -13,7 +13,9 @@ class WormholesService(BaseService): def update(self, *, bucket_id: int, wormhole_id: int, destination_recording_id: int) -> dict[str, Any]: return self._request( - OperationInfo(service="wormholes", operation="update", is_mutation=True, resource_id=wormhole_id), + OperationInfo( + service="wormholes", operation="update", is_mutation=True, project_id=bucket_id, resource_id=wormhole_id + ), "PUT", f"/buckets/{bucket_id}/card_tables/wormholes/{wormhole_id}", json_body=self._compact(destination_recording_id=destination_recording_id), @@ -22,7 +24,9 @@ def update(self, *, bucket_id: int, wormhole_id: int, destination_recording_id: def delete(self, *, bucket_id: int, wormhole_id: int) -> None: self._request_void( - OperationInfo(service="wormholes", operation="delete", is_mutation=True, resource_id=wormhole_id), + OperationInfo( + service="wormholes", operation="delete", is_mutation=True, project_id=bucket_id, resource_id=wormhole_id + ), "DELETE", f"/buckets/{bucket_id}/card_tables/wormholes/{wormhole_id}", operation="DeleteWormhole", @@ -30,7 +34,13 @@ def delete(self, *, bucket_id: int, wormhole_id: int) -> None: def create(self, *, bucket_id: int, card_table_id: int, destination_recording_id: int) -> dict[str, Any]: return self._request( - OperationInfo(service="wormholes", operation="create", is_mutation=True, resource_id=card_table_id), + OperationInfo( + service="wormholes", + operation="create", + is_mutation=True, + project_id=bucket_id, + resource_id=card_table_id, + ), "POST", f"/buckets/{bucket_id}/card_tables/{card_table_id}/wormholes.json", json_body=self._compact(destination_recording_id=destination_recording_id), @@ -41,7 +51,9 @@ def create(self, *, bucket_id: int, card_table_id: int, destination_recording_id class AsyncWormholesService(AsyncBaseService): async def update(self, *, bucket_id: int, wormhole_id: int, destination_recording_id: int) -> dict[str, Any]: return await self._request( - OperationInfo(service="wormholes", operation="update", is_mutation=True, resource_id=wormhole_id), + OperationInfo( + service="wormholes", operation="update", is_mutation=True, project_id=bucket_id, resource_id=wormhole_id + ), "PUT", f"/buckets/{bucket_id}/card_tables/wormholes/{wormhole_id}", json_body=self._compact(destination_recording_id=destination_recording_id), @@ -50,7 +62,9 @@ async def update(self, *, bucket_id: int, wormhole_id: int, destination_recordin async def delete(self, *, bucket_id: int, wormhole_id: int) -> None: await self._request_void( - OperationInfo(service="wormholes", operation="delete", is_mutation=True, resource_id=wormhole_id), + OperationInfo( + service="wormholes", operation="delete", is_mutation=True, project_id=bucket_id, resource_id=wormhole_id + ), "DELETE", f"/buckets/{bucket_id}/card_tables/wormholes/{wormhole_id}", operation="DeleteWormhole", @@ -58,7 +72,13 @@ async def delete(self, *, bucket_id: int, wormhole_id: int) -> None: async def create(self, *, bucket_id: int, card_table_id: int, destination_recording_id: int) -> dict[str, Any]: return await self._request( - OperationInfo(service="wormholes", operation="create", is_mutation=True, resource_id=card_table_id), + OperationInfo( + service="wormholes", + operation="create", + is_mutation=True, + project_id=bucket_id, + resource_id=card_table_id, + ), "POST", f"/buckets/{bucket_id}/card_tables/{card_table_id}/wormholes.json", json_body=self._compact(destination_recording_id=destination_recording_id), diff --git a/python/tests/services/test_tools.py b/python/tests/services/test_tools.py index 46c99f885..7ae4922ff 100644 --- a/python/tests/services/test_tools.py +++ b/python/tests/services/test_tools.py @@ -9,6 +9,15 @@ import respx from basecamp import AsyncClient, Client, ValidationError +from basecamp.hooks import BasecampHooks, OperationInfo + + +class _RecordingHooks(BasecampHooks): + def __init__(self) -> None: + self.operations: list[OperationInfo] = [] + + def on_operation_start(self, info: OperationInfo) -> None: + self.operations.append(info) def _tool(tool_id: int = 800, *, title: str = "Message Board") -> dict: @@ -57,6 +66,23 @@ def test_create_omits_title_when_not_provided(self): assert route.called assert json.loads(route.calls[0].request.content) == {"tool_type": "Message::Board"} + @respx.mock + def test_create_operation_metadata_scopes_project_with_null_resource(self): + respx.post("https://3.basecampapi.com/12345/buckets/456/dock/tools.json").mock( + return_value=httpx.Response(201, json=_tool()) + ) + + hooks = _RecordingHooks() + account = Client(access_token="test-token", hooks=hooks).for_account("12345") + account.tools.create(bucket_id=456, tool_type="Message::Board") + + assert len(hooks.operations) == 1 + info = hooks.operations[0] + assert info.service == "tools" + assert info.operation == "create" + assert info.project_id == 456 + assert info.resource_id is None + @respx.mock def test_create_raises_validation_error_on_422(self): route = respx.post("https://3.basecampapi.com/12345/buckets/456/dock/tools.json").mock( @@ -107,6 +133,24 @@ async def test_create_omits_title_when_not_provided(self): assert route.called assert json.loads(route.calls[0].request.content) == {"tool_type": "Message::Board"} + @pytest.mark.asyncio + @respx.mock + async def test_create_operation_metadata_scopes_project_with_null_resource(self): + respx.post("https://3.basecampapi.com/12345/buckets/456/dock/tools.json").mock( + return_value=httpx.Response(201, json=_tool()) + ) + + hooks = _RecordingHooks() + account = AsyncClient(access_token="test-token", hooks=hooks).for_account("12345") + await account.tools.create(bucket_id=456, tool_type="Message::Board") + + assert len(hooks.operations) == 1 + info = hooks.operations[0] + assert info.service == "tools" + assert info.operation == "create" + assert info.project_id == 456 + assert info.resource_id is None + @pytest.mark.asyncio @respx.mock async def test_create_raises_validation_error_on_422(self): diff --git a/python/tests/services/test_wormholes_service.py b/python/tests/services/test_wormholes_service.py index 7963ba622..564705ccf 100644 --- a/python/tests/services/test_wormholes_service.py +++ b/python/tests/services/test_wormholes_service.py @@ -10,12 +10,21 @@ from basecamp import AsyncClient, Client from basecamp.errors import ForbiddenError, NotFoundError, ValidationError +from basecamp.hooks import BasecampHooks, OperationInfo CREATE_URL = "https://3.basecampapi.com/12345/buckets/2085958499/card_tables/1069479345/wormholes.json" WORMHOLE_URL = "https://3.basecampapi.com/12345/buckets/2085958499/card_tables/wormholes/1069479400" MISSING_URL = "https://3.basecampapi.com/12345/buckets/2085958499/card_tables/wormholes/999" +class _RecordingHooks(BasecampHooks): + def __init__(self) -> None: + self.operations: list[OperationInfo] = [] + + def on_operation_start(self, info: OperationInfo) -> None: + self.operations.append(info) + + def _wormhole(wormhole_id: int = 1069479400, linked: bool = True) -> dict: return { "id": wormhole_id, @@ -49,6 +58,24 @@ def test_create(self): body = json.loads(route.calls.last.request.content) assert body == {"destination_recording_id": 1069479500} + @respx.mock + def test_create_operation_metadata_scopes_project_and_resource(self): + respx.post(CREATE_URL).mock(return_value=httpx.Response(201, json=_wormhole(99))) + + hooks = _RecordingHooks() + c = Client(access_token="test-token", hooks=hooks) + c.for_account("12345").wormholes.create( + bucket_id=2085958499, card_table_id=1069479345, destination_recording_id=1069479500 + ) + c.close() + + assert len(hooks.operations) == 1 + info = hooks.operations[0] + assert info.service == "wormholes" + assert info.operation == "create" + assert info.project_id == 2085958499 + assert info.resource_id == 1069479345 + @respx.mock def test_create_validation_error_at_limit(self): respx.post(CREATE_URL).mock(return_value=httpx.Response(422, json={"error": "Limit reached"})) @@ -144,6 +171,25 @@ async def test_create(self): body = json.loads(route.calls.last.request.content) assert body == {"destination_recording_id": 1069479500} + @pytest.mark.asyncio + @respx.mock + async def test_create_operation_metadata_scopes_project_and_resource(self): + respx.post(CREATE_URL).mock(return_value=httpx.Response(201, json=_wormhole(99))) + + hooks = _RecordingHooks() + c = AsyncClient(access_token="test-token", hooks=hooks) + await c.for_account("12345").wormholes.create( + bucket_id=2085958499, card_table_id=1069479345, destination_recording_id=1069479500 + ) + await c.close() + + assert len(hooks.operations) == 1 + info = hooks.operations[0] + assert info.service == "wormholes" + assert info.operation == "create" + assert info.project_id == 2085958499 + assert info.resource_id == 1069479345 + @pytest.mark.asyncio @respx.mock async def test_create_validation_error_at_limit(self): diff --git a/ruby/lib/basecamp/generated/services/card_columns_service.rb b/ruby/lib/basecamp/generated/services/card_columns_service.rb index 2a54bfba1..5d32dfd36 100644 --- a/ruby/lib/basecamp/generated/services/card_columns_service.rb +++ b/ruby/lib/basecamp/generated/services/card_columns_service.rb @@ -13,7 +13,7 @@ class CardColumnsService < BaseService # @param color [String] Valid colors: white, red, orange, yellow, green, blue, aqua, purple, gray, pink, brown # @return [Hash] response data def set_color(bucket_id:, column_id:, color:) - with_operation(service: "cardcolumns", operation: "set_color", is_mutation: true, resource_id: column_id) do + with_operation(service: "cardcolumns", operation: "set_color", is_mutation: true, project_id: bucket_id, resource_id: column_id) do http_put("/buckets/#{bucket_id}/card_tables/columns/#{column_id}/color.json", body: compact_params(color: color)).json end end @@ -23,7 +23,7 @@ def set_color(bucket_id:, column_id:, color:) # @param column_id [Integer] column id ID # @return [Hash] response data def enable_on_hold(bucket_id:, column_id:) - with_operation(service: "cardcolumns", operation: "enable_on_hold", is_mutation: true, resource_id: column_id) do + with_operation(service: "cardcolumns", operation: "enable_on_hold", is_mutation: true, project_id: bucket_id, resource_id: column_id) do http_post("/buckets/#{bucket_id}/card_tables/columns/#{column_id}/on_hold.json").json end end @@ -33,7 +33,7 @@ def enable_on_hold(bucket_id:, column_id:) # @param column_id [Integer] column id ID # @return [Hash] response data def disable_on_hold(bucket_id:, column_id:) - with_operation(service: "cardcolumns", operation: "disable_on_hold", is_mutation: true, resource_id: column_id) do + with_operation(service: "cardcolumns", operation: "disable_on_hold", is_mutation: true, project_id: bucket_id, resource_id: column_id) do http_delete("/buckets/#{bucket_id}/card_tables/columns/#{column_id}/on_hold.json").json end end diff --git a/ruby/lib/basecamp/generated/services/tools_service.rb b/ruby/lib/basecamp/generated/services/tools_service.rb index 844f81e9f..0944dd088 100644 --- a/ruby/lib/basecamp/generated/services/tools_service.rb +++ b/ruby/lib/basecamp/generated/services/tools_service.rb @@ -13,7 +13,7 @@ class ToolsService < BaseService # @param title [String, nil] Title for the new tool. When omitted, Basecamp assigns the next available default title for the tool type. # @return [Hash] response data def create(bucket_id:, tool_type:, title: nil) - with_operation(service: "tools", operation: "create", is_mutation: true, resource_id: bucket_id) do + with_operation(service: "tools", operation: "create", is_mutation: true, project_id: bucket_id) do http_post("/buckets/#{bucket_id}/dock/tools.json", body: compact_params(tool_type: tool_type, title: title)).json end end diff --git a/ruby/lib/basecamp/generated/services/webhooks_service.rb b/ruby/lib/basecamp/generated/services/webhooks_service.rb index 905f36049..ba5c2d2f9 100644 --- a/ruby/lib/basecamp/generated/services/webhooks_service.rb +++ b/ruby/lib/basecamp/generated/services/webhooks_service.rb @@ -11,7 +11,7 @@ class WebhooksService < BaseService # @param bucket_id [Integer] bucket id ID # @return [Enumerator] paginated results def list(bucket_id:) - wrap_paginated(service: "webhooks", operation: "list", is_mutation: false, resource_id: bucket_id) do + wrap_paginated(service: "webhooks", operation: "list", is_mutation: false, project_id: bucket_id) do paginate("/buckets/#{bucket_id}/webhooks.json") end end @@ -23,7 +23,7 @@ def list(bucket_id:) # @param active [Boolean, nil] active # @return [Hash] response data def create(bucket_id:, payload_url:, types:, active: nil) - with_operation(service: "webhooks", operation: "create", is_mutation: true, resource_id: bucket_id) do + with_operation(service: "webhooks", operation: "create", is_mutation: true, project_id: bucket_id) do http_post("/buckets/#{bucket_id}/webhooks.json", body: compact_params(payload_url: payload_url, types: types, active: active)).json end end diff --git a/ruby/lib/basecamp/generated/services/wormholes_service.rb b/ruby/lib/basecamp/generated/services/wormholes_service.rb index f9c3094c5..b478595ce 100644 --- a/ruby/lib/basecamp/generated/services/wormholes_service.rb +++ b/ruby/lib/basecamp/generated/services/wormholes_service.rb @@ -13,7 +13,7 @@ class WormholesService < BaseService # @param destination_recording_id [Integer] Id of the new destination column (on another accessible card table). # @return [Hash] response data def update(bucket_id:, wormhole_id:, destination_recording_id:) - with_operation(service: "wormholes", operation: "update", is_mutation: true, resource_id: wormhole_id) do + with_operation(service: "wormholes", operation: "update", is_mutation: true, project_id: bucket_id, resource_id: wormhole_id) do http_put("/buckets/#{bucket_id}/card_tables/wormholes/#{wormhole_id}", body: compact_params(destination_recording_id: destination_recording_id)).json end end @@ -23,7 +23,7 @@ def update(bucket_id:, wormhole_id:, destination_recording_id:) # @param wormhole_id [Integer] wormhole id ID # @return [void] def delete(bucket_id:, wormhole_id:) - with_operation(service: "wormholes", operation: "delete", is_mutation: true, resource_id: wormhole_id) do + with_operation(service: "wormholes", operation: "delete", is_mutation: true, project_id: bucket_id, resource_id: wormhole_id) do http_delete("/buckets/#{bucket_id}/card_tables/wormholes/#{wormhole_id}") nil end @@ -35,7 +35,7 @@ def delete(bucket_id:, wormhole_id:) # @param destination_recording_id [Integer] Id of the destination column (on another accessible card table) to link to. # @return [Hash] response data def create(bucket_id:, card_table_id:, destination_recording_id:) - with_operation(service: "wormholes", operation: "create", is_mutation: true, resource_id: card_table_id) do + with_operation(service: "wormholes", operation: "create", is_mutation: true, project_id: bucket_id, resource_id: card_table_id) do http_post("/buckets/#{bucket_id}/card_tables/#{card_table_id}/wormholes.json", body: compact_params(destination_recording_id: destination_recording_id)).json end end diff --git a/ruby/scripts/generate-services.rb b/ruby/scripts/generate-services.rb index 749f58ced..11d0140a7 100644 --- a/ruby/scripts/generate-services.rb +++ b/ruby/scripts/generate-services.rb @@ -704,10 +704,10 @@ def build_hook_kwargs(op, service_name) kwargs << "operation: \"#{op[:method_name]}\"" kwargs << "is_mutation: #{op[:is_mutation]}" - project_param = op[:path_params].find { |p| p[:name] == 'projectId' } - resource_param = op[:path_params].reject { |p| p[:name] == 'projectId' }.last + project_param = op[:path_params].find { |p| %w[projectId bucketId].include?(p[:name]) } + resource_param = op[:path_params].reject { |p| %w[projectId bucketId].include?(p[:name]) }.last - kwargs << "project_id: project_id" if project_param + kwargs << "project_id: #{to_snake_case(project_param[:name])}" if project_param kwargs << "resource_id: #{to_snake_case(resource_param[:name])}" if resource_param kwargs.join(', ') diff --git a/ruby/test/basecamp/services/webhooks_service_test.rb b/ruby/test/basecamp/services/webhooks_service_test.rb index 7a77b96ba..e1469a50d 100644 --- a/ruby/test/basecamp/services/webhooks_service_test.rb +++ b/ruby/test/basecamp/services/webhooks_service_test.rb @@ -20,6 +20,26 @@ def test_list assert_equal "https://example.com/webhook", result.first["payload_url"] end + def test_list_operation_metadata + events = [] + account = create_account_client(account_id: "12345", hooks: CapturingHooks.new(events)) + + response = [ { "id" => 1, "payload_url" => "https://example.com/webhook", "active" => true } ] + stub_request(:get, %r{https://3\.basecampapi\.com/12345/buckets/\d+/webhooks\.json}) + .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) + + # Paginated operations fire hooks on iteration, so drain the enumerator. + account.webhooks.list(bucket_id: 1).to_a + + event = events.find { |e| e[:event] == :on_operation_start } + assert event, "Expected on_operation_start to fire" + info = event[:info] + assert_equal "webhooks", info.service + assert_equal "list", info.operation + assert_equal 1, info.project_id + assert_nil info.resource_id + end + def test_get response = { "id" => 1, "payload_url" => "https://example.com/webhook" } @@ -76,4 +96,20 @@ def test_get_with_recent_deliveries assert_equal "todo_created", delivery["request"]["body"]["kind"] assert_equal 200, delivery["response"]["code"] end + + private + + # Captures the OperationInfo passed to on_operation_start so tests can + # assert the metadata emitted by the real generated service call. + class CapturingHooks + include Basecamp::Hooks + + def initialize(events) + @events = events + end + + def on_operation_start(info) + @events << { event: :on_operation_start, info: info } + end + end end diff --git a/ruby/test/basecamp/services/wormholes_service_test.rb b/ruby/test/basecamp/services/wormholes_service_test.rb index ac9129504..681b3a77f 100644 --- a/ruby/test/basecamp/services/wormholes_service_test.rb +++ b/ruby/test/basecamp/services/wormholes_service_test.rb @@ -43,6 +43,28 @@ def test_create_wormhole assert_not_nil wormhole["destination_url"] end + def test_create_wormhole_operation_metadata + events = [] + account = create_account_client(account_id: "12345", hooks: CapturingHooks.new(events)) + + stub_post("/12345/buckets/2085958499/card_tables/1069479345/wormholes.json", + response_body: sample_wormhole(id: 99)) + + account.wormholes.create( + bucket_id: 2085958499, + card_table_id: 1069479345, + destination_recording_id: 1069479500 + ) + + event = events.find { |e| e[:event] == :on_operation_start } + assert event, "Expected on_operation_start to fire" + info = event[:info] + assert_equal "wormholes", info.service + assert_equal "create", info.operation + assert_equal 2085958499, info.project_id + assert_equal 1069479345, info.resource_id + end + def test_create_wormhole_raises_validation_error_at_limit stub_post("/12345/buckets/2085958499/card_tables/1069479345/wormholes.json", response_body: { "error" => "Limit reached" }, status: 422) @@ -118,4 +140,20 @@ def test_delete_wormhole_not_found @account.wormholes.delete(bucket_id: 2085958499, wormhole_id: 999) end end + + private + + # Captures the OperationInfo passed to on_operation_start so tests can + # assert the metadata emitted by the real generated service call. + class CapturingHooks + include Basecamp::Hooks + + def initialize(events) + @events = events + end + + def on_operation_start(info) + @events << { event: :on_operation_start, info: info } + end + end end diff --git a/swift/Sources/Basecamp/Generated/Metadata.swift b/swift/Sources/Basecamp/Generated/Metadata.swift index 55620d467..1eacefbfb 100644 --- a/swift/Sources/Basecamp/Generated/Metadata.swift +++ b/swift/Sources/Basecamp/Generated/Metadata.swift @@ -228,6 +228,7 @@ enum Metadata { "DeleteTemplate", "DeleteTool", "DeleteWebhook", + "DeleteWormhole", "DestroyGaugeNeedle", "DisableCardColumnOnHold", "DisableOutOfOffice", @@ -285,6 +286,7 @@ enum Metadata { "UpdateUpload", "UpdateVault", "UpdateWebhook", + "UpdateWormhole", ] static func isIdempotent(for operationId: String) -> Bool { diff --git a/swift/Sources/Basecamp/Generated/Services/CardColumnsService.swift b/swift/Sources/Basecamp/Generated/Services/CardColumnsService.swift index db4024b4f..f5fd4885d 100644 --- a/swift/Sources/Basecamp/Generated/Services/CardColumnsService.swift +++ b/swift/Sources/Basecamp/Generated/Services/CardColumnsService.swift @@ -14,7 +14,7 @@ public final class CardColumnsService: BaseService, @unchecked Sendable { public func disableOnHold(bucketId: Int, columnId: Int) async throws -> CardColumn { return try await request( - OperationInfo(service: "CardColumns", operation: "DisableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, resourceId: columnId), + OperationInfo(service: "CardColumns", operation: "DisableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, projectId: bucketId, resourceId: columnId), method: "DELETE", path: "/buckets/\(bucketId)/card_tables/columns/\(columnId)/on_hold.json", retryConfig: Metadata.retryConfig(for: "DisableCardColumnOnHold") @@ -23,7 +23,7 @@ public final class CardColumnsService: BaseService, @unchecked Sendable { public func enableOnHold(bucketId: Int, columnId: Int) async throws -> CardColumn { return try await request( - OperationInfo(service: "CardColumns", operation: "EnableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, resourceId: columnId), + OperationInfo(service: "CardColumns", operation: "EnableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, projectId: bucketId, resourceId: columnId), method: "POST", path: "/buckets/\(bucketId)/card_tables/columns/\(columnId)/on_hold.json", retryConfig: Metadata.retryConfig(for: "EnableCardColumnOnHold") @@ -51,7 +51,7 @@ public final class CardColumnsService: BaseService, @unchecked Sendable { public func setColor(bucketId: Int, columnId: Int, req: SetCardColumnColorRequest) async throws -> CardColumn { return try await request( - OperationInfo(service: "CardColumns", operation: "SetCardColumnColor", resourceType: "card_column_color", isMutation: true, resourceId: columnId), + OperationInfo(service: "CardColumns", operation: "SetCardColumnColor", resourceType: "card_column_color", isMutation: true, projectId: bucketId, resourceId: columnId), method: "PUT", path: "/buckets/\(bucketId)/card_tables/columns/\(columnId)/color.json", body: req, diff --git a/swift/Sources/Basecamp/Generated/Services/ToolsService.swift b/swift/Sources/Basecamp/Generated/Services/ToolsService.swift index dc4879462..f2352ef36 100644 --- a/swift/Sources/Basecamp/Generated/Services/ToolsService.swift +++ b/swift/Sources/Basecamp/Generated/Services/ToolsService.swift @@ -4,7 +4,7 @@ import Foundation public final class ToolsService: BaseService, @unchecked Sendable { public func create(bucketId: Int, req: CreateToolRequest) async throws -> Tool { return try await request( - OperationInfo(service: "Tools", operation: "CreateTool", resourceType: "tool", isMutation: true, resourceId: bucketId), + OperationInfo(service: "Tools", operation: "CreateTool", resourceType: "tool", isMutation: true, projectId: bucketId), method: "POST", path: "/buckets/\(bucketId)/dock/tools.json", body: req, diff --git a/swift/Sources/Basecamp/Generated/Services/WebhooksService.swift b/swift/Sources/Basecamp/Generated/Services/WebhooksService.swift index dfcecd901..926cf64a1 100644 --- a/swift/Sources/Basecamp/Generated/Services/WebhooksService.swift +++ b/swift/Sources/Basecamp/Generated/Services/WebhooksService.swift @@ -13,7 +13,7 @@ public struct ListWebhookOptions: Sendable { public final class WebhooksService: BaseService, @unchecked Sendable { public func create(bucketId: Int, req: CreateWebhookRequest) async throws -> Webhook { return try await request( - OperationInfo(service: "Webhooks", operation: "CreateWebhook", resourceType: "webhook", isMutation: true, resourceId: bucketId), + OperationInfo(service: "Webhooks", operation: "CreateWebhook", resourceType: "webhook", isMutation: true, projectId: bucketId), method: "POST", path: "/buckets/\(bucketId)/webhooks.json", body: req, @@ -41,7 +41,7 @@ public final class WebhooksService: BaseService, @unchecked Sendable { public func list(bucketId: Int, options: ListWebhookOptions? = nil) async throws -> ListResult { return try await requestPaginated( - OperationInfo(service: "Webhooks", operation: "ListWebhooks", resourceType: "webhook", isMutation: false, resourceId: bucketId), + OperationInfo(service: "Webhooks", operation: "ListWebhooks", resourceType: "webhook", isMutation: false, projectId: bucketId), path: "/buckets/\(bucketId)/webhooks.json", paginationOpts: options.flatMap { PaginationOptions(maxItems: $0.maxItems) }, retryConfig: Metadata.retryConfig(for: "ListWebhooks") diff --git a/swift/Sources/Basecamp/Generated/Services/WormholesService.swift b/swift/Sources/Basecamp/Generated/Services/WormholesService.swift index 8bf41b879..b7bfd3b44 100644 --- a/swift/Sources/Basecamp/Generated/Services/WormholesService.swift +++ b/swift/Sources/Basecamp/Generated/Services/WormholesService.swift @@ -4,7 +4,7 @@ import Foundation public final class WormholesService: BaseService, @unchecked Sendable { public func create(bucketId: Int, cardTableId: Int, req: CreateWormholeRequest) async throws -> Wormhole { return try await request( - OperationInfo(service: "Wormholes", operation: "CreateWormhole", resourceType: "wormhole", isMutation: true, resourceId: cardTableId), + OperationInfo(service: "Wormholes", operation: "CreateWormhole", resourceType: "wormhole", isMutation: true, projectId: bucketId, resourceId: cardTableId), method: "POST", path: "/buckets/\(bucketId)/card_tables/\(cardTableId)/wormholes.json", body: req, @@ -14,7 +14,7 @@ public final class WormholesService: BaseService, @unchecked Sendable { public func delete(bucketId: Int, wormholeId: Int) async throws { try await requestVoid( - OperationInfo(service: "Wormholes", operation: "DeleteWormhole", resourceType: "wormhole", isMutation: true, resourceId: wormholeId), + OperationInfo(service: "Wormholes", operation: "DeleteWormhole", resourceType: "wormhole", isMutation: true, projectId: bucketId, resourceId: wormholeId), method: "DELETE", path: "/buckets/\(bucketId)/card_tables/wormholes/\(wormholeId)", retryConfig: Metadata.retryConfig(for: "DeleteWormhole") @@ -23,7 +23,7 @@ public final class WormholesService: BaseService, @unchecked Sendable { public func update(bucketId: Int, wormholeId: Int, req: UpdateWormholeRequest) async throws -> Wormhole { return try await request( - OperationInfo(service: "Wormholes", operation: "UpdateWormhole", resourceType: "wormhole", isMutation: true, resourceId: wormholeId), + OperationInfo(service: "Wormholes", operation: "UpdateWormhole", resourceType: "wormhole", isMutation: true, projectId: bucketId, resourceId: wormholeId), method: "PUT", path: "/buckets/\(bucketId)/card_tables/wormholes/\(wormholeId)", body: req, diff --git a/swift/Sources/BasecampGenerator/ServiceEmitter.swift b/swift/Sources/BasecampGenerator/ServiceEmitter.swift index ad52a7591..307128657 100644 --- a/swift/Sources/BasecampGenerator/ServiceEmitter.swift +++ b/swift/Sources/BasecampGenerator/ServiceEmitter.swift @@ -178,16 +178,16 @@ private func emitMethod(_ op: ParsedOperation, serviceName: String, schemas: [St // Build OperationInfo let swiftPath = pathToSwiftInterpolation(op.path) - let projectParam = op.pathParams.first { $0.name == "projectId" } - let resourceParam = op.pathParams.last { $0.name != "projectId" && $0.name.hasSuffix("Id") } + let projectParam = op.pathParams.first { $0.name == "projectId" || $0.name == "bucketId" } + let resourceParam = op.pathParams.last { $0.name != "projectId" && $0.name != "bucketId" && $0.name.hasSuffix("Id") } var opInfoParts: [String] = [] opInfoParts.append("service: \"\(serviceName)\"") opInfoParts.append("operation: \"\(op.operationId)\"") opInfoParts.append("resourceType: \"\(op.resourceType)\"") opInfoParts.append("isMutation: \(op.isMutation)") - if projectParam != nil { - opInfoParts.append("projectId: projectId") + if let pp = projectParam { + opInfoParts.append("projectId: \(pp.name)") } if let rp = resourceParam { opInfoParts.append("resourceId: \(rp.name)") diff --git a/swift/Tests/BasecampTests/GeneratedServiceTests.swift b/swift/Tests/BasecampTests/GeneratedServiceTests.swift index 93bbe48de..94153f07b 100644 --- a/swift/Tests/BasecampTests/GeneratedServiceTests.swift +++ b/swift/Tests/BasecampTests/GeneratedServiceTests.swift @@ -335,6 +335,23 @@ final class GeneratedServiceTests: XCTestCase { XCTAssertEqual(transport.lastRequest!.request.httpMethod, "POST") } + // ListWebhooks is a bucket-scoped collection op: operation metadata carries + // projectId == bucket id and NO resourceId (there's no deeper target id). + func testListWebhooksEmitsProjectScopeWithoutResourceId() async throws { + let spy = SpyHooks() + let data = try JSONSerialization.data(withJSONObject: [] as [Any]) + let transport = MockTransport(statusCode: 200, data: data, headers: ["X-Total-Count": "0"]) + let account = makeTestAccountClient(transport: transport, hooks: spy) + + _ = try await account.webhooks.list(bucketId: 7) + + XCTAssertEqual(spy.operationStarts.count, 1) + let info = spy.operationStarts.first! + XCTAssertEqual(info.operation, "ListWebhooks") + XCTAssertEqual(info.projectId, 7, "projectId should be the bucket id") + XCTAssertNil(info.resourceId, "collection op has no deeper resource id") + } + func testCommentsServiceGet() async throws { let responseJSON: [String: Any] = [ "id": 7, "content": "Great idea!", "content_attachments": [], @@ -742,3 +759,17 @@ final class GeneratedServiceTests: XCTestCase { XCTAssertEqual(sentJSON["visible_to_clients"] as? Bool, false) } } + +/// Records operation-start callbacks so tests can assert emitted metadata. +private final class SpyHooks: BasecampHooks, @unchecked Sendable { + private let lock = NSLock() + private var _operationStarts: [OperationInfo] = [] + + var operationStarts: [OperationInfo] { + lock.withLock { _operationStarts } + } + + func onOperationStart(_ info: OperationInfo) { + lock.withLock { _operationStarts.append(info) } + } +} diff --git a/swift/Tests/BasecampTests/WormholesServiceTests.swift b/swift/Tests/BasecampTests/WormholesServiceTests.swift index 2da3c01b5..b39ced90b 100644 --- a/swift/Tests/BasecampTests/WormholesServiceTests.swift +++ b/swift/Tests/BasecampTests/WormholesServiceTests.swift @@ -137,6 +137,23 @@ final class WormholesServiceTests: XCTestCase { } } + // CreateWormhole is bucket-scoped with a deeper card-table id: operation + // metadata carries projectId == bucket id AND resourceId == card table id. + func testCreateEmitsProjectAndResourceMetadata() async throws { + let spy = SpyHooks() + let responseData = try JSONSerialization.data(withJSONObject: wormholeJSON(id: 99)) + let transport = MockTransport(statusCode: 201, data: responseData) + let account = makeTestAccountClient(transport: transport, hooks: spy) + + _ = try await account.wormholes.create(bucketId: 7, cardTableId: 42, req: CreateWormholeRequest(destinationRecordingId: 500)) + + XCTAssertEqual(spy.operationStarts.count, 1) + let info = spy.operationStarts.first! + XCTAssertEqual(info.operation, "CreateWormhole") + XCTAssertEqual(info.projectId, 7, "projectId should be the bucket id") + XCTAssertEqual(info.resourceId, 42, "resourceId should be the card table id") + } + func testCardTableDecodesLinkedAndUnlinkedWormholes() async throws { let json: [String: Any] = [ "id": 1069479345, @@ -168,3 +185,17 @@ final class WormholesServiceTests: XCTestCase { XCTAssertNil(cardTable.wormholes?[1].color) } } + +/// Records operation-start callbacks so tests can assert emitted metadata. +private final class SpyHooks: BasecampHooks, @unchecked Sendable { + private let lock = NSLock() + private var _operationStarts: [OperationInfo] = [] + + var operationStarts: [OperationInfo] { + lock.withLock { _operationStarts } + } + + func onOperationStart(_ info: OperationInfo) { + lock.withLock { _operationStarts.append(info) } + } +} diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts index 76ce5a8e0..50f224419 100644 --- a/typescript/scripts/generate-services.ts +++ b/typescript/scripts/generate-services.ts @@ -1291,12 +1291,12 @@ function generateMethod(op: ParsedOperation, serviceName: string): string[] { lines.push(` resourceType: "${op.resourceType}",`); lines.push(` isMutation: ${op.isMutation},`); - const projectParam = op.pathParams.find((p) => p.name === "projectId"); + const projectParam = op.pathParams.find((p) => p.name === "projectId" || p.name === "bucketId"); if (projectParam) { - lines.push(` projectId,`); + lines.push(projectParam.name === "projectId" ? ` projectId,` : ` projectId: ${projectParam.name},`); } - const resourceParam = op.pathParams.findLast((p) => p.name !== "projectId" && p.name.endsWith("Id")); + const resourceParam = op.pathParams.findLast((p) => p.name !== "projectId" && p.name !== "bucketId" && p.name.endsWith("Id")); if (resourceParam) { lines.push(` resourceId: ${resourceParam.name},`); } diff --git a/typescript/src/generated/services/card-columns.ts b/typescript/src/generated/services/card-columns.ts index c67018ebe..c55eb2822 100644 --- a/typescript/src/generated/services/card-columns.ts +++ b/typescript/src/generated/services/card-columns.ts @@ -88,6 +88,7 @@ export class CardColumnsService extends BaseService { operation: "SetCardColumnColor", resourceType: "card_column_color", isMutation: true, + projectId: bucketId, resourceId: columnId, }, () => @@ -122,6 +123,7 @@ export class CardColumnsService extends BaseService { operation: "EnableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, + projectId: bucketId, resourceId: columnId, }, () => @@ -153,6 +155,7 @@ export class CardColumnsService extends BaseService { operation: "DisableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, + projectId: bucketId, resourceId: columnId, }, () => diff --git a/typescript/src/generated/services/tools.ts b/typescript/src/generated/services/tools.ts index 99431dc8e..1ff93762e 100644 --- a/typescript/src/generated/services/tools.ts +++ b/typescript/src/generated/services/tools.ts @@ -73,7 +73,7 @@ export class ToolsService extends BaseService { operation: "CreateTool", resourceType: "tool", isMutation: true, - resourceId: bucketId, + projectId: bucketId, }, () => this.client.POST("/buckets/{bucketId}/dock/tools.json", { diff --git a/typescript/src/generated/services/webhooks.ts b/typescript/src/generated/services/webhooks.ts index 025a91cd0..837c0afcb 100644 --- a/typescript/src/generated/services/webhooks.ts +++ b/typescript/src/generated/services/webhooks.ts @@ -75,7 +75,7 @@ export class WebhooksService extends BaseService { operation: "ListWebhooks", resourceType: "webhook", isMutation: false, - resourceId: bucketId, + projectId: bucketId, }, () => this.client.GET("/buckets/{bucketId}/webhooks.json", { @@ -112,7 +112,7 @@ export class WebhooksService extends BaseService { operation: "CreateWebhook", resourceType: "webhook", isMutation: true, - resourceId: bucketId, + projectId: bucketId, }, () => this.client.POST("/buckets/{bucketId}/webhooks.json", { diff --git a/typescript/src/generated/services/wormholes.ts b/typescript/src/generated/services/wormholes.ts index 82caa758f..830742ab7 100644 --- a/typescript/src/generated/services/wormholes.ts +++ b/typescript/src/generated/services/wormholes.ts @@ -60,6 +60,7 @@ export class WormholesService extends BaseService { operation: "UpdateWormhole", resourceType: "wormhole", isMutation: true, + projectId: bucketId, resourceId: wormholeId, }, () => @@ -94,6 +95,7 @@ export class WormholesService extends BaseService { operation: "DeleteWormhole", resourceType: "wormhole", isMutation: true, + projectId: bucketId, resourceId: wormholeId, }, () => @@ -125,6 +127,7 @@ export class WormholesService extends BaseService { operation: "CreateWormhole", resourceType: "wormhole", isMutation: true, + projectId: bucketId, resourceId: cardTableId, }, () => diff --git a/typescript/tests/services/webhooks.test.ts b/typescript/tests/services/webhooks.test.ts index 1a8eefe1e..32371fbd5 100644 --- a/typescript/tests/services/webhooks.test.ts +++ b/typescript/tests/services/webhooks.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "../setup.js"; import { createBasecampClient, type BasecampClient } from "../../src/client.js"; +import type { OperationInfo } from "../../src/hooks.js"; import { BasecampError } from "../../src/errors.js"; const BASE_URL = "https://3.basecampapi.com/12345"; @@ -72,6 +73,33 @@ describe("WebhooksService", () => { const webhooks = await client.webhooks.list(1); expect(webhooks).toHaveLength(0); }); + + it("scopes the operation to the bucket as project with no resource id", async () => { + const bucketId = 2085958499; + let captured: OperationInfo | undefined; + + const hookedClient = createBasecampClient({ + accountId: "12345", + accessToken: "test-token", + hooks: { + onOperationStart: (info) => { + captured = info; + }, + }, + }); + + server.use( + http.get(`${BASE_URL}/buckets/${bucketId}/webhooks.json`, () => + HttpResponse.json([]) + ) + ); + + await hookedClient.webhooks.list(bucketId); + + expect(captured?.operation).toBe("ListWebhooks"); + expect(captured?.projectId).toBe(bucketId); + expect(captured?.resourceId).toBeUndefined(); + }); }); describe("get", () => { diff --git a/typescript/tests/services/wormholes.test.ts b/typescript/tests/services/wormholes.test.ts index e5362ab87..9d11e45a8 100644 --- a/typescript/tests/services/wormholes.test.ts +++ b/typescript/tests/services/wormholes.test.ts @@ -6,6 +6,7 @@ import { http, HttpResponse } from "msw"; import { server } from "../setup.js"; import { createBasecampClient } from "../../src/client.js"; import type { BasecampClient } from "../../src/client.js"; +import type { OperationInfo } from "../../src/hooks.js"; import { BasecampError } from "../../src/errors.js"; const BASE_URL = "https://3.basecampapi.com/12345"; @@ -97,6 +98,38 @@ describe("WormholesService", () => { client.wormholes.create(bucketId, cardTableId, { destinationRecordingId: 999 }) ).rejects.toThrow(BasecampError); }); + + it("scopes the operation to the bucket as project, card table as resource", async () => { + const bucketId = 2085958499; + const cardTableId = 1069479345; + let captured: OperationInfo | undefined; + + const hookedClient = createBasecampClient({ + accountId: "12345", + accessToken: "test-token", + enableRetry: false, + hooks: { + onOperationStart: (info) => { + captured = info; + }, + }, + }); + + server.use( + http.post( + `${BASE_URL}/buckets/${bucketId}/card_tables/${cardTableId}/wormholes.json`, + () => HttpResponse.json(sampleWormhole(99), { status: 201 }) + ) + ); + + await hookedClient.wormholes.create(bucketId, cardTableId, { + destinationRecordingId: 1069479500, + }); + + expect(captured?.operation).toBe("CreateWormhole"); + expect(captured?.projectId).toBe(bucketId); + expect(captured?.resourceId).toBe(cardTableId); + }); }); describe("update", () => {