Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
81c761a
Flatten Smithy spec routes to remove nested URL scoping
jeremy Feb 2, 2026
e016353
Regenerate all SDK clients from flattened OpenAPI spec
jeremy Feb 2, 2026
897db91
Update Go service layer for flat routes
jeremy Feb 2, 2026
4d6da10
Update Go tests for flat route signatures
jeremy Feb 2, 2026
16e5f69
Update TypeScript tests for flat routes
jeremy Feb 2, 2026
37a8863
Update Ruby tests for flat routes
jeremy Feb 2, 2026
4f35458
Update conformance runner for flat routes
jeremy Feb 2, 2026
a5ae143
Remove unused bucketID parameters from Go service methods
jeremy Feb 3, 2026
0567448
Remove stale bucketID comments from Go service files
jeremy Feb 3, 2026
9f2060d
Flatten conformance test paths to match new flat routes
jeremy Feb 3, 2026
cb75544
Update README examples and cleanup stale bucket references
jeremy Feb 3, 2026
dc32943
Flatten timesheet entry operations added by PR #76
jeremy Feb 5, 2026
31d2e84
Remove dead hand-written TypeScript service files
jeremy Feb 5, 2026
03e0fd4
Fix Ruby card_steps test and remove stale .plan.md
jeremy Feb 5, 2026
9ea6c6d
Regenerate all SDK clients from canonical openapi.json
jeremy Feb 5, 2026
eabf605
Flatten Boost API operations and regenerate all SDK clients
jeremy Feb 8, 2026
99b6818
Flatten Go BoostsService and CreateLine for flat branch
jeremy Feb 8, 2026
a13dc7e
Fix URL router missing .json-suffixed API URLs
jeremy Feb 8, 2026
f1a709c
Flatten webhook get-with-deliveries test URLs for flat branch
jeremy Feb 8, 2026
2cab7c0
Fix Go SDK build: wrap *int64 Id fields with derefInt64() in all conv…
jeremy Feb 22, 2026
7ad95ba
Regenerate all SDK clients from Smithy spec and fix CI
jeremy Feb 22, 2026
d4a2e6b
Fix spec routes C1, C2, C5, C6 and regenerate all SDKs
jeremy Feb 26, 2026
e32ace0
Fix PR review comments: update stale docs and comments
jeremy Feb 26, 2026
b7ec647
Fix timeline/timesheet paths: /buckets/ → /projects/
jeremy Feb 27, 2026
12776d1
Update conformance and SDK tests for /projects/ timeline/timesheet paths
jeremy Feb 27, 2026
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 .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ jobs:
uses: actions/cache@v5
with:
path: ~/go/bin
key: go-tools-${{ runner.os }}-apidiff-v0.9.0
key: go-tools-${{ runner.os }}-go${{ hashFiles('go/go.mod') }}-apidiff-v0.9.0

