http response tooling written

This commit is contained in:
2025-12-30 14:42:04 -07:00
parent 493d15eae8
commit c01d700482
14 changed files with 660 additions and 60 deletions
+83
View File
@@ -0,0 +1,83 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/site/redirect"
)
type (
cookieRes struct {
cookie http.Cookie
res Response
}
)
var _ Response = cookieRes{}
func Cookie(c http.Cookie) Response {
return cookieRes{
cookie: c,
}
}
func (c cookieRes) String() string {
if c.res != nil {
return fmt.Sprintf(`{"cookie": %q, "nested": %s}`, c.cookie, c.res)
}
return fmt.Sprintf(`{"cookie": %q}`, c.cookie)
}
func (c cookieRes) wrap(res Response) Response {
c.res = res
return c
}
func (c cookieRes) Status(code int) Response {
return Status(code).wrap(c)
}
func (c cookieRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(c)
}
func (c cookieRes) JSON(body any) Response {
return JSON(body).wrap(c)
}
func (c cookieRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(c)
}
func (c cookieRes) getStatus() (int, bool) {
if c.res == nil {
return 0, false
}
return c.res.getStatus()
}
func (c cookieRes) getRedirect() (code redirect.Code, to string, ok bool) {
if c.res == nil {
return 0, "", false
}
return c.res.getRedirect()
}
func (c cookieRes) getBody() (body io.ReadCloser, ok bool, err error) {
if c.res == nil {
return nil, false, nil
}
return c.res.getBody()
}
func (c cookieRes) getCookies() []http.Cookie {
if c.res != nil {
return append(c.res.getCookies(), c.cookie)
}
return []http.Cookie{c.cookie}
}
+124
View File
@@ -0,0 +1,124 @@
package response
import (
"errors"
"fmt"
"net/http"
"strings"
)
type (
// ErrorResponse is an error
ErrorResponse struct {
err error
msg string
status int
}
)
// Constructors
func Errorf(format string, args ...any) ErrorResponse {
return ErrorResponse{
err: fmt.Errorf(format, args...),
}
}
func BadRequest() ErrorResponse {
return ErrorResponse{
status: http.StatusBadRequest,
}
}
func NotFound() ErrorResponse {
return ErrorResponse{
status: http.StatusNotFound,
}
}
func Unauthorized() ErrorResponse {
return ErrorResponse{
status: http.StatusUnauthorized,
}
}
func Forbidden() ErrorResponse {
return ErrorResponse{
status: http.StatusForbidden,
}
}
// builder pattern implementation
func (e ErrorResponse) Msg(msg string) ErrorResponse {
e.msg = msg
return e
}
func (e ErrorResponse) Status(status int) ErrorResponse {
e.status = status
return e
}
func (e ErrorResponse) Wrap(err error) ErrorResponse {
e.err = err
return e
}
// error implementation
func (e ErrorResponse) Error() string {
parts := make([]string, 0, 3)
if e.msg != "" {
parts = append(parts, e.msg)
} else if e.status != 0 {
parts = append(parts, fmt.Sprintf("status = %d", e.status))
}
if e.err != nil {
parts = append(parts, e.err.Error())
}
if len(parts) == 0 {
return "status = 500"
}
return strings.Join(parts, ": ")
}
func (e ErrorResponse) Unwrap() error {
return e.err
}
// nested response value resolution
func (e ErrorResponse) getStatus() (int, bool) {
if e.status != 0 {
return e.status, true
}
ce, ok := getError(e.err)
if ok {
return ce.getStatus()
}
return 0, false
}
func (e ErrorResponse) getMsg() (string, bool) {
if e.msg != "" {
return e.msg, true
}
ce, ok := getError(e.err)
if ok {
return ce.getMsg()
}
return "", false
}
func getError(err error) (e ErrorResponse, ok bool) {
ok = errors.As(err, &e)
return e, ok
}
+18
View File
@@ -0,0 +1,18 @@
package response
import (
"net/http"
)
type HandlerFunc = func(r *http.Request) (Response, error)
func Handler(f HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
res, err := f(r)
if err != nil {
WriteError(w, err)
} else {
Write(w, r, res)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package response
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/site/redirect"
)
type (
jsonRes struct {
body any
res Response
}
)
var _ Response = jsonRes{}
func JSON(body any) Response {
return jsonRes{
body: body,
}
}
func (j jsonRes) String() string {
if j.res != nil {
return fmt.Sprintf(`{"body": %q, "nested": %s}`, j.body, j.res)
}
return fmt.Sprintf(`{"body": %q}`, j.body)
}
func (j jsonRes) wrap(res Response) Response {
j.res = res
return j
}
func (j jsonRes) Status(code int) Response {
return Status(code).wrap(j)
}
func (j jsonRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(j)
}
func (j jsonRes) JSON(body any) Response {
j.body = body
return j
}
func (j jsonRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(j)
}
func (j jsonRes) getStatus() (int, bool) {
if j.res == nil {
return 0, false
}
return j.res.getStatus()
}
func (j jsonRes) getRedirect() (code redirect.Code, to string, ok bool) {
if j.res == nil {
return 0, "", false
}
return j.res.getRedirect()
}
func (j jsonRes) getBody() (body io.ReadCloser, ok bool, err error) {
buf := new(bytes.Buffer)
return io.NopCloser(buf), true, json.NewEncoder(buf).Encode(j.body)
}
func (j jsonRes) getCookies() []http.Cookie {
if j.res != nil {
return j.res.getCookies()
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/site/redirect"
)
type (
redirectRes struct {
code redirect.Code
to string
res Response
}
)
var _ Response = redirectRes{}
// convenience constructors
func MovedPermanently(to string) Response {
return redirectRes{
code: redirect.MovedPermanently,
to: to,
}
}
func Found(to string) Response {
return redirectRes{
code: redirect.Found,
to: to,
}
}
func SeeOther(to string) Response {
return redirectRes{
code: redirect.SeeOther,
to: to,
}
}
func TemporaryRedirect(to string) Response {
return redirectRes{
code: redirect.TemporaryRedirect,
to: to,
}
}
func PermanentRedirect(to string) Response {
return redirectRes{
code: redirect.PermanentRedirect,
to: to,
}
}
func Redirect(code redirect.Code, to string) Response {
return redirectRes{
code: code,
to: to,
}
}
func (r redirectRes) String() string {
if r.res != nil {
return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}, "nested": %s}`, r.code, r.to, r.res)
}
return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}}`, r.code, r.to)
}
func (r redirectRes) wrap(res Response) Response {
r.res = res
return r
}
func (r redirectRes) Status(code int) Response {
return Status(code).wrap(r)
}
func (r redirectRes) Redirect(code redirect.Code, to string) Response {
r.code = code
r.to = to
return r
}
func (r redirectRes) JSON(body any) Response {
return JSON(body).wrap(r)
}
func (r redirectRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(r)
}
func (r redirectRes) getStatus() (int, bool) {
if r.res == nil {
return 0, false
}
return r.res.getStatus()
}
func (r redirectRes) getRedirect() (code redirect.Code, to string, ok bool) {
return r.code, r.to, true
}
func (r redirectRes) getBody() (body io.ReadCloser, ok bool, err error) {
if r.res == nil {
return nil, false, nil
}
return r.res.getBody()
}
func (r redirectRes) getCookies() []http.Cookie {
if r.res != nil {
return r.res.getCookies()
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package response
import (
"io"
"net/http"
"ruben/inventory2/internal/site/redirect"
)
type (
Response interface {
Status(int) Response
Redirect(code redirect.Code, to string) Response
JSON(any) Response
Cookie(http.Cookie) Response
getStatus() (code int, ok bool)
getBody() (body io.ReadCloser, ok bool, err error)
getRedirect() (code redirect.Code, to string, ok bool)
getCookies() []http.Cookie
wrap(Response) Response
}
)
+81
View File
@@ -0,0 +1,81 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/site/redirect"
)
type (
statusRes struct {
code int
res Response
}
)
var _ Response = statusRes{}
func Status(code int) Response {
return statusRes{
code: code,
}
}
func (s statusRes) String() string {
if s.res != nil {
return fmt.Sprintf(`{"status": %d, "nested": %s}`, s.code, s.res)
}
return fmt.Sprintf(`{"status": %d}`, s.code)
}
func (s statusRes) wrap(res Response) Response {
s.res = res
return s
}
func (s statusRes) Status(code int) Response {
s.code = code
return s
}
func (s statusRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(s)
}
func (s statusRes) JSON(body any) Response {
return JSON(body).wrap(s)
}
func (s statusRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(s)
}
func (s statusRes) getStatus() (int, bool) {
return s.code, true
}
func (s statusRes) getRedirect() (code redirect.Code, to string, ok bool) {
if s.res == nil {
return 0, "", false
}
return s.res.getRedirect()
}
func (s statusRes) getBody() (body io.ReadCloser, ok bool, err error) {
if s.res == nil {
return nil, false, nil
}
return s.res.getBody()
}
func (s statusRes) getCookies() []http.Cookie {
if s.res != nil {
return s.res.getCookies()
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package response
import (
"fmt"
"io"
"net/http"
)
func Write(w http.ResponseWriter, r *http.Request, res Response) {
// w.Header() must be set before ResponseWriter.WriteHeader is called
// or redirect is attempted
hdrs := w.Header()
for _, ck := range res.getCookies() {
hdrs.Add("Set-Cookie", ck.String())
}
if code, to, ok := res.getRedirect(); ok {
http.Redirect(w, r, to, code.Int())
return
}
// the body is written after the status header, but it's read here
// first, because if an error is incurred, an error status header will
// need to be written.
body, bodySet, err := res.getBody()
if err != nil {
http.Error(
w,
fmt.Sprintf("Failed to construct response body: %v", err.Error()),
http.StatusInternalServerError,
)
return
}
if status, ok := res.getStatus(); ok {
w.WriteHeader(status)
}
if bodySet {
// will automatically set the status header,
// if w.WriteHeader wasn't already called
io.Copy(w, body)
}
}
func WriteError(w http.ResponseWriter, err error) {
var status int
if e, ok := getError(err); ok {
if status, ok = e.getStatus(); !ok {
status = http.StatusInternalServerError
}
}
http.Error(w, err.Error(), status)
}