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

Refactor Writers #3

Merged
merged 5 commits into from
Jan 10, 2024
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
13 changes: 2 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,6 @@ type Application struct {
reply reply.Engine // Use a reply Engine to write responses
}

// NotFound renders the 404 template.
func (app *Application) NotFound(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
app.reply.MethodNotAllowed(w, http.MethodGet)
return
}
app.reply.Write(w, http.StatusNotFound, reply.Options{Template: "404.html"})
}

// Home renders the home template.
func (app *Application) Home(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Expand All @@ -67,6 +58,6 @@ func (app *Application) Home(w http.ResponseWriter, r *http.Request) {

## License

Copyright (c) 2023-present [novrin](https://github.com/novrin)
[MIT](./LICENSE)

Licensed under [MIT License](./LICENSE)
Copyright (c) 2023-present [novrin](https://github.com/novrin)
25 changes: 13 additions & 12 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,61 +10,62 @@ import (

// Writer is used by an Engine to construct replies to HTTP server requests.
type Writer interface {
Error(w http.ResponseWriter, statusCode int)
Write(w http.ResponseWriter, statusCode int, opts Options)
WriteTo(w http.ResponseWriter) (int64, error)
Error(w http.ResponseWriter, error string, code int)
Reply(w http.ResponseWriter, code int, opts Options)
}

// Engine provides convenience reply methods by wrapping its embedded Writer's
// Error and Write.
// Error and Reply.
type Engine struct {
Writer
}

// BadRequest replies with an HTTP Status 400 Bad Request.
func (e Engine) BadRequest(w http.ResponseWriter) {
e.Error(w, http.StatusBadRequest)
e.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
}

// Unauthorized replies with an HTTP Status 401 Unauthorized.
func (e Engine) Unauthorized(w http.ResponseWriter) {
e.Error(w, http.StatusUnauthorized)
e.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}

// Forbidden replies with an HTTP Status 403 Forbidden.
func (e Engine) Forbidden(w http.ResponseWriter) {
e.Error(w, http.StatusForbidden)
e.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
}

// NotFound replies with an HTTP Status 404 Not Found.
func (e Engine) NotFound(w http.ResponseWriter) {
e.Error(w, http.StatusNotFound)
e.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}

// MethodNotAllowed sets an Allow header with the given methods,
// then replies with an HTTP Status 405 Method Not Allowed.
func (e Engine) MethodNotAllowed(w http.ResponseWriter, allow ...string) {
w.Header().Set("Allow", strings.Join(allow, ", "))
e.Error(w, http.StatusMethodNotAllowed)
e.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}

// InternalServerError uses slog to log an error and stack trace,
// then replies with an HTTP Status 500 Internal Server Error.
func (e Engine) InternalServerError(w http.ResponseWriter, err error) {
slog.Error(fmt.Sprintf("%s\n%s", err.Error(), debug.Stack()))
e.Error(w, http.StatusInternalServerError)
e.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}

// OK replies with an HTTP 200 Status OK.
func (e Engine) OK(w http.ResponseWriter, opts Options) {
e.Write(w, http.StatusOK, opts)
e.Reply(w, http.StatusOK, opts)
}

// Created replies with an HTTP 201 Status Created.
func (e Engine) Created(w http.ResponseWriter, opts Options) {
e.Write(w, http.StatusCreated, opts)
e.Reply(w, http.StatusCreated, opts)
}

// NoContent replies with an HTTP Status 204 No Content.
func (e Engine) NoContent(w http.ResponseWriter) {
e.Write(w, http.StatusNoContent, Options{})
e.Reply(w, http.StatusNoContent, Options{Key: "no_content.html"})
}
89 changes: 53 additions & 36 deletions engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package reply

import (
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"strings"
Expand All @@ -15,12 +16,12 @@ func TestBadRequest(t *testing.T) {
wantBody string
}{
"template writer": {
reply: Engine{TemplateWriter{}},
reply: Engine{NewTemplateWriter(map[string]*template.Template{})},
wantCode: http.StatusBadRequest,
wantBody: http.StatusText(http.StatusBadRequest),
wantBody: fmt.Sprintf("<p>%s</p>", http.StatusText(http.StatusBadRequest)),
},
"json writer": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
wantCode: http.StatusBadRequest,
wantBody: `{"error":"Bad Request"}`,
},
Expand All @@ -46,12 +47,12 @@ func TestUnauthorized(t *testing.T) {
wantBody string
}{
"template writer": {
reply: Engine{TemplateWriter{}},
reply: Engine{NewTemplateWriter(map[string]*template.Template{})},
wantCode: http.StatusUnauthorized,
wantBody: http.StatusText(http.StatusUnauthorized),
wantBody: fmt.Sprintf("<p>%s</p>", http.StatusText(http.StatusUnauthorized)),
},
"json writer": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
wantCode: http.StatusUnauthorized,
wantBody: `{"error":"Unauthorized"}`,
},
Expand All @@ -77,12 +78,12 @@ func TestForbidden(t *testing.T) {
wantBody string
}{
"template writer": {
reply: Engine{TemplateWriter{}},
reply: Engine{NewTemplateWriter(map[string]*template.Template{})},
wantCode: http.StatusForbidden,
wantBody: http.StatusText(http.StatusForbidden),
wantBody: fmt.Sprintf("<p>%s</p>", http.StatusText(http.StatusForbidden)),
},
"json writer": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
wantCode: http.StatusForbidden,
wantBody: `{"error":"Forbidden"}`,
},
Expand All @@ -108,12 +109,12 @@ func TestNotFound(t *testing.T) {
wantBody string
}{
"template writer": {
reply: Engine{TemplateWriter{}},
reply: Engine{NewTemplateWriter(map[string]*template.Template{})},
wantCode: http.StatusNotFound,
wantBody: http.StatusText(http.StatusNotFound),
wantBody: fmt.Sprintf("<p>%s</p>", http.StatusText(http.StatusNotFound)),
},
"json writer": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
wantCode: http.StatusNotFound,
wantBody: `{"error":"Not Found"}`,
},
Expand Down Expand Up @@ -141,28 +142,28 @@ func TestMethodNotAllowed(t *testing.T) {
wantAllow string
}{
"template writer; allow one": {
reply: Engine{TemplateWriter{}},
reply: Engine{NewTemplateWriter(map[string]*template.Template{})},
allow: []string{http.MethodGet},
wantCode: http.StatusMethodNotAllowed,
wantBody: http.StatusText(http.StatusMethodNotAllowed),
wantBody: fmt.Sprintf("<p>%s</p>", http.StatusText(http.StatusMethodNotAllowed)),
wantAllow: http.MethodGet,
},
"template writer; allow multiple": {
reply: Engine{TemplateWriter{}},
reply: Engine{NewTemplateWriter(map[string]*template.Template{})},
allow: []string{http.MethodGet, http.MethodPost},
wantCode: http.StatusMethodNotAllowed,
wantBody: http.StatusText(http.StatusMethodNotAllowed),
wantBody: fmt.Sprintf("<p>%s</p>", http.StatusText(http.StatusMethodNotAllowed)),
wantAllow: strings.Join([]string{http.MethodGet, http.MethodPost}, ", "),
},
"json writer; allow one": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
allow: []string{http.MethodGet},
wantCode: http.StatusMethodNotAllowed,
wantBody: `{"error":"Method Not Allowed"}`,
wantAllow: http.MethodGet,
},
"json writer; allow multiple": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
allow: []string{http.MethodGet, http.MethodPost},
wantCode: http.StatusMethodNotAllowed,
wantBody: `{"error":"Method Not Allowed"}`,
Expand Down Expand Up @@ -193,12 +194,12 @@ func TestInternalServerError(t *testing.T) {
wantBody string
}{
"template writer": {
reply: Engine{TemplateWriter{}},
reply: Engine{NewTemplateWriter(map[string]*template.Template{})},
wantCode: http.StatusInternalServerError,
wantBody: http.StatusText(http.StatusInternalServerError),
wantBody: fmt.Sprintf("<p>%s</p>", http.StatusText(http.StatusInternalServerError)),
},
"json writer": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
wantCode: http.StatusInternalServerError,
wantBody: `{"error":"Internal Server Error"}`,
},
Expand All @@ -223,21 +224,29 @@ func TestOK(t *testing.T) {
wantCode int
wantBody string
}{
"template writer": {
reply: Engine{TemplateWriter{}},
"template writer, no template": {
reply: Engine{NewTemplateWriter(map[string]*template.Template{"foo": foo})},
wantCode: http.StatusOK,
wantBody: "",
wantBody: "Hello, Sherlock",
},
"json writer": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
wantCode: http.StatusOK,
wantBody: "null",
wantBody: `{"name":"Sherlock"}`,
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
w := httptest.NewRecorder()
c.reply.OK(w, Options{})
c.reply.OK(w, Options{
Key: "foo",
Name: "base",
Data: struct {
Name string `json:"name"`
}{
Name: "Sherlock",
},
})
if got := w.Code; got != c.wantCode {
t.Fatalf(errorString, got, c.wantCode)
}
Expand All @@ -254,21 +263,29 @@ func TestCreated(t *testing.T) {
wantCode int
wantBody string
}{
"template writer": {
reply: Engine{TemplateWriter{}},
"template writer; no template": {
reply: Engine{NewTemplateWriter(map[string]*template.Template{"foo": foo})},
wantCode: http.StatusCreated,
wantBody: "",
wantBody: "Hello, Sherlock",
},
"json writer": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
wantCode: http.StatusCreated,
wantBody: "null",
wantBody: `{"name":"Sherlock"}`,
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
w := httptest.NewRecorder()
c.reply.Created(w, Options{})
c.reply.Created(w, Options{
Key: "foo",
Name: "base",
Data: struct {
Name string `json:"name"`
}{
Name: "Sherlock",
},
})
if got := w.Code; got != c.wantCode {
t.Fatalf(errorString, got, c.wantCode)
}
Expand All @@ -285,13 +302,13 @@ func TestNoContent(t *testing.T) {
wantCode int
wantBody string
}{
"template writer": {
reply: Engine{TemplateWriter{}},
"template writer; no template": {
reply: Engine{NewTemplateWriter(map[string]*template.Template{"quux": quux})},
wantCode: http.StatusNoContent,
wantBody: "",
},
"json writer": {
reply: Engine{JSONWriter{}},
reply: Engine{NewJSONWriter()},
wantCode: http.StatusNoContent,
wantBody: "null",
},
Expand Down
58 changes: 41 additions & 17 deletions json_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,58 @@ package reply
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)

// JSONWriter implement Writer for JSON responses.
type JSONWriter struct{}
// JSONWriter implements Writer for JSON responses.
type JSONWriter struct {
buffer *bytes.Buffer
}

// Encode writes the JSON encoding of data followed by a newline character to
// jw's buffer. If an error occurs encoding the data or writing its output,
// execution stops, the buffer is reset, and the error is returned.
func (jw JSONWriter) Encode(data any) error {
if err := json.NewEncoder(jw.buffer).Encode(data); err != nil {
jw.buffer.Reset()
return err
}
return nil
}

// WriteTo writes data to w until jw's buffer is drained or an error occurs.
// Any values returned by the buffer's WriteTo are returned.
func (jw JSONWriter) WriteTo(w http.ResponseWriter) (int64, error) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
return jw.buffer.WriteTo(w)
}

// Error replies to the request with the given HTTP status code and its text
// description in a JSON error key.
func (jw JSONWriter) Error(w http.ResponseWriter, statusCode int) {
jw.Write(w, statusCode, Options{
Data: map[string]string{"error": http.StatusText(statusCode)},
})
func (jw JSONWriter) Error(w http.ResponseWriter, error string, code int) {
_ = jw.Encode(map[string]string{"error": error})
w.WriteHeader(code)
_, _ = jw.WriteTo(w)
}

// Write replies to a request with the given status code and opts Data encoded
// to JSON. The encoding is first written to a buffer. If an error occurs, it
// replies with an Internal Server Error. Otherwise, it writes the given status
// code and the encoded data.
func (jw JSONWriter) Write(w http.ResponseWriter, statusCode int, opts Options) {
w.Header().Set("Content-Type", "application/json")
buffer := new(bytes.Buffer)
if err := json.NewEncoder(buffer).Encode(opts.Data); err != nil {
statusCode = http.StatusInternalServerError
buffer.Reset()
e, _ := json.Marshal(map[string]string{"error": fmt.Sprintf("failed to marshal %v", err)})
buffer.Write(e)
func (jw JSONWriter) Reply(w http.ResponseWriter, code int, opts Options) {
if err := jw.Encode(opts.Data); err != nil {
message := err.Error()
if !opts.Debug {
message = http.StatusText(http.StatusInternalServerError)
}
jw.Error(w, message, http.StatusInternalServerError)
}
w.WriteHeader(statusCode)
_, _ = buffer.WriteTo(w)
w.WriteHeader(code)
_, _ = jw.WriteTo(w)
}

// NewJSONWriter returns a new JSONWriter with an empty buffer.
func NewJSONWriter() *JSONWriter {
return &JSONWriter{buffer: new(bytes.Buffer)}
}
Loading
Loading