diff --git a/bitbucket/bitbucket.go b/bitbucket/bitbucket.go index 0f06097..549b9c1 100644 --- a/bitbucket/bitbucket.go +++ b/bitbucket/bitbucket.go @@ -47,6 +47,10 @@ func New(token string, hc *http.Client) forge.Forge { return &bitbucketForge{token: token, httpClient: hc} } +func (f *bitbucketForge) APIBaseURL() string { + return bitbucketAPI +} + type bitbucketRepoService struct { token string httpClient *http.Client diff --git a/forge.go b/forge.go index dbef2d4..6d46242 100644 --- a/forge.go +++ b/forge.go @@ -50,6 +50,12 @@ func (e *HTTPError) Error() string { return fmt.Sprintf("forge: HTTP %d from %s", e.StatusCode, e.URL) } +// APIBaseURLProvider is implemented by forge backends that can expose their +// raw API root URL for arbitrary endpoint requests. +type APIBaseURLProvider interface { + APIBaseURL() string +} + // Forge is the interface each forge backend implements. type Forge interface { Repos() RepoService diff --git a/gitea/gitea.go b/gitea/gitea.go index 62d62d9..5f450a0 100644 --- a/gitea/gitea.go +++ b/gitea/gitea.go @@ -4,6 +4,7 @@ import ( "context" forge "github.com/git-pkgs/forge" "net/http" + "strings" "code.gitea.io/sdk/gitea" ) @@ -34,6 +35,10 @@ func New(baseURL, token string, hc *http.Client) forge.Forge { return &giteaForge{client: c, baseURL: baseURL, token: token, httpClient: hc} } +func (f *giteaForge) APIBaseURL() string { + return strings.TrimRight(f.baseURL, "/") + "/api/v1" +} + type giteaRepoService struct { client *gitea.Client } diff --git a/github/github.go b/github/github.go index bb7299a..1cdabd0 100644 --- a/github/github.go +++ b/github/github.go @@ -4,6 +4,7 @@ import ( "context" forge "github.com/git-pkgs/forge" "net/http" + "strings" "github.com/google/go-github/v82/github" ) @@ -11,7 +12,8 @@ import ( const defaultPageSize = 100 type gitHubForge struct { - client *github.Client + client *github.Client + apiBaseURL string } // New creates a GitHub forge backend for github.com. @@ -20,14 +22,18 @@ func New(token string, hc *http.Client) forge.Forge { if token != "" { c = c.WithAuthToken(token) } - return &gitHubForge{client: c} + return &gitHubForge{client: c, apiBaseURL: "https://api.github.com"} } // NewWithBase creates a GitHub forge backend for a GitHub Enterprise instance. func NewWithBase(baseURL, token string, hc *http.Client) forge.Forge { c := github.NewClient(hc).WithAuthToken(token) c, _ = c.WithEnterpriseURLs(baseURL, baseURL) - return &gitHubForge{client: c} + return &gitHubForge{client: c, apiBaseURL: strings.TrimRight(baseURL, "/") + "/api/v3"} +} + +func (f *gitHubForge) APIBaseURL() string { + return f.apiBaseURL } type gitHubRepoService struct { diff --git a/gitlab/gitlab.go b/gitlab/gitlab.go index 7847d1d..37a6d9f 100644 --- a/gitlab/gitlab.go +++ b/gitlab/gitlab.go @@ -4,24 +4,31 @@ import ( "context" forge "github.com/git-pkgs/forge" "net/http" + "strings" gitlab "gitlab.com/gitlab-org/api/client-go" ) type gitLabForge struct { - client *gitlab.Client + client *gitlab.Client + apiBaseURL string } // New creates a GitLab forge backend. func New(baseURL, token string, hc *http.Client) forge.Forge { + apiBaseURL := strings.TrimRight(baseURL, "/") + "/api/v4" opts := []gitlab.ClientOptionFunc{ - gitlab.WithBaseURL(baseURL + "/api/v4"), + gitlab.WithBaseURL(apiBaseURL), } if hc != nil { opts = append(opts, gitlab.WithHTTPClient(hc)) } c, _ := gitlab.NewClient(token, opts...) - return &gitLabForge{client: c} + return &gitLabForge{client: c, apiBaseURL: apiBaseURL} +} + +func (f *gitLabForge) APIBaseURL() string { + return f.apiBaseURL } type gitLabRepoService struct { diff --git a/internal/cli/api.go b/internal/cli/api.go index bdf0c8e..136eaee 100644 --- a/internal/cli/api.go +++ b/internal/cli/api.go @@ -9,6 +9,7 @@ import ( "os" "strings" + forges "github.com/git-pkgs/forge" "github.com/git-pkgs/forge/internal/resolve" "github.com/spf13/cobra" ) @@ -23,7 +24,7 @@ var apiCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { endpoint := args[0] - _, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } @@ -32,20 +33,8 @@ var apiCmd = &cobra.Command{ endpoint = strings.ReplaceAll(endpoint, "{owner}", owner) endpoint = strings.ReplaceAll(endpoint, "{repo}", repoName) - // Build full URL - var baseURL string - switch { - case strings.Contains(domain, "github"): - baseURL = "https://api." + domain - case strings.Contains(domain, "gitlab"): - baseURL = "https://" + domain + "/api/v4" - case strings.Contains(domain, "bitbucket"): - baseURL = "https://api.bitbucket.org/2.0" - default: - baseURL = "https://" + domain + "/api/v1" - } - - url := baseURL + "/" + strings.TrimLeft(endpoint, "/") + baseURL := apiBaseURL(forge, domain) + url := strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(endpoint, "/") var body io.Reader if len(flagAPIFields) > 0 { @@ -129,6 +118,28 @@ var apiCmd = &cobra.Command{ }, } +func apiBaseURL(forge forges.Forge, domain string) string { + provider, ok := forge.(forges.APIBaseURLProvider) + if ok { + return provider.APIBaseURL() + } + + return legacyAPIBaseURL(domain) +} + +func legacyAPIBaseURL(domain string) string { + switch { + case strings.Contains(domain, "github"): + return "https://api." + domain + case strings.Contains(domain, "gitlab"): + return "https://" + domain + "/api/v4" + case strings.Contains(domain, "bitbucket"): + return "https://api.bitbucket.org/2.0" + default: + return "https://" + domain + "/api/v1" + } +} + var ( flagAPIMethod string flagAPIFields []string diff --git a/internal/cli/api_test.go b/internal/cli/api_test.go new file mode 100644 index 0000000..a82f9f8 --- /dev/null +++ b/internal/cli/api_test.go @@ -0,0 +1,139 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + forges "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/bitbucket" + "github.com/git-pkgs/forge/gitea" + ghforge "github.com/git-pkgs/forge/github" + glforge "github.com/git-pkgs/forge/gitlab" + "github.com/git-pkgs/forge/internal/config" + "github.com/git-pkgs/forge/internal/resolve" +) + +func TestAPIBaseURLFromForge(t *testing.T) { + tests := []struct { + name string + f forges.Forge + want string + }{ + { + name: "github", + f: ghforge.New("", nil), + want: "https://api.github.com", + }, + { + name: "github enterprise", + f: ghforge.NewWithBase("https://github.enterprise.example.org", "", nil), + want: "https://github.enterprise.example.org/api/v3", + }, + { + name: "gitlab", + f: glforge.New("https://mylab.example.org", "", nil), + want: "https://mylab.example.org/api/v4", + }, + { + name: "gitea", + f: gitea.New("https://gitea.example.org", "", nil), + want: "https://gitea.example.org/api/v1", + }, + { + name: "forgejo", + f: gitea.New("https://forgejo.example.org", "", nil), + want: "https://forgejo.example.org/api/v1", + }, + { + name: "bitbucket", + f: bitbucket.New("", nil), + want: "https://api.bitbucket.org/2.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := apiBaseURL(tt.f, "fallback.example.org") + if got != tt.want { + t.Fatalf("APIBaseURL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAPIBaseURLFallsBackToLegacyDomainHeuristics(t *testing.T) { + tests := []struct { + name string + domain string + want string + }{ + { + name: "github", + domain: "github.example.org", + want: "https://api.github.example.org", + }, + { + name: "gitlab", + domain: "gitlab.example.org", + want: "https://gitlab.example.org/api/v4", + }, + { + name: "bitbucket", + domain: "bitbucket.example.org", + want: "https://api.bitbucket.org/2.0", + }, + { + name: "default", + domain: "forge.example.org", + want: "https://forge.example.org/api/v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := apiBaseURL(&mockForge{}, tt.domain) + if got != tt.want { + t.Fatalf("apiBaseURL(..., %q) = %q, want %q", tt.domain, got, tt.want) + } + }) + } +} + +func TestAPIBaseURLUsesLegacyFallbackForUnknownDomainWithoutConfiguredForgeType(t *testing.T) { + got := apiBaseURL(&mockForge{}, "mylab.example.org") + want := "https://mylab.example.org/api/v1" + if got != want { + t.Fatalf("apiBaseURL(..., %q) = %q, want %q", "mylab.example.org", got, want) + } +} + +func TestAPIBaseURLUsesConfiguredForgeTypeForUnknownDomain(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + + xdgConfigHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + + configDir := filepath.Join(xdgConfigHome, "forge") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(configDir, "config") + if err := os.WriteFile(configPath, []byte(`[mylab.example.org] +type = gitlab +`), 0600); err != nil { + t.Fatal(err) + } + + forge, _, _, domain, err := resolve.Repo("mylab.example.org/imt/deployments", "") + if err != nil { + t.Fatal(err) + } + + got := apiBaseURL(forge, domain) + want := "https://mylab.example.org/api/v4" + if got != want { + t.Fatalf("apiBaseURL(..., %q) = %q, want %q", domain, got, want) + } +}