diff --git a/githubapp/caching_client_creator.go b/githubapp/caching_client_creator.go index f0c0697a..c0d4b644 100644 --- a/githubapp/caching_client_creator.go +++ b/githubapp/caching_client_creator.go @@ -31,9 +31,13 @@ const ( // NewDefaultCachingClientCreator returns a ClientCreator using values from the // configuration or other defaults. func NewDefaultCachingClientCreator(c Config, opts ...ClientOption) (ClientCreator, error) { + urls, err := c.GetURLs() + if err != nil { + return nil, err + } delegate := NewClientCreator( - c.V3APIURL, - c.V4APIURL, + urls.APIv3.String(), + urls.APIv4.String(), c.App.IntegrationID, []byte(c.App.PrivateKey), opts..., diff --git a/githubapp/config.go b/githubapp/config.go index 5e5e5eaa..a978e2a4 100644 --- a/githubapp/config.go +++ b/githubapp/config.go @@ -15,11 +15,15 @@ package githubapp import ( + "net/url" "os" "strconv" ) type Config struct { + // BaseURL is a convenience field that sets Web, V3APIURL, and V4APIURL + // automatically via ParseURLs. Explicit URL fields take precedence. + BaseURL string `yaml:"base_url" json:"baseUrl"` WebURL string `yaml:"web_url" json:"webUrl"` V3APIURL string `yaml:"v3_api_url" json:"v3ApiUrl"` V4APIURL string `yaml:"v4_api_url" json:"v4ApiUrl"` @@ -36,10 +40,10 @@ type Config struct { } `yaml:"oauth" json:"oauth"` } -// SetValuesFromEnv sets values in the configuration from coresponding -// environment variables, if they exist. The optional prefix is added to the -// start of the environment variable names. +// SetValuesFromEnv sets configuration values from environment variables. +// The optional prefix is prepended to each variable name. func (c *Config) SetValuesFromEnv(prefix string) { + setStringFromEnv("GITHUB_BASE_URL", prefix, &c.BaseURL) setStringFromEnv("GITHUB_WEB_URL", prefix, &c.WebURL) setStringFromEnv("GITHUB_V3_API_URL", prefix, &c.V3APIURL) setStringFromEnv("GITHUB_V4_API_URL", prefix, &c.V4APIURL) @@ -52,6 +56,59 @@ func (c *Config) SetValuesFromEnv(prefix string) { setStringFromEnv("GITHUB_OAUTH_CLIENT_SECRET", prefix, &c.OAuth.ClientSecret) } +// GetURLs resolves the GitHub endpoint URLs for this configuration. +// Explicit URL fields (WebURL, V3APIURL, V4APIURL) take precedence over +// BaseURL. If none are set, it falls back to ParseURLs("github.com"). +func (c *Config) GetURLs() (*URLs, error) { + // all three explicit URLs are set — use them directly + if c.WebURL != "" && c.V3APIURL != "" && c.V4APIURL != "" { + return parseExplicitURLs(c.WebURL, c.V3APIURL, c.V4APIURL) + } + + // derive from BaseURL, then overlay any explicit overrides + base := c.BaseURL + if base == "" { + base = githubPublicHost + } + urls, err := ParseURLs(base) + if err != nil { + return nil, err + } + + if c.WebURL != "" { + if urls.Web, err = url.Parse(c.WebURL); err != nil { + return nil, err + } + } + if c.V3APIURL != "" { + if urls.APIv3, err = url.Parse(c.V3APIURL); err != nil { + return nil, err + } + } + if c.V4APIURL != "" { + if urls.APIv4, err = url.Parse(c.V4APIURL); err != nil { + return nil, err + } + } + return urls, nil +} + +func parseExplicitURLs(web, v3, v4 string) (*URLs, error) { + wu, err := url.Parse(web) + if err != nil { + return nil, err + } + v3u, err := url.Parse(v3) + if err != nil { + return nil, err + } + v4u, err := url.Parse(v4) + if err != nil { + return nil, err + } + return &URLs{Web: wu, APIv3: v3u, APIv4: v4u}, nil +} + func setStringFromEnv(key, prefix string, value *string) { if v, ok := os.LookupEnv(prefix + key); ok { *value = v diff --git a/githubapp/urls.go b/githubapp/urls.go new file mode 100644 index 00000000..7e8245ed --- /dev/null +++ b/githubapp/urls.go @@ -0,0 +1,87 @@ +// Copyright 2024 Palantir Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package githubapp + +import ( + "fmt" + "net/url" + "strings" +) + +const ( + githubPublicHost = "github.com" + + githubPublicV3URL = "https://api.github.com/" + githubPublicV4URL = "https://api.github.com/graphql" + githubPublicWebURL = "https://github.com" +) + +// URLs holds the resolved endpoint URLs for a GitHub deployment. +type URLs struct { + Web *url.URL + APIv3 *url.URL + APIv4 *url.URL +} + +// ParseURLs resolves all GitHub endpoint URLs from a single base URL. +// For github.com it returns the known public API endpoints; for any other +// host it constructs the standard GitHub Enterprise Server paths. +func ParseURLs(baseURL string) (*URLs, error) { + if baseURL == "" { + return nil, fmt.Errorf("githubapp: base URL must not be empty") + } + + // normalize: add scheme if missing so url.Parse works reliably + if !strings.Contains(baseURL, "://") { + baseURL = "https://" + baseURL + } + + u, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("githubapp: invalid base URL %q: %w", baseURL, err) + } + + host := strings.ToLower(u.Hostname()) + if host == githubPublicHost { + return publicURLs() + } + return enterpriseURLs(u) +} + +func publicURLs() (*URLs, error) { + web, _ := url.Parse(githubPublicWebURL) + v3, _ := url.Parse(githubPublicV3URL) + v4, _ := url.Parse(githubPublicV4URL) + return &URLs{Web: web, APIv3: v3, APIv4: v4}, nil +} + +func enterpriseURLs(base *url.URL) (*URLs, error) { + // strip any path so we build from the root of the host + root := &url.URL{Scheme: base.Scheme, Host: base.Host} + + v3, err := root.Parse("api/v3/") + if err != nil { + return nil, fmt.Errorf("githubapp: could not construct v3 API URL: %w", err) + } + + v4, err := root.Parse("api/graphql") + if err != nil { + return nil, fmt.Errorf("githubapp: could not construct v4 API URL: %w", err) + } + + web := &url.URL{Scheme: root.Scheme, Host: root.Host, Path: "/"} + + return &URLs{Web: web, APIv3: v3, APIv4: v4}, nil +} diff --git a/githubapp/urls_test.go b/githubapp/urls_test.go new file mode 100644 index 00000000..f780727f --- /dev/null +++ b/githubapp/urls_test.go @@ -0,0 +1,152 @@ +// Copyright 2024 Palantir Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package githubapp + +import ( + "testing" +) + +func TestParseURLs(t *testing.T) { + tests := map[string]struct { + input string + wantWeb string + wantV3 string + wantV4 string + wantErr bool + }{ + "publicDomain": { + input: "github.com", + wantWeb: "https://github.com", + wantV3: "https://api.github.com/", + wantV4: "https://api.github.com/graphql", + }, + "publicHTTPS": { + input: "https://github.com", + wantWeb: "https://github.com", + wantV3: "https://api.github.com/", + wantV4: "https://api.github.com/graphql", + }, + "enterprise": { + input: "https://github.example.com", + wantWeb: "https://github.example.com/", + wantV3: "https://github.example.com/api/v3/", + wantV4: "https://github.example.com/api/graphql", + }, + "enterpriseNoScheme": { + input: "github.example.com", + wantWeb: "https://github.example.com/", + wantV3: "https://github.example.com/api/v3/", + wantV4: "https://github.example.com/api/graphql", + }, + "enterpriseWithPath": { + input: "https://github.example.com/some/path", + wantWeb: "https://github.example.com/", + wantV3: "https://github.example.com/api/v3/", + wantV4: "https://github.example.com/api/graphql", + }, + "empty": { + input: "", + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := ParseURLs(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Web.String() != tt.wantWeb { + t.Errorf("Web: got %q, want %q", got.Web, tt.wantWeb) + } + if got.APIv3.String() != tt.wantV3 { + t.Errorf("APIv3: got %q, want %q", got.APIv3, tt.wantV3) + } + if got.APIv4.String() != tt.wantV4 { + t.Errorf("APIv4: got %q, want %q", got.APIv4, tt.wantV4) + } + }) + } +} + +func TestConfigGetURLs(t *testing.T) { + tests := map[string]struct { + config Config + wantV3 string + wantV4 string + wantErr bool + }{ + "baseURLPublic": { + config: Config{BaseURL: "github.com"}, + wantV3: "https://api.github.com/", + wantV4: "https://api.github.com/graphql", + }, + "baseURLEnterprise": { + config: Config{BaseURL: "https://github.example.com"}, + wantV3: "https://github.example.com/api/v3/", + wantV4: "https://github.example.com/api/graphql", + }, + "explicitURLsOverrideBase": { + config: Config{ + BaseURL: "https://github.example.com", + V3APIURL: "https://github.example.com/api/v3/", + V4APIURL: "https://github.example.com/api/graphql", + WebURL: "https://github.example.com", + }, + wantV3: "https://github.example.com/api/v3/", + wantV4: "https://github.example.com/api/graphql", + }, + "noFieldsDefaultsToPublic": { + config: Config{}, + wantV3: "https://api.github.com/", + wantV4: "https://api.github.com/graphql", + }, + "partialOverride": { + config: Config{ + BaseURL: "https://github.example.com", + V3APIURL: "https://custom.example.com/api/v3/", + }, + wantV3: "https://custom.example.com/api/v3/", + wantV4: "https://github.example.com/api/graphql", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := tt.config.GetURLs() + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.APIv3.String() != tt.wantV3 { + t.Errorf("APIv3: got %q, want %q", got.APIv3, tt.wantV3) + } + if got.APIv4.String() != tt.wantV4 { + t.Errorf("APIv4: got %q, want %q", got.APIv4, tt.wantV4) + } + }) + } +}