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

feat: include error code in error response #352

Merged
merged 6 commits into from
Oct 28, 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
4 changes: 2 additions & 2 deletions registry/remote/auth/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (
"sync/atomic"
"testing"

"oras.land/oras-go/v2/registry/remote/internal/errutil"
"oras.land/oras-go/v2/registry/remote/errcode"
)

func TestClient_SetUserAgent(t *testing.T) {
Expand Down Expand Up @@ -2211,7 +2211,7 @@ func TestClient_StaticCredential_withRefreshToken(t *testing.T) {
}
_, err = clientInvalid.Do(req)

var expectedError *errutil.UnexpectedStatusCodeError
var expectedError *errcode.ErrorResponse
if !errors.As(err, &expectedError) || expectedError.StatusCode != http.StatusUnauthorized {
t.Errorf("incorrect error: %v, expected %v", err, expectedError)
}
Expand Down
103 changes: 103 additions & 0 deletions registry/remote/errcode/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
Copyright The ORAS Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package errcode

import (
"fmt"
"net/http"
"net/url"
"strings"
"unicode"
)

// Error represents a response inner error returned by the remote
// registry.
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Detail any `json:"detail,omitempty"`
}

// Error returns a error string describing the error.
func (e Error) Error() string {
code := strings.Map(func(r rune) rune {
if r == '_' {
return ' '
}
return unicode.ToLower(r)
}, e.Code)
if e.Message == "" {
return code
}
if e.Detail == nil {
return fmt.Sprintf("%s: %s", code, e.Message)
}
return fmt.Sprintf("%s: %s: %v", code, e.Message, e.Detail)
}

// Errors represents a list of response inner errors returned by
// the remote server.
type Errors []Error

// Error returns a error string describing the error.
func (errs Errors) Error() string {
switch len(errs) {
case 0:
return "<nil>"
case 1:
return errs[0].Error()
}
var errmsgs []string
for _, err := range errs {
errmsgs = append(errmsgs, err.Error())
}
return strings.Join(errmsgs, "; ")
}

// Unwrap returns the inner error only when there is exactly one error.
func (errs Errors) Unwrap() error {
if len(errs) == 1 {
return errs[0]
}
return nil
}

// ErrorResponse represents an error response.
type ErrorResponse struct {
Method string
URL *url.URL
StatusCode int
Errors Errors
}

// Error returns a error string describing the error.
func (err *ErrorResponse) Error() string {
var errmsg string
if len(err.Errors) > 0 {
errmsg = err.Errors.Error()
} else {
errmsg = http.StatusText(err.StatusCode)
}
return fmt.Sprintf("%s %q: response status code %d: %s", err.Method, err.URL, err.StatusCode, errmsg)
}

// Unwrap returns the internal errors of err if any.
func (err *ErrorResponse) Unwrap() error {
if len(err.Errors) == 0 {
return nil
}
return err.Errors
}
102 changes: 0 additions & 102 deletions registry/remote/internal/errutil/errors.go

This file was deleted.

47 changes: 47 additions & 0 deletions registry/remote/internal/errutil/errutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
Copyright The ORAS Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package errutil

import (
"encoding/json"
"io"
"net/http"

"oras.land/oras-go/v2/registry/remote/errcode"
)

// maxErrorBytes specifies the default limit on how many response bytes are
// allowed in the server's error response.
// A typical error message is around 200 bytes. Hence, 8 KiB should be
// sufficient.
const maxErrorBytes int64 = 8 * 1024 // 8 KiB

// ParseErrorResponse parses the error returned by the remote registry.
func ParseErrorResponse(resp *http.Response) error {
resultErr := &errcode.ErrorResponse{
Method: resp.Request.Method,
URL: resp.Request.URL,
StatusCode: resp.StatusCode,
}
var body struct {
Errors errcode.Errors `json:"errors"`
}
lr := io.LimitReader(resp.Body, maxErrorBytes)
if err := json.NewDecoder(lr).Decode(&body); err == nil {
resultErr.Errors = body.Errors
}
return resultErr
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,40 +16,92 @@ limitations under the License.
package errutil

import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

"oras.land/oras-go/v2/registry/remote/errcode"
)

func Test_ParseErrorResponse(t *testing.T) {
path := "/test"
expectedErrs := errcode.Errors{
{
Code: "UNAUTHORIZED",
Message: "authentication required",
},
{
Code: "NAME_UNKNOWN",
Message: "repository name not known to registry",
},
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
msg := `{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":[{"Type":"repository","Class":"","Name":"library/hello-world","Action":"pull"}]}]}`
w.WriteHeader(http.StatusUnauthorized)
if _, err := w.Write([]byte(msg)); err != nil {
t.Errorf("failed to write %q: %v", r.URL, err)
switch r.URL.Path {
case path:
msg := `{ "errors": [ { "code": "UNAUTHORIZED", "message": "authentication required", "detail": [ { "Type": "repository", "Class": "", "Name": "library/hello-world", "Action": "pull" } ] }, { "code": "NAME_UNKNOWN", "message": "repository name not known to registry" } ] }`
w.WriteHeader(http.StatusUnauthorized)
if _, err := w.Write([]byte(msg)); err != nil {
t.Errorf("failed to write %q: %v", r.URL, err)
}
default:
t.Errorf("unexpected access: %s %s", r.Method, r.URL)
w.WriteHeader(http.StatusNotFound)
}

}))
defer ts.Close()

resp, err := http.Get(ts.URL)
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatalf("failed to do request: %v", err)
}
err = ParseErrorResponse(resp)
if err == nil {
t.Errorf("ParseErrorResponse() error = %v, wantErr %v", err, true)
}

var errResp *errcode.ErrorResponse
if ok := errors.As(err, &errResp); !ok {
t.Errorf("errors.As(err, &UnexpectedStatusCodeError) = %v, want %v", ok, true)
}
if want := http.MethodGet; errResp.Method != want {
t.Errorf("ParseErrorResponse() Method = %v, want Method %v", errResp.Method, want)
}
if want := http.StatusUnauthorized; errResp.StatusCode != want {
t.Errorf("ParseErrorResponse() StatusCode = %v, want StatusCode %v", errResp.StatusCode, want)
}
if want := path; errResp.URL.Path != want {
t.Errorf("ParseErrorResponse() URL = %v, want URL %v", errResp.URL.Path, want)
}
for i, e := range errResp.Errors {
if want := expectedErrs[i].Code; e.Code != expectedErrs[i].Code {
t.Errorf("ParseErrorResponse() Code = %v, want Code %v", e.Code, want)
}
if want := expectedErrs[i].Message; e.Message != want {
t.Errorf("ParseErrorResponse() Message = %v, want Code %v", e.Code, want)
}
}

errmsg := err.Error()
if want := "401"; !strings.Contains(errmsg, want) {
t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want)
}
// first error
if want := "unauthorized"; !strings.Contains(errmsg, want) {
t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want)
}
if want := "authentication required"; !strings.Contains(errmsg, want) {
t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want)
}
// second error
if want := "name unknown"; !strings.Contains(errmsg, want) {
t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want)
}
if want := "repository name not known to registry"; !strings.Contains(errmsg, want) {
t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want)
}
}

func Test_ParseErrorResponse_plain(t *testing.T) {
Expand Down