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

Enhance reliability of how we make requests and parse errors #108

Merged
merged 1 commit into from
Aug 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions management/anomaly.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,28 @@ func newAnomalyManager(m *Management) *AnomalyManager {
//
// See: https://auth0.com/docs/api/management/v2#!/Anomaly/get_ips_by_id
func (m *AnomalyManager) CheckIP(ip string, opts ...RequestOption) (isBlocked bool, err error) {
req, err := m.NewRequest("GET", m.URI("anomaly", "blocks", "ips", ip), nil, opts...)
request, err := m.NewRequest("GET", m.URI("anomaly", "blocks", "ips", ip), nil, opts...)
if err != nil {
return false, err
}

res, err := m.Do(req)
response, err := m.Do(request)
if err != nil {
return false, err
}
defer response.Body.Close()

// 200: IP address specified is currently blocked.
if res.StatusCode == http.StatusOK {
if response.StatusCode == http.StatusOK {
return true, nil
}

// 404: IP address specified is not currently blocked.
if res.StatusCode == http.StatusNotFound {
if response.StatusCode == http.StatusNotFound {
return false, nil
}

return false, newError(res.Body)
return false, newError(response)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've changed the signature of the newError func to accept the entire response so we can have access to the status code as well. Further clarifications as to why this is needed are provided in the comments below.

}

// UnblockIP unblocks an IP address currently blocked by the multiple
Expand Down
17 changes: 1 addition & 16 deletions management/branding.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package management
import (
"encoding/json"
"fmt"
"net/http"
)

// Branding is used to customize the look and feel of Auth0 to align
Expand Down Expand Up @@ -168,21 +167,7 @@ func (m *BrandingManager) UniversalLogin(opts ...RequestOption) (ul *BrandingUni
//
// See: https://auth0.com/docs/api/management/v2#!/Branding/put_universal_login
func (m *BrandingManager) SetUniversalLogin(ul *BrandingUniversalLogin, opts ...RequestOption) (err error) {
req, err := m.NewRequest("PUT", m.URI("branding", "templates", "universal-login"), ul.Body, opts...)
if err != nil {
return err
}

res, err := m.Do(req)
if err != nil {
return err
}

if res.StatusCode >= http.StatusBadRequest {
return newError(res.Body)
}

return nil
Comment on lines -171 to -185
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of this part was just replicating what the m.Request() was doing with the difference that we were not correctly closing the response body in this func, causing memory leaks.

return m.Request("PUT", m.URI("branding", "templates", "universal-login"), ul.Body, opts...)
}

// DeleteUniversalLogin deletes the template for the New Universal Login Experience.
Expand Down
12 changes: 6 additions & 6 deletions management/guardian.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,23 +146,23 @@ type EnrollmentTicket struct {
//
// See: https://auth0.com/docs/api/management/v2#!/Guardian/post_ticket
func (m *EnrollmentManager) CreateTicket(t *CreateEnrollmentTicket, opts ...RequestOption) (EnrollmentTicket, error) {
req, err := m.NewRequest("POST", m.URI("guardian", "enrollments", "ticket"), t, opts...)
request, err := m.NewRequest("POST", m.URI("guardian", "enrollments", "ticket"), t, opts...)
if err != nil {
return EnrollmentTicket{}, err
}

res, err := m.Do(req)
response, err := m.Do(request)
if err != nil {
return EnrollmentTicket{}, err
}
defer res.Body.Close()
defer response.Body.Close()

if res.StatusCode != http.StatusOK {
return EnrollmentTicket{}, newError(res.Body)
if response.StatusCode != http.StatusOK {
return EnrollmentTicket{}, newError(response)
}

var out EnrollmentTicket
err = json.NewDecoder(res.Body).Decode(&out)
err = json.NewDecoder(response.Body).Decode(&out)
Comment on lines +149 to +165
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately we couldn't do the same thing as we did in the branding.go file and simply replace all the logic with a call to m.Request. This is because m.Request expects to change in place the same object that gets passed through with the response data. In this case we pass CreateEnrollmentTicket but return an EnrollmentTicket which are 2 different structs.

return out, err
}

Expand Down
27 changes: 2 additions & 25 deletions management/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/textproto"
"strconv"
"time"
Expand Down Expand Up @@ -126,29 +125,7 @@ func (m *JobManager) ImportUsers(j *Job, opts ...RequestOption) error {
}
mp.Close()

req, err := http.NewRequest("POST", m.URI("jobs", "users-imports"), &payload)
if err != nil {
return err
}
req.Header.Add("Content-Type", mp.FormDataContentType())

for _, option := range opts {
option.apply(req)
}

res, err := m.Do(req)
if err != nil {
return err
}

if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
return newError(res.Body)
}

if res.StatusCode != http.StatusNoContent {
defer res.Body.Close()
return json.NewDecoder(res.Body).Decode(j)
}
opts = append(opts, Header("Content-Type", mp.FormDataContentType()))

return nil
Comment on lines -129 to -153
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, this is doing exactly what the m.Request is doing so we replace it after appending the opts correctly.

return m.Request("POST", m.URI("jobs", "users-imports"), &payload, opts...)
}
24 changes: 18 additions & 6 deletions management/management_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package management
import (
"encoding/json"
"fmt"
"io"
"net/http"
)

// Error is an interface describing any error which
Expand All @@ -21,13 +21,25 @@ type managementError struct {
Message string `json:"message"`
}

func newError(r io.Reader) error {
m := &managementError{}
if err := json.NewDecoder(r).Decode(m); err != nil {
return err
func newError(response *http.Response) error {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quite a few improvements to this func:

  • Preserve status code from the response instead of relying purely that the body contains the status code as well
  • Preserve status code when we fail to decode the response when this is not a valid JSON

apiError := &managementError{}

if err := json.NewDecoder(response.Body).Decode(apiError); err != nil {
return &managementError{
StatusCode: response.StatusCode,
Err: http.StatusText(response.StatusCode),
Message: fmt.Errorf("failed to decode json error response payload: %w", err).Error(),
}
}

// This can happen in case the error message structure changes.
// If that happens we still want to display the correct code.
if apiError.Status() == 0 {
apiError.StatusCode = response.StatusCode
apiError.Err = http.StatusText(response.StatusCode)
}

return m
return apiError
}

// Error formats the error into a string representation.
Expand Down
62 changes: 62 additions & 0 deletions management/management_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package management

import (
"io"
"net/http"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestNewError(t *testing.T) {
var testCases = []struct {
name string
givenResponse http.Response
expectedError managementError
}{
{
name: "it fails to decode if body is not a json",
givenResponse: http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(strings.NewReader("Hello, I'm not a JSON.")),
},
expectedError: managementError{
StatusCode: 403,
Err: "Forbidden",
Message: "failed to decode json error response payload: invalid character 'H' looking for beginning of value",
},
},
{
name: "it correctly decodes the error response payload",
givenResponse: http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(`{"statusCode":400,"error":"Bad Request","message":"One of 'client_id' or 'name' is required."}`)),
},
expectedError: managementError{
StatusCode: 400,
Err: "Bad Request",
Message: "One of 'client_id' or 'name' is required.",
},
},
{
name: "it will still post the correct status code if the body doesn't have the correct structure",
givenResponse: http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(`{"errorMessage":"wrongStruct"}`)),
},
expectedError: managementError{
StatusCode: 500,
Err: "Internal Server Error",
Message: "",
},
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actualError := newError(&testCase.givenResponse)
assert.Equal(t, &testCase.expectedError, actualError)
})
}
}
27 changes: 16 additions & 11 deletions management/management_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,27 +84,32 @@ func (m *Management) Do(req *http.Request) (*http.Response, error) {
}

// Request combines NewRequest and Do, while also handling decoding of response payload.
func (m *Management) Request(method, uri string, v interface{}, options ...RequestOption) error {
request, err := m.NewRequest(method, uri, v, options...)
func (m *Management) Request(method, uri string, payload interface{}, options ...RequestOption) error {
request, err := m.NewRequest(method, uri, payload, options...)
if err != nil {
return err
return fmt.Errorf("failed to create a new request: %w", err)
}

response, err := m.Do(request)
if err != nil {
return fmt.Errorf("request failed: %w", err)
return fmt.Errorf("failed to send the request: %w", err)
}
defer response.Body.Close()

if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest {
return newError(response.Body)
// If the response contains a client or a server error then return the error.
if response.StatusCode >= http.StatusBadRequest {
return newError(response)
}

if response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusAccepted {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check was replaced by if len(responseBody) > 0 && string(responseBody) != "{}"

if err := json.NewDecoder(response.Body).Decode(v); err != nil {
return fmt.Errorf("decoding response payload failed: %w", err)
}
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("failed to read the response body: %w", err)
}

return response.Body.Close()
if len(responseBody) > 0 && string(responseBody) != "{}" {
if err = json.Unmarshal(responseBody, &payload); err != nil {
return fmt.Errorf("failed to unmarshal response payload: %w", err)
}
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion management/management_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ func TestOptionParameter(t *testing.T) {
assert.Equal(t, "xyz", bar)
}

func TestOptionDefauls(t *testing.T) {
func TestOptionDefaults(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)

applyListDefaults([]RequestOption{
Expand Down
24 changes: 15 additions & 9 deletions management/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package management

import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
Expand Down Expand Up @@ -530,26 +532,30 @@ func (m *UserManager) InvalidateRememberBrowser(id string, opts ...RequestOption
//
// See: https://auth0.com/docs/api/management/v2#!/Users/post_identities
func (m *UserManager) Link(id string, il *UserIdentityLink, opts ...RequestOption) (uIDs []UserIdentity, err error) {
req, err := m.NewRequest("POST", m.URI("users", id, "identities"), il, opts...)
request, err := m.NewRequest("POST", m.URI("users", id, "identities"), il, opts...)
if err != nil {
return uIDs, err
}

res, err := m.Do(req)
response, err := m.Do(request)
if err != nil {
return uIDs, err
}
defer response.Body.Close()

if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
return uIDs, newError(res.Body)
if response.StatusCode >= http.StatusBadRequest {
return uIDs, newError(response)
}

if res.StatusCode != http.StatusNoContent && res.StatusCode != http.StatusAccepted {
err := json.NewDecoder(res.Body).Decode(&uIDs)
if err != nil {
return uIDs, err
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return uIDs, fmt.Errorf("failed to read the response body: %w", err)
}

if len(responseBody) > 0 {
if err = json.Unmarshal(responseBody, &uIDs); err != nil {
return uIDs, fmt.Errorf("failed to unmarshal response payload: %w", err)
}
return uIDs, res.Body.Close()
}

return uIDs, nil
Expand Down