Implement entity structs for Kanboard API responses

Add all entity structs matching Kanboard's JSON-RPC API format:

Entity structs:
- Project, Task, Column, Category, Comment
- TaskLink, TaskFile, Tag
- TaskStatus enum (StatusActive, StatusInactive)

Request structs:
- CreateTaskRequest with omitempty for optional fields
- UpdateTaskRequest with pointer fields for zero-value distinction

Custom JSON types for Kanboard's string-encoded values:
- StringBool - handles "0"/"1" string booleans
- StringInt - handles string-encoded integers
- StringInt64 - handles string-encoded int64 values

All structs properly handle Kanboard's quirky JSON format where
numeric fields are often returned as quoted strings.

Closes: kanboard-api-cyc
This commit is contained in:
Oliver Jakoubek 2026-01-15 18:18:47 +01:00
commit a38e62e77a
3 changed files with 779 additions and 1 deletions

View file

@ -10,7 +10,7 @@
{"id":"kanboard-api-9k8","title":"Implement TaskParams builder (Options Pattern)","description":"Implement TaskParams for fluent task creation configuration.\n\n## Requirements\n- TaskParams struct with private fields\n- NewTask(title string) *TaskParams constructor\n- Fluent setter methods:\n - WithDescription(desc string) *TaskParams\n - InColumn(columnID int) *TaskParams\n - WithCategory(categoryID int) *TaskParams\n - WithOwner(ownerID int) *TaskParams\n - WithColor(colorID string) *TaskParams\n - WithPriority(priority int) *TaskParams\n - WithDueDate(date time.Time) *TaskParams\n - WithTags(tags ...string) *TaskParams\n - WithReference(ref string) *TaskParams\n- Internal method to convert to CreateTaskRequest\n\n## Files to create\n- task_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTask(\"My Task\").\n WithDescription(\"Details\").\n InColumn(2).\n WithTags(\"urgent\", \"backend\")\n```\n\n## Acceptance criteria\n- Pure configuration object (no I/O)\n- All setters return *TaskParams for chaining\n- Unset optional fields remain nil","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.439513879+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.439513879+01:00","dependencies":[{"issue_id":"kanboard-api-9k8","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.070100884+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-9k8","title":"Implement TaskParams builder (Options Pattern)","description":"Implement TaskParams for fluent task creation configuration.\n\n## Requirements\n- TaskParams struct with private fields\n- NewTask(title string) *TaskParams constructor\n- Fluent setter methods:\n - WithDescription(desc string) *TaskParams\n - InColumn(columnID int) *TaskParams\n - WithCategory(categoryID int) *TaskParams\n - WithOwner(ownerID int) *TaskParams\n - WithColor(colorID string) *TaskParams\n - WithPriority(priority int) *TaskParams\n - WithDueDate(date time.Time) *TaskParams\n - WithTags(tags ...string) *TaskParams\n - WithReference(ref string) *TaskParams\n- Internal method to convert to CreateTaskRequest\n\n## Files to create\n- task_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTask(\"My Task\").\n WithDescription(\"Details\").\n InColumn(2).\n WithTags(\"urgent\", \"backend\")\n```\n\n## Acceptance criteria\n- Pure configuration object (no I/O)\n- All setters return *TaskParams for chaining\n- Unset optional fields remain nil","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.439513879+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.439513879+01:00","dependencies":[{"issue_id":"kanboard-api-9k8","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.070100884+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-apl","title":"Implement Project API methods","description":"Implement direct API methods for project/board operations.\n\n## Methods to implement\n- GetAllProjects(ctx) ([]Project, error) - getAllProjects\n- GetProjectByID(ctx, projectID int) (*Project, error) - getProjectById\n- GetProjectByName(ctx, name string) (*Project, error) - getProjectByName (Nice-to-have)\n\n## Files to create\n- projects.go\n\n## Acceptance criteria\n- All methods use context for cancellation\n- Proper error handling and wrapping\n- Returns ErrProjectNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:15.864764497+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:15.864764497+01:00","dependencies":[{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:52.850751716+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:52.946556814+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-apl","title":"Implement Project API methods","description":"Implement direct API methods for project/board operations.\n\n## Methods to implement\n- GetAllProjects(ctx) ([]Project, error) - getAllProjects\n- GetProjectByID(ctx, projectID int) (*Project, error) - getProjectById\n- GetProjectByName(ctx, name string) (*Project, error) - getProjectByName (Nice-to-have)\n\n## Files to create\n- projects.go\n\n## Acceptance criteria\n- All methods use context for cancellation\n- Proper error handling and wrapping\n- Returns ErrProjectNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:15.864764497+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:15.864764497+01:00","dependencies":[{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:52.850751716+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:52.946556814+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-c3i","title":"Create README.md with documentation","description":"Create comprehensive README.md documentation in English.\n\n## Sections to include\n- Project overview and features\n- Installation instructions\n- Quick start example\n- API documentation overview\n- Authentication methods (API token, user/password)\n- Fluent API examples\n- Direct API examples\n- Error handling\n- Thread-safety notes\n- Tag operations warning (non-atomic)\n- License (MIT)\n\n## Example code to include\n```go\n// Client creation\nclient := kanboard.NewClient(\"https://kanboard.example.com\").\n WithAPIToken(\"my-token\").\n WithTimeout(30 * time.Second)\n\n// Fluent API\ntask, _ := client.Board(1).CreateTask(ctx,\n kanboard.NewTask(\"My Task\").WithDescription(\"Details\"))\n\n// Direct API\ntasks, _ := client.GetAllTasks(ctx, 1, kanboard.StatusActive)\n```\n\n## Files to create\n- README.md\n\n## Acceptance criteria\n- Clear installation instructions\n- Working code examples\n- Documents all major features","status":"open","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.228407343+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.228407343+01:00","dependencies":[{"issue_id":"kanboard-api-c3i","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:46:55.445847253+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-c3i","title":"Create README.md with documentation","description":"Create comprehensive README.md documentation in English.\n\n## Sections to include\n- Project overview and features\n- Installation instructions\n- Quick start example\n- API documentation overview\n- Authentication methods (API token, user/password)\n- Fluent API examples\n- Direct API examples\n- Error handling\n- Thread-safety notes\n- Tag operations warning (non-atomic)\n- License (MIT)\n\n## Example code to include\n```go\n// Client creation\nclient := kanboard.NewClient(\"https://kanboard.example.com\").\n WithAPIToken(\"my-token\").\n WithTimeout(30 * time.Second)\n\n// Fluent API\ntask, _ := client.Board(1).CreateTask(ctx,\n kanboard.NewTask(\"My Task\").WithDescription(\"Details\"))\n\n// Direct API\ntasks, _ := client.GetAllTasks(ctx, 1, kanboard.StatusActive)\n```\n\n## Files to create\n- README.md\n\n## Acceptance criteria\n- Clear installation instructions\n- Working code examples\n- Documents all major features","status":"open","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.228407343+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.228407343+01:00","dependencies":[{"issue_id":"kanboard-api-c3i","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:46:55.445847253+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-cyc","title":"Implement entity structs (types.go)","description":"Implement all entity structs for Kanboard API responses.\n\n## Structs to implement\n- Project (ID, Name, Description, IsActive, Token, LastModified, etc.)\n- Task (ID, Title, Description, dates, ColorID, ProjectID, ColumnID, etc.)\n- Column (ID, Title, Position, ProjectID, TaskLimit, Description)\n- Category (ID, Name, ProjectID, ColorID)\n- Comment (ID, TaskID, UserID, DateCreation, Content, Username, etc.)\n- TaskLink (ID, LinkID, TaskID, OppositeTaskID, Label, Title)\n- TaskFile (ID, Name, Path, IsImage, TaskID, DateCreation, UserID, Size)\n- Tag (ID, Name, ProjectID, ColorID)\n- TaskStatus enum (StatusActive, StatusInactive)\n\n## Request structs\n- CreateTaskRequest\n- UpdateTaskRequest (with pointer fields for optional values)\n\n## Files to create\n- types.go\n\n## Acceptance criteria\n- All JSON tags match Kanboard API\n- Optional fields use pointers with omitempty\n- Timestamp fields use custom Timestamp type","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.484472208+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:55.484472208+01:00","dependencies":[{"issue_id":"kanboard-api-cyc","depends_on_id":"kanboard-api-25y","type":"blocks","created_at":"2026-01-15T17:42:48.385166815+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-cyc","title":"Implement entity structs (types.go)","description":"Implement all entity structs for Kanboard API responses.\n\n## Structs to implement\n- Project (ID, Name, Description, IsActive, Token, LastModified, etc.)\n- Task (ID, Title, Description, dates, ColorID, ProjectID, ColumnID, etc.)\n- Column (ID, Title, Position, ProjectID, TaskLimit, Description)\n- Category (ID, Name, ProjectID, ColorID)\n- Comment (ID, TaskID, UserID, DateCreation, Content, Username, etc.)\n- TaskLink (ID, LinkID, TaskID, OppositeTaskID, Label, Title)\n- TaskFile (ID, Name, Path, IsImage, TaskID, DateCreation, UserID, Size)\n- Tag (ID, Name, ProjectID, ColorID)\n- TaskStatus enum (StatusActive, StatusInactive)\n\n## Request structs\n- CreateTaskRequest\n- UpdateTaskRequest (with pointer fields for optional values)\n\n## Files to create\n- types.go\n\n## Acceptance criteria\n- All JSON tags match Kanboard API\n- Optional fields use pointers with omitempty\n- Timestamp fields use custom Timestamp type","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.484472208+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:18:40.163015715+01:00","closed_at":"2026-01-15T18:18:40.163015715+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-cyc","depends_on_id":"kanboard-api-25y","type":"blocks","created_at":"2026-01-15T17:42:48.385166815+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-fue","title":"Implement Task search and move methods","description":"Implement task search and movement API methods.\n\n## Methods to implement\n- SearchTasks(ctx, projectID int, query string) ([]Task, error) - searchTasks\n- MoveTaskPosition(ctx, projectID, taskID, columnID, position, swimlaneID int) error - moveTaskPosition\n- MoveTaskToProject(ctx, taskID, projectID int) error - moveTaskToProject\n\n## Files to create\n- tasks.go (extend)\n\n## Acceptance criteria\n- Search supports Kanboard query syntax\n- MoveTaskPosition handles column and position\n- Proper error handling for invalid moves","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:17.380817511+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:17.380817511+01:00","dependencies":[{"issue_id":"kanboard-api-fue","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:42:53.59290181+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-fue","title":"Implement Task search and move methods","description":"Implement task search and movement API methods.\n\n## Methods to implement\n- SearchTasks(ctx, projectID int, query string) ([]Task, error) - searchTasks\n- MoveTaskPosition(ctx, projectID, taskID, columnID, position, swimlaneID int) error - moveTaskPosition\n- MoveTaskToProject(ctx, taskID, projectID int) error - moveTaskToProject\n\n## Files to create\n- tasks.go (extend)\n\n## Acceptance criteria\n- Search supports Kanboard query syntax\n- MoveTaskPosition handles column and position\n- Proper error handling for invalid moves","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:17.380817511+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:17.380817511+01:00","dependencies":[{"issue_id":"kanboard-api-fue","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:42:53.59290181+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-h4u","title":"Implement Comment API methods","description":"Implement direct API methods for comment operations.\n\n## Methods to implement\n- GetAllComments(ctx, taskID int) ([]Comment, error) - getAllComments\n- CreateComment(ctx, taskID, userID int, content string) (*Comment, error) - createComment\n- UpdateComment(ctx, commentID int, content string) error - updateComment\n- RemoveComment(ctx, commentID int) error - removeComment\n\n## TaskScope methods to add\n- AddComment(ctx, content string) (*Comment, error)\n- GetComments(ctx) ([]Comment, error)\n\n## Files to create\n- comments.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateComment returns the created comment\n- Returns ErrCommentNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.156950163+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.156950163+01:00","dependencies":[{"issue_id":"kanboard-api-h4u","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:48.898295911+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-h4u","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.058259216+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-h4u","title":"Implement Comment API methods","description":"Implement direct API methods for comment operations.\n\n## Methods to implement\n- GetAllComments(ctx, taskID int) ([]Comment, error) - getAllComments\n- CreateComment(ctx, taskID, userID int, content string) (*Comment, error) - createComment\n- UpdateComment(ctx, commentID int, content string) error - updateComment\n- RemoveComment(ctx, commentID int) error - removeComment\n\n## TaskScope methods to add\n- AddComment(ctx, content string) (*Comment, error)\n- GetComments(ctx) ([]Comment, error)\n\n## Files to create\n- comments.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateComment returns the created comment\n- Returns ErrCommentNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.156950163+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.156950163+01:00","dependencies":[{"issue_id":"kanboard-api-h4u","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:48.898295911+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-h4u","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.058259216+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-k33","title":"Implement authentication system","description":"Implement HTTP Basic Auth support for both API token and user/password authentication.\n\n## Requirements\n- Authenticator interface for auth strategies\n- API Token auth: HTTP Basic with username `jsonrpc` and API token as password\n- User/Password auth: HTTP Basic with username and password\n- WithAPIToken(token string) fluent method\n- WithBasicAuth(username, password string) fluent method\n- Secure handling - no credential storage/logging\n\n## Files to create\n- auth.go\n\n## Acceptance criteria\n- Both auth methods work correctly\n- Credentials properly encoded in Authorization header\n- No sensitive data logged","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.631074781+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:11:42.58778642+01:00","closed_at":"2026-01-15T18:11:42.58778642+01:00","close_reason":"Closed"} {"id":"kanboard-api-k33","title":"Implement authentication system","description":"Implement HTTP Basic Auth support for both API token and user/password authentication.\n\n## Requirements\n- Authenticator interface for auth strategies\n- API Token auth: HTTP Basic with username `jsonrpc` and API token as password\n- User/Password auth: HTTP Basic with username and password\n- WithAPIToken(token string) fluent method\n- WithBasicAuth(username, password string) fluent method\n- Secure handling - no credential storage/logging\n\n## Files to create\n- auth.go\n\n## Acceptance criteria\n- Both auth methods work correctly\n- Credentials properly encoded in Authorization header\n- No sensitive data logged","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.631074781+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:11:42.58778642+01:00","closed_at":"2026-01-15T18:11:42.58778642+01:00","close_reason":"Closed"}

257
types.go Normal file
View file

@ -0,0 +1,257 @@
package kanboard
import (
"encoding/json"
"strconv"
)
// TaskStatus represents the status of a task.
type TaskStatus int
const (
// StatusActive represents an open/active task.
StatusActive TaskStatus = 1
// StatusInactive represents a closed/inactive task.
StatusInactive TaskStatus = 0
)
// StringBool is a bool that can be unmarshaled from a string "0" or "1".
type StringBool bool
// UnmarshalJSON implements json.Unmarshaler.
func (b *StringBool) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
// Try as raw bool
var boolVal bool
if err := json.Unmarshal(data, &boolVal); err != nil {
return err
}
*b = StringBool(boolVal)
return nil
}
*b = s == "1" || s == "true"
return nil
}
// MarshalJSON implements json.Marshaler.
func (b StringBool) MarshalJSON() ([]byte, error) {
if b {
return []byte(`"1"`), nil
}
return []byte(`"0"`), nil
}
// StringInt is an int that can be unmarshaled from a JSON string.
type StringInt int
// UnmarshalJSON implements json.Unmarshaler.
func (i *StringInt) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
// Try as raw int
var intVal int
if err := json.Unmarshal(data, &intVal); err != nil {
return err
}
*i = StringInt(intVal)
return nil
}
if s == "" {
*i = 0
return nil
}
val, err := strconv.Atoi(s)
if err != nil {
return err
}
*i = StringInt(val)
return nil
}
// StringInt64 is an int64 that can be unmarshaled from a JSON string.
type StringInt64 int64
// UnmarshalJSON implements json.Unmarshaler.
func (i *StringInt64) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
// Try as raw int64
var intVal int64
if err := json.Unmarshal(data, &intVal); err != nil {
return err
}
*i = StringInt64(intVal)
return nil
}
if s == "" {
*i = 0
return nil
}
val, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return err
}
*i = StringInt64(val)
return nil
}
// Project represents a Kanboard project (board).
type Project struct {
ID StringInt `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsActive StringBool `json:"is_active"`
Token string `json:"token"`
LastModified Timestamp `json:"last_modified"`
IsPublic StringBool `json:"is_public"`
IsPrivate StringBool `json:"is_private"`
DefaultSwimlane string `json:"default_swimlane"`
ShowDefaultSwimlane StringBool `json:"show_default_swimlane"`
Identifier string `json:"identifier"`
StartDate Timestamp `json:"start_date"`
EndDate Timestamp `json:"end_date"`
OwnerID StringInt `json:"owner_id"`
PriorityDefault StringInt `json:"priority_default"`
PriorityStart StringInt `json:"priority_start"`
PriorityEnd StringInt `json:"priority_end"`
Email string `json:"email"`
}
// Task represents a Kanboard task.
type Task struct {
ID StringInt `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
DateCreation Timestamp `json:"date_creation"`
DateModification Timestamp `json:"date_modification"`
DateCompleted Timestamp `json:"date_completed"`
DateStarted Timestamp `json:"date_started"`
DateDue Timestamp `json:"date_due"`
ColorID string `json:"color_id"`
ProjectID StringInt `json:"project_id"`
ColumnID StringInt `json:"column_id"`
OwnerID StringInt `json:"owner_id"`
CreatorID StringInt `json:"creator_id"`
Position StringInt `json:"position"`
IsActive StringBool `json:"is_active"`
Score StringInt `json:"score"`
CategoryID StringInt `json:"category_id"`
SwimlaneID StringInt `json:"swimlane_id"`
Priority StringInt `json:"priority"`
Reference string `json:"reference"`
RecurrenceStatus StringInt `json:"recurrence_status"`
RecurrenceTrigger StringInt `json:"recurrence_trigger"`
RecurrenceFactor StringInt `json:"recurrence_factor"`
RecurrenceTimeframe StringInt `json:"recurrence_timeframe"`
RecurrenceBasedate StringInt `json:"recurrence_basedate"`
RecurrenceParent StringInt `json:"recurrence_parent"`
RecurrenceChild StringInt `json:"recurrence_child"`
}
// Column represents a Kanboard column.
type Column struct {
ID StringInt `json:"id"`
Title string `json:"title"`
Position StringInt `json:"position"`
ProjectID StringInt `json:"project_id"`
TaskLimit StringInt `json:"task_limit"`
Description string `json:"description"`
}
// Category represents a Kanboard category.
type Category struct {
ID StringInt `json:"id"`
Name string `json:"name"`
ProjectID StringInt `json:"project_id"`
ColorID string `json:"color_id"`
}
// Comment represents a Kanboard comment.
type Comment struct {
ID StringInt `json:"id"`
TaskID StringInt `json:"task_id"`
UserID StringInt `json:"user_id"`
DateCreation Timestamp `json:"date_creation"`
Content string `json:"comment"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
AvatarPath string `json:"avatar_path"`
}
// TaskLink represents a link between two tasks.
type TaskLink struct {
ID StringInt `json:"id"`
LinkID StringInt `json:"link_id"`
TaskID StringInt `json:"task_id"`
OppositeTaskID StringInt `json:"opposite_task_id"`
Label string `json:"label"`
Title string `json:"title"`
}
// TaskFile represents a file attached to a task.
type TaskFile struct {
ID StringInt `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
IsImage StringBool `json:"is_image"`
TaskID StringInt `json:"task_id"`
DateCreation Timestamp `json:"date_creation"`
UserID StringInt `json:"user_id"`
Size StringInt64 `json:"size"`
}
// Tag represents a Kanboard tag.
type Tag struct {
ID StringInt `json:"id"`
Name string `json:"name"`
ProjectID StringInt `json:"project_id"`
ColorID string `json:"color_id"`
}
// CreateTaskRequest is the request payload for creating a task.
type CreateTaskRequest struct {
Title string `json:"title"`
ProjectID int `json:"project_id"`
Description string `json:"description,omitempty"`
ColumnID int `json:"column_id,omitempty"`
OwnerID int `json:"owner_id,omitempty"`
CreatorID int `json:"creator_id,omitempty"`
ColorID string `json:"color_id,omitempty"`
CategoryID int `json:"category_id,omitempty"`
DateDue int64 `json:"date_due,omitempty"`
Score int `json:"score,omitempty"`
SwimlaneID int `json:"swimlane_id,omitempty"`
Priority int `json:"priority,omitempty"`
Reference string `json:"reference,omitempty"`
Tags []string `json:"tags,omitempty"`
DateStarted int64 `json:"date_started,omitempty"`
RecurrenceStatus int `json:"recurrence_status,omitempty"`
RecurrenceTrigger int `json:"recurrence_trigger,omitempty"`
RecurrenceFactor int `json:"recurrence_factor,omitempty"`
RecurrenceTimeframe int `json:"recurrence_timeframe,omitempty"`
RecurrenceBasedate int `json:"recurrence_basedate,omitempty"`
}
// UpdateTaskRequest is the request payload for updating a task.
// Pointer fields allow distinguishing between "not set" and "set to zero value".
type UpdateTaskRequest struct {
ID int `json:"id"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
ColorID *string `json:"color_id,omitempty"`
OwnerID *int `json:"owner_id,omitempty"`
CategoryID *int `json:"category_id,omitempty"`
DateDue *int64 `json:"date_due,omitempty"`
Score *int `json:"score,omitempty"`
Priority *int `json:"priority,omitempty"`
Reference *string `json:"reference,omitempty"`
DateStarted *int64 `json:"date_started,omitempty"`
RecurrenceStatus *int `json:"recurrence_status,omitempty"`
RecurrenceTrigger *int `json:"recurrence_trigger,omitempty"`
RecurrenceFactor *int `json:"recurrence_factor,omitempty"`
RecurrenceTimeframe *int `json:"recurrence_timeframe,omitempty"`
RecurrenceBasedate *int `json:"recurrence_basedate,omitempty"`
Tags []string `json:"tags,omitempty"`
}

521
types_test.go Normal file
View file

@ -0,0 +1,521 @@
package kanboard
import (
"encoding/json"
"testing"
"time"
)
func TestTaskStatus_Constants(t *testing.T) {
if StatusActive != 1 {
t.Errorf("expected StatusActive=1, got %d", StatusActive)
}
if StatusInactive != 0 {
t.Errorf("expected StatusInactive=0, got %d", StatusInactive)
}
}
func TestStringBool_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"string 1", `"1"`, true},
{"string 0", `"0"`, false},
{"string true", `"true"`, true},
{"string false", `"false"`, false},
{"bool true", `true`, true},
{"bool false", `false`, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var b StringBool
if err := json.Unmarshal([]byte(tt.input), &b); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if bool(b) != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, b)
}
})
}
}
func TestStringInt_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{"string number", `"42"`, 42},
{"string zero", `"0"`, 0},
{"int number", `42`, 42},
{"int zero", `0`, 0},
{"empty string", `""`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var i StringInt
if err := json.Unmarshal([]byte(tt.input), &i); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int(i) != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, i)
}
})
}
}
func TestStringInt64_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
expected int64
}{
{"string number", `"1048576"`, 1048576},
{"string zero", `"0"`, 0},
{"int64 number", `1048576`, 1048576},
{"int64 zero", `0`, 0},
{"empty string", `""`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var i StringInt64
if err := json.Unmarshal([]byte(tt.input), &i); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int64(i) != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, i)
}
})
}
}
func TestProject_UnmarshalJSON(t *testing.T) {
jsonData := `{
"id": "1",
"name": "Test Project",
"description": "A test project",
"is_active": "1",
"token": "abc123",
"last_modified": 1609459200,
"is_public": "0",
"is_private": "1",
"owner_id": "42",
"priority_default": "2"
}`
var project Project
if err := json.Unmarshal([]byte(jsonData), &project); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int(project.ID) != 1 {
t.Errorf("expected ID=1, got %d", project.ID)
}
if project.Name != "Test Project" {
t.Errorf("expected Name='Test Project', got %s", project.Name)
}
if !bool(project.IsActive) {
t.Error("expected IsActive=true")
}
if bool(project.IsPublic) {
t.Error("expected IsPublic=false")
}
if !bool(project.IsPrivate) {
t.Error("expected IsPrivate=true")
}
if int(project.OwnerID) != 42 {
t.Errorf("expected OwnerID=42, got %d", project.OwnerID)
}
}
func TestTask_UnmarshalJSON(t *testing.T) {
jsonData := `{
"id": "42",
"title": "Test Task",
"description": "Task description",
"date_creation": 1609459200,
"date_due": 0,
"color_id": "yellow",
"project_id": "1",
"column_id": "2",
"owner_id": "5",
"is_active": "1",
"priority": "2",
"category_id": "3"
}`
var task Task
if err := json.Unmarshal([]byte(jsonData), &task); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int(task.ID) != 42 {
t.Errorf("expected ID=42, got %d", task.ID)
}
if task.Title != "Test Task" {
t.Errorf("expected Title='Test Task', got %s", task.Title)
}
if int(task.ProjectID) != 1 {
t.Errorf("expected ProjectID=1, got %d", task.ProjectID)
}
if int(task.ColumnID) != 2 {
t.Errorf("expected ColumnID=2, got %d", task.ColumnID)
}
if !bool(task.IsActive) {
t.Error("expected IsActive=true")
}
if task.DateCreation.IsZero() {
t.Error("expected DateCreation to be set")
}
if !task.DateDue.IsZero() {
t.Error("expected DateDue to be zero")
}
}
func TestColumn_UnmarshalJSON(t *testing.T) {
jsonData := `{
"id": "1",
"title": "Backlog",
"position": "1",
"project_id": "5",
"task_limit": "10",
"description": "Tasks to be done"
}`
var column Column
if err := json.Unmarshal([]byte(jsonData), &column); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int(column.ID) != 1 {
t.Errorf("expected ID=1, got %d", column.ID)
}
if column.Title != "Backlog" {
t.Errorf("expected Title='Backlog', got %s", column.Title)
}
if int(column.Position) != 1 {
t.Errorf("expected Position=1, got %d", column.Position)
}
if int(column.TaskLimit) != 10 {
t.Errorf("expected TaskLimit=10, got %d", column.TaskLimit)
}
}
func TestCategory_UnmarshalJSON(t *testing.T) {
jsonData := `{
"id": "3",
"name": "Bug",
"project_id": "1",
"color_id": "red"
}`
var category Category
if err := json.Unmarshal([]byte(jsonData), &category); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int(category.ID) != 3 {
t.Errorf("expected ID=3, got %d", category.ID)
}
if category.Name != "Bug" {
t.Errorf("expected Name='Bug', got %s", category.Name)
}
if category.ColorID != "red" {
t.Errorf("expected ColorID='red', got %s", category.ColorID)
}
}
func TestComment_UnmarshalJSON(t *testing.T) {
jsonData := `{
"id": "10",
"task_id": "42",
"user_id": "5",
"date_creation": 1609459200,
"comment": "This is a comment",
"username": "admin",
"name": "Admin User",
"email": "admin@example.com"
}`
var comment Comment
if err := json.Unmarshal([]byte(jsonData), &comment); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int(comment.ID) != 10 {
t.Errorf("expected ID=10, got %d", comment.ID)
}
if int(comment.TaskID) != 42 {
t.Errorf("expected TaskID=42, got %d", comment.TaskID)
}
if comment.Content != "This is a comment" {
t.Errorf("expected Content='This is a comment', got %s", comment.Content)
}
if comment.Username != "admin" {
t.Errorf("expected Username='admin', got %s", comment.Username)
}
}
func TestTaskLink_UnmarshalJSON(t *testing.T) {
jsonData := `{
"id": "1",
"link_id": "2",
"task_id": "42",
"opposite_task_id": "43",
"label": "blocks",
"title": "Related Task"
}`
var link TaskLink
if err := json.Unmarshal([]byte(jsonData), &link); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int(link.ID) != 1 {
t.Errorf("expected ID=1, got %d", link.ID)
}
if int(link.TaskID) != 42 {
t.Errorf("expected TaskID=42, got %d", link.TaskID)
}
if int(link.OppositeTaskID) != 43 {
t.Errorf("expected OppositeTaskID=43, got %d", link.OppositeTaskID)
}
if link.Label != "blocks" {
t.Errorf("expected Label='blocks', got %s", link.Label)
}
}
func TestTaskFile_UnmarshalJSON(t *testing.T) {
jsonData := `{
"id": "1",
"name": "document.pdf",
"path": "/uploads/document.pdf",
"is_image": "0",
"task_id": "42",
"date_creation": 1609459200,
"user_id": "5",
"size": "1048576"
}`
var file TaskFile
if err := json.Unmarshal([]byte(jsonData), &file); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int(file.ID) != 1 {
t.Errorf("expected ID=1, got %d", file.ID)
}
if file.Name != "document.pdf" {
t.Errorf("expected Name='document.pdf', got %s", file.Name)
}
if bool(file.IsImage) {
t.Error("expected IsImage=false")
}
if int64(file.Size) != 1048576 {
t.Errorf("expected Size=1048576, got %d", file.Size)
}
}
func TestTag_UnmarshalJSON(t *testing.T) {
jsonData := `{
"id": "5",
"name": "urgent",
"project_id": "1",
"color_id": "red"
}`
var tag Tag
if err := json.Unmarshal([]byte(jsonData), &tag); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if int(tag.ID) != 5 {
t.Errorf("expected ID=5, got %d", tag.ID)
}
if tag.Name != "urgent" {
t.Errorf("expected Name='urgent', got %s", tag.Name)
}
if tag.ColorID != "red" {
t.Errorf("expected ColorID='red', got %s", tag.ColorID)
}
}
func TestCreateTaskRequest_MarshalJSON(t *testing.T) {
req := CreateTaskRequest{
Title: "New Task",
ProjectID: 1,
Description: "Task description",
ColumnID: 2,
Priority: 3,
Tags: []string{"urgent", "backend"},
}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var unmarshaled map[string]any
if err := json.Unmarshal(data, &unmarshaled); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if unmarshaled["title"] != "New Task" {
t.Errorf("expected title='New Task', got %v", unmarshaled["title"])
}
if unmarshaled["project_id"].(float64) != 1 {
t.Errorf("expected project_id=1, got %v", unmarshaled["project_id"])
}
tags := unmarshaled["tags"].([]any)
if len(tags) != 2 {
t.Errorf("expected 2 tags, got %d", len(tags))
}
}
func TestCreateTaskRequest_OmitEmpty(t *testing.T) {
req := CreateTaskRequest{
Title: "Minimal Task",
ProjectID: 1,
}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var unmarshaled map[string]any
if err := json.Unmarshal(data, &unmarshaled); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
// Should not have omitempty fields
if _, exists := unmarshaled["description"]; exists {
t.Error("description should be omitted when empty")
}
if _, exists := unmarshaled["color_id"]; exists {
t.Error("color_id should be omitted when empty")
}
}
func TestUpdateTaskRequest_MarshalJSON(t *testing.T) {
title := "Updated Title"
priority := 5
req := UpdateTaskRequest{
ID: 42,
Title: &title,
Priority: &priority,
}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var unmarshaled map[string]any
if err := json.Unmarshal(data, &unmarshaled); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if unmarshaled["id"].(float64) != 42 {
t.Errorf("expected id=42, got %v", unmarshaled["id"])
}
if unmarshaled["title"] != "Updated Title" {
t.Errorf("expected title='Updated Title', got %v", unmarshaled["title"])
}
if unmarshaled["priority"].(float64) != 5 {
t.Errorf("expected priority=5, got %v", unmarshaled["priority"])
}
// Should not have fields that weren't set
if _, exists := unmarshaled["description"]; exists {
t.Error("description should be omitted when nil")
}
}
func TestUpdateTaskRequest_ZeroValueVsNil(t *testing.T) {
// Test that we can distinguish between "not set" and "set to zero"
zero := 0
emptyString := ""
req := UpdateTaskRequest{
ID: 42,
Priority: &zero, // Explicitly set to 0
ColorID: &emptyString, // Explicitly set to empty string
}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var unmarshaled map[string]any
if err := json.Unmarshal(data, &unmarshaled); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
// These should be present even though they're zero values
if _, exists := unmarshaled["priority"]; !exists {
t.Error("priority should be present when explicitly set to 0")
}
if _, exists := unmarshaled["color_id"]; !exists {
t.Error("color_id should be present when explicitly set to empty string")
}
}
func TestTask_TimestampFields(t *testing.T) {
jsonData := `{
"id": "1",
"title": "Test",
"description": "",
"date_creation": 1609459200,
"date_modification": 1609545600,
"date_completed": 0,
"date_due": 1610064000,
"color_id": "blue",
"project_id": "1",
"column_id": "1",
"owner_id": "0",
"creator_id": "1",
"position": "1",
"is_active": "1",
"score": "0",
"category_id": "0",
"swimlane_id": "0",
"priority": "0",
"reference": "",
"recurrence_status": "0",
"recurrence_trigger": "0",
"recurrence_factor": "0",
"recurrence_timeframe": "0",
"recurrence_basedate": "0",
"recurrence_parent": "0",
"recurrence_child": "0"
}`
var task Task
if err := json.Unmarshal([]byte(jsonData), &task); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
// Check that creation date is parsed correctly
expectedCreation := time.Unix(1609459200, 0)
if !task.DateCreation.Time.Equal(expectedCreation) {
t.Errorf("expected DateCreation=%v, got %v", expectedCreation, task.DateCreation.Time)
}
// Check that completed date is zero
if !task.DateCompleted.IsZero() {
t.Error("expected DateCompleted to be zero")
}
// Check that due date is parsed
if task.DateDue.IsZero() {
t.Error("expected DateDue to be set")
}
}