diff --git a/.beads/export-state/6feeba40f0df6736.json b/.beads/export-state/6feeba40f0df6736.json index 3205f28..9f2ad7d 100644 --- a/.beads/export-state/6feeba40f0df6736.json +++ b/.beads/export-state/6feeba40f0df6736.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/bookstack-api", - "last_export_commit": "886369df6979d5fcb8d411f934e0d9d36acdfeed", - "last_export_time": "2026-01-30T09:39:14.535203508+01:00", + "last_export_commit": "7db143aff871bd1fe99598cea93924a4daab706c", + "last_export_time": "2026-01-30T09:39:25.483411643+01:00", "jsonl_hash": "2df1eba6846c6f411ea12ab203711858ea39583c8168227df4e9c126c47940ed" } \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a9e4ccc..37a8236 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -5,7 +5,7 @@ {"id":"bookstack-api-5gi","title":"Implement Attachments CRUD","description":"Implement CRUD operations for Attachments.\n\n## Requirements\nFrom PRD feature table: Attachments: CRUD (P2, v0.4)\n\n## API Endpoints (typical Bookstack pattern)\n- GET /api/attachments - List attachments\n- GET /api/attachments/{id} - Get attachment\n- POST /api/attachments - Create attachment\n- PUT /api/attachments/{id} - Update attachment\n- DELETE /api/attachments/{id} - Delete attachment\n\n## API Methods\n```go\ntype AttachmentsService struct {\n client *Client\n}\n\ntype Attachment struct {\n ID int `json:\"id\"`\n Name string `json:\"name\"`\n Extension string `json:\"extension\"`\n PageID int `json:\"uploaded_to\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n}\n\nfunc (s *AttachmentsService) List(ctx context.Context, opts *ListOptions) ([]*Attachment, error)\nfunc (s *AttachmentsService) Get(ctx context.Context, id int) (*Attachment, error)\nfunc (s *AttachmentsService) Create(ctx context.Context, req *AttachmentCreateRequest) (*Attachment, error)\nfunc (s *AttachmentsService) Update(ctx context.Context, id int, req *AttachmentUpdateRequest) (*Attachment, error)\nfunc (s *AttachmentsService) Delete(ctx context.Context, id int) error\n```\n\n## Technical Details\n- May need multipart/form-data for file uploads\n- Consider file size limits\n\n## Acceptance Criteria\n- [ ] Attachment type defined\n- [ ] AttachmentsService with all CRUD methods\n- [ ] File upload handling\n- [ ] Unit tests with mock server","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:54.242422591+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:54.242422591+01:00"} {"id":"bookstack-api-7qx","title":"Implement Comments CRUD","description":"Implement CRUD operations for Comments.\n\n## Requirements\nFrom PRD feature table: Comments: CRUD (P3, v0.5)\n\n## API Endpoints (typical Bookstack pattern)\n- GET /api/comments - List comments\n- GET /api/comments/{id} - Get comment\n- POST /api/comments - Create comment\n- PUT /api/comments/{id} - Update comment\n- DELETE /api/comments/{id} - Delete comment\n\n## API Methods\n```go\ntype CommentsService struct {\n client *Client\n}\n\ntype Comment struct {\n ID int `json:\"id\"`\n PageID int `json:\"page_id\"`\n ParentID int `json:\"parent_id,omitempty\"`\n HTML string `json:\"html\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n CreatedBy int `json:\"created_by\"`\n}\n\nfunc (s *CommentsService) List(ctx context.Context, opts *ListOptions) ([]*Comment, error)\nfunc (s *CommentsService) Get(ctx context.Context, id int) (*Comment, error)\nfunc (s *CommentsService) Create(ctx context.Context, req *CommentCreateRequest) (*Comment, error)\nfunc (s *CommentsService) Update(ctx context.Context, id int, req *CommentUpdateRequest) (*Comment, error)\nfunc (s *CommentsService) Delete(ctx context.Context, id int) error\n```\n\n## Technical Details\n- Comments are attached to pages\n- Support nested comments (parent_id)\n\n## Acceptance Criteria\n- [ ] Comment type defined\n- [ ] CommentsService with all CRUD methods\n- [ ] Support for page filtering\n- [ ] Unit tests with mock server","status":"open","priority":3,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:40:23.920608941+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:40:23.920608941+01:00"} {"id":"bookstack-api-8ea","title":"Implement HTTP helper and request building","description":"Create internal HTTP helper for making authenticated API requests.\n\n## Requirements\nImplement the internal `do` method on Client:\n\n```go\nfunc (c *Client) do(ctx context.Context, method, path string, body, result any) error {\n // 1. Build request\n // 2. Set Auth header: Authorization: Token \u003cid\u003e:\u003csecret\u003e\n // 3. Execute request\n // 4. Check response status\n // 5. On error: return APIError\n // 6. On success: unmarshal JSON into result\n}\n```\n\n## Technical Details\n- Support GET, POST, PUT, DELETE methods\n- JSON content type for request/response\n- Context support for cancellation\n- Proper URL joining (BaseURL + path)\n\n## Acceptance Criteria\n- [ ] do() method implemented\n- [ ] Auth header correctly formatted\n- [ ] JSON marshaling/unmarshaling works\n- [ ] Context cancellation supported\n- [ ] Unit tests with httptest.Server","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:06.247263859+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:06.247263859+01:00"} -{"id":"bookstack-api-8op","title":"Implement Client and Config structs","description":"Create the main Client struct and Config for client initialization with token-based authentication.\n\n## Requirements\nFrom PRD Section 5 (API \u0026 Interface-Spezifikation):\n\n```go\ntype Config struct {\n BaseURL string // e.g. \"https://docs.jakoubek.net\"\n TokenID string // API Token ID\n TokenSecret string // API Token Secret\n HTTPClient *http.Client // optional, for tests/mocking\n}\n\ntype Client struct {\n Books *BooksService\n Pages *PagesService\n Chapters *ChaptersService\n Shelves *ShelvesService\n Search *SearchService\n}\n\nfunc NewClient(cfg Config) *Client\n```\n\n## Authentication\nHeader format: `Authorization: Token \u003ctoken_id\u003e:\u003ctoken_secret\u003e`\n\n## Acceptance Criteria\n- [ ] Config struct with BaseURL, TokenID, TokenSecret, optional HTTPClient\n- [ ] Client struct with service fields (initially nil)\n- [ ] NewClient() constructor validates required fields\n- [ ] Default http.Client used when not provided\n- [ ] Unit tests for NewClient()","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:06.037277135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:06.037277135+01:00"} +{"id":"bookstack-api-8op","title":"Implement Client and Config structs","description":"Create the main Client struct and Config for client initialization with token-based authentication.\n\n## Requirements\nFrom PRD Section 5 (API \u0026 Interface-Spezifikation):\n\n```go\ntype Config struct {\n BaseURL string // e.g. \"https://docs.jakoubek.net\"\n TokenID string // API Token ID\n TokenSecret string // API Token Secret\n HTTPClient *http.Client // optional, for tests/mocking\n}\n\ntype Client struct {\n Books *BooksService\n Pages *PagesService\n Chapters *ChaptersService\n Shelves *ShelvesService\n Search *SearchService\n}\n\nfunc NewClient(cfg Config) *Client\n```\n\n## Authentication\nHeader format: `Authorization: Token \u003ctoken_id\u003e:\u003ctoken_secret\u003e`\n\n## Acceptance Criteria\n- [ ] Config struct with BaseURL, TokenID, TokenSecret, optional HTTPClient\n- [ ] Client struct with service fields (initially nil)\n- [ ] NewClient() constructor validates required fields\n- [ ] Default http.Client used when not provided\n- [ ] Unit tests for NewClient()","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:06.037277135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-30T09:45:31.33966902+01:00","closed_at":"2026-01-30T09:45:31.33966902+01:00","close_reason":"Closed"} {"id":"bookstack-api-9at","title":"Implement Pages Delete","description":"Implement delete operation for Pages.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| Delete | DELETE /api/pages/{id} | Delete page |\n\n## API Method\n```go\nfunc (s *PagesService) Delete(ctx context.Context, id int) error\n```\n\n## Technical Details\n- Returns no content on success\n- 404 if page not found\n- May require appropriate permissions\n\n## Acceptance Criteria\n- [ ] Delete() removes page by ID\n- [ ] Returns nil on success\n- [ ] Proper error handling (404 -\u003e ErrNotFound, 403 -\u003e ErrForbidden)\n- [ ] Unit tests with mock server","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:53.980583894+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:53.980583894+01:00"} {"id":"bookstack-api-9xo","title":"Implement BooksService (List, Get)","description":"Implement the BooksService with List and Get operations.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| List | GET /api/books | All books |\n| Get | GET /api/books/{id} | Single book |\n\n## API Methods\n```go\ntype BooksService struct {\n client *Client\n}\n\nfunc (s *BooksService) List(ctx context.Context, opts *ListOptions) ([]*Book, error)\nfunc (s *BooksService) Get(ctx context.Context, id int) (*Book, error)\n```\n\n## ListOptions\nSupport pagination parameters:\n- count (max 500)\n- offset\n- sort (+name, -created_at, etc.)\n- filter[field]\n\n## Acceptance Criteria\n- [ ] BooksService struct created\n- [ ] List() returns paginated books\n- [ ] Get() returns single book by ID\n- [ ] Proper error handling (404 -\u003e ErrNotFound)\n- [ ] Unit tests with mock server","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:30.949469353+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:30.949469353+01:00"} {"id":"bookstack-api-adp","title":"Set up Go module and project structure","description":"Initialize the Go module and create the basic project structure as defined in the PRD.\n\n## Requirements\n- Initialize go.mod with module name\n- Create placeholder files for the flat package structure:\n - bookstack.go (Client, Config, NewClient)\n - types.go (data structures)\n - errors.go (error types)\n - http.go (HTTP helpers)\n- Set up .gitignore for Go projects\n\n## Technical Details\n- Go 1.21+ required\n- Zero external dependencies (standard library only)\n- Module should be publishable as standalone Go module\n\n## Acceptance Criteria\n- [ ] go.mod exists with proper module name\n- [ ] Basic file structure created\n- [ ] `go build ./...` succeeds\n- [ ] .gitignore includes Go-specific patterns","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:05.818311718+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-30T09:38:24.407997076+01:00","closed_at":"2026-01-30T09:38:24.407997076+01:00","close_reason":"Closed"} diff --git a/bookstack.go b/bookstack.go index 42b762a..4e2b85c 100644 --- a/bookstack.go +++ b/bookstack.go @@ -1,7 +1,9 @@ package bookstack import ( + "errors" "net/http" + "strings" "time" ) @@ -37,7 +39,22 @@ type Client struct { } // NewClient creates a new Bookstack API client. -func NewClient(cfg Config) *Client { +// Returns an error if BaseURL, TokenID, or TokenSecret are empty. +func NewClient(cfg Config) (*Client, error) { + var errs []string + if cfg.BaseURL == "" { + errs = append(errs, "BaseURL is required") + } + if cfg.TokenID == "" { + errs = append(errs, "TokenID is required") + } + if cfg.TokenSecret == "" { + errs = append(errs, "TokenSecret is required") + } + if len(errs) > 0 { + return nil, errors.New(strings.Join(errs, "; ")) + } + httpClient := cfg.HTTPClient if httpClient == nil { httpClient = &http.Client{ @@ -46,7 +63,7 @@ func NewClient(cfg Config) *Client { } c := &Client{ - baseURL: cfg.BaseURL, + baseURL: strings.TrimRight(cfg.BaseURL, "/"), tokenID: cfg.TokenID, tokenSecret: cfg.TokenSecret, httpClient: httpClient, @@ -59,5 +76,5 @@ func NewClient(cfg Config) *Client { c.Shelves = &ShelvesService{client: c} c.Search = &SearchService{client: c} - return c + return c, nil } diff --git a/bookstack_test.go b/bookstack_test.go new file mode 100644 index 0000000..38f5077 --- /dev/null +++ b/bookstack_test.go @@ -0,0 +1,94 @@ +package bookstack + +import ( + "net/http" + "testing" +) + +func TestNewClient(t *testing.T) { + t.Run("success with all fields", func(t *testing.T) { + c, err := NewClient(Config{ + BaseURL: "https://docs.example.com", + TokenID: "abc", + TokenSecret: "xyz", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != "https://docs.example.com" { + t.Errorf("baseURL = %q, want %q", c.baseURL, "https://docs.example.com") + } + }) + + t.Run("default HTTPClient", func(t *testing.T) { + c, err := NewClient(Config{ + BaseURL: "https://docs.example.com", + TokenID: "abc", + TokenSecret: "xyz", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.httpClient == nil { + t.Fatal("httpClient should not be nil") + } + }) + + t.Run("custom HTTPClient", func(t *testing.T) { + custom := &http.Client{} + c, err := NewClient(Config{ + BaseURL: "https://docs.example.com", + TokenID: "abc", + TokenSecret: "xyz", + HTTPClient: custom, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.httpClient != custom { + t.Error("expected custom HTTPClient to be used") + } + }) + + t.Run("trailing slash stripped", func(t *testing.T) { + c, err := NewClient(Config{ + BaseURL: "https://docs.example.com/", + TokenID: "abc", + TokenSecret: "xyz", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != "https://docs.example.com" { + t.Errorf("baseURL = %q, want trailing slash stripped", c.baseURL) + } + }) + + t.Run("error on missing BaseURL", func(t *testing.T) { + _, err := NewClient(Config{TokenID: "abc", TokenSecret: "xyz"}) + if err == nil { + t.Fatal("expected error for missing BaseURL") + } + }) + + t.Run("error on missing TokenID", func(t *testing.T) { + _, err := NewClient(Config{BaseURL: "https://x.com", TokenSecret: "xyz"}) + if err == nil { + t.Fatal("expected error for missing TokenID") + } + }) + + t.Run("error on missing TokenSecret", func(t *testing.T) { + _, err := NewClient(Config{BaseURL: "https://x.com", TokenID: "abc"}) + if err == nil { + t.Fatal("expected error for missing TokenSecret") + } + }) + + t.Run("error on all fields missing", func(t *testing.T) { + _, err := NewClient(Config{}) + if err == nil { + t.Fatal("expected error for all missing fields") + } + }) +}