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
22 changes: 11 additions & 11 deletions behavior-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,6 @@
]
}
},
"CloneTool": {
"retry": {
"max": 2,
"base_delay_ms": 1000,
"backoff": "exponential",
"retry_on": [
429,
503
]
}
},
"CompleteTodo": {
"idempotent": true,
"retry": {
Expand Down Expand Up @@ -324,6 +313,17 @@
]
}
},
"CreateTool": {
"retry": {
"max": 2,
"base_delay_ms": 1000,
"backoff": "exponential",
"retry_on": [
429,
503
]
}
},
"CreateUpload": {
"retry": {
"max": 2,
Expand Down
11 changes: 6 additions & 5 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,14 +639,15 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T
_, err := account.Tools().Get(ctx, toolID)
return operationResult{err: err}

case "CloneTool":
sourceRecordingID := getInt64Param(tc.RequestBody, "source_recording_id")
case "CreateTool":
bucketID := getInt64Param(tc.PathParams, "bucketId")
toolType := getStringParam(tc.RequestBody, "tool_type")
title := getStringParam(tc.RequestBody, "title")
var opts *basecamp.CloneToolOptions
var opts *basecamp.CreateToolOptions
if title != "" {
opts = &basecamp.CloneToolOptions{Title: title}
opts = &basecamp.CreateToolOptions{Title: title}
}
_, err := account.Tools().Create(ctx, sourceRecordingID, opts)
_, err := account.Tools().Create(ctx, bucketID, toolType, opts)
return operationResult{err: err}

case "EnableTool":
Expand Down
8 changes: 6 additions & 2 deletions conformance/runner/python/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,12 @@ def __call__(self, operation: str, *, path_params: dict, query_params: dict, bod
return self._account.reports.person_progress(person_id=path_params["personId"])
case "GetTool":
return self._account.tools.get(tool_id=path_params["toolId"])
case "CloneTool":
return self._account.tools.clone(source_recording_id=body["source_recording_id"], title=body["title"])
case "CreateTool":
return self._account.tools.create(
bucket_id=path_params["bucketId"],
tool_type=body["tool_type"],
title=body.get("title"),
)
case "EnableTool":
return self._account.tools.enable(tool_id=path_params["toolId"])
case "UploadsDownload":
Expand Down
7 changes: 4 additions & 3 deletions conformance/runner/ruby/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,10 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "")
@account.tools.get(
tool_id: path_params["toolId"]
)
when "CloneTool"
@account.tools.clone(
source_recording_id: body["source_recording_id"],
when "CreateTool"
@account.tools.create(
bucket_id: path_params["bucketId"],
tool_type: body["tool_type"],
title: body["title"]
)
when "EnableTool"
Expand Down
8 changes: 4 additions & 4 deletions conformance/runner/typescript/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,10 @@ async function executeOperation(
await client.tools.get(Number(params.toolId));
break;

case "CloneTool":
await client.tools.clone({
sourceRecordingId: Number(body.source_recording_id),
title: String(body.title || "Conformance Test"),
case "CreateTool":
await client.tools.create(Number(params.bucketId), {
toolType: String(body.tool_type),
title: body.title === undefined ? undefined : String(body.title),
});
break;

Expand Down
32 changes: 25 additions & 7 deletions conformance/tests/paths.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,35 @@
"tags": ["path", "tools"]
},
{
"name": "CloneTool uses flat /dock/tools.json path (no bucketId)",
"description": "Verifies that CloneTool constructs the flat URL path /dock/tools.json without bucket scoping",
"operation": "CloneTool",
"name": "CreateTool uses bucket-scoped /buckets/{bucketId}/dock/tools.json path",
"description": "Verifies that CreateTool constructs the bucket-scoped URL path /buckets/{bucketId}/dock/tools.json",
"operation": "CreateTool",
"method": "POST",
"path": "/dock/tools.json",
"requestBody": {"source_recording_id": 789, "title": "To-dos (Copy)"},
"path": "/buckets/{bucketId}/dock/tools.json",
"pathParams": {"bucketId": 456},
"requestBody": {"tool_type": "Message::Board", "title": "Message Board (Copy)"},
"mockResponses": [
{"status": 201, "body": {"id": 800, "name": "message_board", "title": "Message Board (Copy)", "status": "active", "enabled": true, "position": 5, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "url": "https://3.basecampapi.com/999/buckets/456/message_boards/800.json", "app_url": "https://3.basecamp.com/999/buckets/456/message_boards/800", "bucket": {"id": 456, "name": "Test", "type": "Project"}}}
],
"assertions": [
{"type": "requestPath", "expected": "/999/buckets/456/dock/tools.json"},
{"type": "noError"}
],
"tags": ["path", "tools"]
},
{
"name": "CreateTool accepts omitted title on bucket-scoped path",
"description": "Verifies that CreateTool can omit the optional title while using /buckets/{bucketId}/dock/tools.json",
"operation": "CreateTool",
"method": "POST",
"path": "/buckets/{bucketId}/dock/tools.json",
"pathParams": {"bucketId": 456},
"requestBody": {"tool_type": "Message::Board"},
"mockResponses": [
{"status": 201, "body": {"id": 800, "name": "todoset", "title": "To-dos (Copy)", "status": "active", "enabled": true, "position": 5, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "url": "https://3.basecampapi.com/999/buckets/456/todosets/800.json", "app_url": "https://3.basecamp.com/999/buckets/456/todosets/800", "bucket": {"id": 456, "name": "Test", "type": "Project"}}}
{"status": 201, "body": {"id": 801, "name": "message_board", "title": "Message Board", "status": "active", "enabled": true, "position": 6, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "url": "https://3.basecampapi.com/999/buckets/456/message_boards/801.json", "app_url": "https://3.basecamp.com/999/buckets/456/message_boards/801", "bucket": {"id": 456, "name": "Test", "type": "Project"}}}
],
"assertions": [
{"type": "requestPath", "expected": "/999/dock/tools.json"},
{"type": "requestPath", "expected": "/999/buckets/456/dock/tools.json"},
{"type": "noError"}
],
"tags": ["path", "tools"]
Expand Down
2 changes: 1 addition & 1 deletion go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ cfg, err := basecamp.LoadConfig("/path/to/config.json")
|---------|---------|
| `Projects()` | List, Get, Create, Update, Trash |
| `Templates()` | List, Get, CreateProject |
| `Tools()` | Get, List, Update (enable/disable/reorder dock tools) |
| `Tools()` | Get, Create, Update, Delete, Enable, Disable, Reposition (dock tools) |
| `People()` | List, Get, ListPingable, Me, ListProjectPeople |

### To-dos
Expand Down
28 changes: 18 additions & 10 deletions go/pkg/basecamp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ type Tool struct {
Bucket *Bucket `json:"bucket,omitempty"`
}

// CloneToolOptions specifies optional parameters for cloning a tool.
type CloneToolOptions struct {
// Title for the cloned tool. If empty, the source tool's title is used.
// CreateToolOptions specifies optional parameters for creating a tool.
type CreateToolOptions struct {
// Title for the new tool. If empty, Basecamp assigns the next available default title for the tool type.
Title string
}

Expand Down Expand Up @@ -77,14 +77,17 @@ func (s *ToolsService) Get(ctx context.Context, toolID int64) (result *Tool, err
return &tool, nil
}

// Create clones an existing tool to create a new one.
// An optional title can be provided; if empty, the source tool's title is used.
// Create adds a tool to the destination bucket.
// toolType is required and must be one of Basecamp's dock tool types:
// "Chat::Transcript", "Inbox", "Kanban::Board", "Message::Board",
// "Questionnaire", "Schedule", "Todoset", or "Vault".
// An optional title can be provided; if empty, Basecamp assigns the next available default title for the tool type.
// Returns the newly created tool.
func (s *ToolsService) Create(ctx context.Context, sourceToolID int64, opts *CloneToolOptions) (result *Tool, err error) {
func (s *ToolsService) Create(ctx context.Context, bucketID int64, toolType string, opts *CreateToolOptions) (result *Tool, err error) {
op := OperationInfo{
Service: "Tools", Operation: "Create",
ResourceType: "tool", IsMutation: true,
ResourceID: sourceToolID,
ResourceID: bucketID,
}
if gater, ok := s.client.parent.hooks.(GatingHooks); ok {
if ctx, err = gater.OnOperationGate(ctx, op); err != nil {
Expand All @@ -95,14 +98,19 @@ func (s *ToolsService) Create(ctx context.Context, sourceToolID int64, opts *Clo
ctx = s.client.parent.hooks.OnOperationStart(ctx, op)
defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }()

body := generated.CloneToolJSONRequestBody{
SourceRecordingId: sourceToolID,
if toolType == "" {
err = ErrUsage("tool type is required")
return nil, err
}

body := generated.CreateToolJSONRequestBody{
ToolType: toolType,
}
if opts != nil && opts.Title != "" {
body.Title = opts.Title
}
Comment thread
Copilot marked this conversation as resolved.

resp, err := s.client.parent.gen.CloneToolWithResponse(ctx, s.client.accountID, body)
resp, err := s.client.parent.gen.CreateToolWithResponse(ctx, s.client.accountID, bucketID, body)
if err != nil {
return nil, err
}
Expand Down
146 changes: 146 additions & 0 deletions go/pkg/basecamp/tools_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package basecamp

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)

func toolsFixturesDir() string {
Expand Down Expand Up @@ -77,6 +83,146 @@ func TestTool_UnmarshalGet(t *testing.T) {
}
}

func TestToolsServiceCreatePostsToBucketDock(t *testing.T) {
const (
accountID = "5245563"
bucketID = int64(33861629)
toolType = "Message::Board"
title = "Intervention Log / Journal"
)

expectedPath := fmt.Sprintf("/%s/buckets/%d/dock/tools.json", accountID, bucketID)

var capturedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
if r.Method != http.MethodPost || r.URL.Path != expectedPath {
http.NotFound(w, r)
return
}

body := decodeRequestBody(t, r)
if got := body["tool_type"]; got != toolType {
t.Fatalf("tool_type = %v, want %q", got, toolType)
}
if got := body["title"]; got != title {
t.Fatalf("title = %v, want %q", got, title)
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(loadToolsFixture(t, "create.json"))
}))
defer server.Close()

cfg := DefaultConfig()
cfg.BaseURL = server.URL
var capturedOp OperationInfo
hooks := &testHooks{
onOperationStart: func(ctx context.Context, op OperationInfo) context.Context {
capturedOp = op
return ctx
},
}
client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}, WithHooks(hooks))

_, err := client.ForAccount(accountID).Tools().Create(
context.Background(),
bucketID,
toolType,
&CreateToolOptions{Title: title},
)
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)
}
}

func TestToolsServiceCreateOmitsTitleWhenNotProvided(t *testing.T) {
const (
accountID = "5245563"
bucketID = int64(33861629)
toolType = "Message::Board"
)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := decodeRequestBody(t, r)
if got := body["tool_type"]; got != toolType {
t.Fatalf("tool_type = %v, want %q", got, toolType)
}
if title, present := body["title"]; present {
t.Fatalf("title = %v, want omitted from request body", title)
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(loadToolsFixture(t, "create.json"))
}))
defer server.Close()

cfg := DefaultConfig()
cfg.BaseURL = server.URL
client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"})

if _, err := client.ForAccount(accountID).Tools().Create(context.Background(), bucketID, toolType, nil); err != nil {
t.Fatalf("Create() error = %v", err)
}
}

func TestToolsServiceCreateEmptyToolType(t *testing.T) {
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
http.NotFound(w, r)
}))
defer server.Close()

cfg := DefaultConfig()
cfg.BaseURL = server.URL
var startedOp, endedOp OperationInfo
var startObserved, endObserved bool
var endErr error
hooks := &testHooks{
onOperationStart: func(ctx context.Context, op OperationInfo) context.Context {
startObserved = true
startedOp = op
return ctx
},
onOperationEnd: func(ctx context.Context, op OperationInfo, err error, d time.Duration) {
endObserved = true
endedOp = op
endErr = err
},
}
client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}, WithHooks(hooks))

_, err := client.ForAccount("5245563").Tools().Create(context.Background(), 33861629, "", nil)
if err == nil {
t.Fatal("expected error for empty tool type")
}
apiErr, ok := errors.AsType[*Error](err)
if !ok || apiErr.Code != CodeUsage {
t.Fatalf("expected usage error, got: %v", err)
}
if apiErr.Message != "tool type is required" {
t.Errorf("expected message %q, got %q", "tool type is required", apiErr.Message)
}
if requestCount != 0 {
t.Errorf("expected 0 HTTP requests for client-side validation failure, got %d", requestCount)
}
if !startObserved || !endObserved {
t.Fatalf("expected operation hooks to fire: start=%v end=%v", startObserved, endObserved)
}
if startedOp.Operation != "Create" || endedOp.Operation != "Create" {
t.Errorf("expected hooks to observe Create operation, got start=%q end=%q", startedOp.Operation, endedOp.Operation)
}
if !errors.Is(endErr, err) {
t.Errorf("expected OnOperationEnd to observe the usage error, got: %v", endErr)
}
}

func TestTool_UnmarshalCreate(t *testing.T) {
data := loadToolsFixture(t, "create.json")

Expand Down
Loading
Loading