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:
parent
79385df87b
commit
a56456cc00
3 changed files with 204 additions and 4 deletions
|
|
@ -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"}]}
|
||||
|
|
|
|||
29
client.go
29
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.
|
||||
|
|
@ -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
173
client_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue