Merge pull request #874 from cli/httpmock
Parallelism-safe mechanism for stubbing HTTP responses
This commit is contained in:
commit
3000847bb2
11 changed files with 288 additions and 160 deletions
|
|
@ -5,6 +5,8 @@ import (
|
|||
"io/ioutil"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
)
|
||||
|
||||
func eq(t *testing.T, got interface{}, expected interface{}) {
|
||||
|
|
@ -15,7 +17,7 @@ func eq(t *testing.T, got interface{}, expected interface{}) {
|
|||
}
|
||||
|
||||
func TestGraphQL(t *testing.T) {
|
||||
http := &FakeHTTP{}
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(
|
||||
ReplaceTripper(http),
|
||||
AddHeader("Authorization", "token OTOKEN"),
|
||||
|
|
@ -40,7 +42,7 @@ func TestGraphQL(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGraphQLError(t *testing.T) {
|
||||
http := &FakeHTTP{}
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
|
||||
response := struct{}{}
|
||||
|
|
@ -52,7 +54,7 @@ func TestGraphQLError(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRESTGetDelete(t *testing.T) {
|
||||
http := &FakeHTTP{}
|
||||
http := &httpmock.Registry{}
|
||||
|
||||
client := NewClient(
|
||||
ReplaceTripper(http),
|
||||
|
|
|
|||
120
api/fake_http.go
120
api/fake_http.go
|
|
@ -1,120 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FakeHTTP provides a mechanism by which to stub HTTP responses through
|
||||
type FakeHTTP struct {
|
||||
// Requests stores references to sequential requests that RoundTrip has received
|
||||
Requests []*http.Request
|
||||
count int
|
||||
responseStubs []*http.Response
|
||||
}
|
||||
|
||||
// StubResponse pre-records an HTTP response
|
||||
func (f *FakeHTTP) StubResponse(status int, body io.Reader) {
|
||||
resp := &http.Response{
|
||||
StatusCode: status,
|
||||
Body: ioutil.NopCloser(body),
|
||||
}
|
||||
f.responseStubs = append(f.responseStubs, resp)
|
||||
}
|
||||
|
||||
// RoundTrip satisfies http.RoundTripper
|
||||
func (f *FakeHTTP) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if len(f.responseStubs) <= f.count {
|
||||
return nil, fmt.Errorf("FakeHTTP: missing response stub for request %d", f.count)
|
||||
}
|
||||
resp := f.responseStubs[f.count]
|
||||
f.count++
|
||||
resp.Request = req
|
||||
f.Requests = append(f.Requests, req)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (f *FakeHTTP) StubWithFixture(status int, fixtureFileName string) func() {
|
||||
fixturePath := path.Join("../test/fixtures/", fixtureFileName)
|
||||
fixtureFile, _ := os.Open(fixturePath)
|
||||
f.StubResponse(status, fixtureFile)
|
||||
return func() { fixtureFile.Close() }
|
||||
}
|
||||
|
||||
func (f *FakeHTTP) StubRepoResponse(owner, repo string) {
|
||||
f.StubRepoResponseWithPermission(owner, repo, "WRITE")
|
||||
}
|
||||
|
||||
func (f *FakeHTTP) StubRepoResponseWithPermission(owner, repo, permission string) {
|
||||
body := bytes.NewBufferString(fmt.Sprintf(`
|
||||
{ "data": { "repo_000": {
|
||||
"id": "REPOID",
|
||||
"name": "%s",
|
||||
"owner": {"login": "%s"},
|
||||
"defaultBranchRef": {
|
||||
"name": "master"
|
||||
},
|
||||
"viewerPermission": "%s"
|
||||
} } }
|
||||
`, repo, owner, permission))
|
||||
resp := &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(body),
|
||||
}
|
||||
f.responseStubs = append(f.responseStubs, resp)
|
||||
}
|
||||
|
||||
func (f *FakeHTTP) StubRepoResponseWithDefaultBranch(owner, repo, defaultBranch string) {
|
||||
body := bytes.NewBufferString(fmt.Sprintf(`
|
||||
{ "data": { "repo_000": {
|
||||
"id": "REPOID",
|
||||
"name": "%s",
|
||||
"owner": {"login": "%s"},
|
||||
"defaultBranchRef": {
|
||||
"name": "%s"
|
||||
},
|
||||
"viewerPermission": "READ"
|
||||
} } }
|
||||
`, repo, owner, defaultBranch))
|
||||
resp := &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(body),
|
||||
}
|
||||
f.responseStubs = append(f.responseStubs, resp)
|
||||
}
|
||||
|
||||
func (f *FakeHTTP) StubForkedRepoResponse(forkFullName, parentFullName string) {
|
||||
forkRepo := strings.SplitN(forkFullName, "/", 2)
|
||||
parentRepo := strings.SplitN(parentFullName, "/", 2)
|
||||
body := bytes.NewBufferString(fmt.Sprintf(`
|
||||
{ "data": { "repo_000": {
|
||||
"id": "REPOID2",
|
||||
"name": "%s",
|
||||
"owner": {"login": "%s"},
|
||||
"defaultBranchRef": {
|
||||
"name": "master"
|
||||
},
|
||||
"viewerPermission": "ADMIN",
|
||||
"parent": {
|
||||
"id": "REPOID1",
|
||||
"name": "%s",
|
||||
"owner": {"login": "%s"},
|
||||
"defaultBranchRef": {
|
||||
"name": "master"
|
||||
},
|
||||
"viewerPermission": "READ"
|
||||
}
|
||||
} } }
|
||||
`, forkRepo[1], forkRepo[0], parentRepo[1], parentRepo[0]))
|
||||
resp := &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(body),
|
||||
}
|
||||
f.responseStubs = append(f.responseStubs, resp)
|
||||
}
|
||||
|
|
@ -7,10 +7,11 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
)
|
||||
|
||||
func TestIssueList(t *testing.T) {
|
||||
http := &FakeHTTP{}
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import (
|
|||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
)
|
||||
|
||||
func Test_RepoCreate(t *testing.T) {
|
||||
http := &FakeHTTP{}
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`{}`))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/context"
|
||||
"github.com/cli/cli/git"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/cli/cli/test"
|
||||
)
|
||||
|
||||
|
|
@ -408,20 +409,27 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) {
|
|||
func TestPRCreate_survey_defaults_monocommit(t *testing.T) {
|
||||
initBlankContext("", "OWNER/REPO", "feature")
|
||||
http := initFakeHTTP()
|
||||
http.StubRepoResponse("OWNER", "REPO")
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
{ "data": { "repository": { "forks": { "nodes": [
|
||||
] } } } }
|
||||
defer http.Verify(t)
|
||||
http.Register(httpmock.GraphQL(`\bviewerPermission\b`), httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE")))
|
||||
http.Register(httpmock.GraphQL(`\bforks\(`), httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "forks": { "nodes": [
|
||||
] } } } }
|
||||
`))
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
http.Register(httpmock.GraphQL(`\bpullRequests\(`), httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "pullRequests": { "nodes" : [
|
||||
] } } } }
|
||||
`))
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
http.Register(httpmock.GraphQL(`\bcreatePullRequest\(`), httpmock.GraphQLMutation(`
|
||||
{ "data": { "createPullRequest": { "pullRequest": {
|
||||
"URL": "https://github.com/OWNER/REPO/pull/12"
|
||||
} } } }
|
||||
`))
|
||||
`, func(inputs map[string]interface{}) {
|
||||
eq(t, inputs["repositoryId"], "REPOID")
|
||||
eq(t, inputs["title"], "the sky above the port")
|
||||
eq(t, inputs["body"], "was the color of a television, turned to a dead channel")
|
||||
eq(t, inputs["baseRefName"], "master")
|
||||
eq(t, inputs["headRefName"], "feature")
|
||||
}))
|
||||
|
||||
cs, cmdTeardown := test.InitCmdStubber()
|
||||
defer cmdTeardown()
|
||||
|
|
@ -456,29 +464,6 @@ func TestPRCreate_survey_defaults_monocommit(t *testing.T) {
|
|||
|
||||
output, err := RunCommand(`pr create`)
|
||||
eq(t, err, nil)
|
||||
|
||||
bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body)
|
||||
reqBody := struct {
|
||||
Variables struct {
|
||||
Input struct {
|
||||
RepositoryID string
|
||||
Title string
|
||||
Body string
|
||||
BaseRefName string
|
||||
HeadRefName string
|
||||
}
|
||||
}
|
||||
}{}
|
||||
_ = json.Unmarshal(bodyBytes, &reqBody)
|
||||
|
||||
expectedBody := "was the color of a television, turned to a dead channel"
|
||||
|
||||
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
|
||||
eq(t, reqBody.Variables.Input.Title, "the sky above the port")
|
||||
eq(t, reqBody.Variables.Input.Body, expectedBody)
|
||||
eq(t, reqBody.Variables.Input.BaseRefName, "master")
|
||||
eq(t, reqBody.Variables.Input.HeadRefName, "feature")
|
||||
|
||||
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/context"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/google/shlex"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
|
@ -93,8 +94,8 @@ func initBlankContext(cfg, repo, branch string) {
|
|||
}
|
||||
}
|
||||
|
||||
func initFakeHTTP() *api.FakeHTTP {
|
||||
http := &api.FakeHTTP{}
|
||||
func initFakeHTTP() *httpmock.Registry {
|
||||
http := &httpmock.Registry{}
|
||||
apiClientForContext = func(context.Context) (*api.Client, error) {
|
||||
return api.NewClient(api.ReplaceTripper(http)), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/git"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
)
|
||||
|
||||
func eq(t *testing.T, got interface{}, expected interface{}) {
|
||||
|
|
@ -70,7 +71,7 @@ func Test_translateRemotes(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_resolvedRemotes_triangularSetup(t *testing.T) {
|
||||
http := &api.FakeHTTP{}
|
||||
http := &httpmock.Registry{}
|
||||
apiClient := api.NewClient(api.ReplaceTripper(http))
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
|
|
@ -137,7 +138,7 @@ func Test_resolvedRemotes_triangularSetup(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_resolvedRemotes_forkLookup(t *testing.T) {
|
||||
http := &api.FakeHTTP{}
|
||||
http := &httpmock.Registry{}
|
||||
apiClient := api.NewClient(api.ReplaceTripper(http))
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
|
|
|
|||
89
pkg/httpmock/legacy.go
Normal file
89
pkg/httpmock/legacy.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package httpmock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO: clean up methods in this file when there are no more callers
|
||||
|
||||
func (r *Registry) StubResponse(status int, body io.Reader) {
|
||||
r.Register(MatchAny, func(*http.Request) (*http.Response, error) {
|
||||
return httpResponse(status, body), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Registry) StubWithFixture(status int, fixtureFileName string) func() {
|
||||
fixturePath := path.Join("../test/fixtures/", fixtureFileName)
|
||||
fixtureFile, err := os.Open(fixturePath)
|
||||
r.Register(MatchAny, func(*http.Request) (*http.Response, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return httpResponse(200, fixtureFile), nil
|
||||
})
|
||||
return func() {
|
||||
if err == nil {
|
||||
fixtureFile.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) StubRepoResponse(owner, repo string) {
|
||||
r.StubRepoResponseWithPermission(owner, repo, "WRITE")
|
||||
}
|
||||
|
||||
func (r *Registry) StubRepoResponseWithPermission(owner, repo, permission string) {
|
||||
r.Register(MatchAny, StringResponse(RepoNetworkStubResponse(owner, repo, "master", permission)))
|
||||
}
|
||||
|
||||
func (r *Registry) StubRepoResponseWithDefaultBranch(owner, repo, defaultBranch string) {
|
||||
r.Register(MatchAny, StringResponse(RepoNetworkStubResponse(owner, repo, defaultBranch, "WRITE")))
|
||||
}
|
||||
|
||||
func (r *Registry) StubForkedRepoResponse(ownRepo, parentRepo string) {
|
||||
r.Register(MatchAny, StringResponse(RepoNetworkStubForkResponse(ownRepo, parentRepo)))
|
||||
}
|
||||
|
||||
func RepoNetworkStubResponse(owner, repo, defaultBranch, permission string) string {
|
||||
return fmt.Sprintf(`
|
||||
{ "data": { "repo_000": {
|
||||
"id": "REPOID",
|
||||
"name": "%s",
|
||||
"owner": {"login": "%s"},
|
||||
"defaultBranchRef": {
|
||||
"name": "%s"
|
||||
},
|
||||
"viewerPermission": "%s"
|
||||
} } }
|
||||
`, repo, owner, defaultBranch, permission)
|
||||
}
|
||||
|
||||
func RepoNetworkStubForkResponse(forkFullName, parentFullName string) string {
|
||||
forkRepo := strings.SplitN(forkFullName, "/", 2)
|
||||
parentRepo := strings.SplitN(parentFullName, "/", 2)
|
||||
return fmt.Sprintf(`
|
||||
{ "data": { "repo_000": {
|
||||
"id": "REPOID2",
|
||||
"name": "%s",
|
||||
"owner": {"login": "%s"},
|
||||
"defaultBranchRef": {
|
||||
"name": "master"
|
||||
},
|
||||
"viewerPermission": "ADMIN",
|
||||
"parent": {
|
||||
"id": "REPOID1",
|
||||
"name": "%s",
|
||||
"owner": {"login": "%s"},
|
||||
"defaultBranchRef": {
|
||||
"name": "master"
|
||||
},
|
||||
"viewerPermission": "READ"
|
||||
}
|
||||
} } }
|
||||
`, forkRepo[1], forkRepo[0], parentRepo[1], parentRepo[0])
|
||||
}
|
||||
70
pkg/httpmock/registry.go
Normal file
70
pkg/httpmock/registry.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package httpmock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
stubs []*Stub
|
||||
Requests []*http.Request
|
||||
}
|
||||
|
||||
func (r *Registry) Register(m Matcher, resp Responder) {
|
||||
r.stubs = append(r.stubs, &Stub{
|
||||
Matcher: m,
|
||||
Responder: resp,
|
||||
})
|
||||
}
|
||||
|
||||
type Testing interface {
|
||||
Errorf(string, ...interface{})
|
||||
}
|
||||
|
||||
func (r *Registry) Verify(t Testing) {
|
||||
n := 0
|
||||
for _, s := range r.stubs {
|
||||
if !s.matched {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
// NOTE: stubs offer no useful reflection, so we can't print details
|
||||
// about dead stubs and what they were trying to match
|
||||
t.Errorf("%d unmatched HTTP stubs", n)
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip satisfies http.RoundTripper
|
||||
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
var stub *Stub
|
||||
|
||||
r.mu.Lock()
|
||||
for _, s := range r.stubs {
|
||||
if s.matched || !s.Matcher(req) {
|
||||
continue
|
||||
}
|
||||
// TODO: reinstate this check once the legacy layer has been cleaned up
|
||||
// if stub != nil {
|
||||
// r.mu.Unlock()
|
||||
// return nil, fmt.Errorf("more than 1 stub matched %v", req)
|
||||
// }
|
||||
stub = s
|
||||
break // TODO: remove
|
||||
}
|
||||
if stub != nil {
|
||||
stub.matched = true
|
||||
}
|
||||
|
||||
if stub == nil {
|
||||
r.mu.Unlock()
|
||||
return nil, fmt.Errorf("no registered stubs matched %v", req)
|
||||
}
|
||||
|
||||
r.Requests = append(r.Requests, req)
|
||||
r.mu.Unlock()
|
||||
|
||||
return stub.Responder(req)
|
||||
}
|
||||
96
pkg/httpmock/stub.go
Normal file
96
pkg/httpmock/stub.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package httpmock
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Matcher func(req *http.Request) bool
|
||||
type Responder func(req *http.Request) (*http.Response, error)
|
||||
|
||||
type Stub struct {
|
||||
matched bool
|
||||
Matcher Matcher
|
||||
Responder Responder
|
||||
}
|
||||
|
||||
func MatchAny(*http.Request) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func GraphQL(q string) Matcher {
|
||||
re := regexp.MustCompile(q)
|
||||
|
||||
return func(req *http.Request) bool {
|
||||
if !strings.EqualFold(req.Method, "POST") {
|
||||
return false
|
||||
}
|
||||
if req.URL.Path != "/graphql" {
|
||||
return false
|
||||
}
|
||||
|
||||
var bodyData struct {
|
||||
Query string
|
||||
}
|
||||
_ = decodeJSONBody(req, &bodyData)
|
||||
|
||||
return re.MatchString(bodyData.Query)
|
||||
}
|
||||
}
|
||||
|
||||
func readBody(req *http.Request) ([]byte, error) {
|
||||
bodyCopy := &bytes.Buffer{}
|
||||
r := io.TeeReader(req.Body, bodyCopy)
|
||||
req.Body = ioutil.NopCloser(bodyCopy)
|
||||
return ioutil.ReadAll(r)
|
||||
}
|
||||
|
||||
func decodeJSONBody(req *http.Request, dest interface{}) error {
|
||||
b, err := readBody(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, dest)
|
||||
}
|
||||
|
||||
func StringResponse(body string) Responder {
|
||||
return func(*http.Request) (*http.Response, error) {
|
||||
return httpResponse(200, bytes.NewBufferString(body)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func JSONResponse(body interface{}) Responder {
|
||||
return func(*http.Request) (*http.Response, error) {
|
||||
b, _ := json.Marshal(body)
|
||||
return httpResponse(200, bytes.NewBuffer(b)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func GraphQLMutation(body string, cb func(map[string]interface{})) Responder {
|
||||
return func(req *http.Request) (*http.Response, error) {
|
||||
var bodyData struct {
|
||||
Variables struct {
|
||||
Input map[string]interface{}
|
||||
}
|
||||
}
|
||||
err := decodeJSONBody(req, &bodyData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cb(bodyData.Variables.Input)
|
||||
|
||||
return httpResponse(200, bytes.NewBufferString(body)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func httpResponse(status int, body io.Reader) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: ioutil.NopCloser(body),
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
)
|
||||
|
||||
func TestCheckForUpdate(t *testing.T) {
|
||||
|
|
@ -51,7 +52,7 @@ func TestCheckForUpdate(t *testing.T) {
|
|||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.Name, func(t *testing.T) {
|
||||
http := &api.FakeHTTP{}
|
||||
http := &httpmock.Registry{}
|
||||
client := api.NewClient(api.ReplaceTripper(http))
|
||||
http.StubResponse(200, bytes.NewBufferString(fmt.Sprintf(`{
|
||||
"tag_name": "%s",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue