diff --git a/cover.html b/cover.html
new file mode 100644
index 0000000..dff6d59
--- /dev/null
+++ b/cover.html
@@ -0,0 +1,370 @@
+
+
+
+
+
+
+
package jsrest
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/go-resty/resty/v2"
+)
+
+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 GetHTTPError(err) != nil {
+ 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(resp *resty.Response) error {
+ jse := &JSONError{}
+
+ err := json.Unmarshal(resp.Body(), jse)
+ if err == nil {
+ return jse
+ }
+
+ return NewHTTPError(resp.StatusCode())
+}
+
+type singleUnwrap interface {
+ Unwrap() error
+}
+
+type multiUnwrap interface {
+ Unwrap() []error
+}
+
+func GetHTTPError(err error) *HTTPError {
+ hErr := &HTTPError{}
+
+ if errors.As(err, &hErr) {
+ return hErr
+ }
+
+ return nil
+}
+
+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)
+ }
+ }
+}
+
+
+
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
+}
+
+
+
+
+
+
diff --git a/cover.out b/cover.out
new file mode 100644
index 0000000..5494182
--- /dev/null
+++ b/cover.out
@@ -0,0 +1,45 @@
+mode: atomic
+github.com/gopatchy/jsrest/error.go:61.40,66.2 1 40
+github.com/gopatchy/jsrest/error.go:68.38,70.2 1 2
+github.com/gopatchy/jsrest/error.go:76.49,80.2 1 2
+github.com/gopatchy/jsrest/error.go:82.44,84.2 1 1
+github.com/gopatchy/jsrest/error.go:86.46,88.2 1 7
+github.com/gopatchy/jsrest/error.go:90.51,98.2 5 0
+github.com/gopatchy/jsrest/error.go:100.59,103.30 2 3
+github.com/gopatchy/jsrest/error.go:103.30,105.3 1 1
+github.com/gopatchy/jsrest/error.go:107.2,107.28 1 2
+github.com/gopatchy/jsrest/error.go:115.37,116.26 1 0
+github.com/gopatchy/jsrest/error.go:116.26,118.3 1 0
+github.com/gopatchy/jsrest/error.go:120.2,120.27 1 0
+github.com/gopatchy/jsrest/error.go:123.37,124.26 1 0
+github.com/gopatchy/jsrest/error.go:124.26,129.3 1 0
+github.com/gopatchy/jsrest/error.go:131.2,131.12 1 0
+github.com/gopatchy/jsrest/error.go:134.40,141.2 3 2
+github.com/gopatchy/jsrest/error.go:143.44,147.16 3 0
+github.com/gopatchy/jsrest/error.go:147.16,149.3 1 0
+github.com/gopatchy/jsrest/error.go:151.2,151.40 1 0
+github.com/gopatchy/jsrest/error.go:162.41,165.27 2 3
+github.com/gopatchy/jsrest/error.go:165.27,167.3 1 1
+github.com/gopatchy/jsrest/error.go:169.2,169.12 1 2
+github.com/gopatchy/jsrest/error.go:172.45,173.36 1 9
+github.com/gopatchy/jsrest/error.go:173.36,175.3 1 2
+github.com/gopatchy/jsrest/error.go:177.2,177.42 1 9
+github.com/gopatchy/jsrest/error.go:177.42,179.3 1 7
+github.com/gopatchy/jsrest/error.go:181.2,181.42 1 9
+github.com/gopatchy/jsrest/error.go:181.42,183.3 1 1
+github.com/gopatchy/jsrest/error.go:183.8,183.48 1 8
+github.com/gopatchy/jsrest/error.go:183.48,184.39 1 3
+github.com/gopatchy/jsrest/error.go:184.39,186.4 1 6
+github.com/gopatchy/jsrest/json.go:14.43,17.21 2 2
+github.com/gopatchy/jsrest/json.go:18.10,19.14 1 0
+github.com/gopatchy/jsrest/json.go:20.26,21.8 1 2
+github.com/gopatchy/jsrest/json.go:23.10,24.74 1 0
+github.com/gopatchy/jsrest/json.go:27.2,31.16 4 2
+github.com/gopatchy/jsrest/json.go:31.16,33.3 1 0
+github.com/gopatchy/jsrest/json.go:35.2,35.12 1 2
+github.com/gopatchy/jsrest/json.go:38.50,47.16 6 0
+github.com/gopatchy/jsrest/json.go:47.16,49.3 1 0
+github.com/gopatchy/jsrest/json.go:51.2,51.12 1 0
+github.com/gopatchy/jsrest/json.go:54.70,61.16 5 0
+github.com/gopatchy/jsrest/json.go:61.16,63.3 1 0
+github.com/gopatchy/jsrest/json.go:65.2,65.12 1 0