github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/api/client/charms/localcharmclient_test.go (about)

     1  // Copyright 2023 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package charms_test
     5  
     6  import (
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"regexp"
    11  	"strings"
    12  
    13  	"github.com/juju/charm/v12"
    14  	"github.com/juju/errors"
    15  	jc "github.com/juju/testing/checkers"
    16  	"github.com/juju/version/v2"
    17  	"go.uber.org/mock/gomock"
    18  	gc "gopkg.in/check.v1"
    19  	"gopkg.in/httprequest.v1"
    20  
    21  	basemocks "github.com/juju/juju/api/base/mocks"
    22  	"github.com/juju/juju/api/client/charms"
    23  	"github.com/juju/juju/api/http/mocks"
    24  	"github.com/juju/juju/testcharms"
    25  	"github.com/juju/juju/testing"
    26  	coretesting "github.com/juju/juju/testing"
    27  	jujuversion "github.com/juju/juju/version"
    28  )
    29  
    30  type addCharmSuite struct {
    31  	coretesting.BaseSuite
    32  }
    33  
    34  var _ = gc.Suite(&addCharmSuite{})
    35  
    36  // TestLegacyAddLocalCharm runs the same test as AddLocalCharm,
    37  // but backs our client with the legacy http putter
    38  func (s *addCharmSuite) TestLegacyAddLocalCharm(c *gc.C) {
    39  	ctrl := gomock.NewController(c)
    40  	defer ctrl.Finish()
    41  
    42  	mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl)
    43  	mockCaller := basemocks.NewMockAPICaller(ctrl)
    44  	mockHttpDoer := mocks.NewMockHTTPClient(ctrl)
    45  	reqClient := &httprequest.Client{
    46  		BaseURL: "http://somewhere.invalid",
    47  		Doer:    mockHttpDoer,
    48  	}
    49  
    50  	mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes()
    51  	mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes()
    52  
    53  	curl, charmArchive := s.testCharm(c)
    54  	resp := &http.Response{
    55  		StatusCode: 200,
    56  		Header:     make(http.Header),
    57  		Body:       io.NopCloser(strings.NewReader(`{"charm-url": "local:quantal/dummy-1"}`)),
    58  	}
    59  	resp.Header.Add("Content-Type", "application/json")
    60  	mockHttpDoer.EXPECT().Do(
    61  		&httpURLMatcher{"http://somewhere.invalid/charms\\?revision=1&schema=local&series=quantal"},
    62  	).Return(resp, nil).MinTimes(1)
    63  
    64  	httpPutter := charms.NewHTTPPutterWithHTTPClient(reqClient)
    65  	client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter)
    66  	vers := version.MustParse("2.6.6")
    67  	// Test the sanity checks first.
    68  	_, err := client.AddLocalCharm(charm.MustParseURL("ch:wordpress-1"), nil, false, vers)
    69  	c.Assert(err, gc.ErrorMatches, `expected charm URL with local: schema, got "ch:wordpress-1"`)
    70  
    71  	// Upload an archive with its original revision.
    72  	savedURL, err := client.AddLocalCharm(curl, charmArchive, false, vers)
    73  	c.Assert(err, jc.ErrorIsNil)
    74  	c.Assert(savedURL.String(), gc.Equals, curl.String())
    75  
    76  	// Upload a charm directory with changed revision.
    77  	resp.Body = io.NopCloser(strings.NewReader(`{"charm-url": "local:quantal/dummy-42"}`))
    78  	charmDir := testcharms.Repo.ClonedDir(c.MkDir(), "dummy")
    79  	err = charmDir.SetDiskRevision(42)
    80  	c.Assert(err, jc.ErrorIsNil)
    81  	savedURL, err = client.AddLocalCharm(curl, charmDir, false, vers)
    82  	c.Assert(err, jc.ErrorIsNil)
    83  	c.Assert(savedURL.Revision, gc.Equals, 42)
    84  
    85  	// Upload a charm directory again, revision should be bumped.
    86  	resp.Body = io.NopCloser(strings.NewReader(`{"charm-url": "local:quantal/dummy-43"}`))
    87  	savedURL, err = client.AddLocalCharm(curl, charmDir, false, vers)
    88  	c.Assert(err, jc.ErrorIsNil)
    89  	c.Assert(savedURL.String(), gc.Equals, curl.WithRevision(43).String())
    90  }
    91  
    92  func (s *addCharmSuite) TestAddLocalCharm(c *gc.C) {
    93  	ctrl := gomock.NewController(c)
    94  	defer ctrl.Finish()
    95  
    96  	mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl)
    97  	mockCaller := basemocks.NewMockAPICaller(ctrl)
    98  	mockHttpDoer := mocks.NewMockHTTPClient(ctrl)
    99  	reqClient := &httprequest.Client{
   100  		BaseURL: "http://somewhere.invalid",
   101  		Doer:    mockHttpDoer,
   102  	}
   103  
   104  	mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes()
   105  	mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes()
   106  
   107  	curl, charmArchive := s.testCharm(c)
   108  	resp := &http.Response{
   109  		StatusCode: 200,
   110  		Header:     make(http.Header),
   111  	}
   112  	resp.Header.Add("Content-Type", "application/json")
   113  	resp.Header.Add("Juju-Curl", "local:quantal/dummy-1")
   114  	mockHttpDoer.EXPECT().Do(
   115  		&httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/dummy-[a-f0-9]{7}", testing.ModelTag.Id())},
   116  	).Return(resp, nil).MinTimes(1)
   117  
   118  	httpPutter := charms.NewS3PutterWithHTTPClient(reqClient)
   119  	client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter)
   120  	vers := version.MustParse("2.6.6")
   121  	// Test the sanity checks first.
   122  	_, err := client.AddLocalCharm(charm.MustParseURL("ch:wordpress-1"), nil, false, vers)
   123  	c.Assert(err, gc.ErrorMatches, `expected charm URL with local: schema, got "ch:wordpress-1"`)
   124  
   125  	// Upload an archive with its original revision.
   126  	savedURL, err := client.AddLocalCharm(curl, charmArchive, false, vers)
   127  	c.Assert(err, jc.ErrorIsNil)
   128  	c.Assert(savedURL.String(), gc.Equals, curl.String())
   129  
   130  	// Upload a charm directory with changed revision.
   131  	resp.Header.Set("Juju-Curl", "local:quantal/dummy-42")
   132  	charmDir := testcharms.Repo.ClonedDir(c.MkDir(), "dummy")
   133  	err = charmDir.SetDiskRevision(42)
   134  	c.Assert(err, jc.ErrorIsNil)
   135  	savedURL, err = client.AddLocalCharm(curl, charmDir, false, vers)
   136  	c.Assert(err, jc.ErrorIsNil)
   137  	c.Assert(savedURL.Revision, gc.Equals, 42)
   138  
   139  	// Upload a charm directory again, revision should be bumped.
   140  	resp.Header.Set("Juju-Curl", "local:quantal/dummy-43")
   141  	savedURL, err = client.AddLocalCharm(curl, charmDir, false, vers)
   142  	c.Assert(err, jc.ErrorIsNil)
   143  	c.Assert(savedURL.String(), gc.Equals, curl.WithRevision(43).String())
   144  }
   145  
   146  func (s *addCharmSuite) TestAddLocalCharmFindingHooksError(c *gc.C) {
   147  	s.assertAddLocalCharmFailed(c,
   148  		func(string) (bool, error) {
   149  			return true, fmt.Errorf("bad zip")
   150  		},
   151  		`bad zip`)
   152  }
   153  
   154  func (s *addCharmSuite) TestAddLocalCharmNoHooks(c *gc.C) {
   155  	s.assertAddLocalCharmFailed(c,
   156  		func(string) (bool, error) {
   157  			return false, nil
   158  		},
   159  		`invalid charm \"dummy\": has no hooks nor dispatch file`)
   160  }
   161  
   162  func (s *addCharmSuite) TestAddLocalCharmWithLXDProfile(c *gc.C) {
   163  	ctrl := gomock.NewController(c)
   164  	defer ctrl.Finish()
   165  
   166  	mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl)
   167  	mockCaller := basemocks.NewMockAPICaller(ctrl)
   168  	mockHttpDoer := mocks.NewMockHTTPClient(ctrl)
   169  	reqClient := &httprequest.Client{
   170  		BaseURL: "http://somewhere.invalid",
   171  		Doer:    mockHttpDoer,
   172  	}
   173  
   174  	mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes()
   175  	mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes()
   176  
   177  	resp := &http.Response{
   178  		StatusCode: 200,
   179  		Header:     make(http.Header),
   180  	}
   181  	resp.Header.Add("Content-Type", "application/json")
   182  	resp.Header.Add("Juju-Curl", "local:quantal/lxd-profile-0")
   183  	mockHttpDoer.EXPECT().Do(
   184  		&httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/lxd-profile-[a-f0-9]{7}", testing.ModelTag.Id())},
   185  	).Return(resp, nil).MinTimes(1)
   186  
   187  	httpPutter := charms.NewS3PutterWithHTTPClient(reqClient)
   188  	client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter)
   189  
   190  	charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "lxd-profile")
   191  	curl := charm.MustParseURL(
   192  		fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()),
   193  	)
   194  
   195  	vers := version.MustParse("2.6.6")
   196  	savedURL, err := client.AddLocalCharm(curl, charmArchive, false, vers)
   197  	c.Assert(err, jc.ErrorIsNil)
   198  	c.Assert(savedURL.String(), gc.Equals, "local:quantal/lxd-profile-0")
   199  }
   200  
   201  func (s *addCharmSuite) TestAddLocalCharmWithInvalidLXDProfile(c *gc.C) {
   202  	ctrl := gomock.NewController(c)
   203  	defer ctrl.Finish()
   204  
   205  	mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl)
   206  	mockHttpDoer := mocks.NewMockHTTPClient(ctrl)
   207  	reqClient := &httprequest.Client{
   208  		BaseURL: "http://somewhere.invalid",
   209  		Doer:    mockHttpDoer,
   210  	}
   211  	httpPutter := charms.NewS3PutterWithHTTPClient(reqClient)
   212  	client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter)
   213  
   214  	charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "lxd-profile-fail")
   215  	curl := charm.MustParseURL(
   216  		fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()),
   217  	)
   218  
   219  	vers := version.MustParse("2.6.6")
   220  	_, err := client.AddLocalCharm(curl, charmArchive, false, vers)
   221  	c.Assert(err, gc.ErrorMatches, "invalid lxd-profile.yaml: contains device type \"unix-disk\"")
   222  }
   223  
   224  func (s *addCharmSuite) TestAddLocalCharmWithValidLXDProfileWithForceSucceeds(c *gc.C) {
   225  	s.testAddLocalCharmWithForceSucceeds("lxd-profile", c)
   226  }
   227  
   228  func (s *addCharmSuite) TestAddLocalCharmWithInvalidLXDProfileWithForceSucceeds(c *gc.C) {
   229  	s.testAddLocalCharmWithForceSucceeds("lxd-profile-fail", c)
   230  }
   231  
   232  func (s *addCharmSuite) testAddLocalCharmWithForceSucceeds(name string, c *gc.C) {
   233  	ctrl := gomock.NewController(c)
   234  	defer ctrl.Finish()
   235  
   236  	mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl)
   237  	mockCaller := basemocks.NewMockAPICaller(ctrl)
   238  	mockHttpDoer := mocks.NewMockHTTPClient(ctrl)
   239  	reqClient := &httprequest.Client{
   240  		BaseURL: "http://somewhere.invalid",
   241  		Doer:    mockHttpDoer,
   242  	}
   243  
   244  	mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes()
   245  	mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes()
   246  
   247  	resp := &http.Response{
   248  		StatusCode: 200,
   249  		Header:     make(http.Header),
   250  	}
   251  	resp.Header.Add("Content-Type", "application/json")
   252  	resp.Header.Add("Juju-Curl", "local:quantal/lxd-profile-0")
   253  	mockHttpDoer.EXPECT().Do(
   254  		&httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/lxd-profile-[a-f0-9]{7}", testing.ModelTag.Id())},
   255  	).Return(resp, nil).MinTimes(1)
   256  
   257  	httpPutter := charms.NewS3PutterWithHTTPClient(reqClient)
   258  	client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter)
   259  
   260  	charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "lxd-profile")
   261  	curl := charm.MustParseURL(
   262  		fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()),
   263  	)
   264  
   265  	vers := version.MustParse("2.6.6")
   266  	savedURL, err := client.AddLocalCharm(curl, charmArchive, false, vers)
   267  	c.Assert(err, jc.ErrorIsNil)
   268  	c.Assert(savedURL.String(), gc.Equals, "local:quantal/lxd-profile-0")
   269  }
   270  
   271  func (s *addCharmSuite) assertAddLocalCharmFailed(c *gc.C, f func(string) (bool, error), msg string) {
   272  	ctrl := gomock.NewController(c)
   273  	defer ctrl.Finish()
   274  
   275  	curl, ch := s.testCharm(c)
   276  	s.PatchValue(charms.HasHooksOrDispatch, f)
   277  
   278  	mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl)
   279  	mockHttpDoer := mocks.NewMockHTTPClient(ctrl)
   280  	reqClient := &httprequest.Client{
   281  		BaseURL: "http://somewhere.invalid",
   282  		Doer:    mockHttpDoer,
   283  	}
   284  	httpPutter := charms.NewS3PutterWithHTTPClient(reqClient)
   285  	client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter)
   286  	vers := version.MustParse("2.6.6")
   287  	_, err := client.AddLocalCharm(curl, ch, false, vers)
   288  	c.Assert(err, gc.ErrorMatches, msg)
   289  }
   290  
   291  func (s *addCharmSuite) TestAddLocalCharmDefinitelyWithHooks(c *gc.C) {
   292  	ctrl := gomock.NewController(c)
   293  	defer ctrl.Finish()
   294  
   295  	curl, ch := s.testCharm(c)
   296  	s.PatchValue(charms.HasHooksOrDispatch, func(string) (bool, error) {
   297  		return true, nil
   298  	})
   299  	mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl)
   300  	mockCaller := basemocks.NewMockAPICaller(ctrl)
   301  	mockHttpDoer := mocks.NewMockHTTPClient(ctrl)
   302  	reqClient := &httprequest.Client{
   303  		BaseURL: "http://somewhere.invalid",
   304  		Doer:    mockHttpDoer,
   305  	}
   306  
   307  	mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes()
   308  	mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes()
   309  
   310  	resp := &http.Response{
   311  		StatusCode: 200,
   312  		Header:     make(http.Header),
   313  	}
   314  	resp.Header.Add("Content-Type", "application/json")
   315  	resp.Header.Add("Juju-Curl", "local:quantal/dummy-1")
   316  	mockHttpDoer.EXPECT().Do(
   317  		&httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/dummy-[a-f0-9]{7}", testing.ModelTag.Id())},
   318  	).Return(resp, nil).MinTimes(1)
   319  
   320  	httpPutter := charms.NewS3PutterWithHTTPClient(reqClient)
   321  	client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter)
   322  
   323  	vers := version.MustParse("2.6.6")
   324  	savedCURL, err := client.AddLocalCharm(curl, ch, false, vers)
   325  	c.Assert(err, jc.ErrorIsNil)
   326  	c.Assert(savedCURL.String(), gc.Equals, curl.String())
   327  }
   328  
   329  func (s *addCharmSuite) testCharm(c *gc.C) (*charm.URL, charm.Charm) {
   330  	charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "dummy")
   331  	curl := charm.MustParseURL(
   332  		fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()),
   333  	)
   334  	return curl, charmArchive
   335  }
   336  
   337  func (s *addCharmSuite) TestAddLocalCharmError(c *gc.C) {
   338  	ctrl := gomock.NewController(c)
   339  	defer ctrl.Finish()
   340  
   341  	curl, charmArchive := s.testCharm(c)
   342  	s.PatchValue(charms.HasHooksOrDispatch, func(string) (bool, error) {
   343  		return true, nil
   344  	})
   345  	mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl)
   346  	mockCaller := basemocks.NewMockAPICaller(ctrl)
   347  	mockHttpDoer := mocks.NewMockHTTPClient(ctrl)
   348  	reqClient := &httprequest.Client{
   349  		BaseURL: "http://somewhere.invalid",
   350  		Doer:    mockHttpDoer,
   351  	}
   352  
   353  	mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes()
   354  	mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes()
   355  	resp := &http.Response{
   356  		StatusCode: 200,
   357  		Header:     make(http.Header),
   358  	}
   359  	resp.Header.Add("Content-Type", "application/json")
   360  	resp.Header.Add("Juju-Curl", "local:quantal/dummy-1")
   361  	mockHttpDoer.EXPECT().Do(
   362  		&httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/dummy-[a-f0-9]{7}", testing.ModelTag.Id())},
   363  	).Return(nil, errors.New("boom")).MinTimes(1)
   364  
   365  	httpPutter := charms.NewS3PutterWithHTTPClient(reqClient)
   366  	client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter)
   367  
   368  	vers := version.MustParse("2.6.6")
   369  	_, err := client.AddLocalCharm(curl, charmArchive, false, vers)
   370  	c.Assert(err, gc.ErrorMatches, `.*boom$`)
   371  }
   372  
   373  func (s *addCharmSuite) TestMinVersionLocalCharm(c *gc.C) {
   374  	tests := []minverTest{
   375  		{"2.0.0", "1.0.0", false, true},
   376  		{"1.0.0", "2.0.0", false, false},
   377  		{"1.25.0", "1.24.0", false, true},
   378  		{"1.24.0", "1.25.0", false, false},
   379  		{"1.25.1", "1.25.0", false, true},
   380  		{"1.25.0", "1.25.1", false, false},
   381  		{"1.25.0", "1.25.0", false, true},
   382  		{"1.25.0", "1.25-alpha1", false, true},
   383  		{"1.25-alpha1", "1.25.0", false, true},
   384  		{"2.0.0", "1.0.0", true, true},
   385  		{"1.0.0", "2.0.0", true, false},
   386  		{"1.25.0", "1.24.0", true, true},
   387  		{"1.24.0", "1.25.0", true, false},
   388  		{"1.25.1", "1.25.0", true, true},
   389  		{"1.25.0", "1.25.1", true, false},
   390  		{"1.25.0", "1.25.0", true, true},
   391  		{"1.25.0", "1.25-alpha1", true, true},
   392  		{"1.25-alpha1", "1.25.0", true, true},
   393  	}
   394  	for _, t := range tests {
   395  		testMinVer(t, c)
   396  	}
   397  }
   398  
   399  type minverTest struct {
   400  	juju  string
   401  	charm string
   402  	force bool
   403  	ok    bool
   404  }
   405  
   406  func testMinVer(t minverTest, c *gc.C) {
   407  	ctrl := gomock.NewController(c)
   408  	defer ctrl.Finish()
   409  
   410  	mockFacadeCaller := basemocks.NewMockFacadeCaller(ctrl)
   411  	mockCaller := basemocks.NewMockAPICaller(ctrl)
   412  	mockHttpDoer := mocks.NewMockHTTPClient(ctrl)
   413  	reqClient := &httprequest.Client{
   414  		BaseURL: "http://somewhere.invalid",
   415  		Doer:    mockHttpDoer,
   416  	}
   417  
   418  	mockCaller.EXPECT().ModelTag().Return(testing.ModelTag, false).AnyTimes()
   419  	mockFacadeCaller.EXPECT().RawAPICaller().Return(mockCaller).AnyTimes()
   420  
   421  	resp := &http.Response{
   422  		StatusCode: 200,
   423  		Header:     make(http.Header),
   424  	}
   425  	resp.Header.Add("Content-Type", "application/json")
   426  	resp.Header.Add("Juju-Curl", "local:quantal/dummy-1")
   427  	mockHttpDoer.EXPECT().Do(
   428  		&httpURLMatcher{fmt.Sprintf("http://somewhere.invalid/model-%s/charms/dummy-[a-f0-9]{7}", testing.ModelTag.Id())},
   429  	).Return(resp, nil).AnyTimes()
   430  
   431  	httpPutter := charms.NewS3PutterWithHTTPClient(reqClient)
   432  	client := charms.NewLocalCharmClientWithFacade(mockFacadeCaller, nil, httpPutter)
   433  
   434  	charmMinVer := version.MustParse(t.charm)
   435  	jujuVer := version.MustParse(t.juju)
   436  
   437  	charmArchive := testcharms.Repo.CharmArchive(c.MkDir(), "dummy")
   438  	curl := charm.MustParseURL(
   439  		fmt.Sprintf("local:quantal/%s-%d", charmArchive.Meta().Name, charmArchive.Revision()),
   440  	)
   441  	charmArchive.Meta().MinJujuVersion = charmMinVer
   442  
   443  	_, err := client.AddLocalCharm(curl, charmArchive, t.force, jujuVer)
   444  
   445  	if t.ok {
   446  		if err != nil {
   447  			c.Errorf("Unexpected non-nil error for jujuver %v, minver %v: %#v", t.juju, t.charm, err)
   448  		}
   449  	} else {
   450  		if err == nil {
   451  			c.Errorf("Unexpected nil error for jujuver %v, minver %v", t.juju, t.charm)
   452  		} else if !jujuversion.IsMinVersionError(err) {
   453  			c.Errorf("Wrong error for jujuver %v, minver %v: expected minVersionError, got: %#v", t.juju, t.charm, err)
   454  		}
   455  	}
   456  }
   457  
   458  type httpURLMatcher struct {
   459  	expectedURL string
   460  }
   461  
   462  func (m httpURLMatcher) Matches(x interface{}) bool {
   463  	req, ok := x.(*http.Request)
   464  	if !ok {
   465  		return false
   466  	}
   467  	match, err := regexp.MatchString(m.expectedURL, req.URL.String())
   468  	if err != nil {
   469  		panic("httpURLMatcher regexp invalid")
   470  	}
   471  	return match
   472  }
   473  
   474  func (m httpURLMatcher) String() string {
   475  	return fmt.Sprintf("Request URL to match %s", m.expectedURL)
   476  }