Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
cover.out
|
||||
cover.html
|
||||
194
error.go
Normal file
194
error.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package jsrest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadRequest = NewHTTPError(http.StatusBadRequest)
|
||||
ErrUnauthorized = NewHTTPError(http.StatusUnauthorized)
|
||||
ErrPaymentRequired = NewHTTPError(http.StatusPaymentRequired)
|
||||
ErrForbidden = NewHTTPError(http.StatusForbidden)
|
||||
ErrNotFound = NewHTTPError(http.StatusNotFound)
|
||||
ErrMethodNotAllowed = NewHTTPError(http.StatusMethodNotAllowed)
|
||||
ErrNotAcceptable = NewHTTPError(http.StatusNotAcceptable)
|
||||
ErrProxyAuthRequired = NewHTTPError(http.StatusProxyAuthRequired)
|
||||
ErrRequestTimeout = NewHTTPError(http.StatusRequestTimeout)
|
||||
ErrConflict = NewHTTPError(http.StatusConflict)
|
||||
ErrGone = NewHTTPError(http.StatusGone)
|
||||
ErrLengthRequired = NewHTTPError(http.StatusLengthRequired)
|
||||
ErrPreconditionFailed = NewHTTPError(http.StatusPreconditionFailed)
|
||||
ErrRequestEntityTooLarge = NewHTTPError(http.StatusRequestEntityTooLarge)
|
||||
ErrRequestURITooLong = NewHTTPError(http.StatusRequestURITooLong)
|
||||
ErrUnsupportedMediaType = NewHTTPError(http.StatusUnsupportedMediaType)
|
||||
ErrRequestedRangeNotSatisfiable = NewHTTPError(http.StatusRequestedRangeNotSatisfiable)
|
||||
ErrExpectationFailed = NewHTTPError(http.StatusExpectationFailed)
|
||||
ErrTeapot = NewHTTPError(http.StatusTeapot)
|
||||
ErrMisdirectedRequest = NewHTTPError(http.StatusMisdirectedRequest)
|
||||
ErrUnprocessableEntity = NewHTTPError(http.StatusUnprocessableEntity)
|
||||
ErrLocked = NewHTTPError(http.StatusLocked)
|
||||
ErrFailedDependency = NewHTTPError(http.StatusFailedDependency)
|
||||
ErrTooEarly = NewHTTPError(http.StatusTooEarly)
|
||||
ErrUpgradeRequired = NewHTTPError(http.StatusUpgradeRequired)
|
||||
ErrPreconditionRequired = NewHTTPError(http.StatusPreconditionRequired)
|
||||
ErrTooManyRequests = NewHTTPError(http.StatusTooManyRequests)
|
||||
ErrRequestHeaderFieldsTooLarge = NewHTTPError(http.StatusRequestHeaderFieldsTooLarge)
|
||||
ErrUnavailableForLegalReasons = NewHTTPError(http.StatusUnavailableForLegalReasons)
|
||||
|
||||
ErrInternalServerError = NewHTTPError(http.StatusInternalServerError)
|
||||
ErrNotImplemented = NewHTTPError(http.StatusNotImplemented)
|
||||
ErrBadGateway = NewHTTPError(http.StatusBadGateway)
|
||||
ErrServiceUnavailable = NewHTTPError(http.StatusServiceUnavailable)
|
||||
ErrGatewayTimeout = NewHTTPError(http.StatusGatewayTimeout)
|
||||
ErrHTTPVersionNotSupported = NewHTTPError(http.StatusHTTPVersionNotSupported)
|
||||
ErrVariantAlsoNegotiates = NewHTTPError(http.StatusVariantAlsoNegotiates)
|
||||
ErrInsufficientStorage = NewHTTPError(http.StatusInsufficientStorage)
|
||||
ErrLoopDetected = NewHTTPError(http.StatusLoopDetected)
|
||||
ErrNotExtended = NewHTTPError(http.StatusNotExtended)
|
||||
ErrNetworkAuthenticationRequired = NewHTTPError(http.StatusNetworkAuthenticationRequired)
|
||||
)
|
||||
|
||||
type HTTPError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func NewHTTPError(code int) *HTTPError {
|
||||
return &HTTPError{
|
||||
Code: code,
|
||||
Message: http.StatusText(code),
|
||||
}
|
||||
}
|
||||
|
||||
func (err *HTTPError) Error() string {
|
||||
return fmt.Sprintf("[%d] %s", err.Code, err.Message)
|
||||
}
|
||||
|
||||
type SilentJoinError struct {
|
||||
Wraps []error
|
||||
}
|
||||
|
||||
func SilentJoin(errs ...error) *SilentJoinError {
|
||||
return &SilentJoinError{
|
||||
Wraps: errs,
|
||||
}
|
||||
}
|
||||
|
||||
func (err *SilentJoinError) Error() string {
|
||||
return err.Wraps[0].Error()
|
||||
}
|
||||
|
||||
func (err *SilentJoinError) Unwrap() []error {
|
||||
return err.Wraps
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, err error) {
|
||||
je := ToJSONError(err)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(je.Code)
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
_ = enc.Encode(je) //nolint:errchkjson
|
||||
}
|
||||
|
||||
func Errorf(he *HTTPError, format string, a ...any) error {
|
||||
err := fmt.Errorf(format, a...) //nolint:goerr113
|
||||
|
||||
if hasHTTPError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return SilentJoin(err, he)
|
||||
}
|
||||
|
||||
type JSONError struct {
|
||||
Code int `json:"-"`
|
||||
Messages []string `json:"messages"`
|
||||
}
|
||||
|
||||
func (je *JSONError) Error() string {
|
||||
if len(je.Messages) > 0 {
|
||||
return je.Messages[0]
|
||||
}
|
||||
|
||||
return "no error message"
|
||||
}
|
||||
|
||||
func (je *JSONError) Unwrap() error {
|
||||
if len(je.Messages) > 1 {
|
||||
return &JSONError{
|
||||
Code: je.Code,
|
||||
Messages: je.Messages[1:],
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ToJSONError(err error) *JSONError {
|
||||
je := &JSONError{
|
||||
Code: 500,
|
||||
}
|
||||
je.importError(err)
|
||||
|
||||
return je
|
||||
}
|
||||
|
||||
func ReadError(in []byte) error {
|
||||
jse := &JSONError{}
|
||||
|
||||
err := json.Unmarshal(in, jse)
|
||||
if err == nil {
|
||||
return jse
|
||||
}
|
||||
|
||||
return errors.New(string(in)) //nolint:goerr113
|
||||
}
|
||||
|
||||
type singleUnwrap interface {
|
||||
Unwrap() error
|
||||
}
|
||||
|
||||
type multiUnwrap interface {
|
||||
Unwrap() []error
|
||||
}
|
||||
|
||||
func hasHTTPError(err error) bool {
|
||||
if _, has := err.(*HTTPError); has { //nolint:errorlint
|
||||
return true
|
||||
}
|
||||
|
||||
if unwrap, ok := err.(singleUnwrap); ok { //nolint:errorlint
|
||||
return hasHTTPError(unwrap.Unwrap())
|
||||
} else if unwrap, ok := err.(multiUnwrap); ok { //nolint:errorlint
|
||||
for _, sub := range unwrap.Unwrap() {
|
||||
if hasHTTPError(sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (je *JSONError) importError(err error) {
|
||||
if he, ok := err.(*HTTPError); ok { //nolint:errorlint
|
||||
je.Code = he.Code
|
||||
}
|
||||
|
||||
if _, is := err.(*SilentJoinError); !is { //nolint:errorlint
|
||||
je.Messages = append(je.Messages, err.Error())
|
||||
}
|
||||
|
||||
if unwrap, ok := err.(singleUnwrap); ok { //nolint:errorlint
|
||||
je.importError(unwrap.Unwrap())
|
||||
} else if unwrap, ok := err.(multiUnwrap); ok { //nolint:errorlint
|
||||
for _, sub := range unwrap.Unwrap() {
|
||||
je.importError(sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
47
error_test.go
Normal file
47
error_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package jsrest_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gopatchy/jsrest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFromError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e1 := errors.New("error 1") //nolint:goerr113
|
||||
e2 := jsrest.Errorf(jsrest.ErrBadGateway, "error 2: %w", e1)
|
||||
|
||||
require.ErrorIs(t, e2, e1)
|
||||
require.ErrorIs(t, e2, jsrest.ErrBadGateway)
|
||||
|
||||
je := jsrest.ToJSONError(e2)
|
||||
|
||||
require.Equal(t, http.StatusBadGateway, je.Code)
|
||||
require.Contains(t, je.Messages, "error 1")
|
||||
require.Contains(t, je.Messages, "error 2: error 1")
|
||||
require.Contains(t, je.Messages, "[502] Bad Gateway")
|
||||
}
|
||||
|
||||
func TestFromErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e1 := jsrest.Errorf(jsrest.ErrForbidden, "error 1")
|
||||
e2 := errors.New("error 2") //nolint:goerr113
|
||||
e3 := jsrest.Errorf(jsrest.ErrBadGateway, "error 3: %w + %w", e1, e2)
|
||||
|
||||
require.ErrorIs(t, e3, e1)
|
||||
require.ErrorIs(t, e3, e2)
|
||||
require.ErrorIs(t, e3, jsrest.ErrForbidden)
|
||||
|
||||
je := jsrest.ToJSONError(e3)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, je.Code)
|
||||
require.Contains(t, je.Messages, "error 1")
|
||||
require.Contains(t, je.Messages, "error 2")
|
||||
require.Contains(t, je.Messages, "error 3: error 1 + error 2")
|
||||
require.Contains(t, je.Messages, "[403] Forbidden")
|
||||
}
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module github.com/gopatchy/jsrest
|
||||
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/gopatchy/metadata v0.0.0-20230420053349-25837551c11d
|
||||
github.com/stretchr/testify v1.8.2
|
||||
github.com/vfaronov/httpheader v0.1.0
|
||||
go.uber.org/goleak v1.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
28
go.sum
Normal file
28
go.sum
Normal file
@@ -0,0 +1,28 @@
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gopatchy/metadata v0.0.0-20230420053349-25837551c11d h1:chunoM47vkWSanIvLx4uRSkLMG6chDZOy09L2tt/bv8=
|
||||
github.com/gopatchy/metadata v0.0.0-20230420053349-25837551c11d/go.mod h1:VgD33raUShjDePCDBo55aj+eSXFtUEpMzs+Ie39g2zo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4 h1:Ha8xCaq6ln1a+R91Km45Oq6lPXj2Mla6CRJYcuV2h1w=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/vfaronov/httpheader v0.1.0 h1:VdzetvOKRoQVHjSrXcIOwCV6JG5BCAW9rjbVbFPBmb0=
|
||||
github.com/vfaronov/httpheader v0.1.0/go.mod h1:ZBxgbYu6nbN5V9Ptd1yYUUan0voD0O8nZLXHyxLgoLE=
|
||||
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
|
||||
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
66
json.go
Normal file
66
json.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package jsrest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gopatchy/metadata"
|
||||
"github.com/vfaronov/httpheader"
|
||||
)
|
||||
|
||||
var ErrUnsupportedContentType = errors.New("unsupported Content-Type")
|
||||
|
||||
func Read(r *http.Request, obj any) error {
|
||||
contentType, _ := httpheader.ContentType(r.Header)
|
||||
|
||||
switch contentType {
|
||||
case "":
|
||||
fallthrough
|
||||
case "application/json":
|
||||
break
|
||||
|
||||
default:
|
||||
return Errorf(ErrUnsupportedMediaType, "Content-Type: %s", contentType)
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
err := dec.Decode(obj)
|
||||
if err != nil {
|
||||
return Errorf(ErrBadRequest, "decode JSON request body failed (%w)", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Write(w http.ResponseWriter, obj any) error {
|
||||
m := metadata.GetMetadata(obj)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
httpheader.SetETag(w.Header(), httpheader.EntityTag{Opaque: m.ETag})
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
|
||||
err := enc.Encode(obj)
|
||||
if err != nil {
|
||||
return Errorf(ErrInternalServerError, "encode JSON response failed (%w)", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteList(w http.ResponseWriter, list []any, etag string) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
httpheader.SetETag(w.Header(), httpheader.EntityTag{Opaque: etag})
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
|
||||
err := enc.Encode(list)
|
||||
if err != nil {
|
||||
return Errorf(ErrInternalServerError, "encode JSON response failed (%w)", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
48
json_test.go
Normal file
48
json_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package jsrest_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gopatchy/jsrest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testType struct {
|
||||
Text1 string
|
||||
}
|
||||
|
||||
func TestRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := bytes.NewBufferString(`{"text1":"foo"}`)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "xyz", body)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
obj := &testType{}
|
||||
|
||||
err = jsrest.Read(req, obj)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "foo", obj.Text1)
|
||||
}
|
||||
|
||||
func TestReadContentTypeParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := bytes.NewBufferString(`{"text1":"bar"}`)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "xyz", body) //nolint:noctx
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
obj := &testType{}
|
||||
|
||||
err = jsrest.Read(req, obj)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "bar", obj.Text1)
|
||||
}
|
||||
18
justfile
Normal file
18
justfile
Normal file
@@ -0,0 +1,18 @@
|
||||
go := env_var_or_default('GOCMD', 'go')
|
||||
|
||||
default: tidy test
|
||||
|
||||
tidy:
|
||||
{{go}} mod tidy
|
||||
goimports -l -w .
|
||||
gofumpt -l -w .
|
||||
{{go}} fmt ./...
|
||||
|
||||
test:
|
||||
{{go}} vet ./...
|
||||
golangci-lint run ./...
|
||||
{{go}} test -race -coverprofile=cover.out -timeout=60s -parallel=10 ./...
|
||||
{{go}} tool cover -html=cover.out -o=cover.html
|
||||
|
||||
todo:
|
||||
-git grep -e TODO --and --not -e ignoretodo
|
||||
11
pkg_test.go
Normal file
11
pkg_test.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package jsrest_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
Reference in New Issue
Block a user