Implement JSON-RPC client foundation
Add core JSON-RPC 2.0 protocol implementation for Kanboard API: - JSONRPCRequest/Response/Error structs with proper JSON tags - Generic call() method for sending requests and parsing responses - Thread-safe request ID generation using atomic.Int64 - Automatic /jsonrpc.php path appending to baseURL - Support for subdirectory installations - HTTP Basic Auth support (API token and username/password) - Error handling for unauthorized/forbidden responses Includes comprehensive tests with httptest mock server. Closes: kanboard-api-2g1
This commit is contained in:
parent
347fb75f11
commit
a486a73ce1
6 changed files with 611 additions and 1 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{"id":"kanboard-api-0fz","title":"Implement Category API methods","description":"Implement direct API methods for category operations.\n\n## Methods to implement\n- GetAllCategories(ctx, projectID int) ([]Category, error) - getAllCategories\n- GetCategory(ctx, categoryID int) (*Category, error) - getCategory (Nice-to-have)\n\n## Files to create\n- categories.go\n\n## Acceptance criteria\n- Proper error handling\n- Returns empty slice when no categories exist","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.6133153+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.6133153+01:00","dependencies":[{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.161416595+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.226963473+01:00","created_by":"Oliver Jakoubek"}]}
|
{"id":"kanboard-api-0fz","title":"Implement Category API methods","description":"Implement direct API methods for category operations.\n\n## Methods to implement\n- GetAllCategories(ctx, projectID int) ([]Category, error) - getAllCategories\n- GetCategory(ctx, categoryID int) (*Category, error) - getCategory (Nice-to-have)\n\n## Files to create\n- categories.go\n\n## Acceptance criteria\n- Proper error handling\n- Returns empty slice when no categories exist","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.6133153+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.6133153+01:00","dependencies":[{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.161416595+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.226963473+01:00","created_by":"Oliver Jakoubek"}]}
|
||||||
{"id":"kanboard-api-16r","title":"Implement Tag API methods (CRITICAL)","description":"Implement direct API methods for tag operations. Tags are CRITICAL - heavily used.\n\n## Direct Client methods (Must-have)\n- GetTaskTags(ctx, taskID int) (map[int]string, error) - getTaskTags\n- SetTaskTags(ctx, projectID, taskID int, tags []string) error - setTaskTags\n\n## Direct Client methods (Nice-to-have)\n- GetAllTags(ctx) ([]Tag, error) - getAllTags\n- GetTagsByProject(ctx, projectID int) ([]Tag, error) - getTagsByProject\n- CreateTag(ctx, projectID int, name, colorID string) (int, error) - createTag\n- UpdateTag(ctx, tagID int, name, colorID string) error - updateTag\n- RemoveTag(ctx, tagID int) error - removeTag\n\n## Files to create\n- tags.go\n\n## IMPORTANT NOTE\nsetTaskTags REPLACES ALL tags. Individual add/remove requires read-modify-write pattern (implemented in TaskScope).\n\n## Acceptance criteria\n- GetTaskTags returns map[tagID]tagName\n- SetTaskTags accepts tag names (auto-creates if needed)","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.526810135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.526810135+01:00","dependencies":[{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.223137796+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.402237867+01:00","created_by":"Oliver Jakoubek"}]}
|
{"id":"kanboard-api-16r","title":"Implement Tag API methods (CRITICAL)","description":"Implement direct API methods for tag operations. Tags are CRITICAL - heavily used.\n\n## Direct Client methods (Must-have)\n- GetTaskTags(ctx, taskID int) (map[int]string, error) - getTaskTags\n- SetTaskTags(ctx, projectID, taskID int, tags []string) error - setTaskTags\n\n## Direct Client methods (Nice-to-have)\n- GetAllTags(ctx) ([]Tag, error) - getAllTags\n- GetTagsByProject(ctx, projectID int) ([]Tag, error) - getTagsByProject\n- CreateTag(ctx, projectID int, name, colorID string) (int, error) - createTag\n- UpdateTag(ctx, tagID int, name, colorID string) error - updateTag\n- RemoveTag(ctx, tagID int) error - removeTag\n\n## Files to create\n- tags.go\n\n## IMPORTANT NOTE\nsetTaskTags REPLACES ALL tags. Individual add/remove requires read-modify-write pattern (implemented in TaskScope).\n\n## Acceptance criteria\n- GetTaskTags returns map[tagID]tagName\n- SetTaskTags accepts tag names (auto-creates if needed)","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.526810135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.526810135+01:00","dependencies":[{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.223137796+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.402237867+01:00","created_by":"Oliver Jakoubek"}]}
|
||||||
{"id":"kanboard-api-25y","title":"Implement Timestamp type with JSON handling","description":"Implement custom Timestamp type that handles Kanboard's Unix timestamp format.\n\n## Requirements\n- Timestamp struct wrapping time.Time\n- UnmarshalJSON supporting:\n - Unix timestamps as integers\n - Empty strings and \"0\" as zero time\n - Zero value (0) as zero time\n- MarshalJSON returning Unix timestamp or 0 for zero time\n\n## Files to create\n- timestamp.go\n\n## Acceptance criteria\n- Correctly parses integer Unix timestamps\n- Handles empty strings and \"0\" strings\n- Zero time marshals to 0\n- Non-zero time marshals to Unix timestamp","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.0044989+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:55.0044989+01:00"}
|
{"id":"kanboard-api-25y","title":"Implement Timestamp type with JSON handling","description":"Implement custom Timestamp type that handles Kanboard's Unix timestamp format.\n\n## Requirements\n- Timestamp struct wrapping time.Time\n- UnmarshalJSON supporting:\n - Unix timestamps as integers\n - Empty strings and \"0\" as zero time\n - Zero value (0) as zero time\n- MarshalJSON returning Unix timestamp or 0 for zero time\n\n## Files to create\n- timestamp.go\n\n## Acceptance criteria\n- Correctly parses integer Unix timestamps\n- Handles empty strings and \"0\" strings\n- Zero time marshals to 0\n- Non-zero time marshals to Unix timestamp","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.0044989+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:55.0044989+01:00"}
|
||||||
{"id":"kanboard-api-2g1","title":"Implement JSON-RPC client foundation","description":"Implement the core JSON-RPC 2.0 client for Kanboard API communication.\n\n## Requirements\n- JSONRPCRequest struct with jsonrpc, method, id, params fields\n- JSONRPCResponse struct with jsonrpc, id, result, error fields \n- JSONRPCError struct with code and message\n- Generic `call` method to send requests and parse responses\n- Automatic `/jsonrpc.php` path appending to baseURL\n- Thread-safe request ID generation via atomic.Int64\n\n## Files to create\n- jsonrpc.go\n\n## Acceptance criteria\n- All JSON-RPC structs properly marshal/unmarshal\n- Request IDs increment atomically\n- Supports subdirectory installations (e.g. /kanboard/jsonrpc.php)","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.232007312+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:53.232007312+01:00"}
|
{"id":"kanboard-api-2g1","title":"Implement JSON-RPC client foundation","description":"Implement the core JSON-RPC 2.0 client for Kanboard API communication.\n\n## Requirements\n- JSONRPCRequest struct with jsonrpc, method, id, params fields\n- JSONRPCResponse struct with jsonrpc, id, result, error fields \n- JSONRPCError struct with code and message\n- Generic `call` method to send requests and parse responses\n- Automatic `/jsonrpc.php` path appending to baseURL\n- Thread-safe request ID generation via atomic.Int64\n\n## Files to create\n- jsonrpc.go\n\n## Acceptance criteria\n- All JSON-RPC structs properly marshal/unmarshal\n- Request IDs increment atomically\n- Supports subdirectory installations (e.g. /kanboard/jsonrpc.php)","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.232007312+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:10:29.68466887+01:00","closed_at":"2026-01-15T18:10:29.68466887+01:00","close_reason":"Closed"}
|
||||||
{"id":"kanboard-api-2ze","title":"Implement BoardScope fluent builder","description":"Implement BoardScope for fluent project-scoped operations.\n\n## Requirements\n- BoardScope struct with client and projectID\n- Client.Board(projectID int) *BoardScope method\n- BoardScope methods:\n - GetColumns(ctx) ([]Column, error)\n - GetCategories(ctx) ([]Category, error)\n - GetTasks(ctx, status TaskStatus) ([]Task, error)\n - SearchTasks(ctx, query string) ([]Task, error)\n - CreateTask(ctx, task *TaskParams) (*Task, error)\n\n## Files to create\n- board_scope.go\n\n## Example usage\n```go\ncolumns, _ := client.Board(1).GetColumns(ctx)\ntask, _ := client.Board(1).CreateTask(ctx, kanboard.NewTask(\"Title\"))\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:40.044649709+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.044649709+01:00","dependencies":[{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:43:30.81063282+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:43:30.874964284+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-0fz","type":"blocks","created_at":"2026-01-15T17:43:30.939377116+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.005026627+01:00","created_by":"Oliver Jakoubek"}]}
|
{"id":"kanboard-api-2ze","title":"Implement BoardScope fluent builder","description":"Implement BoardScope for fluent project-scoped operations.\n\n## Requirements\n- BoardScope struct with client and projectID\n- Client.Board(projectID int) *BoardScope method\n- BoardScope methods:\n - GetColumns(ctx) ([]Column, error)\n - GetCategories(ctx) ([]Category, error)\n - GetTasks(ctx, status TaskStatus) ([]Task, error)\n - SearchTasks(ctx, query string) ([]Task, error)\n - CreateTask(ctx, task *TaskParams) (*Task, error)\n\n## Files to create\n- board_scope.go\n\n## Example usage\n```go\ncolumns, _ := client.Board(1).GetColumns(ctx)\ntask, _ := client.Board(1).CreateTask(ctx, kanboard.NewTask(\"Title\"))\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:40.044649709+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.044649709+01:00","dependencies":[{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:43:30.81063282+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:43:30.874964284+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-0fz","type":"blocks","created_at":"2026-01-15T17:43:30.939377116+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.005026627+01:00","created_by":"Oliver Jakoubek"}]}
|
||||||
{"id":"kanboard-api-3dc","title":"Implement Link API methods","description":"Implement direct API methods for task link operations.\n\n## Methods to implement\n- GetAllTaskLinks(ctx, taskID int) ([]TaskLink, error) - getAllTaskLinks\n- CreateTaskLink(ctx, taskID, oppositeTaskID, linkID int) (int, error) - createTaskLink\n- RemoveTaskLink(ctx, taskLinkID int) error - removeTaskLink (Nice-to-have)\n\n## TaskScope methods to add\n- GetLinks(ctx) ([]TaskLink, error)\n- LinkTo(ctx, oppositeTaskID, linkID int) error\n\n## Files to create\n- links.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateTaskLink returns the link ID\n- Links include related task information","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.328552773+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.328552773+01:00","dependencies":[{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.785710003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.886111429+01:00","created_by":"Oliver Jakoubek"}]}
|
{"id":"kanboard-api-3dc","title":"Implement Link API methods","description":"Implement direct API methods for task link operations.\n\n## Methods to implement\n- GetAllTaskLinks(ctx, taskID int) ([]TaskLink, error) - getAllTaskLinks\n- CreateTaskLink(ctx, taskID, oppositeTaskID, linkID int) (int, error) - createTaskLink\n- RemoveTaskLink(ctx, taskLinkID int) error - removeTaskLink (Nice-to-have)\n\n## TaskScope methods to add\n- GetLinks(ctx) ([]TaskLink, error)\n- LinkTo(ctx, oppositeTaskID, linkID int) error\n\n## Files to create\n- links.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateTaskLink returns the link ID\n- Links include related task information","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.328552773+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.328552773+01:00","dependencies":[{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.785710003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.886111429+01:00","created_by":"Oliver Jakoubek"}]}
|
||||||
{"id":"kanboard-api-5fb","title":"Implement TaskUpdateParams builder","description":"Implement TaskUpdateParams for fluent task update configuration.\n\n## Requirements\n- TaskUpdateParams struct with pointer fields\n- NewTaskUpdate() *TaskUpdateParams constructor\n- Fluent setter methods:\n - SetTitle(title string) *TaskUpdateParams\n - SetDescription(desc string) *TaskUpdateParams\n - SetColor(colorID string) *TaskUpdateParams\n - SetOwner(ownerID int) *TaskUpdateParams\n - SetCategory(categoryID int) *TaskUpdateParams\n - SetPriority(priority int) *TaskUpdateParams\n - SetDueDate(date time.Time) *TaskUpdateParams\n- Internal method to convert to UpdateTaskRequest\n\n## Files to create\n- task_update_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTaskUpdate().\n SetTitle(\"New Title\").\n SetPriority(2)\n```\n\n## Acceptance criteria\n- Only set fields are included in update request\n- All setters return *TaskUpdateParams for chaining","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.814955926+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.814955926+01:00","dependencies":[{"issue_id":"kanboard-api-5fb","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.134402453+01:00","created_by":"Oliver Jakoubek"}]}
|
{"id":"kanboard-api-5fb","title":"Implement TaskUpdateParams builder","description":"Implement TaskUpdateParams for fluent task update configuration.\n\n## Requirements\n- TaskUpdateParams struct with pointer fields\n- NewTaskUpdate() *TaskUpdateParams constructor\n- Fluent setter methods:\n - SetTitle(title string) *TaskUpdateParams\n - SetDescription(desc string) *TaskUpdateParams\n - SetColor(colorID string) *TaskUpdateParams\n - SetOwner(ownerID int) *TaskUpdateParams\n - SetCategory(categoryID int) *TaskUpdateParams\n - SetPriority(priority int) *TaskUpdateParams\n - SetDueDate(date time.Time) *TaskUpdateParams\n- Internal method to convert to UpdateTaskRequest\n\n## Files to create\n- task_update_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTaskUpdate().\n SetTitle(\"New Title\").\n SetPriority(2)\n```\n\n## Acceptance criteria\n- Only set fields are included in update request\n- All setters return *TaskUpdateParams for chaining","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.814955926+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.814955926+01:00","dependencies":[{"issue_id":"kanboard-api-5fb","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.134402453+01:00","created_by":"Oliver Jakoubek"}]}
|
||||||
|
|
|
||||||
29
auth.go
Normal file
29
auth.go
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package kanboard
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// Authenticator applies authentication to HTTP requests.
|
||||||
|
type Authenticator interface {
|
||||||
|
Apply(req *http.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiTokenAuth implements API token authentication.
|
||||||
|
type apiTokenAuth struct {
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply adds HTTP Basic Auth with username "jsonrpc" and the API token.
|
||||||
|
func (a *apiTokenAuth) Apply(req *http.Request) {
|
||||||
|
req.SetBasicAuth("jsonrpc", a.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// basicAuth implements username/password authentication.
|
||||||
|
type basicAuth struct {
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply adds HTTP Basic Auth with username and password.
|
||||||
|
func (a *basicAuth) Apply(req *http.Request) {
|
||||||
|
req.SetBasicAuth(a.username, a.password)
|
||||||
|
}
|
||||||
47
client.go
Normal file
47
client.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package kanboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is the Kanboard API client.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
endpoint string
|
||||||
|
httpClient *http.Client
|
||||||
|
auth Authenticator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Kanboard API client.
|
||||||
|
// The baseURL should be the base URL of the Kanboard instance (e.g., "https://kanboard.example.com").
|
||||||
|
// The path /jsonrpc.php is appended automatically.
|
||||||
|
// Supports subdirectory installations (e.g., "https://example.com/kanboard" → POST https://example.com/kanboard/jsonrpc.php).
|
||||||
|
func NewClient(baseURL string) *Client {
|
||||||
|
// Ensure no trailing slash
|
||||||
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
endpoint: baseURL + "/jsonrpc.php",
|
||||||
|
httpClient: http.DefaultClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAPIToken configures the client to use API token authentication.
|
||||||
|
func (c *Client) WithAPIToken(token string) *Client {
|
||||||
|
c.auth = &apiTokenAuth{token: token}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBasicAuth configures the client to use username/password authentication.
|
||||||
|
func (c *Client) WithBasicAuth(username, password string) *Client {
|
||||||
|
c.auth = &basicAuth{username: username, password: password}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHTTPClient sets a custom HTTP client.
|
||||||
|
func (c *Client) WithHTTPClient(client *http.Client) *Client {
|
||||||
|
c.httpClient = client
|
||||||
|
return c
|
||||||
|
}
|
||||||
28
errors.go
Normal file
28
errors.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package kanboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrConnectionFailed indicates a connection to the Kanboard server failed.
|
||||||
|
ErrConnectionFailed = errors.New("connection to Kanboard server failed")
|
||||||
|
|
||||||
|
// ErrUnauthorized indicates authentication failed.
|
||||||
|
ErrUnauthorized = errors.New("authentication failed: invalid credentials")
|
||||||
|
|
||||||
|
// ErrForbidden indicates insufficient permissions.
|
||||||
|
ErrForbidden = errors.New("access forbidden: insufficient permissions")
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIError represents an error returned by the Kanboard API.
|
||||||
|
type APIError struct {
|
||||||
|
Code int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface.
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
return fmt.Sprintf("Kanboard API error (code %d): %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
114
jsonrpc.go
Normal file
114
jsonrpc.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
package kanboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONRPCRequest represents a JSON-RPC 2.0 request.
|
||||||
|
type JSONRPCRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Params interface{} `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONRPCResponse represents a JSON-RPC 2.0 response.
|
||||||
|
type JSONRPCResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *JSONRPCError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONRPCError represents a JSON-RPC 2.0 error.
|
||||||
|
type JSONRPCError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface.
|
||||||
|
func (e *JSONRPCError) Error() string {
|
||||||
|
return fmt.Sprintf("JSON-RPC error (code %d): %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestIDCounter provides thread-safe request ID generation.
|
||||||
|
var requestIDCounter atomic.Int64
|
||||||
|
|
||||||
|
// nextRequestID returns the next request ID in a thread-safe manner.
|
||||||
|
func nextRequestID() int64 {
|
||||||
|
return requestIDCounter.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// call sends a JSON-RPC request and parses the response.
|
||||||
|
// The result parameter should be a pointer to the expected result type.
|
||||||
|
func (c *Client) call(ctx context.Context, method string, params interface{}, result interface{}) error {
|
||||||
|
req := JSONRPCRequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Method: method,
|
||||||
|
ID: nextRequestID(),
|
||||||
|
Params: params,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if c.auth != nil {
|
||||||
|
c.auth.Apply(httpReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrConnectionFailed, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rpcResp JSONRPCResponse
|
||||||
|
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rpcResp.Error != nil {
|
||||||
|
return &APIError{
|
||||||
|
Code: rpcResp.Error.Code,
|
||||||
|
Message: rpcResp.Error.Message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && rpcResp.Result != nil {
|
||||||
|
if err := json.Unmarshal(rpcResp.Result, result); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal result: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
392
jsonrpc_test.go
Normal file
392
jsonrpc_test.go
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
package kanboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJSONRPCRequest_Marshal(t *testing.T) {
|
||||||
|
req := JSONRPCRequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Method: "getTask",
|
||||||
|
ID: 1,
|
||||||
|
Params: map[string]int{"task_id": 42},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmarshaled map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unmarshaled["jsonrpc"] != "2.0" {
|
||||||
|
t.Errorf("expected jsonrpc=2.0, got %v", unmarshaled["jsonrpc"])
|
||||||
|
}
|
||||||
|
if unmarshaled["method"] != "getTask" {
|
||||||
|
t.Errorf("expected method=getTask, got %v", unmarshaled["method"])
|
||||||
|
}
|
||||||
|
if unmarshaled["id"].(float64) != 1 {
|
||||||
|
t.Errorf("expected id=1, got %v", unmarshaled["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONRPCRequest_MarshalWithoutParams(t *testing.T) {
|
||||||
|
req := JSONRPCRequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Method: "getAllProjects",
|
||||||
|
ID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmarshaled map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := unmarshaled["params"]; exists {
|
||||||
|
t.Error("params should be omitted when nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONRPCResponse_Unmarshal(t *testing.T) {
|
||||||
|
data := `{"jsonrpc":"2.0","id":1,"result":{"id":42,"title":"Test Task"}}`
|
||||||
|
|
||||||
|
var resp JSONRPCResponse
|
||||||
|
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.JSONRPC != "2.0" {
|
||||||
|
t.Errorf("expected jsonrpc=2.0, got %v", resp.JSONRPC)
|
||||||
|
}
|
||||||
|
if resp.ID != 1 {
|
||||||
|
t.Errorf("expected id=1, got %v", resp.ID)
|
||||||
|
}
|
||||||
|
if resp.Error != nil {
|
||||||
|
t.Error("expected no error")
|
||||||
|
}
|
||||||
|
if resp.Result == nil {
|
||||||
|
t.Error("expected result to be present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONRPCResponse_UnmarshalError(t *testing.T) {
|
||||||
|
data := `{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}`
|
||||||
|
|
||||||
|
var resp JSONRPCResponse
|
||||||
|
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Error == nil {
|
||||||
|
t.Fatal("expected error to be present")
|
||||||
|
}
|
||||||
|
if resp.Error.Code != -32600 {
|
||||||
|
t.Errorf("expected error code=-32600, got %v", resp.Error.Code)
|
||||||
|
}
|
||||||
|
if resp.Error.Message != "Invalid Request" {
|
||||||
|
t.Errorf("expected error message='Invalid Request', got %v", resp.Error.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONRPCError_Error(t *testing.T) {
|
||||||
|
err := &JSONRPCError{
|
||||||
|
Code: -32600,
|
||||||
|
Message: "Invalid Request",
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "JSON-RPC error (code -32600): Invalid Request"
|
||||||
|
if err.Error() != expected {
|
||||||
|
t.Errorf("expected %q, got %q", expected, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextRequestID_Increments(t *testing.T) {
|
||||||
|
// Get the current counter value
|
||||||
|
initial := nextRequestID()
|
||||||
|
|
||||||
|
// Verify increments
|
||||||
|
for i := int64(1); i <= 5; i++ {
|
||||||
|
got := nextRequestID()
|
||||||
|
expected := initial + i
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("expected %d, got %d", expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextRequestID_ThreadSafe(t *testing.T) {
|
||||||
|
const goroutines = 100
|
||||||
|
const iterations = 100
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
ids := make(chan int64, goroutines*iterations)
|
||||||
|
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < iterations; j++ {
|
||||||
|
ids <- nextRequestID()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(ids)
|
||||||
|
|
||||||
|
// Collect all IDs and check for uniqueness
|
||||||
|
seen := make(map[int64]bool)
|
||||||
|
for id := range ids {
|
||||||
|
if seen[id] {
|
||||||
|
t.Errorf("duplicate request ID: %d", id)
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(seen) != goroutines*iterations {
|
||||||
|
t.Errorf("expected %d unique IDs, got %d", goroutines*iterations, len(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Call_Success(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/jsonrpc.php" {
|
||||||
|
t.Errorf("expected /jsonrpc.php, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req JSONRPCRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatalf("failed to decode request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.JSONRPC != "2.0" {
|
||||||
|
t.Errorf("expected jsonrpc=2.0, got %s", req.JSONRPC)
|
||||||
|
}
|
||||||
|
if req.Method != "getTask" {
|
||||||
|
t.Errorf("expected method=getTask, got %s", req.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: json.RawMessage(`{"id":42,"title":"Test Task"}`),
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.call(context.Background(), "getTask", map[string]int{"task_id": 42}, &result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ID != 42 {
|
||||||
|
t.Errorf("expected id=42, got %d", result.ID)
|
||||||
|
}
|
||||||
|
if result.Title != "Test Task" {
|
||||||
|
t.Errorf("expected title='Test Task', got %s", result.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Call_APIError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req JSONRPCRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
resp := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Error: &JSONRPCError{
|
||||||
|
Code: -32600,
|
||||||
|
Message: "Invalid Request",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
err := client.call(context.Background(), "invalidMethod", nil, &result)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.Code != -32600 {
|
||||||
|
t.Errorf("expected code=-32600, got %d", apiErr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Call_Unauthorized(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL).WithAPIToken("invalid-token")
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
err := client.call(context.Background(), "getTask", nil, &result)
|
||||||
|
|
||||||
|
if err != ErrUnauthorized {
|
||||||
|
t.Errorf("expected ErrUnauthorized, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Call_Forbidden(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
err := client.call(context.Background(), "getTask", nil, &result)
|
||||||
|
|
||||||
|
if err != ErrForbidden {
|
||||||
|
t.Errorf("expected ErrForbidden, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Call_ContextCanceled(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Simulate slow response
|
||||||
|
select {}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
err := client.call(ctx, "getTask", nil, &result)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error due to canceled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Call_SubdirectoryInstallation(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/kanboard/jsonrpc.php" {
|
||||||
|
t.Errorf("expected /kanboard/jsonrpc.php, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req JSONRPCRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
resp := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: json.RawMessage(`true`),
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Client with subdirectory path
|
||||||
|
client := NewClient(server.URL + "/kanboard").WithAPIToken("test-token")
|
||||||
|
|
||||||
|
var result bool
|
||||||
|
err := client.call(context.Background(), "getVersion", nil, &result)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Call_TrailingSlashHandling(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/jsonrpc.php" {
|
||||||
|
t.Errorf("expected /jsonrpc.php, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req JSONRPCRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
resp := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: json.RawMessage(`true`),
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Client with trailing slash
|
||||||
|
client := NewClient(server.URL + "/").WithAPIToken("test-token")
|
||||||
|
|
||||||
|
var result bool
|
||||||
|
err := client.call(context.Background(), "getVersion", nil, &result)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Call_AuthHeaderSent(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
t.Error("expected Basic Auth header")
|
||||||
|
}
|
||||||
|
if username != "jsonrpc" {
|
||||||
|
t.Errorf("expected username=jsonrpc, got %s", username)
|
||||||
|
}
|
||||||
|
if password != "test-token" {
|
||||||
|
t.Errorf("expected password=test-token, got %s", password)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req JSONRPCRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
resp := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: json.RawMessage(`true`),
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||||
|
|
||||||
|
var result bool
|
||||||
|
err := client.call(context.Background(), "getVersion", nil, &result)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue