Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,38 @@ git checkout
The remote URL may identify a page by `pageId`, a display page URL, or a
Confluence space.

## REST API Path

By default, REST requests use the traditional unversioned `/rest/api` root. If
a Confluence installation exposes the API below a different root or requires an
explicit version, set either or both of these variables when cloning:

```sh
CONFLUENCE_API_ROOT=custom/api/root \
CONFLUENCE_API_VERSION=2.0 \
CONFLUENCE_PAT=... \
git clone 'confluence::https://confluence.example.com/pages/viewpage.action?pageId=123456789'
```

This example sends content requests below
`/custom/api/root/2.0/content/...`. `CONFLUENCE_API_VERSION` is omitted by
default, preserving `/rest/api/content/...`. The aliases
`GIT_REMOTE_CONFLUENCE_API_ROOT` and `GIT_REMOTE_CONFLUENCE_API_VERSION` are
also accepted.

For an existing configured remote, use Git configuration instead:

```ini
[remote "origin"]
apiRoot = custom/api/root
apiVersion = 2.0
```

The fallback keys are `confluence.apiRoot`, `confluence.apiVersion`,
`remote.confluence.apiRoot`, and `remote.confluence.apiVersion`. Download and
browser links returned by Confluence remain relative to the site URL; the API
root and version are not added to them.

## Push

