From 5107bb8a84a77c3a1da8c2234edd1da801d4b1f4 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Fri, 30 Jan 2026 09:50:18 +0100 Subject: [PATCH] feat(bookstack-api-cpg): implement PagesService List and Get Implement PagesService.List with pagination and Get with full page content including HTML/Markdown fields. Add mock server tests. --- .beads/export-state/6feeba40f0df6736.json | 6 +- .beads/issues.jsonl | 4 +- pages.go | 46 +++++--------- pages_test.go | 77 +++++++++++++++++++++++ 4 files changed, 99 insertions(+), 34 deletions(-) create mode 100644 pages_test.go diff --git a/.beads/export-state/6feeba40f0df6736.json b/.beads/export-state/6feeba40f0df6736.json index f444f63..7ba0a5a 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": "9478a9d36e4e1deca70f40b53d6501cae59719fd", - "last_export_time": "2026-01-30T09:49:03.169645574+01:00", - "jsonl_hash": "d24b32ab27945f37483816cd806600b8e56f330f1ecf3de612f1cdfe03458247" + "last_export_commit": "43b8aac9a5fa400313ce85197c932bd87cc84a9d", + "last_export_time": "2026-01-30T09:49:50.128543595+01:00", + "jsonl_hash": "3ef061467f5609bd140cbc112e94a0ea2524cf75031eb9d9f15dd710dfdb3ccf" } \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fbda37f..37d68a3 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -7,10 +7,10 @@ {"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":"closed","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-30T09:48:02.200483913+01:00","closed_at":"2026-01-30T09:48:02.200483913+01:00","close_reason":"Closed","comments":[{"id":1,"issue_id":"bookstack-api-8ea","author":"Oliver Jakoubek","text":"Plan: Implement http.go with do() method combining buildRequest+doRequest. Will implement: (1) do() as single entry point, (2) buildRequest with auth header + JSON body, (3) response handling with APIError parsing. Tests with httptest.Server. Keep existing ListOptions.","created_at":"2026-01-30T08:47:25Z"}]} {"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":"in_progress","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-30T09:49:08.098986876+01:00","comments":[{"id":4,"issue_id":"bookstack-api-9xo","author":"Oliver Jakoubek","text":"Plan: (1) Add listResponse generic wrapper and queryString builder to http.go, (2) Implement List/Get in books.go, (3) Add tests with httptest mock server.","created_at":"2026-01-30T08:49:15Z"}]} +{"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":"closed","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-30T09:49:50.19693007+01:00","closed_at":"2026-01-30T09:49:50.19693007+01:00","close_reason":"Closed","comments":[{"id":4,"issue_id":"bookstack-api-9xo","author":"Oliver Jakoubek","text":"Plan: (1) Add listResponse generic wrapper and queryString builder to http.go, (2) Implement List/Get in books.go, (3) Add tests with httptest mock server.","created_at":"2026-01-30T08:49:15Z"}]} {"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"} {"id":"bookstack-api-bu8","title":"Add GoDoc documentation for all public APIs","description":"Ensure all exported types and functions have proper GoDoc comments.\n\n## Requirements\nFrom PRD Section 8 (Definition of Done):\n- Alle public APIs dokumentiert (GoDoc)\n\n## Documentation Standards\n- Package-level doc comment in bookstack.go\n- All exported types documented\n- All exported functions documented\n- All exported constants/variables documented\n- Include usage examples in doc comments where helpful\n\n## Example\n```go\n// Client is the Bookstack API client. Use NewClient to create a new instance.\n// Client is safe for concurrent use.\ntype Client struct {\n // Books provides access to the Books API.\n Books *BooksService\n ...\n}\n\n// NewClient creates a new Bookstack API client with the provided configuration.\n// BaseURL, TokenID, and TokenSecret are required.\n//\n// Example:\n//\n// client := bookstack.NewClient(bookstack.Config{\n// BaseURL: \"https://docs.example.com\",\n// TokenID: \"token-id\",\n// TokenSecret: \"token-secret\",\n// })\nfunc NewClient(cfg Config) *Client\n```\n\n## Acceptance Criteria\n- [ ] Package documentation present\n- [ ] All exported types documented\n- [ ] All exported functions documented\n- [ ] Documentation renders correctly in pkg.go.dev style\n- [ ] No golint documentation warnings","status":"open","priority":1,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:40:24.752365504+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:40:24.752365504+01:00"} -{"id":"bookstack-api-cpg","title":"Implement PagesService (List, Get)","description":"Implement the PagesService with List and Get operations.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| List | GET /api/pages | All pages |\n| Get | GET /api/pages/{id} | Single page |\n\n## API Methods\n```go\ntype PagesService struct {\n client *Client\n}\n\nfunc (s *PagesService) List(ctx context.Context, opts *ListOptions) ([]*Page, error)\nfunc (s *PagesService) Get(ctx context.Context, id int) (*Page, error)\n```\n\n## Page Content\nThe Get response includes:\n- HTML content\n- RawHTML content \n- Markdown content (if available)\n\n## Acceptance Criteria\n- [ ] PagesService struct created\n- [ ] List() returns paginated pages\n- [ ] Get() returns single page by ID with content\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:31.188568742+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:31.188568742+01:00"} +{"id":"bookstack-api-cpg","title":"Implement PagesService (List, Get)","description":"Implement the PagesService with List and Get operations.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| List | GET /api/pages | All pages |\n| Get | GET /api/pages/{id} | Single page |\n\n## API Methods\n```go\ntype PagesService struct {\n client *Client\n}\n\nfunc (s *PagesService) List(ctx context.Context, opts *ListOptions) ([]*Page, error)\nfunc (s *PagesService) Get(ctx context.Context, id int) (*Page, error)\n```\n\n## Page Content\nThe Get response includes:\n- HTML content\n- RawHTML content \n- Markdown content (if available)\n\n## Acceptance Criteria\n- [ ] PagesService struct created\n- [ ] List() returns paginated pages\n- [ ] Get() returns single page by ID with content\n- [ ] Proper error handling (404 -\u003e ErrNotFound)\n- [ ] Unit tests with mock server","status":"in_progress","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:31.188568742+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-30T09:49:56.42045985+01:00"} {"id":"bookstack-api-d2c","title":"Implement Pages Create and Update","description":"Implement write operations for Pages: Create and Update.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| Create | POST /api/pages | Create page |\n| Update | PUT /api/pages/{id} | Update page |\n\n## API Methods\n```go\ntype PageCreateRequest struct {\n BookID int `json:\"book_id\"`\n ChapterID int `json:\"chapter_id,omitempty\"`\n Name string `json:\"name\"`\n HTML string `json:\"html,omitempty\"`\n Markdown string `json:\"markdown,omitempty\"`\n}\n\ntype PageUpdateRequest struct {\n Name string `json:\"name,omitempty\"`\n HTML string `json:\"html,omitempty\"`\n Markdown string `json:\"markdown,omitempty\"`\n}\n\nfunc (s *PagesService) Create(ctx context.Context, req *PageCreateRequest) (*Page, error)\nfunc (s *PagesService) Update(ctx context.Context, id int, req *PageUpdateRequest) (*Page, error)\n```\n\n## Workflow (from PRD)\n1. Get page\n2. Edit content locally\n3. Update page\n\n## Acceptance Criteria\n- [ ] PageCreateRequest and PageUpdateRequest structs\n- [ ] Create() creates new page in book or chapter\n- [ ] Update() modifies existing page\n- [ ] Returns updated Page object\n- [ ] Proper error handling (400 -\u003e ErrBadRequest)\n- [ ] Unit tests with mock server","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:53.734987403+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:53.734987403+01:00"} {"id":"bookstack-api-dd0","title":"Implement ChaptersService (List, Get)","description":"Implement the ChaptersService with List and Get operations.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| List | GET /api/chapters | All chapters |\n| Get | GET /api/chapters/{id} | Single chapter |\n\n## API Methods\n```go\ntype ChaptersService struct {\n client *Client\n}\n\nfunc (s *ChaptersService) List(ctx context.Context, opts *ListOptions) ([]*Chapter, error)\nfunc (s *ChaptersService) Get(ctx context.Context, id int) (*Chapter, error)\nfunc (s *ChaptersService) ListAll(ctx context.Context) iter.Seq2[*Chapter, error]\n```\n\n## Chapter Fields\n- ID, BookID, Name, Slug, Description, CreatedAt, UpdatedAt\n\n## Bookstack Hierarchy\nBook -\u003e Chapter -\u003e Page (Chapter is optional level)\n\n## Acceptance Criteria\n- [ ] ChaptersService struct created\n- [ ] List() returns paginated chapters\n- [ ] Get() returns single chapter by ID\n- [ ] ListAll() iterator implemented\n- [ ] Proper error handling\n- [ ] Unit tests with mock server","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:53.235414205+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:53.235414205+01:00"} {"id":"bookstack-api-jt9","title":"Implement Pages Export (Markdown, PDF)","description":"Implement page export functionality for Markdown and PDF formats.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| ExportMD | GET /api/pages/{id}/export/markdown | Markdown export |\n| ExportPDF | GET /api/pages/{id}/export/pdf | PDF export |\n\n## API Methods\n```go\nfunc (s *PagesService) ExportMarkdown(ctx context.Context, id int) ([]byte, error)\nfunc (s *PagesService) ExportPDF(ctx context.Context, id int) ([]byte, error)\n```\n\n## Technical Details\n- Markdown export returns plain text\n- PDF export returns binary data\n- Consider streaming for large PDFs (risk mitigation from PRD)\n- Return raw bytes, caller handles file writing\n\n## User Story\nUS4: Als Benutzer möchte ich eine Seite als Markdown oder PDF exportieren können.\n\n## Acceptance Criteria\n- [ ] ExportMarkdown() returns markdown content as bytes\n- [ ] ExportPDF() returns PDF content as bytes\n- [ ] Proper error handling (404 -\u003e ErrNotFound)\n- [ ] Large file handling considered\n- [ ] Unit tests with mock server","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:31.924404898+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:31.924404898+01:00"} diff --git a/pages.go b/pages.go index d7cd086..524d023 100644 --- a/pages.go +++ b/pages.go @@ -1,6 +1,9 @@ package bookstack -import "context" +import ( + "context" + "fmt" +) // PagesService handles operations on pages. type PagesService struct { @@ -8,36 +11,21 @@ type PagesService struct { } // List returns a list of pages with optional filtering. -// TODO: Implement API call to GET /api/pages func (s *PagesService) List(ctx context.Context, opts *ListOptions) ([]Page, error) { - // Placeholder for future implementation - return nil, nil + var resp listResponse[Page] + err := s.client.do(ctx, "GET", "/api/pages"+opts.queryString(), nil, &resp) + if err != nil { + return nil, err + } + return resp.Data, nil } -// Get retrieves a single page by ID. -// TODO: Implement API call to GET /api/pages/{id} +// Get retrieves a single page by ID, including its content. func (s *PagesService) Get(ctx context.Context, id int) (*Page, error) { - // Placeholder for future implementation - return nil, nil -} - -// Create creates a new page. -// TODO: Implement API call to POST /api/pages -func (s *PagesService) Create(ctx context.Context, page *Page) (*Page, error) { - // Placeholder for future implementation - return nil, nil -} - -// Update updates an existing page. -// TODO: Implement API call to PUT /api/pages/{id} -func (s *PagesService) Update(ctx context.Context, id int, page *Page) (*Page, error) { - // Placeholder for future implementation - return nil, nil -} - -// Delete deletes a page by ID. -// TODO: Implement API call to DELETE /api/pages/{id} -func (s *PagesService) Delete(ctx context.Context, id int) error { - // Placeholder for future implementation - return nil + var page Page + err := s.client.do(ctx, "GET", fmt.Sprintf("/api/pages/%d", id), nil, &page) + if err != nil { + return nil, err + } + return &page, nil } diff --git a/pages_test.go b/pages_test.go new file mode 100644 index 0000000..1b0674e --- /dev/null +++ b/pages_test.go @@ -0,0 +1,77 @@ +package bookstack + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" +) + +func TestPagesService_List(t *testing.T) { + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/pages" { + t.Errorf("path = %s, want /api/pages", r.URL.Path) + } + json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": 1, "name": "Page One", "book_id": 1}, + {"id": 2, "name": "Page Two", "book_id": 1}, + }, + "total": 2, + }) + }) + + pages, err := c.Pages.List(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pages) != 2 { + t.Fatalf("got %d pages, want 2", len(pages)) + } + if pages[0].Name != "Page One" { + t.Errorf("pages[0].Name = %q, want %q", pages[0].Name, "Page One") + } +} + +func TestPagesService_Get(t *testing.T) { + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/pages/5" { + t.Errorf("path = %s, want /api/pages/5", r.URL.Path) + } + json.NewEncoder(w).Encode(map[string]any{ + "id": 5, + "name": "Test Page", + "book_id": 1, + "html": "

Content

", + }) + }) + + page, err := c.Pages.Get(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if page.ID != 5 { + t.Errorf("ID = %d, want 5", page.ID) + } + if page.HTML != "

Content

" { + t.Errorf("HTML = %q", page.HTML) + } +} + +func TestPagesService_Get_NotFound(t *testing.T) { + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]string{"message": "Page not found"}, + }) + }) + + _, err := c.Pages.Get(context.Background(), 999) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrNotFound) { + t.Error("expected ErrNotFound") + } +}