feat(bookstack-api-8ea): implement HTTP helper and request building
Implement do() method on Client with auth header, JSON marshaling, error parsing into APIError, and context support. Add httptest-based unit tests.
This commit is contained in:
parent
8223a37f53
commit
4875540f21
4 changed files with 233 additions and 13 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"worktree_root": "/home/oli/Dev/bookstack-api",
|
||||
"last_export_commit": "342614b614492172594067045e5f179ddc055249",
|
||||
"last_export_time": "2026-01-30T09:45:35.253718252+01:00",
|
||||
"last_export_commit": "b015f450c5866c8ffaa14b7dfca13e0bc70ca332",
|
||||
"last_export_time": "2026-01-30T09:45:40.578813449+01:00",
|
||||
"jsonl_hash": "ff5c226edc309dac071527616158396e9f145f1b7137097bd4acced43d38fc35"
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
{"id":"bookstack-api-42g","title":"Implement ShelvesService (List, Get)","description":"Implement the ShelvesService with List and Get operations.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| List | GET /api/shelves | All shelves |\n| Get | GET /api/shelves/{id} | Single shelf |\n\n## API Methods\n```go\ntype ShelvesService struct {\n client *Client\n}\n\nfunc (s *ShelvesService) List(ctx context.Context, opts *ListOptions) ([]*Shelf, error)\nfunc (s *ShelvesService) Get(ctx context.Context, id int) (*Shelf, error)\nfunc (s *ShelvesService) ListAll(ctx context.Context) iter.Seq2[*Shelf, error]\n```\n\n## Shelf Fields\n- ID, Name, Slug, Description, CreatedAt, UpdatedAt\n\n## Bookstack Hierarchy\nShelf is the top-level container: Shelf -\u003e Book -\u003e Chapter -\u003e Page\n\n## Acceptance Criteria\n- [ ] ShelvesService struct created\n- [ ] List() returns paginated shelves\n- [ ] Get() returns single shelf 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.490673653+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:53.490673653+01:00"}
|
||||
{"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-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":"in_progress","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:47:17.861500157+01:00","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":"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"}
|
||||
|
|
|
|||
74
http.go
74
http.go
|
|
@ -1,21 +1,77 @@
|
|||
package bookstack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// buildRequest creates an HTTP request with proper authentication headers.
|
||||
// TODO: Implement request building with Authorization header (Token <id>:<secret>)
|
||||
func (c *Client) buildRequest(ctx context.Context, method, path string, body interface{}) (*http.Request, error) {
|
||||
// Placeholder for future implementation
|
||||
return nil, nil
|
||||
// do executes an authenticated API request and unmarshals the response.
|
||||
// method is the HTTP method, path is appended to BaseURL (e.g., "/api/books"),
|
||||
// body is JSON-encoded as the request body (nil for no body),
|
||||
// and result is the target for JSON unmarshaling (nil to discard response body).
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, result any) error {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Token %s:%s", c.tokenID, c.tokenSecret))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("executing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
apiErr := &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
}
|
||||
// Try to parse error details from JSON response
|
||||
var errResp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" {
|
||||
apiErr.Code = errResp.Error.Code
|
||||
apiErr.Message = errResp.Error.Message
|
||||
} else {
|
||||
apiErr.Message = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
if result != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("unmarshaling response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// doRequest executes an HTTP request and handles the response.
|
||||
// TODO: Implement response handling, error parsing, and JSON unmarshaling
|
||||
func (c *Client) doRequest(ctx context.Context, req *http.Request, v interface{}) error {
|
||||
// Placeholder for future implementation
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
164
http_test.go
Normal file
164
http_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package bookstack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testClient(t *testing.T, handler http.HandlerFunc) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
c, err := NewClient(Config{
|
||||
BaseURL: srv.URL,
|
||||
TokenID: "test-id",
|
||||
TokenSecret: "test-secret",
|
||||
HTTPClient: srv.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestDo_AuthHeader(t *testing.T) {
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
got := r.Header.Get("Authorization")
|
||||
want := "Token test-id:test-secret"
|
||||
if got != want {
|
||||
t.Errorf("Authorization = %q, want %q", got, want)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
err := c.do(context.Background(), http.MethodGet, "/api/test", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_GET_JSON(t *testing.T) {
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if r.Header.Get("Accept") != "application/json" {
|
||||
t.Error("missing Accept: application/json")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"name": "Test Book"})
|
||||
})
|
||||
|
||||
var result struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
err := c.do(context.Background(), http.MethodGet, "/api/books/1", nil, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Name != "Test Book" {
|
||||
t.Errorf("Name = %q, want %q", result.Name, "Test Book")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_POST_JSON(t *testing.T) {
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Error("missing Content-Type: application/json")
|
||||
}
|
||||
var body map[string]string
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["name"] != "New Book" {
|
||||
t.Errorf("body name = %q, want %q", body["name"], "New Book")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "New Book"})
|
||||
})
|
||||
|
||||
reqBody := map[string]string{"name": "New Book"}
|
||||
var result struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
err := c.do(context.Background(), http.MethodPost, "/api/books", reqBody, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.ID != 1 {
|
||||
t.Errorf("ID = %d, want 1", result.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_APIError(t *testing.T) {
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]string{
|
||||
"code": "not_found",
|
||||
"message": "Book not found",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
err := c.do(context.Background(), http.MethodGet, "/api/books/999", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T", err)
|
||||
}
|
||||
if apiErr.StatusCode != 404 {
|
||||
t.Errorf("StatusCode = %d, want 404", apiErr.StatusCode)
|
||||
}
|
||||
if apiErr.Code != "not_found" {
|
||||
t.Errorf("Code = %q, want %q", apiErr.Code, "not_found")
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Error("expected errors.Is(err, ErrNotFound) to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_ContextCancellation(t *testing.T) {
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := c.do(ctx, http.MethodGet, "/api/test", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_NonJSONError(t *testing.T) {
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("internal error"))
|
||||
})
|
||||
|
||||
err := c.do(context.Background(), http.MethodGet, "/api/test", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T", err)
|
||||
}
|
||||
if apiErr.Message != "Internal Server Error" {
|
||||
t.Errorf("Message = %q, want fallback status text", apiErr.Message)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue