Skip to content

Commit 355ddfc

Browse files
authored
fix forge api base url construction (#141)
* api: extract API base URL resolution Add unit tests covering known domains, explicit forge types, configured forge types, and unknown-domain fallback behavior. * api: resolve base URL from forge type * api: resolve base URL from forge backend Add an optional APIBaseURLProvider interface and implement it for the built-in forge backends. Use the resolved forge in `forge api` to derive the API root, while preserving the legacy domain heuristic as a fallback for forge implementations that do not expose an API base URL. Add tests for backend-provided API URLs, legacy fallback behavior, and self-hosted GitLab domains configured with `type = gitlab`.
1 parent 586640b commit 355ddfc

7 files changed

Lines changed: 199 additions & 21 deletions

File tree

bitbucket/bitbucket.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ func New(token string, hc *http.Client) forge.Forge {
4747
return &bitbucketForge{token: token, httpClient: hc}
4848
}
4949

50+
func (f *bitbucketForge) APIBaseURL() string {
51+
return bitbucketAPI
52+
}
53+
5054
type bitbucketRepoService struct {
5155
token string
5256
httpClient *http.Client

forge.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ func (e *HTTPError) Error() string {
5050
return fmt.Sprintf("forge: HTTP %d from %s", e.StatusCode, e.URL)
5151
}
5252

53+
// APIBaseURLProvider is implemented by forge backends that can expose their
54+
// raw API root URL for arbitrary endpoint requests.
55+
type APIBaseURLProvider interface {
56+
APIBaseURL() string
57+
}
58+
5359
// Forge is the interface each forge backend implements.
5460
type Forge interface {
5561
Repos() RepoService

gitea/gitea.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
forge "github.com/git-pkgs/forge"
66
"net/http"
7+
"strings"
78

89
"code.gitea.io/sdk/gitea"
910
)
@@ -34,6 +35,10 @@ func New(baseURL, token string, hc *http.Client) forge.Forge {
3435
return &giteaForge{client: c, baseURL: baseURL, token: token, httpClient: hc}
3536
}
3637

38+
func (f *giteaForge) APIBaseURL() string {
39+
return strings.TrimRight(f.baseURL, "/") + "/api/v1"
40+
}
41+
3742
type giteaRepoService struct {
3843
client *gitea.Client
3944
}

github/github.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import (
44
"context"
55
forge "github.com/git-pkgs/forge"
66
"net/http"
7+
"strings"
78

89
"github.com/google/go-github/v82/github"
910
)
1011

1112
const defaultPageSize = 100
1213

1314
type gitHubForge struct {
14-
client *github.Client
15+
client *github.Client
16+
apiBaseURL string
1517
}
1618

1719
// New creates a GitHub forge backend for github.com.
@@ -20,14 +22,18 @@ func New(token string, hc *http.Client) forge.Forge {
2022
if token != "" {
2123
c = c.WithAuthToken(token)
2224
}
23-
return &gitHubForge{client: c}
25+
return &gitHubForge{client: c, apiBaseURL: "https://api.github.com"}
2426
}
2527

2628
// NewWithBase creates a GitHub forge backend for a GitHub Enterprise instance.
2729
func NewWithBase(baseURL, token string, hc *http.Client) forge.Forge {
2830
c := github.NewClient(hc).WithAuthToken(token)
2931
c, _ = c.WithEnterpriseURLs(baseURL, baseURL)
30-
return &gitHubForge{client: c}
32+
return &gitHubForge{client: c, apiBaseURL: strings.TrimRight(baseURL, "/") + "/api/v3"}
33+
}
34+
35+
func (f *gitHubForge) APIBaseURL() string {
36+
return f.apiBaseURL
3137
}
3238

3339
type gitHubRepoService struct {

gitlab/gitlab.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,31 @@ import (
44
"context"
55
forge "github.com/git-pkgs/forge"
66
"net/http"
7+
"strings"
78

89
gitlab "gitlab.com/gitlab-org/api/client-go"
910
)
1011

1112
type gitLabForge struct {
12-
client *gitlab.Client
13+
client *gitlab.Client
14+
apiBaseURL string
1315
}
1416

1517
// New creates a GitLab forge backend.
1618
func New(baseURL, token string, hc *http.Client) forge.Forge {
19+
apiBaseURL := strings.TrimRight(baseURL, "/") + "/api/v4"
1720
opts := []gitlab.ClientOptionFunc{
18-
gitlab.WithBaseURL(baseURL + "/api/v4"),
21+
gitlab.WithBaseURL(apiBaseURL),
1922
}
2023
if hc != nil {
2124
opts = append(opts, gitlab.WithHTTPClient(hc))
2225
}
2326
c, _ := gitlab.NewClient(token, opts...)
24-
return &gitLabForge{client: c}
27+
return &gitLabForge{client: c, apiBaseURL: apiBaseURL}
28+
}
29+
30+
func (f *gitLabForge) APIBaseURL() string {
31+
return f.apiBaseURL
2532
}
2633

2734
type gitLabRepoService struct {

internal/cli/api.go

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"strings"
1111

12+
forges "github.com/git-pkgs/forge"
1213
"github.com/git-pkgs/forge/internal/resolve"
1314
"github.com/spf13/cobra"
1415
)
@@ -23,7 +24,7 @@ var apiCmd = &cobra.Command{
2324
RunE: func(cmd *cobra.Command, args []string) error {
2425
endpoint := args[0]
2526

26-
_, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType)
27+
forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType)
2728
if err != nil {
2829
return err
2930
}
@@ -32,20 +33,8 @@ var apiCmd = &cobra.Command{
3233
endpoint = strings.ReplaceAll(endpoint, "{owner}", owner)
3334
endpoint = strings.ReplaceAll(endpoint, "{repo}", repoName)
3435

35-
// Build full URL
36-
var baseURL string
37-
switch {
38-
case strings.Contains(domain, "github"):
39-
baseURL = "https://api." + domain
40-
case strings.Contains(domain, "gitlab"):
41-
baseURL = "https://" + domain + "/api/v4"
42-
case strings.Contains(domain, "bitbucket"):
43-
baseURL = "https://api.bitbucket.org/2.0"
44-
default:
45-
baseURL = "https://" + domain + "/api/v1"
46-
}
47-
48-
url := baseURL + "/" + strings.TrimLeft(endpoint, "/")
36+
baseURL := apiBaseURL(forge, domain)
37+
url := strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(endpoint, "/")
4938

5039
var body io.Reader
5140
if len(flagAPIFields) > 0 {
@@ -129,6 +118,28 @@ var apiCmd = &cobra.Command{
129118
},
130119
}
131120

121+
func apiBaseURL(forge forges.Forge, domain string) string {
122+
provider, ok := forge.(forges.APIBaseURLProvider)
123+
if ok {
124+
return provider.APIBaseURL()
125+
}
126+
127+
return legacyAPIBaseURL(domain)
128+
}
129+
130+
func legacyAPIBaseURL(domain string) string {
131+
switch {
132+
case strings.Contains(domain, "github"):
133+
return "https://api." + domain
134+
case strings.Contains(domain, "gitlab"):
135+
return "https://" + domain + "/api/v4"
136+
case strings.Contains(domain, "bitbucket"):
137+
return "https://api.bitbucket.org/2.0"
138+
default:
139+
return "https://" + domain + "/api/v1"
140+
}
141+
}
142+
132143
var (
133144
flagAPIMethod string
134145
flagAPIFields []string

internal/cli/api_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
forges "github.com/git-pkgs/forge"
9+
"github.com/git-pkgs/forge/bitbucket"
10+
"github.com/git-pkgs/forge/gitea"
11+
ghforge "github.com/git-pkgs/forge/github"
12+
glforge "github.com/git-pkgs/forge/gitlab"
13+
"github.com/git-pkgs/forge/internal/config"
14+
"github.com/git-pkgs/forge/internal/resolve"
15+
)
16+
17+
func TestAPIBaseURLFromForge(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
f forges.Forge
21+
want string
22+
}{
23+
{
24+
name: "github",
25+
f: ghforge.New("", nil),
26+
want: "https://api.github.com",
27+
},
28+
{
29+
name: "github enterprise",
30+
f: ghforge.NewWithBase("https://github.enterprise.example.org", "", nil),
31+
want: "https://github.enterprise.example.org/api/v3",
32+
},
33+
{
34+
name: "gitlab",
35+
f: glforge.New("https://mylab.example.org", "", nil),
36+
want: "https://mylab.example.org/api/v4",
37+
},
38+
{
39+
name: "gitea",
40+
f: gitea.New("https://gitea.example.org", "", nil),
41+
want: "https://gitea.example.org/api/v1",
42+
},
43+
{
44+
name: "forgejo",
45+
f: gitea.New("https://forgejo.example.org", "", nil),
46+
want: "https://forgejo.example.org/api/v1",
47+
},
48+
{
49+
name: "bitbucket",
50+
f: bitbucket.New("", nil),
51+
want: "https://api.bitbucket.org/2.0",
52+
},
53+
}
54+
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
got := apiBaseURL(tt.f, "fallback.example.org")
58+
if got != tt.want {
59+
t.Fatalf("APIBaseURL() = %q, want %q", got, tt.want)
60+
}
61+
})
62+
}
63+
}
64+
65+
func TestAPIBaseURLFallsBackToLegacyDomainHeuristics(t *testing.T) {
66+
tests := []struct {
67+
name string
68+
domain string
69+
want string
70+
}{
71+
{
72+
name: "github",
73+
domain: "github.example.org",
74+
want: "https://api.github.example.org",
75+
},
76+
{
77+
name: "gitlab",
78+
domain: "gitlab.example.org",
79+
want: "https://gitlab.example.org/api/v4",
80+
},
81+
{
82+
name: "bitbucket",
83+
domain: "bitbucket.example.org",
84+
want: "https://api.bitbucket.org/2.0",
85+
},
86+
{
87+
name: "default",
88+
domain: "forge.example.org",
89+
want: "https://forge.example.org/api/v1",
90+
},
91+
}
92+
93+
for _, tt := range tests {
94+
t.Run(tt.name, func(t *testing.T) {
95+
got := apiBaseURL(&mockForge{}, tt.domain)
96+
if got != tt.want {
97+
t.Fatalf("apiBaseURL(..., %q) = %q, want %q", tt.domain, got, tt.want)
98+
}
99+
})
100+
}
101+
}
102+
103+
func TestAPIBaseURLUsesLegacyFallbackForUnknownDomainWithoutConfiguredForgeType(t *testing.T) {
104+
got := apiBaseURL(&mockForge{}, "mylab.example.org")
105+
want := "https://mylab.example.org/api/v1"
106+
if got != want {
107+
t.Fatalf("apiBaseURL(..., %q) = %q, want %q", "mylab.example.org", got, want)
108+
}
109+
}
110+
111+
func TestAPIBaseURLUsesConfiguredForgeTypeForUnknownDomain(t *testing.T) {
112+
config.ResetCache()
113+
defer config.ResetCache()
114+
115+
xdgConfigHome := t.TempDir()
116+
t.Setenv("XDG_CONFIG_HOME", xdgConfigHome)
117+
118+
configDir := filepath.Join(xdgConfigHome, "forge")
119+
if err := os.MkdirAll(configDir, 0700); err != nil {
120+
t.Fatal(err)
121+
}
122+
configPath := filepath.Join(configDir, "config")
123+
if err := os.WriteFile(configPath, []byte(`[mylab.example.org]
124+
type = gitlab
125+
`), 0600); err != nil {
126+
t.Fatal(err)
127+
}
128+
129+
forge, _, _, domain, err := resolve.Repo("mylab.example.org/imt/deployments", "")
130+
if err != nil {
131+
t.Fatal(err)
132+
}
133+
134+
got := apiBaseURL(forge, domain)
135+
want := "https://mylab.example.org/api/v4"
136+
if got != want {
137+
t.Fatalf("apiBaseURL(..., %q) = %q, want %q", domain, got, want)
138+
}
139+
}

0 commit comments

Comments
 (0)