package api import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" ) // ContentResponse represents a Confluence page from the REST API. type ContentResponse struct { ID string `json:"id"` Type string `json:"type"` Title string `json:"title"` Version Version `json:"version"` Body Body `json:"body"` } // Version holds page version info. type Version struct { Number int `json:"number"` Message string `json:"message,omitempty"` } // Body holds page body content. type Body struct { Storage StorageBody `json:"storage"` } // StorageBody holds the storage format representation. type StorageBody struct { Value string `json:"value"` Representation string `json:"representation"` } // updateRequest is the JSON payload for updating a page. type updateRequest struct { Version Version `json:"version"` Title string `json:"title"` Type string `json:"type"` Body Body `json:"body"` } // searchResults wraps the /rest/api/content search response. type searchResults struct { Results []ContentResponse `json:"results"` Size int `json:"size"` } // GetContent fetches a page by ID with storage body and version info. func (c *Client) GetContent(pageID string) (*ContentResponse, error) { path := fmt.Sprintf("/rest/api/content/%s?expand=body.storage,version", pageID) req, err := c.newRequest(http.MethodGet, path) if err != nil { return nil, err } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("fetching page %s: %w", pageID, err) } defer resp.Body.Close() if err := checkResponse(resp); err != nil { return nil, err } var content ContentResponse if err := json.NewDecoder(resp.Body).Decode(&content); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return &content, nil } // FindContent searches for a page by space key and title. func (c *Client) FindContent(spaceKey, title string) (*ContentResponse, error) { params := url.Values{ "spaceKey": {spaceKey}, "title": {title}, "expand": {"body.storage,version"}, } path := "/rest/api/content?" + params.Encode() req, err := c.newRequest(http.MethodGet, path) if err != nil { return nil, err } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("searching for page %q in space %q: %w", title, spaceKey, err) } defer resp.Body.Close() if err := checkResponse(resp); err != nil { return nil, err } var results searchResults if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { return nil, fmt.Errorf("decoding search results: %w", err) } if results.Size == 0 { return nil, fmt.Errorf("page %q not found in space %q", title, spaceKey) } return &results.Results[0], nil } // GetPage resolves a PageRef to a full ContentResponse. func (c *Client) GetPage(ref *PageRef) (*ContentResponse, error) { if ref.PageID != "" { return c.GetContent(ref.PageID) } return c.FindContent(ref.SpaceKey, ref.Title) } // UpdateContent updates a page's storage body, incrementing the version. func (c *Client) UpdateContent(pageID string, current *ContentResponse, newBody string, message string) error { payload := updateRequest{ Version: Version{ Number: current.Version.Number + 1, Message: message, }, Title: current.Title, Type: current.Type, Body: Body{ Storage: StorageBody{ Value: newBody, Representation: "storage", }, }, } data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshaling update: %w", err) } path := fmt.Sprintf("/rest/api/content/%s", pageID) req, err := c.newRequest(http.MethodPut, path) if err != nil { return err } req.Body = io.NopCloser(bytes.NewReader(data)) req.ContentLength = int64(len(data)) resp, err := c.HTTPClient.Do(req) if err != nil { return fmt.Errorf("updating page %s: %w", pageID, err) } defer resp.Body.Close() return checkResponse(resp) } // checkResponse returns an error for non-2xx status codes. func checkResponse(resp *http.Response) error { if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } body, _ := io.ReadAll(resp.Body) switch resp.StatusCode { case http.StatusUnauthorized: return fmt.Errorf("authentication failed (401): invalid or expired token") case http.StatusForbidden: return fmt.Errorf("access denied (403): insufficient permissions") case http.StatusNotFound: return fmt.Errorf("page not found (404)") case http.StatusConflict: return fmt.Errorf("version conflict (409): page was modified since last fetch, re-pull and try again") default: return fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(body)) } }