diff --git a/.deepsource.toml b/.deepsource.toml
new file mode 100644
index 0000000..048a125
--- /dev/null
+++ b/.deepsource.toml
@@ -0,0 +1,7 @@
+version = 1
+
+[[analyzers]]
+name = "go"
+
+ [analyzers.meta]
+ import_root = "github.com/yaitoo/xun"
\ No newline at end of file
diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..13722be
--- /dev/null
+++ b/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,11 @@
+### Changes
+-
+
+### Fixes
+-
+
+### Tests
+Tasks to complete before merging PR:
+- [ ] Ensure unit tests are passing. If not run `make unit-test` to check for any regressions :clipboard:
+- [ ] Ensure lint tests are passing. if not run `make lint` to check for any issues
+- [ ] Ensure codecov/patch is passing for changes.
\ No newline at end of file
diff --git a/README.md b/README.md
index cfcbc64..da96a85 100644
--- a/README.md
+++ b/README.md
@@ -8,10 +8,10 @@ Xun [ʃʊn] (pronounced 'shoon'), derived from the Chinese character 迅, signif
[![Go Reference](https://pkg.go.dev/badge/github.com/yaitoo/xun.svg)](https://pkg.go.dev/github.com/yaitoo/xun)
[![Codecov](https://codecov.io/gh/yaitoo/xun/branch/main/graph/badge.svg)](https://codecov.io/gh/yaitoo/xun)
[![GitHub Release](https://img.shields.io/github/v/release/yaitoo/xun)](https://github.com/yaitoo/xun/blob/main/CHANGELOG.md)
-[![Go Report Card](https://goreportcard.com/badge/yaitoo/xun)](http://goreportcard.com/report/yaitoo/xun)
+[![Go Report Card](https://goreportcard.com/badge/github.com/yaitoo/xun)](https://goreportcard.com/report/github.com/yaitoo/xun)
## Features
-- Works with Go's built-in `net/http.ServeMux` router. that was introduced in 1.22. [Routing Enhancements for Go 1.22](https://go.dev/blog/routing-enhancements).
+- Works with Go's built-in `net/http.ServeMux` router that was introduced in 1.22. [Routing Enhancements for Go 1.22](https://go.dev/blog/routing-enhancements).
- Works with Go's built-in `html/template`. It is built-in support for Server-Side Rendering (SSR).
- Built-in Form and Validate feature with i18n support.
- Support Page Router in `StaticViewEngine` and `HtmlViewEngine`.
diff --git a/app.go b/app.go
index bf7fd6a..0c95bda 100644
--- a/app.go
+++ b/app.go
@@ -35,6 +35,7 @@ type App struct {
watch bool
watcher *fsnotify.Watcher
interceptor Interceptor
+ compressors []Compressor
}
// New allocates an App instance and loads all view engines.
@@ -249,12 +250,14 @@ func (app *App) handleFunc(pattern string, hf HandleFunc, opts []RoutingOption,
app.routes[pattern] = r
app.mux.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) {
+ rw := app.createWriter(req, w)
+ defer rw.Close()
+
ctx := &Context{
- req: req,
- rw: w,
- Routing: *r,
- app: app,
- interceptor: app.interceptor,
+ req: req,
+ rw: rw,
+ Routing: *r,
+ app: app,
}
err := r.Next(ctx)
@@ -319,12 +322,14 @@ func (app *App) HandlePage(pattern string, viewName string, v Viewer) {
app.routes[pattern] = r
app.mux.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) {
+ rw := app.createWriter(req, w)
+ defer rw.Close()
+
ctx := &Context{
- req: req,
- rw: w,
- Routing: *r,
- app: app,
- interceptor: app.interceptor,
+ req: req,
+ rw: rw,
+ Routing: *r,
+ app: app,
}
err := r.Next(ctx)
@@ -342,6 +347,19 @@ func (app *App) HandlePage(pattern string, viewName string, v Viewer) {
}
+func (app *App) createWriter(req *http.Request, w http.ResponseWriter) ResponseWriter {
+ acceptEncoding := req.Header.Get("Accept-Encoding")
+
+ stars := strings.ContainsAny(acceptEncoding, "*")
+
+ for _, compressor := range app.compressors {
+ if stars || strings.Contains(acceptEncoding, compressor.AcceptEncoding()) {
+ return compressor.New(w)
+ }
+ }
+ return &responseWriter{ResponseWriter: w}
+}
+
// HandleFile registers a route handler for serving a file.
//
// This function associates a FileViewer with a given file name
@@ -379,12 +397,14 @@ func (app *App) HandleFile(name string, v *FileViewer) {
r.Viewers[v.MimeType()] = v
app.mux.HandleFunc(pat, func(w http.ResponseWriter, req *http.Request) {
+ rw := app.createWriter(req, w)
+ defer rw.Close()
+
ctx := &Context{
- req: req,
- rw: w,
- Routing: *r,
- app: app,
- interceptor: app.interceptor,
+ req: req,
+ rw: rw,
+ Routing: *r,
+ app: app,
}
err := r.Next(ctx)
diff --git a/compressor.go b/compressor.go
new file mode 100644
index 0000000..e148ede
--- /dev/null
+++ b/compressor.go
@@ -0,0 +1,15 @@
+package xun
+
+import "net/http"
+
+// Compressor is an interface that defines methods for handling HTTP response compression.
+// Implementations of this interface should provide the specific encoding type they support
+// and a method to create a new ResponseWriter that applies the compression.
+//
+// AcceptEncoding returns the encoding type that the compressor supports.
+//
+// New takes an http.ResponseWriter and returns a ResponseWriter that applies the compression.
+type Compressor interface {
+ AcceptEncoding() string
+ New(rw http.ResponseWriter) ResponseWriter
+}
diff --git a/compressor_deflate.go b/compressor_deflate.go
new file mode 100644
index 0000000..20e85fe
--- /dev/null
+++ b/compressor_deflate.go
@@ -0,0 +1,29 @@
+package xun
+
+import (
+ "compress/flate"
+ "net/http"
+)
+
+// DeflateCompressor is a struct that provides functionality for compressing data using the DEFLATE algorithm.
+type DeflateCompressor struct {
+}
+
+// AcceptEncoding returns the encoding type that the DeflateCompressor supports.
+// In this case, it returns the string "deflate".
+func (c *DeflateCompressor) AcceptEncoding() string {
+ return "deflate"
+}
+
+// New creates a new deflateResponseWriter that wraps the provided http.ResponseWriter.
+// It sets the "Content-Encoding" header to "deflate" and initializes a flate.Writer
+// with the default compression level.
+func (c *DeflateCompressor) New(rw http.ResponseWriter) ResponseWriter {
+ rw.Header().Set("Content-Encoding", "deflate")
+ w, _ := flate.NewWriter(rw, flate.DefaultCompression) //nolint: errcheck because flate.DefaultCompression is a valid compression level
+
+ return &deflateResponseWriter{
+ w: w,
+ ResponseWriter: rw,
+ }
+}
diff --git a/compressor_deflate_test.go b/compressor_deflate_test.go
new file mode 100644
index 0000000..0c0104f
--- /dev/null
+++ b/compressor_deflate_test.go
@@ -0,0 +1,110 @@
+package xun
+
+import (
+ "compress/flate"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "testing/fstest"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeflateCompressor(t *testing.T) {
+ fsys := fstest.MapFS{
+ "public/skin.css": {
+ Data: []byte("body { color: red; }"),
+ },
+ "pages/index.html": {
+ Data: []byte("
index"),
+ },
+ }
+
+ m := http.NewServeMux()
+ srv := httptest.NewServer(m)
+ defer srv.Close()
+
+ app := New(WithMux(m), WithFsys(fsys), WithCompressor(&DeflateCompressor{}))
+ defer app.Close()
+
+ app.Get("/json", func(c *Context) error {
+ return c.View(map[string]string{"message": "hello"})
+ })
+
+ go app.Start()
+
+ var tests = []struct {
+ name string
+ acceptEncoding string
+ contentEncoding string
+ createReader func(r io.Reader) io.Reader
+ }{
+ {
+ name: "deflate",
+ acceptEncoding: "deflate",
+ contentEncoding: "deflate",
+ createReader: func(r io.Reader) io.Reader {
+ return flate.NewReader(r)
+ },
+ },
+ {
+ name: "any",
+ acceptEncoding: "*",
+ contentEncoding: "deflate",
+ createReader: func(r io.Reader) io.Reader {
+ return flate.NewReader(r)
+ },
+ },
+ {
+ name: "plain",
+ acceptEncoding: "",
+ contentEncoding: "",
+ createReader: func(r io.Reader) io.Reader {
+ return r
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/skin.css", nil)
+ require.NoError(t, err)
+ req.Header.Set("Accept-Encoding", test.acceptEncoding)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, test.contentEncoding, resp.Header.Get("Content-Encoding"))
+
+ buf, err := io.ReadAll(test.createReader(resp.Body))
+ require.NoError(t, err)
+ require.Equal(t, fsys["public/skin.css"].Data, buf)
+
+ req, err = http.NewRequest(http.MethodGet, srv.URL+"/", nil)
+ require.NoError(t, err)
+ req.Header.Set("Accept-Encoding", test.acceptEncoding)
+
+ resp, err = client.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, test.contentEncoding, resp.Header.Get("Content-Encoding"))
+
+ buf, err = io.ReadAll(test.createReader(resp.Body))
+ require.NoError(t, err)
+ require.Equal(t, fsys["pages/index.html"].Data, buf)
+
+ req, err = http.NewRequest(http.MethodGet, srv.URL+"/json", nil)
+ require.NoError(t, err)
+ req.Header.Set("Accept-Encoding", test.acceptEncoding)
+
+ resp, err = client.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, test.contentEncoding, resp.Header.Get("Content-Encoding"))
+
+ data := make(map[string]string)
+ err = json.NewDecoder(test.createReader(resp.Body)).Decode(&data)
+ require.NoError(t, err)
+ require.Equal(t, "hello", data["message"])
+ })
+ }
+
+}
diff --git a/compressor_gzip.go b/compressor_gzip.go
new file mode 100644
index 0000000..22e5dfa
--- /dev/null
+++ b/compressor_gzip.go
@@ -0,0 +1,28 @@
+package xun
+
+import (
+ "compress/gzip"
+ "net/http"
+)
+
+// GzipCompressor is a struct that provides methods for compressing and decompressing data using the Gzip algorithm.
+type GzipCompressor struct {
+}
+
+// AcceptEncoding returns the encoding type that the GzipCompressor supports.
+// In this case, it returns "gzip".
+func (c *GzipCompressor) AcceptEncoding() string {
+ return "gzip"
+}
+
+// New creates a new gzipResponseWriter that wraps the provided http.ResponseWriter.
+// It sets the "Content-Encoding" header to "gzip" and returns the wrapped writer.
+func (c *GzipCompressor) New(rw http.ResponseWriter) ResponseWriter {
+ rw.Header().Set("Content-Encoding", "gzip")
+
+ return &gzipResponseWriter{
+ w: gzip.NewWriter(rw),
+ ResponseWriter: rw,
+ }
+
+}
diff --git a/compressor_gzip_test.go b/compressor_gzip_test.go
new file mode 100644
index 0000000..a358e82
--- /dev/null
+++ b/compressor_gzip_test.go
@@ -0,0 +1,121 @@
+package xun
+
+import (
+ "compress/flate"
+ "compress/gzip"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "testing/fstest"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestGzipCompressor(t *testing.T) {
+ fsys := fstest.MapFS{
+ "public/skin.css": {
+ Data: []byte("body { color: red; }"),
+ },
+ "pages/index.html": {
+ Data: []byte("index"),
+ },
+ }
+
+ m := http.NewServeMux()
+ srv := httptest.NewServer(m)
+ defer srv.Close()
+
+ app := New(WithMux(m), WithFsys(fsys), WithCompressor(&GzipCompressor{}, &DeflateCompressor{}))
+ defer app.Close()
+
+ app.Get("/json", func(c *Context) error {
+ return c.View(map[string]string{"message": "hello"})
+ })
+
+ go app.Start()
+
+ var tests = []struct {
+ name string
+ acceptEncoding string
+ contentEncoding string
+ createReader func(r io.Reader) io.Reader
+ }{
+ {
+ name: "gzip",
+ acceptEncoding: "gzip",
+ contentEncoding: "gzip",
+ createReader: func(r io.Reader) io.Reader {
+ gr, _ := gzip.NewReader(r)
+ return gr
+ },
+ },
+ {
+ name: "any",
+ acceptEncoding: "*",
+ contentEncoding: "gzip",
+ createReader: func(r io.Reader) io.Reader {
+ gr, _ := gzip.NewReader(r)
+ return gr
+ },
+ },
+ {
+ name: "plain",
+ acceptEncoding: "",
+ contentEncoding: "",
+ createReader: func(r io.Reader) io.Reader {
+ return r
+ },
+ },
+ {
+ name: "deflate",
+ acceptEncoding: "deflate",
+ contentEncoding: "deflate",
+ createReader: func(r io.Reader) io.Reader {
+ return flate.NewReader(r)
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/skin.css", nil)
+ require.NoError(t, err)
+ req.Header.Set("Accept-Encoding", test.acceptEncoding)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, test.contentEncoding, resp.Header.Get("Content-Encoding"))
+
+ buf, err := io.ReadAll(test.createReader(resp.Body))
+ require.NoError(t, err)
+ require.Equal(t, fsys["public/skin.css"].Data, buf)
+
+ req, err = http.NewRequest(http.MethodGet, srv.URL+"/", nil)
+ require.NoError(t, err)
+ req.Header.Set("Accept-Encoding", test.acceptEncoding)
+
+ resp, err = client.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, test.contentEncoding, resp.Header.Get("Content-Encoding"))
+
+ buf, err = io.ReadAll(test.createReader(resp.Body))
+ require.NoError(t, err)
+ require.Equal(t, fsys["pages/index.html"].Data, buf)
+
+ req, err = http.NewRequest(http.MethodGet, srv.URL+"/json", nil)
+ require.NoError(t, err)
+ req.Header.Set("Accept-Encoding", test.acceptEncoding)
+
+ resp, err = client.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, test.contentEncoding, resp.Header.Get("Content-Encoding"))
+
+ data := make(map[string]string)
+ err = json.NewDecoder(test.createReader(resp.Body)).Decode(&data)
+ require.NoError(t, err)
+ require.Equal(t, "hello", data["message"])
+ })
+ }
+
+}
diff --git a/context.go b/context.go
index 5bcf5c6..39002c4 100644
--- a/context.go
+++ b/context.go
@@ -17,7 +17,6 @@ type Context struct {
writtenStatus bool
values map[string]any
- interceptor Interceptor
}
// Writer returns the http.ResponseWriter associated with the current context.
@@ -112,8 +111,8 @@ func (c *Context) View(items ...any) error {
// It uses the given status code. If the status code is not provided,
// it uses http.StatusFound (302).
func (c *Context) Redirect(url string, statusCode ...int) {
- if c.interceptor != nil {
- if c.interceptor.Redirect(c, url, statusCode...) {
+ if c.app.interceptor != nil {
+ if c.app.interceptor.Redirect(c, url, statusCode...) {
return
}
@@ -168,8 +167,8 @@ func (c *Context) Accept() (types []string) {
// RequestReferer returns the referer of the request.
func (c *Context) RequestReferer() *url.URL {
var v string
- if c.interceptor != nil {
- v = c.interceptor.RequestReferer(c)
+ if c.app.interceptor != nil {
+ v = c.app.interceptor.RequestReferer(c)
}
if v == "" {
diff --git a/option.go b/option.go
index 7582504..d64d8e8 100644
--- a/option.go
+++ b/option.go
@@ -54,8 +54,34 @@ func WithViewEngines(ve ...ViewEngine) Option {
}
}
+// WithInterceptor returns an Option that sets the provided Interceptor
+// to the App. This allows customization of the App's behavior by
+// intercepting and potentially modifying requests or responses.
+//
+// Parameters:
+// - i: An Interceptor instance to be set in the App.
+//
+// Returns:
+// - Option: A function that takes an App pointer and sets its interceptor
+// to the provided Interceptor.
func WithInterceptor(i Interceptor) Option {
return func(app *App) {
app.interceptor = i
}
}
+
+// WithCompressor is an option function that sets the compressors for the application.
+// It takes a variadic parameter of Compressor type and assigns it to the app's compressors field.
+//
+// Parameters:
+//
+// c ...Compressor - A variadic list of Compressor instances to be used by the application.
+//
+// Returns:
+//
+// Option - A function that takes an App pointer and sets its compressors field.
+func WithCompressor(c ...Compressor) Option {
+ return func(app *App) {
+ app.compressors = c
+ }
+}
diff --git a/response_writer.go b/response_writer.go
new file mode 100644
index 0000000..7f66688
--- /dev/null
+++ b/response_writer.go
@@ -0,0 +1,23 @@
+package xun
+
+import (
+ "net/http"
+)
+
+// ResponseWriter is an interface that extends the standard http.ResponseWriter
+// interface with an additional Close method. It is used to write HTTP responses
+// and perform any necessary cleanup or finalization when the response is complete.
+type ResponseWriter interface {
+ http.ResponseWriter
+
+ Close()
+}
+
+// responseWriter is a wrapper around http.ResponseWriter that allows for
+// additional functionality to be added to the standard ResponseWriter.
+type responseWriter struct {
+ http.ResponseWriter
+}
+
+func (w *responseWriter) Close() {
+}
diff --git a/response_writer_deflate.go b/response_writer_deflate.go
new file mode 100644
index 0000000..db965e4
--- /dev/null
+++ b/response_writer_deflate.go
@@ -0,0 +1,25 @@
+package xun
+
+import (
+ "compress/flate"
+ "net/http"
+)
+
+// deflateResponseWriter is a custom http.ResponseWriter that wraps the standard
+// ResponseWriter and compresses the response using the deflate algorithm.
+type deflateResponseWriter struct {
+ w *flate.Writer
+ http.ResponseWriter
+}
+
+// Write writes the data to the underlying gzip writer.
+// It implements the io.Writer interface.
+func (w *deflateResponseWriter) Write(p []byte) (int, error) {
+ return w.w.Write(p)
+}
+
+// Close closes the underlying writer, flushing any buffered data to the client.
+// It is important to call this method to ensure all data is properly sent.
+func (w *deflateResponseWriter) Close() {
+ w.w.Close()
+}
diff --git a/response_writer_gzip.go b/response_writer_gzip.go
new file mode 100644
index 0000000..27cb2bb
--- /dev/null
+++ b/response_writer_gzip.go
@@ -0,0 +1,24 @@
+package xun
+
+import (
+ "compress/gzip"
+ "net/http"
+)
+
+// gzipResponseWriter is a custom http.ResponseWriter that wraps the standard
+// ResponseWriter and compresses the response using gzip.
+type gzipResponseWriter struct {
+ w *gzip.Writer
+ http.ResponseWriter
+}
+
+// Write writes the data to the underlying gzip writer.
+// It implements the io.Writer interface.
+func (w *gzipResponseWriter) Write(p []byte) (int, error) {
+ return w.w.Write(p)
+}
+
+// Close closes the gzipResponseWriter, ensuring that the underlying writer is also closed.
+func (w *gzipResponseWriter) Close() {
+ w.w.Close()
+}