Initial commit

This commit is contained in:
Ian Gulliver
2023-04-20 16:12:34 +00:00
parent 366ffe2c6f
commit 12a6d6da8b
9 changed files with 431 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
cover.out
cover.html

194
error.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,11 @@
package jsrest_test
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}