From 084785aee61ad52a49753a8a6f7299e13906d1df Mon Sep 17 00:00:00 2001 From: Hiroaki KAWAI Date: Mon, 17 Aug 2026 16:33:20 +0900 Subject: [PATCH] Make Confluence API path configurable --- README.md | 32 +++++++++++++++ internal/confluence/attachments.go | 2 +- internal/confluence/attachments_test.go | 42 ++++++++++++++++++- internal/confluence/client.go | 33 ++++++++++++--- internal/confluence/client_test.go | 16 ++++++++ internal/confluence/location.go | 29 +++++++++++++ internal/confluence/location_test.go | 54 ++++++++++++++++++++++++- internal/remotehelper/helper.go | 5 ++- 8 files changed, 204 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 13f4859..a953081 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/confluence/attachments.go b/internal/confluence/attachments.go index b4883a7..7ae1298 100644 --- a/internal/confluence/attachments.go +++ b/internal/confluence/attachments.go @@ -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 } diff --git a/internal/confluence/attachments_test.go b/internal/confluence/attachments_test.go index 7cd953f..792120e 100644 --- a/internal/confluence/attachments_test.go +++ b/internal/confluence/attachments_test.go @@ -1,6 +1,10 @@ package confluence -import "testing" +import ( + "net/http" + "net/http/httptest" + "testing" +) func TestSafeAttachmentName(t *testing.T) { tests := []struct { @@ -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) + } +} diff --git a/internal/confluence/client.go b/internal/confluence/client.go index 6172fb3..7ac5e47 100644 --- a/internal/confluence/client.go +++ b/internal/confluence/client.go @@ -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" @@ -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 } @@ -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 } @@ -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) { @@ -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 } @@ -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 { @@ -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) { diff --git a/internal/confluence/client_test.go b/internal/confluence/client_test.go index 1227b7e..0a89787 100644 --- a/internal/confluence/client_test.go +++ b/internal/confluence/client_test.go @@ -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") diff --git a/internal/confluence/location.go b/internal/confluence/location.go index 1575d45..e187c26 100644 --- a/internal/confluence/location.go +++ b/internal/confluence/location.go @@ -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::"): diff --git a/internal/confluence/location_test.go b/internal/confluence/location_test.go index 309574f..b93260a 100644 --- a/internal/confluence/location_test.go +++ b/internal/confluence/location_test.go @@ -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") @@ -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) + } +} diff --git a/internal/remotehelper/helper.go b/internal/remotehelper/helper.go index 632be62..d694a86 100644 --- a/internal/remotehelper/helper.go +++ b/internal/remotehelper/helper.go @@ -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) {