diff --git a/CHANGELOG.md b/CHANGELOG.md index 881d4839..dc836266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ * Support PATCH HTTP method for Proxy update(`PATCH /proxies/{proxy}`) and Toxic update(`PATCH /proxies/{proxy}/toxics/{toxic}`) endpoints. Deprecat POST HTTP method for those endpoints. (@miry) +* Client does not parse response body in case of errors for Populate. + Requires to get current proxies with new command. (#441, @miry) +* Client specifies `User-Agent` HTTP header for all requests as + "toxiproxy-cli/ /". + Specifies client request content type as `application/json`. (#441, @miry) # [2.5.0] - 2022-09-10 diff --git a/api.go b/api.go index b8975174..b7c5ef4c 100644 --- a/api.go +++ b/api.go @@ -243,10 +243,13 @@ func (server *ApiServer) ProxyCreate(response http.ResponseWriter, request *http func (server *ApiServer) Populate(response http.ResponseWriter, request *http.Request) { proxies, err := server.Collection.PopulateJson(server, request.Body) + log := zerolog.Ctx(request.Context()) + if err != nil { + log.Warn().Err(err).Msg("Populate errors") + } apiErr, ok := err.(*ApiError) if !ok && err != nil { - log := zerolog.Ctx(request.Context()) log.Warn().Err(err).Msg("Error did not include status code") apiErr = &ApiError{err.Error(), http.StatusInternalServerError} } @@ -268,7 +271,6 @@ func (server *ApiServer) Populate(response http.ResponseWriter, request *http.Re response.WriteHeader(responseCode) _, err = response.Write(data) if err != nil { - log := zerolog.Ctx(request.Context()) log.Warn().Err(err).Msg("Populate: Failed to write response to client") } } @@ -474,10 +476,12 @@ func (server *ApiServer) ToxicDelete(response http.ResponseWriter, request *http } func (server *ApiServer) Version(response http.ResponseWriter, request *http.Request) { - response.Header().Set("Content-Type", "text/plain;charset=utf-8") - _, err := response.Write([]byte(Version)) + log := zerolog.Ctx(request.Context()) + + response.Header().Set("Content-Type", "application/json;charset=utf-8") + version := fmt.Sprintf(`{"version": "%s"}\n`, Version) + _, err := response.Write([]byte(version)) if err != nil { - log := zerolog.Ctx(request.Context()) log.Warn().Err(err).Msg("Version: Failed to write response to client") } } diff --git a/api_test.go b/api_test.go index 7ac93243..aeee36e2 100644 --- a/api_test.go +++ b/api_test.go @@ -128,10 +128,12 @@ func TestIndexWithNoProxies(t *testing.T) { func TestCreateProxyBlankName(t *testing.T) { WithServer(t, func(addr string) { _, err := client.CreateProxy("", "", "") + + expected := "Create: HTTP 400: missing required field: name" if err == nil { - t.Fatal("Expected error creating proxy, got nil") - } else if err.Error() != "Create: HTTP 400: missing required field: name" { - t.Fatal("Expected different error creating proxy:", err) + t.Error("Expected error creating proxy, got nil") + } else if err.Error() != expected { + t.Errorf("Expected error `%s',\n\tgot: `%s'", expected, err) } }) } @@ -140,9 +142,9 @@ func TestCreateProxyBlankUpstream(t *testing.T) { WithServer(t, func(addr string) { _, err := client.CreateProxy("test", "", "") if err == nil { - t.Fatal("Expected error creating proxy, got nil") + t.Error("Expected error creating proxy, got nil") } else if err.Error() != "Create: HTTP 400: missing required field: upstream" { - t.Fatal("Expected different error creating proxy:", err) + t.Error("Expected different error creating proxy:", err) } }) } @@ -333,8 +335,9 @@ func TestPopulateWithBadName(t *testing.T) { t.Fatal("Expected Populate to fail.") } - if err.Error() != "Populate: HTTP 400: missing required field: name at proxy 2" { - t.Fatal("Expected different error during populate:", err) + expected := "Populate: HTTP 400: missing required field: name at proxy 2" + if err.Error() != expected { + t.Fatalf("Expected error `%s',\n\tgot: `%s'", expected, err) } if len(testProxies) != 0 { @@ -377,21 +380,25 @@ func TestPopulateProxyWithBadDataShouldReturnError(t *testing.T) { t.Fatal("Expected Populate to fail.") } - if len(testProxies) != 1 { - t.Fatalf("Wrong number of proxies returned: %d != %d", len(testProxies), 1) + if len(testProxies) != 0 { + t.Fatalf("Expected Proxies to be empty, got %v", testProxies) } - if testProxies[0].Name != "one" { - t.Fatalf("Wrong proxy name returned: %s != one", testProxies[0].Name) + proxies, err := client.Proxies() + if err != nil { + t.Fatalf("Expected no error, got: %v", err) } - for _, p := range testProxies { - AssertProxyUp(t, p.Listen, true) + if len(proxies) != 1 { + t.Fatalf("Wrong number of proxies returned: %d != %d", len(proxies), 1) } - proxies, err := client.Proxies() - if err != nil { - t.Fatal(err) + if _, ok := proxies["one"]; !ok { + t.Fatal("Proxy `one' was not created!") + } + + for _, p := range testProxies { + AssertProxyUp(t, p.Listen, true) } for _, p := range proxies { @@ -642,11 +649,12 @@ func TestDeleteProxy(t *testing.T) { t.Fatal("Expected proxy to be deleted from list") } + expected := "Delete: HTTP 404: proxy not found" err = testProxy.Delete() if err == nil { - t.Fatal("Proxy did not result in not found.") - } else if err.Error() != "Delete: HTTP 404: proxy not found" { - t.Fatal("Incorrect error removing proxy:", err) + t.Error("Proxy did not result in not found.") + } else if err.Error() != expected { + t.Errorf("Expected error `%s',\n\tgot: `%s'", expected, err) } }) } @@ -658,12 +666,12 @@ func TestCreateProxyPortConflict(t *testing.T) { t.Fatal("Unable to create proxy:", err) } + expected := "Create: HTTP 500: listen tcp 127.0.0.1:3310: bind: address already in use" _, err = client.CreateProxy("test", "localhost:3310", "localhost:20001") if err == nil { - t.Fatal("Proxy did not result in conflict.") - } else if err.Error() != - "Create: HTTP 500: listen tcp 127.0.0.1:3310: bind: address already in use" { - t.Fatal("Incorrect error adding proxy:", err) + t.Error("Proxy did not result in conflict.") + } else if err.Error() != expected { + t.Errorf("Expected error `%s',\n\tgot: `%s'", expected, err) } err = testProxy.Delete() @@ -684,11 +692,12 @@ func TestCreateProxyNameConflict(t *testing.T) { t.Fatal("Unable to create proxy:", err) } + expected := "Create: HTTP 409: proxy already exists" _, err = client.CreateProxy("mysql_master", "localhost:3311", "localhost:20001") if err == nil { t.Fatal("Proxy did not result in conflict.") - } else if err.Error() != "Create: HTTP 409: proxy already exists" { - t.Fatal("Incorrect error adding proxy:", err) + } else if err.Error() != expected { + t.Fatalf("Expected error `%s',\n\tgot: `%s'", expected, err) } err = testProxy.Delete() @@ -1094,7 +1103,7 @@ func TestVersionEndpointReturnsVersion(t *testing.T) { t.Fatal("Unable to read body from response") } - if string(body) != toxiproxy.Version { + if string(body) != `{"version": "git"}\n` { t.Fatal("Expected to return Version from /version, got:", string(body)) } }) diff --git a/client/api_error.go b/client/api_error.go index 1aad1dd2..946e54a5 100644 --- a/client/api_error.go +++ b/client/api_error.go @@ -6,9 +6,7 @@ package toxiproxy import ( - "encoding/json" "fmt" - "net/http" ) type ApiError struct { @@ -19,16 +17,3 @@ type ApiError struct { func (err *ApiError) Error() string { return fmt.Sprintf("HTTP %d: %s", err.Status, err.Message) } - -func checkError(resp *http.Response, expectedCode int, caller string) error { - if resp.StatusCode != expectedCode { - apiError := new(ApiError) - err := json.NewDecoder(resp.Body).Decode(apiError) - if err != nil { - apiError.Message = fmt.Sprintf("Unexpected response code, expected %d", expectedCode) - apiError.Status = resp.StatusCode - } - return fmt.Errorf("%s: %v", caller, apiError) - } - return nil -} diff --git a/client/client.go b/client/client.go index 00c4f339..7100fde1 100644 --- a/client/client.go +++ b/client/client.go @@ -9,43 +9,56 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "strings" + "time" ) // Client holds information about where to connect to Toxiproxy. type Client struct { - endpoint string + UserAgent string + endpoint string + http *http.Client } // NewClient creates a new client which provides the base of all communication // with Toxiproxy. Endpoint is the address to the proxy (e.g. localhost:8474 if // not overridden). func NewClient(endpoint string) *Client { - if !strings.HasPrefix(endpoint, "https://") && !strings.HasPrefix(endpoint, "http://") { + if !strings.HasPrefix(endpoint, "https://") && + !strings.HasPrefix(endpoint, "http://") { endpoint = "http://" + endpoint } - return &Client{endpoint: endpoint} + + http := &http.Client{ + Timeout: 30 * time.Second, + } + + return &Client{ + UserAgent: "toxiproxy-cli", + endpoint: endpoint, + http: http, + } +} + +// Version returns a Toxiproxy running version. +func (client *Client) Version() ([]byte, error) { + return client.get("/version") } // Proxies returns a map with all the proxies and their toxics. func (client *Client) Proxies() (map[string]*Proxy, error) { - resp, err := http.Get(client.endpoint + "/proxies") - if err != nil { - return nil, err - } - - err = checkError(resp, http.StatusOK, "Proxies") + resp, err := client.get("/proxies") if err != nil { return nil, err } proxies := make(map[string]*Proxy) - err = json.NewDecoder(resp.Body).Decode(&proxies) + err = json.Unmarshal(resp, &proxies) if err != nil { return nil, err } + for _, proxy := range proxies { proxy.client = client proxy.created = true @@ -75,7 +88,7 @@ func (client *Client) CreateProxy(name, listen, upstream string) (*Proxy, error) err := proxy.Save() if err != nil { - return nil, err + return nil, fmt.Errorf("Create: %w", err) } return proxy, nil @@ -83,19 +96,13 @@ func (client *Client) CreateProxy(name, listen, upstream string) (*Proxy, error) // Proxy returns a proxy by name. func (client *Client) Proxy(name string) (*Proxy, error) { - // TODO url encode - resp, err := http.Get(client.endpoint + "/proxies/" + name) - if err != nil { - return nil, err - } - - err = checkError(resp, http.StatusOK, "Proxy") + resp, err := client.get("/proxies/" + name) if err != nil { return nil, err } proxy := new(Proxy) - err = json.NewDecoder(resp.Body).Decode(proxy) + err = json.Unmarshal(resp, &proxy) if err != nil { return nil, err } @@ -118,32 +125,20 @@ func (client *Client) Populate(config []Proxy) ([]*Proxy, error) { return nil, err } - resp, err := http.Post( - client.endpoint+"/populate", - "application/json", - bytes.NewReader(request), - ) + resp, err := client.post("/populate", bytes.NewReader(request)) if err != nil { - return nil, err + return nil, fmt.Errorf("Populate: %w", err) } - // Response body may need to be read twice, we want to return both the proxy list and any errors - var body bytes.Buffer - tee := io.TeeReader(resp.Body, &body) - err = json.NewDecoder(tee).Decode(&proxies) + err = json.Unmarshal(resp, &proxies) if err != nil { return nil, err } - resp.Body = ioutil.NopCloser(&body) - err = checkError(resp, http.StatusCreated, "Populate") - if err != nil { - return proxies.Proxies, err - } - for _, proxy := range proxies.Proxies { proxy.client = client } + return proxies.Proxies, err } @@ -213,10 +208,73 @@ func (client *Client) RemoveToxic(options *ToxicOptions) error { // ResetState resets the state of all proxies and toxics in Toxiproxy. func (client *Client) ResetState() error { - resp, err := http.Post(client.endpoint+"/reset", "text/plain", bytes.NewReader([]byte{})) + _, err := client.post("/reset", bytes.NewReader([]byte{})) + return err +} + +func (c *Client) get(path string) ([]byte, error) { + return c.send("GET", path, nil) +} + +func (c *Client) post(path string, body io.Reader) ([]byte, error) { + return c.send("POST", path, body) +} + +func (c *Client) patch(path string, body io.Reader) ([]byte, error) { + return c.send("PATCH", path, body) +} + +func (c *Client) delete(path string) error { + _, err := c.send("DELETE", path, nil) + return err +} + +func (c *Client) send(verb, path string, body io.Reader) ([]byte, error) { + req, err := http.NewRequest(verb, c.endpoint+path, body) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("fail to request: %w", err) + } + + err = c.validateResponse(resp) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + result, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) validateResponse(resp *http.Response) error { + if resp.StatusCode < 300 && resp.StatusCode >= 200 { + return nil + } + + apiError := new(ApiError) + err := json.NewDecoder(resp.Body).Decode(&apiError) if err != nil { return err } + resp.Body.Close() - return checkError(resp, http.StatusNoContent, "ResetState") + if err != nil { + apiError.Message = fmt.Sprintf( + "Unexpected response code %d", + resp.StatusCode, + ) + apiError.Status = resp.StatusCode + } + return apiError } diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 00000000..0fb16b28 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,74 @@ +package toxiproxy_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + toxiproxy "github.com/Shopify/toxiproxy/v2/client" +) + +func TestClient_Headers(t *testing.T) { + t.Parallel() + + expected := "toxiproxy-cli/v1.25.0 (darwin/arm64)" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + ua := r.Header.Get("User-Agent") + + if ua != expected { + t.Errorf("User-Agent for %s %s is expected `%s', got: `%s'", + r.Method, + r.URL, + expected, + ua) + } + + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Content-Type for %s %s is expected `application/json', got: `%s'", + r.Method, + r.URL, + contentType) + } + w.Write([]byte(`foo`)) + })) + defer server.Close() + + client := toxiproxy.NewClient(server.URL) + client.UserAgent = expected + + cases := []struct { + name string + fn func(c *toxiproxy.Client) + }{ + {"get version", func(c *toxiproxy.Client) { c.Version() }}, + {"get proxies", func(c *toxiproxy.Client) { c.Proxies() }}, + {"create proxy", func(c *toxiproxy.Client) { + c.CreateProxy("foo", "example.com:0", "example.com:0") + }}, + {"get proxy", func(c *toxiproxy.Client) { c.Proxy("foo") }}, + {"post populate", func(c *toxiproxy.Client) { + c.Populate([]toxiproxy.Proxy{{}}) + }}, + {"create toxic", func(c *toxiproxy.Client) { + c.AddToxic(&toxiproxy.ToxicOptions{}) + }}, + {"update toxic", func(c *toxiproxy.Client) { + c.UpdateToxic(&toxiproxy.ToxicOptions{}) + }}, + {"delete toxic", func(c *toxiproxy.Client) { + c.RemoveToxic(&toxiproxy.ToxicOptions{}) + }}, + {"reset state", func(c *toxiproxy.Client) { + c.ResetState() + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.fn(client) + }) + } +} diff --git a/client/proxy.go b/client/proxy.go index 5e16f6d0..f1f3689a 100644 --- a/client/proxy.go +++ b/client/proxy.go @@ -7,7 +7,7 @@ package toxiproxy import ( "bytes" "encoding/json" - "net/http" + "fmt" ) type Proxy struct { @@ -28,33 +28,25 @@ func (proxy *Proxy) Save() error { if err != nil { return err } + data := bytes.NewReader(request) - path := proxy.client.endpoint + "/proxies" - contenttype := "application/json" + var resp []byte if proxy.created { - path += "/" + proxy.Name - contenttype = "text/plain" - } - - resp, err := http.Post(path, contenttype, bytes.NewReader(request)) - if err != nil { - return err - } - defer resp.Body.Close() - - if proxy.created { - err = checkError(resp, http.StatusOK, "Save") + // TODO: Release PATCH only for v3.0 + // resp, err = proxy.client.patch("/proxies/"+proxy.Name, data) + resp, err = proxy.client.post("/proxies/"+proxy.Name, data) } else { - err = checkError(resp, http.StatusCreated, "Create") + resp, err = proxy.client.post("/proxies", data) } if err != nil { return err } - err = json.NewDecoder(resp.Body).Decode(proxy) + err = json.Unmarshal(resp, proxy) if err != nil { return err } + proxy.created = true return nil @@ -76,34 +68,22 @@ func (proxy *Proxy) Disable() error { // the proxy such as listen port and active toxics will be deleted as well. If you just wish to // stop and later enable a proxy, use `Enable()` and `Disable()`. func (proxy *Proxy) Delete() error { - httpClient := &http.Client{} - req, err := http.NewRequest("DELETE", proxy.client.endpoint+"/proxies/"+proxy.Name, nil) + err := proxy.client.delete("/proxies/" + proxy.Name) if err != nil { - return err + return fmt.Errorf("Delete: %w", err) } - - resp, err := httpClient.Do(req) - if err != nil { - return err - } - - return checkError(resp, http.StatusNoContent, "Delete") + return nil } // Toxics returns a map of all the active toxics and their attributes. func (proxy *Proxy) Toxics() (Toxics, error) { - resp, err := http.Get(proxy.client.endpoint + "/proxies/" + proxy.Name + "/toxics") - if err != nil { - return nil, err - } - - err = checkError(resp, http.StatusOK, "Toxics") + resp, err := proxy.client.get("/proxies/" + proxy.Name + "/toxics") if err != nil { return nil, err } toxics := make(Toxics, 0) - err = json.NewDecoder(resp.Body).Decode(&toxics) + err = json.Unmarshal(resp, &toxics) if err != nil { return nil, err } @@ -130,22 +110,16 @@ func (proxy *Proxy) AddToxic( return nil, err } - resp, err := http.Post( - proxy.client.endpoint+"/proxies/"+proxy.Name+"/toxics", - "application/json", + resp, err := proxy.client.post( + "/proxies/"+proxy.Name+"/toxics", bytes.NewReader(request), ) if err != nil { - return nil, err - } - - err = checkError(resp, http.StatusOK, "AddToxic") - if err != nil { - return nil, err + return nil, fmt.Errorf("AddToxic: %w", err) } result := &Toxic{} - err = json.NewDecoder(resp.Body).Decode(result) + err = json.Unmarshal(resp, result) if err != nil { return nil, err } @@ -167,22 +141,16 @@ func (proxy *Proxy) UpdateToxic(name string, toxicity float32, attrs Attributes) return nil, err } - resp, err := http.Post( - proxy.client.endpoint+"/proxies/"+proxy.Name+"/toxics/"+name, - "application/json", + resp, err := proxy.client.patch( + "/proxies/"+proxy.Name+"/toxics/"+name, bytes.NewReader(request), ) if err != nil { return nil, err } - err = checkError(resp, http.StatusOK, "UpdateToxic") - if err != nil { - return nil, err - } - result := &Toxic{} - err = json.NewDecoder(resp.Body).Decode(result) + err = json.Unmarshal(resp, result) if err != nil { return nil, err } @@ -192,20 +160,5 @@ func (proxy *Proxy) UpdateToxic(name string, toxicity float32, attrs Attributes) // RemoveToxic renives the toxic with the given name. func (proxy *Proxy) RemoveToxic(name string) error { - httpClient := &http.Client{} - req, err := http.NewRequest( - "DELETE", - proxy.client.endpoint+"/proxies/"+proxy.Name+"/toxics/"+name, - nil, - ) - if err != nil { - return err - } - - resp, err := httpClient.Do(req) - if err != nil { - return err - } - - return checkError(resp, http.StatusNoContent, "RemoveToxic") + return proxy.client.delete("/proxies/" + proxy.Name + "/toxics/" + name) } diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 9659b8c1..5af52a74 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "runtime" "sort" "strconv" "strings" @@ -262,6 +263,12 @@ type toxiAction func(*cli.Context, *toxiproxy.Client) error func withToxi(f toxiAction) func(*cli.Context) error { return func(c *cli.Context) error { toxiproxyClient := toxiproxy.NewClient(hostname) + toxiproxyClient.UserAgent = fmt.Sprintf( + "toxiproxy-cli/%s (%s/%s)", + c.App.Version, + runtime.GOOS, + runtime.GOARCH, + ) return f(c, toxiproxyClient) } }