Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
3 changes: 3 additions & 0 deletions go/pkg/basecamp/cards.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions go/pkg/basecamp/gauges.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions go/pkg/basecamp/observability.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
131 changes: 131 additions & 0 deletions go/pkg/basecamp/operation_scope_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
4 changes: 4 additions & 0 deletions go/pkg/basecamp/otel/otel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
4 changes: 4 additions & 0 deletions go/pkg/basecamp/otel/otel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func TestOnOperationStartEnd(t *testing.T) {
Operation: "Complete",
ResourceType: "todo",
IsMutation: true,
ProjectID: 789,
ResourceID: 456,
}

Expand Down Expand Up @@ -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"])
}
Expand Down
2 changes: 2 additions & 0 deletions go/pkg/basecamp/people.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions go/pkg/basecamp/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions go/pkg/basecamp/timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions go/pkg/basecamp/timesheet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion go/pkg/basecamp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions go/pkg/basecamp/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
2 changes: 2 additions & 0 deletions go/pkg/basecamp/webhooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions go/pkg/basecamp/wormholes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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, {
Expand All @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading