From a56456cc00213ad0a5ca398890c7c6fb7e92ad21 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 15 Jan 2026 18:14:22 +0100 Subject: [PATCH] Complete Client struct with fluent configuration Add full client configuration with fluent builder pattern: - WithTimeout(duration) - configurable request timeout - WithLogger(slog.Logger) - optional debug logging - DefaultTimeout constant (30 seconds) - Default HTTP client with timeout on construction All fluent methods return same client instance for chaining. Client is thread-safe for concurrent use. Closes: kanboard-api-uls --- .beads/issues.jsonl | 2 +- client.go | 33 ++++++++- client_test.go | 173 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 client_test.go diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4e96556..9fe2dc1 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -20,7 +20,7 @@ {"id":"kanboard-api-s7k","title":"Implement error types and handling","description":"Implement comprehensive error types for the Kanboard API client.\n\n## Sentinel Errors\n- ErrConnectionFailed, ErrTimeout (network)\n- ErrUnauthorized, ErrForbidden (auth)\n- ErrNotFound, ErrProjectNotFound, ErrTaskNotFound, ErrColumnNotFound, ErrCommentNotFound (resources)\n- ErrAlreadyInLastColumn, ErrAlreadyInFirstColumn, ErrTaskClosed, ErrTaskOpen (logic)\n- ErrEmptyTitle, ErrInvalidProjectID (validation)\n- ErrAPIError (API)\n\n## APIError struct\n- Code int\n- Message string\n- Error() string method\n\n## Helper functions\n- IsNotFound(err error) bool\n- IsUnauthorized(err error) bool\n- IsAPIError(err error) bool\n\n## Files to create\n- errors.go\n\n## Acceptance criteria\n- All errors properly support errors.Is/errors.As\n- Error wrapping with %w for context preservation","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.484116412+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:13:03.195687148+01:00","closed_at":"2026-01-15T18:13:03.195687148+01:00","close_reason":"Closed"} {"id":"kanboard-api-t36","title":"Implement MoveToNextColumn with column logic","description":"Implement TaskScope.MoveToNextColumn with automatic column position logic.\n\n## Method to implement\n- TaskScope.MoveToNextColumn(ctx) error\n\n## Workflow\n1. Get task via getTask\n2. Get columns for task's project via getColumns\n3. Sort columns by position\n4. Find current column position\n5. If last column: return ErrAlreadyInLastColumn\n6. Move to next column via moveTaskPosition\n\n## Files to modify\n- task_scope.go\n\n## Acceptance criteria\n- Correctly identifies next column by position\n- Returns ErrAlreadyInLastColumn for last column\n- Handles column gaps (positions 1, 3, 5 etc.)","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:29.900647957+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:29.900647957+01:00","dependencies":[{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:45:32.256714732+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:45:32.319227723+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-ukc","title":"Set up Mage build system","description":"Set up Mage build system for development automation.\n\n## Directory structure\n```\nmagefiles/\n├── go.mod\n├── go.sum\n└── magefile.go\n```\n\n## Targets to implement\n- Test() - Run all tests with race detection and coverage\n- Coverage() - Run tests and open HTML coverage report\n- Lint() - Run golangci-lint\n- Build() - Verify module compiles\n\n## Files to create\n- magefiles/go.mod\n- magefiles/magefile.go\n\n## Acceptance criteria\n- `mage test` runs tests with -race and -coverprofile\n- `mage lint` runs golangci-lint\n- `mage build` verifies compilation","status":"open","priority":2,"issue_type":"chore","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:30.260235504+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:30.260235504+01:00"} -{"id":"kanboard-api-uls","title":"Implement Client struct with fluent configuration","description":"Implement the main Client struct with fluent builder pattern for configuration.\n\n## Requirements\n- Client struct with baseURL, httpClient, auth, logger, requestID\n- NewClient(baseURL string) constructor\n- Fluent configuration methods:\n - WithAPIToken(token string) *Client\n - WithBasicAuth(username, password string) *Client\n - WithHTTPClient(client *http.Client) *Client\n - WithTimeout(timeout time.Duration) *Client\n - WithLogger(logger *slog.Logger) *Client\n- Default timeout of 30 seconds\n- Thread-safe for concurrent use\n\n## Files to create\n- client.go\n\n## Acceptance criteria\n- Client is immutable after configuration\n- Thread-safe concurrent usage\n- Configurable HTTP client and logger","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.051926732+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:54.051926732+01:00","dependencies":[{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-2g1","type":"blocks","created_at":"2026-01-15T17:42:48.457362117+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-k33","type":"blocks","created_at":"2026-01-15T17:42:48.576622508+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-s7k","type":"blocks","created_at":"2026-01-15T17:42:48.749342195+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"kanboard-api-uls","title":"Implement Client struct with fluent configuration","description":"Implement the main Client struct with fluent builder pattern for configuration.\n\n## Requirements\n- Client struct with baseURL, httpClient, auth, logger, requestID\n- NewClient(baseURL string) constructor\n- Fluent configuration methods:\n - WithAPIToken(token string) *Client\n - WithBasicAuth(username, password string) *Client\n - WithHTTPClient(client *http.Client) *Client\n - WithTimeout(timeout time.Duration) *Client\n - WithLogger(logger *slog.Logger) *Client\n- Default timeout of 30 seconds\n- Thread-safe for concurrent use\n\n## Files to create\n- client.go\n\n## Acceptance criteria\n- Client is immutable after configuration\n- Thread-safe concurrent usage\n- Configurable HTTP client and logger","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.051926732+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:14:16.530243958+01:00","closed_at":"2026-01-15T18:14:16.530243958+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-2g1","type":"blocks","created_at":"2026-01-15T17:42:48.457362117+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-k33","type":"blocks","created_at":"2026-01-15T17:42:48.576622508+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-s7k","type":"blocks","created_at":"2026-01-15T17:42:48.749342195+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-una","title":"Implement TaskScope fluent builder (core methods)","description":"Implement TaskScope for fluent task-scoped operations (core methods).\n\n## Requirements\n- TaskScope struct with client and taskID\n- Client.Task(taskID int) *TaskScope method\n- Core TaskScope methods:\n - Get(ctx) (*Task, error)\n - Close(ctx) error\n - Open(ctx) error\n - MoveToColumn(ctx, columnID int) error\n - MoveToProject(ctx, projectID int) error\n - Update(ctx, params *TaskUpdateParams) error\n\n## Files to create\n- task_scope.go\n\n## Example usage\n```go\ntask, _ := client.Task(123).Get(ctx)\nclient.Task(123).Close(ctx)\nclient.Task(123).Update(ctx, kanboard.NewTaskUpdate().SetTitle(\"New\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:41.173930396+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:41.173930396+01:00","dependencies":[{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.198935686+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-5fb","type":"blocks","created_at":"2026-01-15T17:43:31.261453756+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-xhf","title":"Create example programs","description":"Create example programs demonstrating library usage.\n\n## Examples to create\n```\nexamples/\n├── basic/\n│ └── main.go # Basic client setup and simple operations\n├── fluent/\n│ └── main.go # Fluent API demonstration\n└── search/\n └── main.go # Search functionality demo\n```\n\n## basic/main.go\n- Client creation with API token\n- Get all projects\n- Get tasks from a project\n- Create a simple task\n\n## fluent/main.go\n- Client configuration with all options\n- Task creation with TaskParams\n- Task updates with TaskUpdateParams\n- Tag operations\n\n## search/main.go\n- Project-specific search\n- Global search across all projects\n\n## Files to create\n- examples/basic/main.go\n- examples/fluent/main.go\n- examples/search/main.go\n\n## Acceptance criteria\n- Examples compile and are well-commented\n- Cover main use cases\n- Show both fluent and direct API styles","status":"open","priority":3,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.604889443+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.604889443+01:00","dependencies":[{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-2ze","type":"blocks","created_at":"2026-01-15T17:46:55.571585285+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:46:55.63515762+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-zfc","title":"Implement TaskScope tag methods with read-modify-write (CRITICAL)","description":"Implement TaskScope tag methods with read-modify-write pattern. CRITICAL feature.\n\n## TaskScope methods to implement\n- GetTags(ctx) (map[int]string, error)\n- SetTags(ctx, tags ...string) error - replaces ALL tags\n- ClearTags(ctx) error - removes ALL tags\n- AddTag(ctx, tag string) error - read-modify-write\n- RemoveTag(ctx, tag string) error - read-modify-write\n- HasTag(ctx, tag string) (bool, error)\n\n## Read-Modify-Write Workflow for AddTag\n1. Get task via getTask (need project_id)\n2. Get current tags via getTaskTags\n3. Check if tag already exists\n4. If not: add tag to list\n5. Call setTaskTags with updated list\n\n## Read-Modify-Write Workflow for RemoveTag\n1. Get task via getTask (need project_id)\n2. Get current tags via getTaskTags\n3. Filter out the tag to remove\n4. Call setTaskTags with filtered list\n5. If tag didn't exist: no error (idempotent)\n\n## Files to modify\n- task_scope.go\n\n## IMPORTANT WARNING\nThis is NOT atomic. Concurrent tag modifications may cause data loss. Document this limitation.\n\n## Acceptance criteria\n- AddTag is idempotent (no error if tag exists)\n- RemoveTag is idempotent (no error if tag doesn't exist)\n- HasTag correctly checks tag existence","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.911429864+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.911429864+01:00","dependencies":[{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-16r","type":"blocks","created_at":"2026-01-15T17:43:49.517064988+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:43:49.593313748+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:49.690872321+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/client.go b/client.go index 29639b8..8940bc3 100644 --- a/client.go +++ b/client.go @@ -1,16 +1,23 @@ package kanboard import ( + "log/slog" "net/http" "strings" + "time" ) +// DefaultTimeout is the default HTTP timeout for API requests. +const DefaultTimeout = 30 * time.Second + // Client is the Kanboard API client. +// It is safe for concurrent use by multiple goroutines. type Client struct { baseURL string endpoint string httpClient *http.Client auth Authenticator + logger *slog.Logger } // NewClient creates a new Kanboard API client. @@ -22,9 +29,11 @@ func NewClient(baseURL string) *Client { baseURL = strings.TrimSuffix(baseURL, "/") return &Client{ - baseURL: baseURL, - endpoint: baseURL + "/jsonrpc.php", - httpClient: http.DefaultClient, + baseURL: baseURL, + endpoint: baseURL + "/jsonrpc.php", + httpClient: &http.Client{ + Timeout: DefaultTimeout, + }, } } @@ -41,7 +50,25 @@ func (c *Client) WithBasicAuth(username, password string) *Client { } // WithHTTPClient sets a custom HTTP client. +// This replaces the default client entirely, including any timeout settings. func (c *Client) WithHTTPClient(client *http.Client) *Client { c.httpClient = client return c } + +// WithTimeout sets the HTTP client timeout. +// This creates a new HTTP client with the specified timeout. +func (c *Client) WithTimeout(timeout time.Duration) *Client { + c.httpClient = &http.Client{ + Timeout: timeout, + Transport: c.httpClient.Transport, + } + return c +} + +// WithLogger sets the logger for debug output. +// If set, the client will log request/response details at debug level. +func (c *Client) WithLogger(logger *slog.Logger) *Client { + c.logger = logger + return c +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..5d37fb9 --- /dev/null +++ b/client_test.go @@ -0,0 +1,173 @@ +package kanboard + +import ( + "log/slog" + "net/http" + "os" + "testing" + "time" +) + +func TestNewClient(t *testing.T) { + client := NewClient("https://kanboard.example.com") + + if client.baseURL != "https://kanboard.example.com" { + t.Errorf("expected baseURL='https://kanboard.example.com', got %s", client.baseURL) + } + if client.endpoint != "https://kanboard.example.com/jsonrpc.php" { + t.Errorf("expected endpoint='https://kanboard.example.com/jsonrpc.php', got %s", client.endpoint) + } + if client.httpClient == nil { + t.Error("expected httpClient to be initialized") + } + if client.httpClient.Timeout != DefaultTimeout { + t.Errorf("expected timeout=%v, got %v", DefaultTimeout, client.httpClient.Timeout) + } +} + +func TestNewClient_TrailingSlash(t *testing.T) { + client := NewClient("https://kanboard.example.com/") + + if client.baseURL != "https://kanboard.example.com" { + t.Errorf("trailing slash should be removed, got %s", client.baseURL) + } + if client.endpoint != "https://kanboard.example.com/jsonrpc.php" { + t.Errorf("expected endpoint='https://kanboard.example.com/jsonrpc.php', got %s", client.endpoint) + } +} + +func TestNewClient_Subdirectory(t *testing.T) { + client := NewClient("https://example.com/kanboard") + + if client.endpoint != "https://example.com/kanboard/jsonrpc.php" { + t.Errorf("expected endpoint='https://example.com/kanboard/jsonrpc.php', got %s", client.endpoint) + } +} + +func TestDefaultTimeout(t *testing.T) { + if DefaultTimeout != 30*time.Second { + t.Errorf("expected DefaultTimeout=30s, got %v", DefaultTimeout) + } +} + +func TestClient_WithAPIToken(t *testing.T) { + client := NewClient("https://example.com") + result := client.WithAPIToken("my-token") + + // Should return same client instance + if client != result { + t.Error("WithAPIToken should return the same client instance") + } + + if client.auth == nil { + t.Error("auth should be set") + } +} + +func TestClient_WithBasicAuth(t *testing.T) { + client := NewClient("https://example.com") + result := client.WithBasicAuth("admin", "password") + + // Should return same client instance + if client != result { + t.Error("WithBasicAuth should return the same client instance") + } + + if client.auth == nil { + t.Error("auth should be set") + } +} + +func TestClient_WithHTTPClient(t *testing.T) { + customClient := &http.Client{ + Timeout: 60 * time.Second, + } + + client := NewClient("https://example.com") + result := client.WithHTTPClient(customClient) + + // Should return same client instance + if client != result { + t.Error("WithHTTPClient should return the same client instance") + } + + if client.httpClient != customClient { + t.Error("httpClient should be set to custom client") + } +} + +func TestClient_WithTimeout(t *testing.T) { + client := NewClient("https://example.com") + originalClient := client.httpClient + + result := client.WithTimeout(60 * time.Second) + + // Should return same client instance + if client != result { + t.Error("WithTimeout should return the same client instance") + } + + // Should create new http.Client + if client.httpClient == originalClient { + t.Error("WithTimeout should create a new http.Client") + } + + if client.httpClient.Timeout != 60*time.Second { + t.Errorf("expected timeout=60s, got %v", client.httpClient.Timeout) + } +} + +func TestClient_WithLogger(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + client := NewClient("https://example.com") + result := client.WithLogger(logger) + + // Should return same client instance + if client != result { + t.Error("WithLogger should return the same client instance") + } + + if client.logger != logger { + t.Error("logger should be set") + } +} + +func TestClient_FluentChaining(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + client := NewClient("https://example.com"). + WithAPIToken("my-token"). + WithTimeout(60 * time.Second). + WithLogger(logger) + + if client.auth == nil { + t.Error("auth should be set via chaining") + } + if client.httpClient.Timeout != 60*time.Second { + t.Errorf("timeout should be 60s, got %v", client.httpClient.Timeout) + } + if client.logger != logger { + t.Error("logger should be set via chaining") + } +} + +func TestClient_DefaultsWithoutConfiguration(t *testing.T) { + client := NewClient("https://example.com") + + // Should have defaults + if client.httpClient == nil { + t.Error("httpClient should not be nil") + } + if client.httpClient.Timeout != DefaultTimeout { + t.Errorf("default timeout should be %v", DefaultTimeout) + } + + // Should have nil for optional fields + if client.auth != nil { + t.Error("auth should be nil by default") + } + if client.logger != nil { + t.Error("logger should be nil by default") + } +}