feat(veans): add JSON HTTP client and wire types

This commit is contained in:
Tink bot 2026-05-26 22:38:27 +02:00 committed by kolaente
parent e4c4837805
commit 87c312fb2b
13 changed files with 771 additions and 0 deletions

View File

@ -0,0 +1,16 @@
package client
import (
"context"
"fmt"
)
// AddAssignee assigns a user (typically the bot) to a task.
func (c *Client) AddAssignee(ctx context.Context, taskID, userID int64) error {
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
}
// RemoveAssignee unassigns.
func (c *Client) RemoveAssignee(ctx context.Context, taskID, userID int64) error {
return c.Do(ctx, "DELETE", fmt.Sprintf("/tasks/%d/assignees/%d", taskID, userID), nil, nil, nil)
}

View File

@ -0,0 +1,23 @@
package client
import "context"
// Login posts to /login and returns the JWT bundle. The returned token is a
// JWT good for the user's normal API calls; we use it transiently during init.
func (c *Client) Login(ctx context.Context, req *LoginRequest) (*LoginResponse, error) {
var out LoginResponse
if err := c.Do(ctx, "POST", "/login", nil, req, &out); err != nil {
return nil, err
}
return &out, nil
}
// CurrentUser fetches /user — handy for resolving the bot's own user_id from
// its API token without poking the human's data.
func (c *Client) CurrentUser(ctx context.Context) (*User, error) {
var out User
if err := c.Do(ctx, "GET", "/user", nil, nil, &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@ -0,0 +1,30 @@
package client
import (
"context"
"fmt"
)
// ListBuckets returns the buckets configured on a Kanban view.
func (c *Client) ListBuckets(ctx context.Context, projectID, viewID int64) ([]*Bucket, error) {
var out []*Bucket
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
if err := c.Do(ctx, "GET", path, nil, nil, &out); err != nil {
return nil, err
}
return out, nil
}
// CreateBucket inserts a new bucket into a Kanban view.
func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *Bucket) (*Bucket, error) {
var out Bucket
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
if b == nil {
b = &Bucket{}
}
b.ProjectViewID = viewID
if err := c.Do(ctx, "PUT", path, nil, b, &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@ -0,0 +1,166 @@
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"code.vikunja.io/veans/internal/output"
)
// Client is a thin JSON wrapper around the Vikunja REST API. It holds the
// server base URL and a bearer token (either a JWT from POST /login or an
// API token minted via PUT /tokens). Every method in this package is a thin
// shim over Do.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
UserAgent string
}
func New(baseURL, token string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
UserAgent: "veans/0.1",
}
}
// vikunjaError matches `web.HTTPError` on the wire.
type vikunjaError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// Do performs a single JSON request against /api/v1<path>. body, if non-nil,
// is JSON-marshalled. out, if non-nil, is JSON-unmarshalled. query is appended
// as URL-encoded params.
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
full := c.BaseURL + "/api/v1" + path
if len(query) > 0 {
full += "?" + query.Encode()
}
var bodyReader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
bodyReader = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, full, bodyReader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return output.Wrap(output.CodeUnknown, err, "%s %s: %v", method, path, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
return mapHTTPError(method, path, resp.StatusCode, respBody)
}
if out != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("decode %s %s: %w", method, path, err)
}
}
return nil
}
// DoRaw is the escape hatch used by `veans api`. It returns the raw response
// body and status. Auth + base URL handling matches Do.
func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Values, body []byte) (status int, respBody []byte, err error) {
full := c.BaseURL + "/api/v1" + path
if len(query) > 0 {
full += "?" + query.Encode()
}
var br io.Reader
if len(body) > 0 {
br = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, full, br)
if err != nil {
return 0, nil, err
}
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
respBody, err = io.ReadAll(resp.Body)
return resp.StatusCode, respBody, err
}
func mapHTTPError(method, path string, status int, body []byte) error {
var ve vikunjaError
_ = json.Unmarshal(body, &ve)
msg := strings.TrimSpace(ve.Message)
if msg == "" {
msg = strings.TrimSpace(string(body))
if msg == "" {
msg = http.StatusText(status)
}
}
var code output.Code
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
code = output.CodeAuth
case status == http.StatusNotFound:
code = output.CodeNotFound
case status == http.StatusConflict:
code = output.CodeConflict
case status == http.StatusTooManyRequests:
code = output.CodeRateLimited
case status >= 400 && status < 500:
code = output.CodeValidation
default:
code = output.CodeUnknown
}
return &output.Error{
Code: code,
Message: fmt.Sprintf("%s %s: %d %s", method, path, status, msg),
Cause: errors.New(msg),
}
}

