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