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
This commit is contained in:
Oliver Jakoubek 2026-01-15 18:14:22 +01:00
commit a56456cc00
3 changed files with 204 additions and 4 deletions

View file

@ -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"}]}

View file

@ -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.
@ -24,7 +31,9 @@ func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
endpoint: baseURL + "/jsonrpc.php",
httpClient: http.DefaultClient,
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
}

173
client_test.go Normal file
View file

@ -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")
}
}