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() +}