After editing and committing page Markdown, push existing page body updates back
Expand Down
2 changes: 1 addition & 1 deletion internal/confluence/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func (c *Client) FetchAttachments(pageID string) ([]Attachment, error) {
values.Set("limit", strconv.Itoa(limit))

var response attachmentListResponse
path := "/rest/api/content/" + url.PathEscape(pageID) + "/child/attachment"
path := c.apiPath("content/" + url.PathEscape(pageID) + "/child/attachment")
if err := c.getJSON(path, values, &response); err != nil {
return nil, err
}
Expand Down
42 changes: 41 additions & 1 deletion internal/confluence/attachments_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package confluence

import "testing"
import (
"net/http"
"net/http/httptest"
"testing"
)

func TestSafeAttachmentName(t *testing.T) {
tests := []struct {
Expand All @@ -21,3 +25,39 @@ func TestSafeAttachmentName(t *testing.T) {
}
}
}

func TestAttachmentsUseConfiguredAPIPathButDownloadDoesNot(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/custom/api/2.0/content/1/child/attachment":
writeJSON(t, w, map[string]any{
"results": []any{map[string]any{
"id": "10", "title": "diagram.png",
"_links": map[string]any{"download": "/download/attachments/1/diagram.png"},
}},
})
case "/download/attachments/1/diagram.png":
_, _ = w.Write([]byte("image data"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()

client := NewClient(server.URL, "secret-token")
client.SetAPIPath("custom/api", "2.0")
attachments, err := client.FetchAttachments("1")
if err != nil {
t.Fatal(err)
}
if len(attachments) != 1 {
t.Fatalf("attachments = %d", len(attachments))
}
data, err := client.DownloadAttachment(attachments[0])
if err != nil {
t.Fatal(err)
}
if string(data) != "image data" {
t.Fatalf("attachment data = %q", data)
}
}
33 changes: 28 additions & 5 deletions internal/confluence/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/hkwi/git-remote-confluence/internal/confluencetypes"
)

const appName = "git-remote-confluence"
const defaultAPIRoot = "rest/api"

var userAgentVersion = "dev"

Expand All @@ -37,6 +39,8 @@ func clientUserAgent(c *Client) string {
type Client struct {
BaseURL string
PAT string
APIRoot string
APIVersion string
HTTPClient *http.Client
UserAgent string
}
Expand Down Expand Up @@ -94,15 +98,34 @@ func NewClient(baseURL, pat string) *Client {
return &Client{
BaseURL: baseURL,
PAT: pat,
APIRoot: defaultAPIRoot,
HTTPClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}

func (c *Client) SetAPIPath(apiRoot, apiVersion string) {
apiRoot = strings.Trim(apiRoot, "/")
if apiRoot == "" {
apiRoot = defaultAPIRoot
}
c.APIRoot = apiRoot
c.APIVersion = strings.Trim(apiVersion, "/")
}

func (c *Client) apiPath(resource string) string {
parts := []string{strings.Trim(c.APIRoot, "/")}
if c.APIVersion != "" {
parts = append(parts, strings.Trim(c.APIVersion, "/"))
}
parts = append(parts, strings.TrimLeft(resource, "/"))
return "/" + strings.Join(parts, "/")
}

func (c *Client) FetchPage(pageID string) (Page, error) {
var page Page
err := c.getJSON("/rest/api/content/"+url.PathEscape(pageID), commonExpand(), &page)
err := c.getJSON(c.apiPath("content/"+url.PathEscape(pageID)), commonExpand(), &page)
if err != nil {
return Page{}, err
}
Expand All @@ -113,7 +136,7 @@ func (c *Client) FetchPage(pageID string) (Page, error) {
}

func (c *Client) FetchChildren(pageID string) ([]Page, error) {
return c.paginated("/rest/api/content/"+url.PathEscape(pageID)+"/child/page", commonExpand())
return c.paginated(c.apiPath("content/"+url.PathEscape(pageID)+"/child/page"), commonExpand())
}

func (c *Client) FetchPageByTitle(spaceKey, title string) (Page, error) {
Expand All @@ -123,7 +146,7 @@ func (c *Client) FetchPageByTitle(spaceKey, title string) (Page, error) {
values.Set("type", "page")
values.Set("status", "current")

pages, err := c.paginated("/rest/api/content", values)
pages, err := c.paginated(c.apiPath("content"), values)
if err != nil {
return Page{}, err
}
Expand All @@ -144,7 +167,7 @@ func (c *Client) FetchSpacePages(spaceKey string) ([]Page, error) {
values.Set("spaceKey", spaceKey)
values.Set("type", "page")
values.Set("status", "current")
return c.paginated("/rest/api/content", values)
return c.paginated(c.apiPath("content"), values)
}

func (c *Client) UpdatePage(update PageUpdate) error {
Expand Down Expand Up @@ -179,7 +202,7 @@ func (c *Client) UpdatePage(update PageUpdate) error {
}

var page Page
return c.putJSON("/rest/api/content/"+url.PathEscape(update.ID), payload, &page)
return c.putJSON(c.apiPath("content/"+url.PathEscape(update.ID)), payload, &page)
}

func (c *Client) paginated(path string, baseValues url.Values) ([]Page, error) {
Expand Down
16 changes: 16 additions & 0 deletions internal/confluence/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ func TestUserAgentUsesConfiguredVersion(t *testing.T) {
}
}

func TestClientUsesConfiguredAPIPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/custom/api/root/2.0/content/1" {
t.Fatalf("path = %s", r.URL.Path)
}
writeJSON(t, w, Page{ID: "1"})
}))
defer server.Close()

client := NewClient(server.URL, "secret-token")
client.SetAPIPath("/custom/api/root/", "/2.0/")
if _, err := client.FetchPage("1"); err != nil {
t.Fatal(err)
}
}

func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
Expand Down
29 changes: 29 additions & 0 deletions internal/confluence/location.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,35 @@ func ResolvePAT(remoteName string) string {
return ""
}

func ResolveAPIPath(remoteName string) (string, string) {
apiRoot := resolveRemoteSetting(remoteName, "apiRoot", "CONFLUENCE_API_ROOT", "GIT_REMOTE_CONFLUENCE_API_ROOT")
if apiRoot == "" {
apiRoot = defaultAPIRoot
}
apiVersion := resolveRemoteSetting(remoteName, "apiVersion", "CONFLUENCE_API_VERSION", "GIT_REMOTE_CONFLUENCE_API_VERSION")
return strings.Trim(apiRoot, "/"), strings.Trim(apiVersion, "/")
}

func resolveRemoteSetting(remoteName, configName string, envNames ...string) string {
for _, name := range envNames {
if value := os.Getenv(name); value != "" {
return value
}
}

var keys []string
if regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(remoteName) {
keys = append(keys, "remote."+remoteName+"."+configName)
}
keys = append(keys, "confluence."+configName, "remote.confluence."+configName)
for _, key := range keys {
if value := gitConfigGet(key); value != "" {
return value
}
}
return ""
}

func stripTransportPrefix(rawURL string) string {
switch {
case strings.HasPrefix(rawURL, "confluence::"):
Expand Down
54 changes: 53 additions & 1 deletion internal/confluence/location_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package confluence

import "testing"
import (
"os/exec"
"testing"
)

func TestParseLocationPageURL(t *testing.T) {
location, err := ParseLocation("confluence::https://cf.example.test/wiki/pages/viewpage.action?pageId=123456789")
Expand Down Expand Up @@ -53,3 +56,52 @@ func TestParseLocationDisplayPageURLPreservesEscapedPlus(t *testing.T) {
t.Fatalf("page title = %q", location.PageTitle)
}
}

func TestResolveAPIPathFromEnvironment(t *testing.T) {
t.Setenv("CONFLUENCE_API_ROOT", "/custom/api/root/")
t.Setenv("CONFLUENCE_API_VERSION", "/2.0/")

apiRoot, apiVersion := ResolveAPIPath("origin")
if apiRoot != "custom/api/root" || apiVersion != "2.0" {
t.Fatalf("API path = %q version %q", apiRoot, apiVersion)
}
}

func TestResolveAPIPathDefaultsToUnversionedRESTAPI(t *testing.T) {
t.Chdir(t.TempDir())
t.Setenv("CONFLUENCE_API_ROOT", "")
t.Setenv("GIT_REMOTE_CONFLUENCE_API_ROOT", "")
t.Setenv("CONFLUENCE_API_VERSION", "")
t.Setenv("GIT_REMOTE_CONFLUENCE_API_VERSION", "")
t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")

apiRoot, apiVersion := ResolveAPIPath("invalid remote name")
if apiRoot != "rest/api" || apiVersion != "" {
t.Fatalf("API path = %q version %q", apiRoot, apiVersion)
}
}

func TestResolveAPIPathFromRemoteConfig(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")

for _, args := range [][]string{
{"init"},
{"config", "remote.origin.apiRoot", "legacy/rest/api"},
{"config", "remote.origin.apiVersion", "1.0"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
}

apiRoot, apiVersion := ResolveAPIPath("origin")
if apiRoot != "legacy/rest/api" || apiVersion != "1.0" {
t.Fatalf("API path = %q version %q", apiRoot, apiVersion)
}
}
5 changes: 4 additions & 1 deletion internal/remotehelper/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ func (h *helper) confluenceClient() (confluence.Location, *confluence.Client, er
return confluence.Location{}, nil, fmt.Errorf("Confluence PAT is required; set CONFLUENCE_PAT or remote.%s.pat", h.remoteName)
}

return location, confluence.NewClient(location.BaseURL, pat), nil
client := confluence.NewClient(location.BaseURL, pat)
apiRoot, apiVersion := confluence.ResolveAPIPath(h.remoteName)
client.SetAPIPath(apiRoot, apiVersion)
return location, client, nil
}

func (h *helper) reportProgress(format string, args ...any) {
Expand Down