diff --git a/client/auth_rep.go b/client/auth_rep.go index 2eb9a05..1f07bf2 100644 --- a/client/auth_rep.go +++ b/client/auth_rep.go @@ -51,7 +51,7 @@ func (client *ThreeScaleClient) authRep(values url.Values, extensions map[string } req.URL.RawQuery = values.Encode() - resp, err = client.doHttpReq(req) + resp, err = client.doHttpReq(req, extensions) if err != nil { return resp, fmt.Errorf("error calling 3Scale API - %s", err.Error()) } diff --git a/client/auth_rep_test.go b/client/auth_rep_test.go index 1fcb0ad..6e19512 100644 --- a/client/auth_rep_test.go +++ b/client/auth_rep_test.go @@ -109,6 +109,7 @@ func TestAuthRepKey(t *testing.T) { fakeUserKey, fakeServiceToken, fakeServiceId := "userkey12345", "servicetoken54321", "555000" fakeMetricKey := "usage[hits]" authRepInputs := []struct { + name string userKey, svcId string auth TokenAuth extensions map[string]string diff --git a/client/authz.go b/client/authz.go index d2aa016..ac802c4 100644 --- a/client/authz.go +++ b/client/authz.go @@ -23,7 +23,7 @@ func (client *ThreeScaleClient) Authorize(appId string, serviceToken string, ser values.Add("service_id", serviceId) req.URL.RawQuery = values.Encode() - authRepRes, err := client.doHttpReq(req) + authRepRes, err := client.doHttpReq(req, extensions) if err != nil { return authRepResp, fmt.Errorf("error calling 3Scale API - %s", err.Error()) } @@ -45,7 +45,7 @@ func (client *ThreeScaleClient) AuthorizeKey(userKey string, serviceToken string values.Add("service_id", serviceId) req.URL.RawQuery = values.Encode() - resp, err = client.doHttpReq(req) + resp, err = client.doHttpReq(req, extensions) if err != nil { return resp, fmt.Errorf("error calling 3Scale API - %s", err.Error()) } diff --git a/client/client.go b/client/client.go index fa947c1..085dd2b 100644 --- a/client/client.go +++ b/client/client.go @@ -9,11 +9,15 @@ import ( "net/url" "reflect" "strconv" + "strings" ) const ( - defaultBackendUrl = "https://su1.3scale.net:443" - queryTag = "query" + defaultBackendUrl = "https://su1.3scale.net:443" + queryTag = "query" + limitExtensions = "limit_headers" + limitRemainingHeaderKey = "3scale-limit-remaining" + limitResetHeaderKey = "3scale-limit-reset" ) var httpReqError = errors.New("error building http request") @@ -52,7 +56,7 @@ func NewThreeScale(backEnd *Backend, httpClient *http.Client) *ThreeScaleClient } // GetPeer - a utility method that returns the remote hostname of the client -func (client *ThreeScaleClient) GetPeer() string { +func (client *ThreeScaleClient) GetPeer() string { return client.backend.host } @@ -93,7 +97,7 @@ func encodeExtensions(extensions map[string]string) string { } // Call 3scale backend with the provided HTTP request -func (client *ThreeScaleClient) doHttpReq(req *http.Request) (ApiResponse, error) { +func (client *ThreeScaleClient) doHttpReq(req *http.Request, ext map[string]string) (ApiResponse, error) { var authRepRes ApiResponse resp, err := client.httpClient.Do(req) @@ -108,10 +112,56 @@ func (client *ThreeScaleClient) doHttpReq(req *http.Request) (ApiResponse, error if err != nil { return authRepRes, err } + authRepRes.StatusCode = resp.StatusCode + + if ext != nil { + if _, ok := ext[limitExtensions]; ok { + authRepRes.RateLimits = &RateLimits{} + if limitRem := resp.Header[limitRemainingHeaderKey][0]; limitRem != "" { + remainingLimit, err := strconv.Atoi(limitRem) + if err != nil { + authRepRes.RateLimits = nil + goto out + } + authRepRes.RateLimits.limitRemaining = remainingLimit + } + + if limReset := resp.Header[limitResetHeaderKey][0]; limReset != "" { + resetLimit, err := strconv.Atoi(limReset) + if err != nil { + authRepRes.RateLimits = nil + goto out + } + authRepRes.RateLimits.limitReset = resetLimit + } + } + } + +out: return authRepRes, nil } +// GetLimitRemaining - An integer stating the amount of hits left for the full combination of metrics authorized in this call +// before the rate limiting logic would start denying authorizations for the current period. +// A value of -1 indicates there is no limit in the amount of hits. +// Nil value will indicate the extension has not been used. +func (r RateLimits) GetLimitRemaining() int { + return r.limitRemaining +} + +// GetLimitReset - An integer stating the amount of seconds left for the current limiting period to elapse. +// A value of -1 indicates there i is no limit in time. +// Nil value will indicate the extension has not been used. +func (r RateLimits) GetLimitReset() int { + return r.limitReset +} + +// GetHierarchy - A list of children (methods) associated with a parent(metric) +func (r ApiResponse) GetHierarchy() map[string][]string { + return r.hierarchy +} + // Add a metric to list of metrics to be reported // Returns error if provided value is non-positive and entry will be ignored func (m Metrics) Add(name string, value int) error { @@ -179,9 +229,32 @@ func getApiResp(r io.Reader) (ApiResponse, error) { resp.Reason = apiResp.Code } } + + if len(apiResp.Hierarchy.Metric) > 0 { + resp.hierarchy = make(map[string][]string, len(apiResp.Hierarchy.Metric)) + for _, i := range apiResp.Hierarchy.Metric { + if i.Children != "" { + children := strings.Split(i.Children, " ") + for _, child := range children { + if !contains(child, resp.hierarchy[i.Name]) { + resp.hierarchy[i.Name] = append(resp.hierarchy[i.Name], child) + } + } + } + } + } return resp, nil } +func contains(key string, in []string) bool { + for _, i := range in { + if key == i { + return true + } + } + return false +} + // Helper function to read custom tags and add them to query string // Returns a list of values formatted as expected by 3scale API func parseQueries(obj interface{}, values url.Values, m Metrics, l Log) url.Values { diff --git a/client/client_test.go b/client/client_test.go index c9aa080..6c85057 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -1,7 +1,9 @@ package client import ( + "bytes" "fmt" + "io/ioutil" "math/rand" "net/http" "path/filepath" @@ -12,6 +14,8 @@ import ( "testing" "time" "unsafe" + + "github.com/3scale/3scale-go-client/fake" ) var ext_tested bool @@ -83,6 +87,92 @@ func TestNewThreeScale(t *testing.T) { equals(t, threeScaleTwo.backend, DefaultBackend()) } +// Asserts correct behaviour from response when specific extensions are enabled +func TestExtensions(t *testing.T) { + const empty = "" + + tokenAuth := TokenAuth{Type: serviceToken, Value: empty} + + inputs := []struct { + name string + extensions map[string]string + xmlResponse string + headers http.Header + // function which should error out or complete if no error detected + isOK func(r ApiResponse, e error) + }{ + { + name: "Test Hierarchy Extension", + extensions: map[string]string{"hierarchy": "1"}, + xmlResponse: fake.GetHierarchyEnabledResponse(), + isOK: func(r ApiResponse, e error) { + if e != nil { + t.Errorf("expected nil error") + } + if len(r.GetHierarchy()) != 1 { + t.Errorf("expected only one parent in hierarchy") + } + if len(r.GetHierarchy()["hits"]) != 3 { + t.Errorf("expected three children for hits metric") + } + + }, + headers: make(http.Header), + }, + { + name: "Test Limit Extension", + extensions: map[string]string{"limit_headers": "1"}, + xmlResponse: fake.GetAuthSuccess(), + isOK: func(r ApiResponse, e error) { + if e != nil { + t.Errorf("expected nil error") + } + + if limRem := r.RateLimits.GetLimitRemaining(); limRem != 10 { + t.Errorf("unexpected limit parsing - limit remaining") + } + + if limRes := r.RateLimits.GetLimitReset(); limRes != 500 { + t.Errorf("unexpected limit parsing - limit reset") + } + }, + headers: http.Header{limitRemainingHeaderKey: []string{"10"}, limitResetHeaderKey: []string{"500"}}, + }, + } + for _, input := range inputs { + t.Run(input.name, func(t *testing.T) { + httpClient := NewTestClient(func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewBufferString(input.xmlResponse)), + Header: input.headers, + } + }) + c := threeScaleTestClient(httpClient) + + r, err := c.AuthRepAppID(tokenAuth, empty, empty, AuthRepParams{}, input.extensions) + input.isOK(r, err) + + r, err = c.AuthRepUserKey(tokenAuth, empty, empty, AuthRepParams{}, input.extensions) + input.isOK(r, err) + + r, err = c.Authorize(empty, empty, empty, AuthorizeParams{}, input.extensions) + input.isOK(r, err) + + r, err = c.AuthorizeKey(empty, empty, empty, AuthorizeKeyParams{}, input.extensions) + input.isOK(r, err) + + r, err = c.ReportAppID(tokenAuth, empty, ReportTransactions{}, input.extensions) + input.isOK(r, err) + + r, err = c.ReportUserKey(tokenAuth, empty, ReportTransactions{}, input.extensions) + input.isOK(r, err) + }) + + } + +} + // Returns a default client for testing func threeScaleTestClient(hc *http.Client) *ThreeScaleClient { client := NewThreeScale(DefaultBackend(), hc) @@ -104,11 +194,11 @@ func getExtensions(t *testing.T) map[string]string { // ensure we at least return the extensions the first time we get called if !ext_tested || rand.Intn(2) != 0 { ext_tested = true - return map[string]string { - "no_body": "1", + return map[string]string{ + "no_body": "1", "asingle;field": "and;single;value", - "many@@and==": "should@@befine==", - "a test&": "&ok", + "many@@and==": "should@@befine==", + "a test&": "&ok", } } else { return nil @@ -118,10 +208,10 @@ func getExtensions(t *testing.T) map[string]string { // returns a randomly-ordered list of strings for extensions with format "key=value" func getExtensionsValue() []string { expected := map[string]string{ - "no_body": "1", - "asingle%3Bfield": "and%3Bsingle%3Bvalue", + "no_body": "1", + "asingle%3Bfield": "and%3Bsingle%3Bvalue", "many%40%40and%3D%3D": "should%40%40befine%3D%3D", - "a+test%26": "%26ok", + "a+test%26": "%26ok", } exp := make([]string, 0, unsafe.Sizeof(expected)) @@ -147,7 +237,7 @@ func checkExtensions(t *testing.T, req *http.Request) (bool, string) { sort.Strings(expected) sort.Strings(found) - return false, fmt.Sprintf("\nexpected extension header value %s\n" + + return false, fmt.Sprintf("\nexpected extension header value %s\n"+ " but found %s", strings.Join(expected, ", "), strings.Join(found, ", ")) diff --git a/client/report.go b/client/report.go index 1052782..f79c5e4 100644 --- a/client/report.go +++ b/client/report.go @@ -44,7 +44,7 @@ func (client *ThreeScaleClient) report(values url.Values, extensions map[string] } req.URL.RawQuery = values.Encode() - resp, err = client.doHttpReq(req) + resp, err = client.doHttpReq(req, extensions) if err != nil { return resp, fmt.Errorf("error calling 3Scale API - %s", err.Error()) } diff --git a/client/types.go b/client/types.go index d499868..fcbc8fb 100644 --- a/client/types.go +++ b/client/types.go @@ -15,14 +15,18 @@ type ApiResponse struct { Reason string Success bool StatusCode int + // nil value indicates 'limit_headers' extension not in use or parsing error with 3scale response. + RateLimits *RateLimits + hierarchy map[string][]string } // ApiResponseXML - response from backend API type ApiResponseXML struct { - Name xml.Name `xml:",any"` - Authorized bool `xml:"authorized,omitempty"` - Reason string `xml:"reason,omitempty"` - Code string `xml:"code,attr,omitempty"` + Name xml.Name `xml:",any"` + Authorized bool `xml:"authorized,omitempty"` + Reason string `xml:"reason,omitempty"` + Code string `xml:"code,attr,omitempty"` + Hierarchy Hierarchy `xml:"hierarchy"` } // AuthorizeParams - optional parameters for the Authorize API - App ID pattern @@ -80,7 +84,21 @@ type TokenAuth struct { Value string } -func (auth *TokenAuth) SetURLValues(values *url.Values) (error) { +// Hierarchy encapsulates the return value when using "hierarchy" extension +type Hierarchy struct { + Metric []struct { + Name string `xml:"name,attr"` + Children string `xml:"children,attr"` + } `xml:"metric"` +} + +// RateLimits encapsulates the return values when using the "limit_headers" extension +type RateLimits struct { + limitRemaining int + limitReset int +} + +func (auth *TokenAuth) SetURLValues(values *url.Values) error { switch auth.Type { case serviceToken: diff --git a/fake/xml_resp.go b/fake/xml_resp.go index b26a9e3..a27cf35 100644 --- a/fake/xml_resp.go +++ b/fake/xml_resp.go @@ -46,3 +46,30 @@ func GetLimitExceededResp() string { ` } + +// Get mock response with hierarchy extension enabled +func GetHierarchyEnabledResponse() string { + return ` + + true + Basic + + + 2019-02-22 14:32:00 +0000 + 2019-02-22 14:33:00 +0000 + 4 + 1 + + + 2019-02-18 00:00:00 +0000 + 2019-02-25 00:00:00 +0000 + 6 + 0 + + + + + + +` +}