- name: Install apidiff
run: |
Expand Down
47 changes: 37 additions & 10 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ func runTest(tc TestCase) TestResult {
sdkResp, sdkErr = client.TrashProject(ctx, testAccountID, projectId)

case "CreateTodo":
projectId := getInt64Param(tc.PathParams, "projectId")
todolistId := getInt64Param(tc.PathParams, "todolistId")
Comment thread
jeremy marked this conversation as resolved.
body := generated.CreateTodoJSONRequestBody{
Content: getStringParam(tc.RequestBody, "content"),
Expand All @@ -221,20 +220,17 @@ func runTest(tc TestCase) TestResult {
body.DueOn = d
}
}
sdkResp, sdkErr = client.CreateTodo(ctx, testAccountID, projectId, todolistId, body)
sdkResp, sdkErr = client.CreateTodo(ctx, testAccountID, todolistId, body)

case "ListTodos":
projectId := getInt64Param(tc.PathParams, "projectId")
todolistId := getInt64Param(tc.PathParams, "todolistId")
Comment thread
jeremy marked this conversation as resolved.
sdkResp, sdkErr = client.ListTodos(ctx, testAccountID, projectId, todolistId, nil)
sdkResp, sdkErr = client.ListTodos(ctx, testAccountID, todolistId, nil)

case "GetTimesheetEntry":
projectId := getInt64Param(tc.PathParams, "projectId")
entryId := getInt64Param(tc.PathParams, "entryId")
sdkResp, sdkErr = client.GetTimesheetEntry(ctx, testAccountID, projectId, entryId)
sdkResp, sdkErr = client.GetTimesheetEntry(ctx, testAccountID, entryId)

case "CreateTimesheetEntry":
projectId := getInt64Param(tc.PathParams, "projectId")
recordingId := getInt64Param(tc.PathParams, "recordingId")
body := generated.CreateTimesheetEntryJSONRequestBody{
Date: getStringParam(tc.RequestBody, "date"),
Expand All @@ -243,10 +239,9 @@ func runTest(tc TestCase) TestResult {
if desc := getStringParam(tc.RequestBody, "description"); desc != "" {
body.Description = desc
}
sdkResp, sdkErr = client.CreateTimesheetEntry(ctx, testAccountID, projectId, recordingId, body)
sdkResp, sdkErr = client.CreateTimesheetEntry(ctx, testAccountID, recordingId, body)

case "UpdateTimesheetEntry":
projectId := getInt64Param(tc.PathParams, "projectId")
entryId := getInt64Param(tc.PathParams, "entryId")
body := generated.UpdateTimesheetEntryJSONRequestBody{}
if date := getStringParam(tc.RequestBody, "date"); date != "" {
Expand All @@ -258,7 +253,7 @@ func runTest(tc TestCase) TestResult {
if desc := getStringParam(tc.RequestBody, "description"); desc != "" {
body.Description = desc
}
sdkResp, sdkErr = client.UpdateTimesheetEntry(ctx, testAccountID, projectId, entryId, body)
sdkResp, sdkErr = client.UpdateTimesheetEntry(ctx, testAccountID, entryId, body)

case "GetProjectTimeline":
projectId := getInt64Param(tc.PathParams, "projectId")
Expand All @@ -271,6 +266,22 @@ func runTest(tc TestCase) TestResult {
personId := getInt64Param(tc.PathParams, "personId")
sdkResp, sdkErr = client.GetPersonProgress(ctx, testAccountID, personId)

case "GetProjectTimesheet":
projectId := getInt64Param(tc.PathParams, "projectId")
sdkResp, sdkErr = client.GetProjectTimesheet(ctx, testAccountID, projectId, nil)

case "ListWebhooks":
bucketId := getInt64Param(tc.PathParams, "bucketId")
sdkResp, sdkErr = client.ListWebhooks(ctx, testAccountID, bucketId)

case "CreateWebhook":
bucketId := getInt64Param(tc.PathParams, "bucketId")
body := generated.CreateWebhookJSONRequestBody{
PayloadUrl: getStringParam(tc.RequestBody, "payload_url"),
Types: getStringSliceParam(tc.RequestBody, "types"),
}
sdkResp, sdkErr = client.CreateWebhook(ctx, testAccountID, bucketId, body)

default:
return TestResult{
Name: tc.Name,
Expand Down Expand Up @@ -386,3 +397,19 @@ func getStringParam(params map[string]interface{}, key string) string {
}
return ""
}

// getStringSliceParam extracts a []string parameter from a map (JSON arrays of strings)
func getStringSliceParam(params map[string]interface{}, key string) []string {
if val, ok := params[key]; ok {
if arr, ok := val.([]interface{}); ok {
result := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
}
}
return nil
}
2 changes: 1 addition & 1 deletion conformance/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"path": {
"type": "string",
"description": "URL path pattern (e.g., /projects.json, /buckets/{projectId}/todos/{todoId})"
"description": "URL path pattern (e.g., /projects.json, /todos/{todoId})"
},
"pathParams": {
"type": "object",
Expand Down
4 changes: 2 additions & 2 deletions conformance/tests/idempotency.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
"description": "Verifies that POST operations do NOT retry since they are not idempotent",
"operation": "CreateTodo",
"method": "POST",
"path": "/buckets/{projectId}/todolists/{todolistId}/todos.json",
"pathParams": {"projectId": 12345, "todolistId": 67890},
"path": "/todolists/{todolistId}/todos.json",
"pathParams": {"todolistId": 67890},
"requestBody": {"content": "New todo", "due_on": "2024-01-01"},
"mockResponses": [
{"status": 503}
Expand Down
4 changes: 2 additions & 2 deletions conformance/tests/pagination.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
"description": "Verifies that X-Total-Count header is parsed and accessible",
"operation": "ListTodos",
"method": "GET",
"path": "/buckets/{projectId}/todolists/{todolistId}/todos.json",
"pathParams": {"projectId": 12345, "todolistId": 67890},
"path": "/todolists/{todolistId}/todos.json",
"pathParams": {"todolistId": 67890},
"mockResponses": [
{
"status": 200,
Expand Down
86 changes: 84 additions & 2 deletions conformance/tests/paths.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"name": "Project timeline uses /projects/ path",
"description": "Verifies that GetProjectTimeline constructs the URL path using /projects/{projectId}/ not /buckets/{projectId}/",
"name": "Project timeline uses project-scoped /projects/{projectId}/timeline.json path",
"description": "Verifies that GetProjectTimeline constructs the project-scoped URL path /projects/{projectId}/timeline.json",
"operation": "GetProjectTimeline",
"method": "GET",
"path": "/projects/{projectId}/timeline.json",
Expand Down Expand Up @@ -45,5 +45,87 @@
{"type": "noError"}
],
"tags": ["path", "reports"]
},
{
"name": "Project timesheet uses project-scoped /projects/{projectId}/timesheet.json path",
"description": "Verifies that GetProjectTimesheet constructs the project-scoped URL path /projects/{projectId}/timesheet.json",
"operation": "GetProjectTimesheet",
"method": "GET",
"path": "/projects/{projectId}/timesheet.json",
"pathParams": {"projectId": 12345},
"mockResponses": [
{"status": 200, "body": []}
],
"assertions": [
{"type": "requestPath", "expected": "/999/projects/12345/timesheet.json"},
{"type": "noError"}
],
"tags": ["path", "timesheets"]
},
{
"name": "Timesheet entry get uses /timesheet_entries/{entryId} path",
"description": "Verifies that GetTimesheetEntry uses timesheet_entries (underscore) not timesheet/entries",
"operation": "GetTimesheetEntry",
"method": "GET",
"path": "/timesheet_entries/{entryId}",
"pathParams": {"entryId": 999},
"mockResponses": [
{"status": 200, "body": {"id": 999}}
],
"assertions": [
{"type": "requestPath", "expected": "/999/timesheet_entries/999"},
{"type": "noError"}
],
"tags": ["path", "timesheets"]
},
{
"name": "Timesheet entry update uses /timesheet_entries/{entryId} path",
"description": "Verifies that UpdateTimesheetEntry uses timesheet_entries (underscore) not timesheet/entries",
"operation": "UpdateTimesheetEntry",
"method": "PUT",
"path": "/timesheet_entries/{entryId}",
"pathParams": {"entryId": 999},
"requestBody": {"hours": "4.0"},
"mockResponses": [
{"status": 200, "body": {"id": 999, "hours": "4.0"}}
],
"assertions": [
{"type": "requestPath", "expected": "/999/timesheet_entries/999"},
{"type": "noError"}
],
"tags": ["path", "timesheets"]
},
{
"name": "List webhooks uses bucket-scoped /buckets/{bucketId}/webhooks.json path",
"description": "Verifies that ListWebhooks constructs the bucket-scoped URL path /buckets/{bucketId}/webhooks.json",
"operation": "ListWebhooks",
"method": "GET",
"path": "/buckets/{bucketId}/webhooks.json",
"pathParams": {"bucketId": 456},
"mockResponses": [
{"status": 200, "body": []}
],
"assertions": [
{"type": "requestPath", "expected": "/999/buckets/456/webhooks.json"},
{"type": "noError"}
],
"tags": ["path", "webhooks"]
},
{
"name": "Create webhook uses bucket-scoped /buckets/{bucketId}/webhooks.json path",
"description": "Verifies that CreateWebhook constructs the bucket-scoped URL path /buckets/{bucketId}/webhooks.json",
"operation": "CreateWebhook",
"method": "POST",
"path": "/buckets/{bucketId}/webhooks.json",
"pathParams": {"bucketId": 456},
"requestBody": {"payload_url": "https://example.com/hook", "types": ["Todo"]},
"mockResponses": [
{"status": 201, "body": {"id": 1, "payload_url": "https://example.com/hook", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "url": "https://3.basecampapi.com/999/buckets/456/webhooks/1.json", "app_url": "https://3.basecamp.com/999/buckets/456/webhooks/1", "active": true, "types": ["Todo"]}}
],
"assertions": [
{"type": "requestPath", "expected": "/999/buckets/456/webhooks.json"},
{"type": "noError"}
],
"tags": ["path", "webhooks"]
}
]
8 changes: 4 additions & 4 deletions conformance/tests/status-codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
"description": "Verifies that CreateTodo returns 201 Created per Smithy spec (code: 201)",
"operation": "CreateTodo",
"method": "POST",
"path": "/buckets/{projectId}/todolists/{todolistId}/todos.json",
"pathParams": {"projectId": 12345, "todolistId": 67890},
"path": "/todolists/{todolistId}/todos.json",
"pathParams": {"todolistId": 67890},
"requestBody": {"content": "New todo item"},
"mockResponses": [
{"status": 201, "body": {"id": 111, "content": "New todo item", "status": "active", "visible_to_clients": false, "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "title": "New todo item", "inherits_status": true, "type": "Todo", "url": "https://3.basecampapi.com/999/buckets/12345/todos/111.json", "app_url": "https://3.basecamp.com/999/buckets/12345/todos/111", "parent": {"id": 67890, "title": "Todolist", "type": "Todolist", "url": "https://3.basecampapi.com/999/buckets/12345/todolists/67890.json", "app_url": "https://3.basecamp.com/999/buckets/12345/todolists/67890"}, "bucket": {"id": 12345, "name": "Project", "type": "Project"}, "creator": {"id": 1, "name": "Test User", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z"}}}
Expand Down Expand Up @@ -213,8 +213,8 @@
"description": "Verifies that 422 validation errors on POST create operations are not retried",
"operation": "CreateTodo",
"method": "POST",
"path": "/buckets/{projectId}/todolists/{todolistId}/todos.json",
"pathParams": {"projectId": 12345, "todolistId": 67890},
"path": "/todolists/{todolistId}/todos.json",
"pathParams": {"todolistId": 67890},
"requestBody": {"content": ""},
"mockResponses": [
{"status": 422, "body": {"error": "Content can't be blank"}}
Expand Down
30 changes: 16 additions & 14 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,36 +232,37 @@ cfg, err := basecamp.LoadConfig("/path/to/config.json")
ctx := context.Background()

// List todos in a todolist
todos, err := account.Todos().List(ctx, projectID, todolistID, nil)
todos, err := account.Todos().List(ctx, todolistID, nil)

// Create a todo
todo, err := account.Todos().Create(ctx, projectID, todolistID, &basecamp.CreateTodoRequest{
todo, err := account.Todos().Create(ctx, todolistID, &basecamp.CreateTodoRequest{
Content: "Review pull request",
Description: "Check the new authentication flow",
DueOn: "2026-02-01",
AssigneeIDs: []int64{12345},
})

// Complete a todo
err = account.Todos().Complete(ctx, projectID, todoID)
err = account.Todos().Complete(ctx, todoID)

// Reposition a todo
err = account.Todos().Reposition(ctx, projectID, todoID, 1) // Move to first position
err = account.Todos().Reposition(ctx, todoID, 1) // Move to first position
```

## Working with Messages

```go
ctx := context.Background()

// Get the message board for a project
board, err := account.MessageBoards().Get(ctx, projectID)
// Get the message board (boardID from project dock/tools)
var boardID int64 = 12345
board, err := account.MessageBoards().Get(ctx, boardID)

// List messages
messages, err := account.Messages().List(ctx, projectID, board.ID, nil)
messages, err := account.Messages().List(ctx, board.ID, nil)

// Create a message
msg, err := account.Messages().Create(ctx, projectID, board.ID, &basecamp.CreateMessageRequest{
msg, err := account.Messages().Create(ctx, board.ID, &basecamp.CreateMessageRequest{
Subject: "Weekly Update",
Content: "<p>Here's what we accomplished this week...</p>",
})
Expand All @@ -276,28 +277,29 @@ ctx := context.Background()
campfires, err := account.Campfires().List(ctx)

// Send a message
line, err := account.Campfires().CreateLine(ctx, projectID, campfireID, "Hello, team!")
line, err := account.Campfires().CreateLine(ctx, campfireID, "Hello, team!")

// List recent messages
lines, err := account.Campfires().ListLines(ctx, projectID, campfireID)
lines, err := account.Campfires().ListLines(ctx, campfireID)
```

## Working with Webhooks

```go
ctx := context.Background()
var bucketID int64 = 12345 // project/bucket ID

// Create a webhook
webhook, err := account.Webhooks().Create(ctx, projectID, &basecamp.CreateWebhookRequest{
webhook, err := account.Webhooks().Create(ctx, bucketID, &basecamp.CreateWebhookRequest{
PayloadURL: "https://example.com/webhook",
Types: []string{"Todo", "Comment"},
})

// List webhooks
webhooks, err := account.Webhooks().List(ctx, projectID)
webhooks, err := account.Webhooks().List(ctx, bucketID)

// Delete a webhook
err = account.Webhooks().Delete(ctx, projectID, webhookID)
err = account.Webhooks().Delete(ctx, webhookID)
Comment thread
jeremy marked this conversation as resolved.
```

## Error Handling
Expand Down Expand Up @@ -411,7 +413,7 @@ client := basecamp.NewClient(cfg, token, basecamp.WithHooks(hooks))
Output:
```
level=DEBUG msg="basecamp operation start" service=Todos operation=Complete resource_type=todo is_mutation=true
level=DEBUG msg="basecamp request start" method=POST url=https://3.basecampapi.com/123/buckets/456/todos/789/completion.json attempt=1
level=DEBUG msg="basecamp request start" method=POST url=https://3.basecampapi.com/123/todos/789/completion.json attempt=1
level=DEBUG msg="basecamp request complete" method=POST url=... duration=145ms status=204 from_cache=false
level=DEBUG msg="basecamp operation complete" service=Todos operation=Complete duration=147ms
```
Expand Down
Loading
Loading