View File

@ -0,0 +1,24 @@
package client
import (
"context"
"fmt"
)
// AddTaskComment posts a new comment on a task.
func (c *Client) AddTaskComment(ctx context.Context, taskID int64, body string) (*TaskComment, error) {
var out TaskComment
if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/comments", taskID), nil, &TaskComment{Comment: body}, &out); err != nil {
return nil, err
}
return &out, nil
}
// ListTaskComments returns all comments on a task.
func (c *Client) ListTaskComments(ctx context.Context, taskID int64) ([]*TaskComment, error) {
var out []*TaskComment
if err := c.Do(ctx, "GET", fmt.Sprintf("/tasks/%d/comments", taskID), nil, nil, &out); err != nil {
return nil, err
}
return out, nil
}

View File

@ -0,0 +1,12 @@
package client
import "context"
// Info fetches GET /info. No auth required.
func (c *Client) Info(ctx context.Context) (*Info, error) {
var out Info
if err := c.Do(ctx, "GET", "/info", nil, nil, &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@ -0,0 +1,49 @@
package client
import (
"context"
"fmt"
"net/url"
"strconv"
)
// ListLabels paginates GET /labels and returns every label visible to the
// authenticated user (labels are global per user, not scoped to a project).
func (c *Client) ListLabels(ctx context.Context, search string) ([]*Label, error) {
var all []*Label
for page := 1; ; page++ {
q := url.Values{}
q.Set("page", strconv.Itoa(page))
q.Set("per_page", "50")
if search != "" {
q.Set("s", search)
}
var batch []*Label
if err := c.Do(ctx, "GET", "/labels", q, nil, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < 50 {
return all, nil
}
}
}
// CreateLabel creates a new label owned by the authenticated user.
func (c *Client) CreateLabel(ctx context.Context, l *Label) (*Label, error) {
var out Label
if err := c.Do(ctx, "PUT", "/labels", nil, l, &out); err != nil {
return nil, err
}
return &out, nil
}
// AddLabelToTask attaches an existing label to a task.
func (c *Client) AddLabelToTask(ctx context.Context, taskID, labelID int64) error {
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/labels", taskID), nil, &LabelTask{LabelID: labelID}, nil)
}
// RemoveLabelFromTask detaches a label.
func (c *Client) RemoveLabelFromTask(ctx context.Context, taskID, labelID int64) error {
return c.Do(ctx, "DELETE", fmt.Sprintf("/tasks/%d/labels/%d", taskID, labelID), nil, nil, nil)
}

View File

@ -0,0 +1,53 @@
package client
import (
"context"
"fmt"
"net/url"
"strconv"
)
// ListProjects pages through GET /projects, accumulating until exhausted.
func (c *Client) ListProjects(ctx context.Context) ([]*Project, error) {
var all []*Project
for page := 1; ; page++ {
q := url.Values{}
q.Set("page", strconv.Itoa(page))
q.Set("per_page", "50")
var batch []*Project
if err := c.Do(ctx, "GET", "/projects", q, nil, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < 50 {
return all, nil
}
}
}
// GetProject fetches a single project by ID.
func (c *Client) GetProject(ctx context.Context, id int64) (*Project, error) {
var out Project
if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d", id), nil, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// ShareProjectWithUser grants `username` `permission` on project `id`.
func (c *Client) ShareProjectWithUser(ctx context.Context, projectID int64, share *ProjectUser) (*ProjectUser, error) {
var out ProjectUser
if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/users", projectID), nil, share, &out); err != nil {
return nil, err
}
return &out, nil
}
// ListProjectViews returns saved views (Kanban, List, …) on a project.
func (c *Client) ListProjectViews(ctx context.Context, projectID int64) ([]*ProjectView, error) {
var out []*ProjectView
if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d/views", projectID), nil, nil, &out); err != nil {
return nil, err
}
return out, nil
}

View File

@ -0,0 +1,17 @@
package client
import (
"context"
"fmt"
)
// CreateRelation links two tasks. relationKind is "subtask", "parenttask",
// "blocking", "blocked", "related", etc.
func (c *Client) CreateRelation(ctx context.Context, taskID int64, otherTaskID int64, relationKind string) (*TaskRelation, error) {
var out TaskRelation
body := &TaskRelation{OtherTaskID: otherTaskID, RelationKind: relationKind}
if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/relations", taskID), nil, body, &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@ -0,0 +1,97 @@
package client
import (
"context"
"fmt"
"net/url"
"strconv"
)
// TaskListOptions selects which tasks to return from ListProjectTasks.
type TaskListOptions struct {
Filter string
Page int
PerPage int
SortBy []string
OrderBy []string
Expand []string
}
func (o *TaskListOptions) values() url.Values {
q := url.Values{}
if o == nil {
return q
}
if o.Filter != "" {
q.Set("filter", o.Filter)
}
if o.Page > 0 {
q.Set("page", strconv.Itoa(o.Page))
}
if o.PerPage > 0 {
q.Set("per_page", strconv.Itoa(o.PerPage))
}
for _, s := range o.SortBy {
q.Add("sort_by", s)
}
for _, s := range o.OrderBy {
q.Add("order_by", s)
}
for _, e := range o.Expand {
q.Add("expand", e)
}
return q
}
// ListProjectTasks paginates `GET /projects/{id}/tasks` exhaustively.
func (c *Client) ListProjectTasks(ctx context.Context, projectID int64, opts *TaskListOptions) ([]*Task, error) {
if opts == nil {
opts = &TaskListOptions{}
}
per := opts.PerPage
if per <= 0 {
per = 50
}
var all []*Task
for page := 1; ; page++ {
o := *opts
o.Page = page
o.PerPage = per
var batch []*Task
if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d/tasks", projectID), o.values(), nil, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < per {
return all, nil
}
}
}
// GetTask fetches a single task by numeric ID.
func (c *Client) GetTask(ctx context.Context, id int64) (*Task, error) {
var out Task
if err := c.Do(ctx, "GET", fmt.Sprintf("/tasks/%d", id), nil, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// CreateTask inserts a task into a project (PUT /projects/{id}/tasks).
func (c *Client) CreateTask(ctx context.Context, projectID int64, t *Task) (*Task, error) {
var out Task
if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/tasks", projectID), nil, t, &out); err != nil {
return nil, err
}
return &out, nil
}
// UpdateTask updates a task (POST /tasks/{id}). bucket_id moves the task
// between buckets in the same view.
func (c *Client) UpdateTask(ctx context.Context, id int64, t *Task) (*Task, error) {
var out Task
if err := c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d", id), nil, t, &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@ -0,0 +1,53 @@
package client
import (
"context"
"fmt"
)
// FullPermissions is the broadest set of API token scopes a veans bot needs:
// read+write on every resource it touches. Vikunja's permission map is
// `{resource: [actions]}` shaped; the keys here cover everything the CLI
// calls for normal operation.
//
// We over-grant intentionally — the bot needs to claim, comment, label,
// relate, and update tasks; revoking unused scopes after the fact is cheap.
func FullPermissions() map[string][]string {
return map[string][]string{
"tasks": {"read_one", "read_all", "create", "update", "delete"},
"projects": {"read_one", "read_all", "create", "update", "delete"},
"labels": {"read_one", "read_all", "create", "update", "delete"},
"task_comments": {"read_one", "read_all", "create", "update", "delete"},
"task_assignees": {"create", "delete", "read_all"},
"task_relations": {"create", "delete"},
"task_attachments":{"create", "read_one", "delete"},
"buckets": {"read_all", "create", "update", "delete"},
"project_views": {"read_one", "read_all"},
"users": {"read_all"},
}
}
// CreateToken mints an API token. If t.OwnerID is non-zero, the token is
// minted FOR that user — the caller must be the bot's owner (i.e. created
// the bot in step 8 of init).
func (c *Client) CreateToken(ctx context.Context, t *APIToken) (*APIToken, error) {
var out APIToken
if err := c.Do(ctx, "PUT", "/tokens", nil, t, &out); err != nil {
return nil, err
}
return &out, nil
}
// ListTokens returns every API token the authenticated user can see.
func (c *Client) ListTokens(ctx context.Context) ([]*APIToken, error) {
var out []*APIToken
if err := c.Do(ctx, "GET", "/tokens", nil, nil, &out); err != nil {
return nil, err
}
return out, nil
}
// DeleteToken revokes a token by ID. Used by `veans login` rotation.
func (c *Client) DeleteToken(ctx context.Context, id int64) error {
return c.Do(ctx, "DELETE", fmt.Sprintf("/tokens/%d", id), nil, nil, nil)
}

View File

@ -0,0 +1,186 @@
// Package client is a hand-rolled JSON client for the Vikunja REST API. It
// mirrors the wire types as plain Go structs so we don't pull XORM into the
// CLI binary.
package client
import "time"
// User mirrors the public fields of pkg/user.User on the wire.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
}
// BotUser is what `PUT /bots` returns.
type BotUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
Name string `json:"name,omitempty"`
Status int `json:"status,omitempty"`
Created time.Time `json:"created,omitempty"`
}
// BotUserCreate is the request body for PUT /bots.
type BotUserCreate struct {
Username string `json:"username"`
Name string `json:"name,omitempty"`
}
// Project mirrors pkg/models/project.Project.
type Project struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Identifier string `json:"identifier,omitempty"`
IsArchived bool `json:"is_archived,omitempty"`
}
// ProjectView is a saved view (Kanban/List/Gantt/Table) on a project.
type ProjectView struct {
ID int64 `json:"id"`
Title string `json:"title"`
ProjectID int64 `json:"project_id"`
ViewKind int `json:"view_kind"`
BucketConfMode int `json:"bucket_configuration_mode,omitempty"`
}
const (
ViewKindList = 0
ViewKindGantt = 1
ViewKindTable = 2
ViewKindKanban = 3
)
// Bucket is a kanban bucket bound to a single project view.
type Bucket struct {
ID int64 `json:"id"`
Title string `json:"title"`
ProjectViewID int64 `json:"project_view_id"`
Limit int64 `json:"limit,omitempty"`
Position float64 `json:"position,omitempty"`
}
// Task mirrors the on-the-wire task representation. Many fields are omitted —
// veans only consumes what its commands print or filter on.
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Done bool `json:"done"`
DoneAt *time.Time `json:"done_at,omitempty"`
Priority int64 `json:"priority,omitempty"`
ProjectID int64 `json:"project_id"`
Index int64 `json:"index,omitempty"`
Identifier string `json:"identifier,omitempty"`
Position float64 `json:"position,omitempty"`
Created time.Time `json:"created,omitempty"`
Updated time.Time `json:"updated,omitempty"`
BucketID int64 `json:"bucket_id,omitempty"`
Assignees []*User `json:"assignees,omitempty"`
Labels []*Label `json:"labels,omitempty"`
StartDate *time.Time `json:"start_date,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
PercentDone float64 `json:"percent_done,omitempty"`
Reactions interface{} `json:"reactions,omitempty"`
}
// TaskComment matches pkg/models/task_comments.TaskComment.
type TaskComment struct {
ID int64 `json:"id"`
Comment string `json:"comment"`
Author *User `json:"author,omitempty"`
Created time.Time `json:"created,omitempty"`
Updated time.Time `json:"updated,omitempty"`
}
// Label is a global (per-user) label.
type Label struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
HexColor string `json:"hex_color,omitempty"`
Created time.Time `json:"created,omitempty"`
Updated time.Time `json:"updated,omitempty"`
}
// LabelTask is the body for `PUT /tasks/{id}/labels`.
type LabelTask struct {
LabelID int64 `json:"label_id"`
}
// TaskRelation is the body for `PUT /tasks/{id}/relations` and the row
// returned. RelationKind is one of: subtask, parenttask, related, duplicates,
// duplicateof, blocking, blocked, precedes, follows, copiedfrom, copiedto.
type TaskRelation struct {
TaskID int64 `json:"task_id,omitempty"`
OtherTaskID int64 `json:"other_task_id"`
RelationKind string `json:"relation_kind"`
}
// TaskAssignee is the body for `PUT /tasks/{id}/assignees`.
type TaskAssignee struct {
UserID int64 `json:"user_id"`
}
// ProjectUser is the body and response for `PUT /projects/{id}/users`.
type ProjectUser struct {
ID int64 `json:"id,omitempty"`
Username string `json:"username"`
Permission int `json:"permission"`
}
// Permission constants for project sharing.
const (
PermissionRead = 0
PermissionReadWrite = 1
PermissionAdmin = 2
)
// APIToken is the request and response shape for `PUT /tokens`. The plaintext
// `Token` field is only populated on creation.
type APIToken struct {
ID int64 `json:"id,omitempty"`
Title string `json:"title"`
Token string `json:"token,omitempty"`
Permissions map[string][]string `json:"permissions"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
OwnerID int64 `json:"owner_id,omitempty"`
Created time.Time `json:"created,omitempty"`
}
// Info is the parsed shape of `GET /info`.
type Info struct {
Version string `json:"version"`
FrontendURL string `json:"frontend_url"`
MOTD string `json:"motd,omitempty"`
LinkSharingEnabled bool `json:"link_sharing_enabled"`
RegistrationEnabled bool `json:"registration_enabled"`
Auth struct {
Local struct {
Enabled bool `json:"enabled"`
} `json:"local"`
OpenIDConnect struct {
Enabled bool `json:"enabled"`
Providers []struct {
Key string `json:"key"`
Name string `json:"name"`
} `json:"providers"`
} `json:"openid_connect"`
} `json:"auth"`
}
// LoginRequest is the body for `POST /login`.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPPasscode string `json:"totp_passcode,omitempty"`
LongToken bool `json:"long_token,omitempty"`
}
// LoginResponse is the JWT bundle.
type LoginResponse struct {
Token string `json:"token"`
}

