diff --git a/README.md b/README.md index 0d54aae..f934dbf 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ If newer version than itself is detected, it downloads released binary from GitH - Update the binary with rollback support on failure - Tested on Linux, macOS and Windows (using Travis CI and AppVeyor) - Many archive and compression formats are supported (zip, tar, gzip, xzip) +- Support private repositories +- Support [GitHub Enterprise][] And small wrapper CLIs are provided: @@ -81,6 +83,9 @@ It provides `selfupdate` package. - `selfupdate.UpdateCommand()`: Detect the latest version of given repository and update given command. - `selfupdate.DetectLatest()`: Detect the latest version of given repository. - `selfupdate.UpdateTo()`: Update given command to the binary hosted on given URL. +- `selfupdate.Updater`: Context manager of self-upadte process. If you want to customize some behavior + of self-update (e.g. specify API token, use GitHub Enterprise, ...), please make an instance of + `Updater` and use its methods. Following is the easiest way to use this package. @@ -154,8 +159,55 @@ func confirmAndSelfUpdate() { } ``` +If GitHub API token is set to `[token]` section in `gitconfig` or `$GITHUB_TOKEN` environment variable, +this library will use it to call GitHub REST API. It's useful when reaching rate limits or when using +this library with private repositories. + Please see [the documentation page][GoDoc] for more detail. +This library should work with [GitHub Enterprise][]. To configure API base URL, please setup `Updater` +instance and use its method instead (Actually all functions above are just a shortcuts of methods of +`Updater` instance). + +Following is an example of usage with GitHub Enterprise. + +```go +import ( + "log" + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" +) + +const version = "1.2.3" + +func doSelfUpdate(token string) { + v := semver.MustParse(version) + up, err := selfupdate.NewUpdater(selfupdate.Config{ + APIToken: token, + EnterpriseBaseURL: "https://github.your.company.com/api/v3", + }) + latest, err := up.UpdateSelf(v, "myname/myrepo") + if err != nil { + log.Println("Binary update failed:", err) + return + } + if latest.Version.Equals(v) { + // latest version is the same as current version. It means current binary is up to date. + log.Println("Current binary is the latest version", version) + } else { + log.Println("Successfully updated to version", latest.Version) + log.Println("Release note:\n", latest.ReleaseNotes) + } +} +``` + +If `APIToken` field is not given, it tries to retrieve API token from `[token]` section of `.gitconfig` +or `$GITHUB_TOKEN` environment variable. If no token is found, it raises an error because GitHub Enterprise +API does not work without authentication. + +If your GitHub Enterprise instance's upload URL is different from the base URL, please also set `EnterpriseUploadURL` +field + ### Naming Rules of Released Binaries go-github-selfupdate assumes that released binaries are put for each combination of platforms and archs. @@ -279,3 +331,4 @@ Distributed under the [MIT License](LICENSE) [AppVeyor]: https://ci.appveyor.com/project/rhysd/go-github-selfupdate/branch/master [Codecov Status]: https://codecov.io/gh/rhysd/go-github-selfupdate/branch/master/graph/badge.svg [Codecov]: https://codecov.io/gh/rhysd/go-github-selfupdate +[GitHub Enterprise]: https://enterprise.github.com/home diff --git a/selfupdate/detect.go b/selfupdate/detect.go index 3bbaf45..ae04fc2 100644 --- a/selfupdate/detect.go +++ b/selfupdate/detect.go @@ -1,28 +1,17 @@ package selfupdate import ( - "context" "fmt" - "net/http" - "os" "regexp" "runtime" "strings" "github.com/blang/semver" "github.com/google/go-github/github" - gitconfig "github.com/tcnksm/go-gitconfig" - "golang.org/x/oauth2" ) var reVersion = regexp.MustCompile(`\d+\.\d+\.\d+`) -// ReleaseDetector is responsible for detecting the latest release using GitHub Releases API. -type ReleaseDetector struct { - api *github.Client - apiCtx context.Context -} - func findSuitableReleaseAndAsset(rels []*github.RepositoryRelease) (*github.RepositoryRelease, *github.ReleaseAsset, bool) { // Generate candidates cs := make([]string, 0, 8) @@ -64,33 +53,15 @@ func findSuitableReleaseAndAsset(rels []*github.RepositoryRelease) (*github.Repo return nil, nil, false } -// NewDetector crates a new detector instance. It initializes GitHub API client. -func NewDetector() *ReleaseDetector { - token := os.Getenv("GITHUB_TOKEN") - if token == "" { - token, _ = gitconfig.GithubToken() - } - ctx := context.Background() - - var auth *http.Client - if token != "" { - src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) - auth = oauth2.NewClient(ctx, src) - } - - client := github.NewClient(auth) - return &ReleaseDetector{client, ctx} -} - // DetectLatest tries to get the latest version of the repository on GitHub. 'slug' means 'owner/name' formatted string. -func (d *ReleaseDetector) DetectLatest(slug string) (release *Release, found bool, err error) { +func (up *Updater) DetectLatest(slug string) (release *Release, found bool, err error) { repo := strings.Split(slug, "/") if len(repo) != 2 || repo[0] == "" || repo[1] == "" { err = fmt.Errorf("Invalid slug format. It should be 'owner/name': %s", slug) return } - rels, res, err := d.api.Repositories.ListReleases(d.apiCtx, repo[0], repo[1], nil) + rels, res, err := up.api.Repositories.ListReleases(up.apiCtx, repo[0], repo[1], nil) if err != nil { log.Println("API returned an error response:", err) if res != nil && res.StatusCode == 404 { @@ -121,10 +92,13 @@ func (d *ReleaseDetector) DetectLatest(slug string) (release *Release, found boo release = &Release{ AssetURL: url, AssetByteSize: asset.GetSize(), + AssetID: asset.GetID(), URL: rel.GetHTMLURL(), ReleaseNotes: rel.GetBody(), Name: rel.GetName(), PublishedAt: &publishedAt, + RepoOwner: repo[0], + RepoName: repo[1], } release.Version, err = semver.Make(tag) @@ -133,5 +107,5 @@ func (d *ReleaseDetector) DetectLatest(slug string) (release *Release, found boo // DetectLatest detects the latest release of the slug (owner/repo). func DetectLatest(slug string) (*Release, bool, error) { - return NewDetector().DetectLatest(slug) + return DefaultUpdater().DetectLatest(slug) } diff --git a/selfupdate/detect_test.go b/selfupdate/detect_test.go index 5a14e4a..2bce039 100644 --- a/selfupdate/detect_test.go +++ b/selfupdate/detect_test.go @@ -8,14 +8,6 @@ import ( "testing" ) -func TestGitHubTokenEnv(t *testing.T) { - token := os.Getenv("GITHUB_TOKEN") - if token == "" { - t.Skip("because $GITHUB_TOKEN is not set") - } - _ = NewDetector() -} - func TestDetectReleaseWithVersionPrefix(t *testing.T) { r, ok, err := DetectLatest("rhysd/github-clone-all") if err != nil { @@ -45,9 +37,18 @@ func TestDetectReleaseWithVersionPrefix(t *testing.T) { if r.AssetByteSize == 0 { t.Error("Asset's size is unexpectedly zero") } + if r.AssetID == 0 { + t.Error("Asset's ID is unexpectedly zero") + } if r.PublishedAt.IsZero() { t.Error("Release time is unexpectedly zero") } + if r.RepoOwner != "rhysd" { + t.Error("Repo owner is not correct:", r.RepoOwner) + } + if r.RepoName != "github-clone-all" { + t.Error("Repo name was not properly detectd:", r.RepoName) + } } func TestDetectReleasesForVariousArchives(t *testing.T) { @@ -91,9 +92,18 @@ func TestDetectReleasesForVariousArchives(t *testing.T) { if r.AssetByteSize == 0 { t.Error("Asset's size is unexpectedly zero") } + if r.AssetID == 0 { + t.Error("Asset's ID is unexpectedly zero") + } if r.PublishedAt.IsZero() { t.Error("Release time is unexpectedly zero") } + if r.RepoOwner != "rhysd-test" { + t.Error("Repo owner should be rhysd-test:", r.RepoOwner) + } + if !strings.HasPrefix(r.RepoName, "test-release-") { + t.Error("Repo name was not properly detectd:", r.RepoName) + } }) } } @@ -119,7 +129,7 @@ func TestDetectNoRelease(t *testing.T) { } func TestInvalidSlug(t *testing.T) { - d := NewDetector() + up := DefaultUpdater() for _, slug := range []string{ "foo", @@ -128,7 +138,7 @@ func TestInvalidSlug(t *testing.T) { "/bar", "foo/bar/piyo", } { - _, _, err := d.DetectLatest(slug) + _, _, err := up.DetectLatest(slug) if err == nil { t.Error(slug, "should be invalid slug") } @@ -139,8 +149,7 @@ func TestInvalidSlug(t *testing.T) { } func TestNonExistingRepo(t *testing.T) { - d := NewDetector() - v, ok, err := d.DetectLatest("rhysd/non-existing-repo") + v, ok, err := DetectLatest("rhysd/non-existing-repo") if err != nil { t.Fatal("Non-existing repo should not cause an error:", v) } @@ -150,8 +159,7 @@ func TestNonExistingRepo(t *testing.T) { } func TestNoReleaseFound(t *testing.T) { - d := NewDetector() - _, ok, err := d.DetectLatest("rhysd/misc") + _, ok, err := DetectLatest("rhysd/misc") if err != nil { t.Fatal("Repo having no release should not cause an error:", err) } @@ -159,3 +167,48 @@ func TestNoReleaseFound(t *testing.T) { t.Fatal("Repo having no release should not be found") } } + +func TestDetectFromBrokenGitHubEnterpriseURL(t *testing.T) { + up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: "https://example.com"}) + if err != nil { + t.Fatal(err) + } + _, ok, _ := up.DetectLatest("foo/bar") + if ok { + t.Fatal("Invalid GitHub Enterprise base URL should raise an error") + } +} + +func TestDetectFromGitHubEnterpriseRepo(t *testing.T) { + token := os.Getenv("GITHUB_ENTERPRISE_TOKEN") + base := os.Getenv("GITHUB_ENTERPRISE_BASE_URL") + repo := os.Getenv("GITHUB_ENTERPRISE_REPO") + if token == "" { + t.Skip("because token for GHE is not found") + } + if base == "" { + t.Skip("because base URL for GHE is not found") + } + if repo == "" { + t.Skip("because repo slug for GHE is not found") + } + + up, err := NewUpdater(Config{APIToken: token, EnterpriseBaseURL: base}) + if err != nil { + t.Fatal(err) + } + + r, ok, err := up.DetectLatest(repo) + if err != nil { + t.Fatal("Fetch failed:", err) + } + if !ok { + t.Fatal(repo, "not found") + } + if r == nil { + t.Fatal("Release not detected") + } + if !r.Version.Equals(semver.MustParse("1.2.3")) { + t.Error("") + } +} diff --git a/selfupdate/release.go b/selfupdate/release.go index b8bcdff..7515250 100644 --- a/selfupdate/release.go +++ b/selfupdate/release.go @@ -13,6 +13,8 @@ type Release struct { AssetURL string // AssetSize represents the size of asset in bytes AssetByteSize int + // AssetID is the ID of the asset on GitHub + AssetID int // URL is a URL to release page for browsing URL string // ReleaseNotes is a release notes of the release @@ -21,4 +23,8 @@ type Release struct { Name string // PublishedAt is the time when the release was published PublishedAt *time.Time + // RepoOwner is the owner of the repository of the release + RepoOwner string + // RepoName is the name of the repository of the release + RepoName string } diff --git a/selfupdate/update.go b/selfupdate/update.go index 29f9eff..844b830 100644 --- a/selfupdate/update.go +++ b/selfupdate/update.go @@ -2,6 +2,7 @@ package selfupdate import ( "fmt" + "io" "net/http" "os" "path/filepath" @@ -12,20 +13,11 @@ import ( "github.com/inconshreveable/go-update" ) -// UpdateTo download an executable from assetURL and replace the current binary with the downloaded one. cmdPath is a file path to command executable. -func UpdateTo(assetURL, cmdPath string) error { - res, err := http.Get(assetURL) - if err != nil { - return fmt.Errorf("Failed to download a release file from %s: %s", assetURL, err) - } +func uncompressAndUpdate(src io.ReadCloser, assetURL, cmdPath string) error { + defer src.Close() - if res.StatusCode != 200 { - return fmt.Errorf("Failed to download a release file from %s", assetURL) - } - - defer res.Body.Close() _, cmd := filepath.Split(cmdPath) - asset, err := UncompressCommand(res.Body, assetURL, cmd) + asset, err := UncompressCommand(src, assetURL, cmd) if err != nil { return err } @@ -36,9 +28,51 @@ func UpdateTo(assetURL, cmdPath string) error { }) } +func (up *Updater) downloadDirectlyFromURL(assetURL string) (io.ReadCloser, error) { + req, err := http.NewRequest("GET", assetURL, nil) + if err != nil { + return nil, fmt.Errorf("Failed to create HTTP request to %s: %s", assetURL, err) + } + + req.Header.Add("Accept", "application/octet-stream") + req = req.WithContext(up.apiCtx) + + // OAuth HTTP client is not available to download blob from URL when the URL is a redirect URL + // returned from GitHub Releases API (response status 400). + // Use default HTTP client instead. + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("Failed to download a release file from %s: %s", assetURL, err) + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("Failed to download a release file from %s: Not successful status %d", assetURL, res.StatusCode) + } + + return res.Body, nil +} + +// UpdateTo downloads an executable from GitHub Releases API and replace current binary with the downloaded one. +// It downloads a release asset via GitHub Releases API so this function is available for update releases on private repository. +// If a redirect occurs, it fallbacks into directly downloading from the redirect URL. +func (up *Updater) UpdateTo(rel *Release, cmdPath string) error { + src, redirectURL, err := up.api.Repositories.DownloadReleaseAsset(up.apiCtx, rel.RepoOwner, rel.RepoName, rel.AssetID) + if err != nil { + return fmt.Errorf("Failed to call GitHub Releases API for getting an asset(ID: %d) for repository '%s/%s': %s", rel.AssetID, rel.RepoOwner, rel.RepoName, err) + } + if redirectURL != "" { + log.Println("Redirect URL was returned while trying to download a release asset from GitHub API. Falling back to downloading from asset URL directly:", redirectURL) + src, err = up.downloadDirectlyFromURL(redirectURL) + if err != nil { + return err + } + } + return uncompressAndUpdate(src, rel.AssetURL, cmdPath) +} + // UpdateCommand updates a given command binary to the latest version. // 'slug' represents 'owner/name' repository on GitHub and 'current' means the current version. -func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) { +func (up *Updater) UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) { if runtime.GOOS == "windows" && !strings.HasSuffix(cmdPath, ".exe") { // Ensure to add '.exe' to given path on Windows cmdPath = cmdPath + ".exe" @@ -56,7 +90,7 @@ func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Releas cmdPath = p } - rel, ok, err := DetectLatest(slug) + rel, ok, err := up.DetectLatest(slug) if err != nil { return nil, err } @@ -69,7 +103,7 @@ func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Releas return rel, nil } log.Println("Will update", cmdPath, "to the latest version", rel.Version) - if err := UpdateTo(rel.AssetURL, cmdPath); err != nil { + if err := up.UpdateTo(rel, cmdPath); err != nil { return nil, err } return rel, nil @@ -77,10 +111,35 @@ func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Releas // UpdateSelf updates the running executable itself to the latest version. // 'slug' represents 'owner/name' repository on GitHub and 'current' means the current version. -func UpdateSelf(current semver.Version, slug string) (*Release, error) { +func (up *Updater) UpdateSelf(current semver.Version, slug string) (*Release, error) { cmdPath, err := os.Executable() if err != nil { return nil, err } return UpdateCommand(cmdPath, current, slug) } + +// UpdateTo downloads an executable from assetURL and replace the current binary with the downloaded one. +// This function is low-level API to update the binary. Because it does not use GitHub API and downloads asset directly from the URL via HTTP, +// this function is not available to update a release for private repositories. +// cmdPath is a file path to command executable. +func UpdateTo(assetURL, cmdPath string) error { + up := DefaultUpdater() + src, err := up.downloadDirectlyFromURL(assetURL) + if err != nil { + return err + } + return uncompressAndUpdate(src, assetURL, cmdPath) +} + +// UpdateCommand updates a given command binary to the latest version. +// This function is a shortcut version of updater.UpdateCommand. +func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) { + return DefaultUpdater().UpdateCommand(cmdPath, current, slug) +} + +// UpdateSelf updates the running executable itself to the latest version. +// This function is a shortcut version of updater.UpdateSelf. +func UpdateSelf(current semver.Version, slug string) (*Release, error) { + return DefaultUpdater().UpdateSelf(current, slug) +} diff --git a/selfupdate/update_test.go b/selfupdate/update_test.go index 68b1326..01376ad 100644 --- a/selfupdate/update_test.go +++ b/selfupdate/update_test.go @@ -256,3 +256,61 @@ func TestBrokenAsset(t *testing.T) { t.Fatal("Unexpected error:", err) } } + +func TestBrokenGitHubEnterpriseURL(t *testing.T) { + up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: "https://example.com"}) + if err != nil { + t.Fatal(err) + } + err = up.UpdateTo(&Release{AssetURL: "https://example.com"}, "foo") + if err == nil { + t.Fatal("Invalid GitHub Enterprise base URL should raise an error") + } + if !strings.Contains(err.Error(), "Failed to call GitHub Releases API for getting an asset") { + t.Error("Unexpected error occurred:", err) + } +} + +func TestUpdateFromGitHubEnterprise(t *testing.T) { + token := os.Getenv("GITHUB_ENTERPRISE_TOKEN") + base := os.Getenv("GITHUB_ENTERPRISE_BASE_URL") + repo := os.Getenv("GITHUB_ENTERPRISE_REPO") + if token == "" { + t.Skip("because token for GHE is not found") + } + if base == "" { + t.Skip("because base URL for GHE is not found") + } + if repo == "" { + t.Skip("because repo slug for GHE is not found") + } + + setupTestBinary() + defer teardownTestBinary() + + up, err := NewUpdater(Config{APIToken: token, EnterpriseBaseURL: base}) + if err != nil { + t.Fatal(err) + } + + latest := semver.MustParse("1.2.3") + prev := semver.MustParse("1.2.2") + rel, err := up.UpdateCommand("github-release-test", prev, repo) + if err != nil { + t.Fatal(err) + } + + if rel.Version.NE(latest) { + t.Error("Version is not latest", rel.Version) + } + + bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output() + if err != nil { + t.Fatal("Failed to run test binary after update:", err) + } + + out := string(bytes) + if out != "v1.2.3\n" { + t.Error("Output from test binary after update is unexpected:", out) + } +} diff --git a/selfupdate/updater.go b/selfupdate/updater.go new file mode 100644 index 0000000..551326a --- /dev/null +++ b/selfupdate/updater.go @@ -0,0 +1,85 @@ +package selfupdate + +import ( + "context" + "errors" + "net/http" + "os" + + "github.com/google/go-github/github" + gitconfig "github.com/tcnksm/go-gitconfig" + "golang.org/x/oauth2" +) + +// Updater is responsible for managing the context of self-update. +// It contains GitHub client and its context. +type Updater struct { + api *github.Client + apiCtx context.Context + httpClient *http.Client +} + +// Config represents the configuration of self-update. +type Config struct { + // APIToken represents GitHub API token. If it's not empty, it will be used for authentication of GitHub API + APIToken string + // EnterpriseBaseURL is a base URL of GitHub API. If you want to use this library with GitHub Enterprise, + // please set "https://{your-organization-address}/api/v3/" to this field. + EnterpriseBaseURL string + // EnterpriseUploadURL is a URL to upload stuffs to GitHub Enterprise instance. This is often the same as an API base URL. + // So if this field is not set and EnterpriseBaseURL is set, EnterpriseBaseURL is also set to this field. + EnterpriseUploadURL string +} + +func newHTTPClient(ctx context.Context, token string) *http.Client { + if token == "" { + return http.DefaultClient + } + src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + return oauth2.NewClient(ctx, src) +} + +// NewUpdater creates a new updater instance. It initializes GitHub API client. +// If you set your API token to $GITHUB_TOKEN, the client will use it. +func NewUpdater(config Config) (*Updater, error) { + token := config.APIToken + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } + if token == "" { + token, _ = gitconfig.GithubToken() + } + ctx := context.Background() + hc := newHTTPClient(ctx, token) + + if config.EnterpriseBaseURL == "" { + client := github.NewClient(hc) + return &Updater{client, ctx, hc}, nil + } + + if token == "" { + return nil, errors.New("GitHub API token cannot be empty when releases are hosted on GitHub Enterprise instance") + } + u := config.EnterpriseUploadURL + if u == "" { + u = config.EnterpriseBaseURL + } + client, err := github.NewEnterpriseClient(config.EnterpriseBaseURL, u, hc) + if err != nil { + return nil, err + } + return &Updater{client, ctx, hc}, nil +} + +// DefaultUpdater creates a new updater instance with default configuration. +// It initializes GitHub API client with default API base URL. +// If you set your API token to $GITHUB_TOKEN, the client will use it. +func DefaultUpdater() *Updater { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + token, _ = gitconfig.GithubToken() + } + ctx := context.Background() + client := newHTTPClient(ctx, token) + return &Updater{github.NewClient(client), ctx, client} +} diff --git a/selfupdate/updater_test.go b/selfupdate/updater_test.go new file mode 100644 index 0000000..4cadadd --- /dev/null +++ b/selfupdate/updater_test.go @@ -0,0 +1,67 @@ +package selfupdate + +import ( + "os" + "testing" +) + +func TestGitHubTokenEnv(t *testing.T) { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + t.Skip("because $GITHUB_TOKEN is not set") + } + _ = DefaultUpdater() + if _, err := NewUpdater(Config{}); err != nil { + t.Error("Failed to initialize updater with empty config") + } + if _, err := NewUpdater(Config{APIToken: token}); err != nil { + t.Error("Failed to initialize updater with API token config") + } +} + +func TestGitHubEnterpriseClient(t *testing.T) { + url := "https://github.company.com/api/v3/" + up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: url}) + if err != nil { + t.Fatal(err) + } + if up.api.BaseURL.String() != url { + t.Error("Base URL was set to", up.api.BaseURL, ", want", url) + } + if up.api.UploadURL.String() != url { + t.Error("Upload URL was set to", up.api.UploadURL, ", want", url) + } + + url2 := "https://upload.github.company.com/api/v3/" + up, err = NewUpdater(Config{ + APIToken: "hogehoge", + EnterpriseBaseURL: url, + EnterpriseUploadURL: url2, + }) + if err != nil { + t.Fatal(err) + } + if up.api.BaseURL.String() != url { + t.Error("Base URL was set to", up.api.BaseURL, ", want", url) + } + if up.api.UploadURL.String() != url2 { + t.Error("Upload URL was set to", up.api.UploadURL, ", want", url2) + } +} + +func TestGitHubEnterpriseClientWithoutToken(t *testing.T) { + token := os.Getenv("GITHUB_TOKEN") + defer os.Setenv("GITHUB_TOKEN", token) + os.Setenv("GITHUB_TOKEN", "") + _, err := NewUpdater(Config{EnterpriseBaseURL: "https://github.company.com/api/v3/"}) + if err == nil { + t.Fatal("Error should be reported because of empty token") + } +} + +func TestGitHubEnterpriseClientInvalidURL(t *testing.T) { + _, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: ":this is not a URL"}) + if err == nil { + t.Fatal("Invalid URL should raise an error") + } +}