Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

client: Expose rate limit extensions in response #12

Merged
merged 8 commits into from
Feb 25, 2019
2 changes: 1 addition & 1 deletion client/auth_rep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
1 change: 1 addition & 0 deletions client/auth_rep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions client/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand All @@ -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())
}
Expand Down
81 changes: 77 additions & 4 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
106 changes: 98 additions & 8 deletions client/client_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package client

import (
"bytes"
"fmt"
"io/ioutil"
"math/rand"
"net/http"
"path/filepath"
Expand All @@ -12,6 +14,8 @@ import (
"testing"
"time"
"unsafe"

"github.com/3scale/3scale-go-client/fake"
)

var ext_tested bool
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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, ", "))

Expand Down
2 changes: 1 addition & 1 deletion client/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
28 changes: 23 additions & 5 deletions client/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions fake/xml_resp.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,30 @@ func GetLimitExceededResp() string {
</usage_reports>
</status>`
}

// Get mock response with hierarchy extension enabled
func GetHierarchyEnabledResponse() string {
return `<?xml version="1.0" encoding="UTF-8"?>
<status>
<authorized>true</authorized>
<plan>Basic</plan>
<usage_reports>
<usage_report metric="hits" period="minute">
<period_start>2019-02-22 14:32:00 +0000</period_start>
<period_end>2019-02-22 14:33:00 +0000</period_end>
<max_value>4</max_value>
<current_value>1</current_value>
</usage_report>
<usage_report metric="test_metric" period="week">
<period_start>2019-02-18 00:00:00 +0000</period_start>
<period_end>2019-02-25 00:00:00 +0000</period_end>
<max_value>6</max_value>
<current_value>0</current_value>
</usage_report>
</usage_reports>
<hierarchy>
<metric name="hits" children="example sample test" />
<metric name="test_metric" children="" />
</hierarchy>
</status>`
}