View File

@ -0,0 +1,45 @@
package client
import (
"context"
"errors"
"net/http"
"code.vikunja.io/veans/internal/output"
)
// CreateBotUser provisions a bot user via PUT /bots. The username must be
// prefixed `bot-` (Vikunja enforces this). The caller becomes the bot's
// owner, which is what allows them to mint API tokens for the bot via
// PUT /tokens with owner_id.
//
// On Vikunja versions that predate the /bots endpoint, the server returns
// 404, which we surface as BOT_USERS_UNAVAILABLE so init can fail fast with
// a clear message.
func (c *Client) CreateBotUser(ctx context.Context, username, name string) (*BotUser, error) {
var out BotUser
err := c.Do(ctx, "PUT", "/bots", nil, &BotUserCreate{Username: username, Name: name}, &out)
if err != nil {
var oe *output.Error
if errors.As(err, &oe) && oe.Code == output.CodeNotFound {
return nil, output.Wrap(output.CodeBotUsersUnavailable, err,
"this Vikunja instance does not expose /bots — upgrade to a newer version")
}
return nil, err
}
return &out, nil
}
// ListBotUsers returns all bot users owned by the authenticated user.
func (c *Client) ListBotUsers(ctx context.Context) ([]*BotUser, error) {
var out []*BotUser
if err := c.Do(ctx, "GET", "/bots", nil, nil, &out); err != nil {
return nil, err
}
return out, nil
}
// statusCheck pulls the HTTP status off an error for callers that need to
// distinguish 404-on-/bots from other failures. Currently unused outside this
// file, but kept for symmetry.
var _ = http.StatusNotFound