Add validation and error handling to NewClient

- NewClient now returns (*Client, error) instead of *Client
- Validate that BaseURL, TokenID, and TokenSecret are non-empty
- Strip trailing slash from BaseURL
- Add comprehensive unit tests for NewClient
This commit is contained in:
Oliver Jakoubek 2026-01-30 09:45:35 +01:00
commit b015f450c5
4 changed files with 117 additions and 6 deletions

View file

@ -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"
}

View file

@ -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"}

View file

@ -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
}

94
bookstack_test.go Normal file
View file

@ -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")
}
})
}