-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Add Brotli support with pure go implementation #1587
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
package middleware | ||
|
||
import ( | ||
"bufio" | ||
"io" | ||
"io/ioutil" | ||
"net" | ||
"net/http" | ||
"strings" | ||
|
||
"github.com/andybalholm/brotli" | ||
|
||
"github.com/labstack/echo/v4" | ||
) | ||
|
||
type ( | ||
// BrotliConfig defines the config for Brotli middleware. | ||
BrotliConfig struct { | ||
// Skipper defines a function to skip middleware. | ||
Skipper Skipper | ||
|
||
// Brotli compression level. | ||
// Optional. Default value -1. | ||
Level int `yaml:"level"` | ||
} | ||
|
||
brotliResponseWriter struct { | ||
io.Writer | ||
http.ResponseWriter | ||
} | ||
) | ||
|
||
const ( | ||
brotliScheme = "br" | ||
) | ||
|
||
var ( | ||
// DefaultBrotliConfig is the default Brotli middleware config. | ||
DefaultBrotliConfig = BrotliConfig{ | ||
Skipper: DefaultSkipper, | ||
Level: brotli.DefaultCompression, | ||
} | ||
) | ||
|
||
// Brotli returns a middleware which compresses HTTP response using brotli compression | ||
// scheme. | ||
func Brotli() echo.MiddlewareFunc { | ||
return BrotliWithConfig(DefaultBrotliConfig) | ||
} | ||
|
||
// BrotliWithConfig return Brotli middleware with config. | ||
// See: `Brotli()`. | ||
func BrotliWithConfig(config BrotliConfig) echo.MiddlewareFunc { | ||
// Defaults | ||
if config.Skipper == nil { | ||
config.Skipper = DefaultBrotliConfig.Skipper | ||
} | ||
if config.Level == 0 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Be aware that for the Brotli library that you added a Level equal to 0 is a valid level. I thinkit is better to remove this check and keep the Level received by parameter. https://github.com/andybalholm/brotli/blob/729edfbcfeaad43a6412c185205501ec80773250/writer.go#L8-L12 const (
BestSpeed = 0
BestCompression = 11
DefaultCompression = 6
) |
||
config.Level = DefaultBrotliConfig.Level | ||
} | ||
|
||
return func(next echo.HandlerFunc) echo.HandlerFunc { | ||
return func(c echo.Context) error { | ||
if config.Skipper(c) { | ||
return next(c) | ||
} | ||
|
||
res := c.Response() | ||
res.Header().Add(echo.HeaderVary, echo.HeaderAcceptEncoding) | ||
if strings.Contains(c.Request().Header.Get(echo.HeaderAcceptEncoding), brotliScheme) { | ||
res.Header().Set(echo.HeaderContentEncoding, brotliScheme) // Issue #806 | ||
rw := res.Writer | ||
|
||
w := brotli.NewWriterOptions(rw, brotli.WriterOptions{Quality: config.Level}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please use a sync.Pool here to reduce the amount of allocations performed by this middleware. Yo can check the current implementation of compress.go or use #1672 as a reference |
||
|
||
defer func() { | ||
if res.Size == 0 { | ||
if res.Header().Get(echo.HeaderContentEncoding) == brotliScheme { | ||
res.Header().Del(echo.HeaderContentEncoding) | ||
} | ||
// We have to reset response to it's pristine state when | ||
// nothing is written to body or error is returned. | ||
// See issue #424, #407. | ||
res.Writer = rw | ||
w.Reset(ioutil.Discard) | ||
} | ||
w.Close() | ||
}() | ||
grw := &brotliResponseWriter{Writer: w, ResponseWriter: rw} | ||
res.Writer = grw | ||
} | ||
return next(c) | ||
} | ||
} | ||
} | ||
|
||
func (w *brotliResponseWriter) WriteHeader(code int) { | ||
if code == http.StatusNoContent { // Issue #489 | ||
w.ResponseWriter.Header().Del(echo.HeaderContentEncoding) | ||
} | ||
w.Header().Del(echo.HeaderContentLength) // Issue #444 | ||
w.ResponseWriter.WriteHeader(code) | ||
} | ||
|
||
func (w *brotliResponseWriter) Write(b []byte) (int, error) { | ||
if w.Header().Get(echo.HeaderContentType) == "" { | ||
w.Header().Set(echo.HeaderContentType, http.DetectContentType(b)) | ||
} | ||
return w.Writer.Write(b) | ||
} | ||
|
||
func (w *brotliResponseWriter) Flush() { | ||
w.Writer.(*brotli.Writer).Flush() | ||
if flusher, ok := w.ResponseWriter.(http.Flusher); ok { | ||
flusher.Flush() | ||
} | ||
} | ||
|
||
func (w *brotliResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { | ||
return w.ResponseWriter.(http.Hijacker).Hijack() | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
package middleware | ||
|
||
import ( | ||
"bytes" | ||
"io" | ||
"io/ioutil" | ||
"net/http" | ||
"net/http/httptest" | ||
"testing" | ||
|
||
"github.com/andybalholm/brotli" | ||
|
||
"github.com/labstack/echo/v4" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestBrotli(t *testing.T) { | ||
e := echo.New() | ||
req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
rec := httptest.NewRecorder() | ||
c := e.NewContext(req, rec) | ||
|
||
// Skip if no Accept-Encoding header | ||
h := Brotli()(func(c echo.Context) error { | ||
c.Response().Write([]byte("test")) // For Content-Type sniffing | ||
return nil | ||
}) | ||
h(c) | ||
|
||
assert := assert.New(t) | ||
|
||
assert.Equal("test", rec.Body.String()) | ||
|
||
// Brotli | ||
req = httptest.NewRequest(http.MethodGet, "/", nil) | ||
req.Header.Set(echo.HeaderAcceptEncoding, brotliScheme) | ||
rec = httptest.NewRecorder() | ||
c = e.NewContext(req, rec) | ||
h(c) | ||
assert.Equal(brotliScheme, rec.Header().Get(echo.HeaderContentEncoding)) | ||
assert.Contains(rec.Header().Get(echo.HeaderContentType), echo.MIMETextPlain) | ||
|
||
r := brotli.NewReader(rec.Body) | ||
buf := new(bytes.Buffer) | ||
buf.ReadFrom(r) | ||
assert.Equal("test", buf.String()) | ||
|
||
chunkBuf := make([]byte, 5) | ||
|
||
// Brotli chunked | ||
req = httptest.NewRequest(http.MethodGet, "/", nil) | ||
req.Header.Set(echo.HeaderAcceptEncoding, brotliScheme) | ||
rec = httptest.NewRecorder() | ||
|
||
c = e.NewContext(req, rec) | ||
Brotli()(func(c echo.Context) error { | ||
c.Response().Header().Set("Content-Type", "text/event-stream") | ||
c.Response().Header().Set("Transfer-Encoding", "chunked") | ||
|
||
// Write and flush the first part of the data | ||
c.Response().Write([]byte("test\n")) | ||
c.Response().Flush() | ||
|
||
// Read the first part of the data | ||
assert.True(rec.Flushed) | ||
assert.Equal(brotliScheme, rec.Header().Get(echo.HeaderContentEncoding)) | ||
r.Reset(rec.Body) | ||
|
||
_, err := io.ReadFull(r, chunkBuf) | ||
assert.NoError(err) | ||
assert.Equal("test\n", string(chunkBuf)) | ||
|
||
// Write and flush the second part of the data | ||
c.Response().Write([]byte("test\n")) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please use other body here to really be sure that you are getting the second part of the data and not the first part again |
||
c.Response().Flush() | ||
|
||
_, err = io.ReadFull(r, chunkBuf) | ||
assert.NoError(err) | ||
assert.Equal("test\n", string(chunkBuf)) | ||
|
||
// Write the final part of the data and return | ||
c.Response().Write([]byte("test")) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here |
||
return nil | ||
})(c) | ||
|
||
buf = new(bytes.Buffer) | ||
buf.ReadFrom(r) | ||
assert.Equal("test", buf.String()) | ||
} | ||
|
||
func TestBrotliNoContent(t *testing.T) { | ||
e := echo.New() | ||
req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
req.Header.Set(echo.HeaderAcceptEncoding, brotliScheme) | ||
rec := httptest.NewRecorder() | ||
c := e.NewContext(req, rec) | ||
h := Brotli()(func(c echo.Context) error { | ||
return c.NoContent(http.StatusNoContent) | ||
}) | ||
if assert.NoError(t, h(c)) { | ||
assert.Empty(t, rec.Header().Get(echo.HeaderContentEncoding)) | ||
assert.Empty(t, rec.Header().Get(echo.HeaderContentType)) | ||
assert.Equal(t, 0, len(rec.Body.Bytes())) | ||
} | ||
} | ||
|
||
func TestBrotliErrorReturned(t *testing.T) { | ||
e := echo.New() | ||
e.Use(Brotli()) | ||
e.GET("/", func(c echo.Context) error { | ||
return echo.ErrNotFound | ||
}) | ||
req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
req.Header.Set(echo.HeaderAcceptEncoding, brotliScheme) | ||
rec := httptest.NewRecorder() | ||
e.ServeHTTP(rec, req) | ||
assert.Equal(t, http.StatusNotFound, rec.Code) | ||
assert.Empty(t, rec.Header().Get(echo.HeaderContentEncoding)) | ||
} | ||
|
||
// Issue #806 | ||
func TestBrotliWithStatic(t *testing.T) { | ||
e := echo.New() | ||
e.Use(Brotli()) | ||
e.Static("/test", "../_fixture/images") | ||
req := httptest.NewRequest(http.MethodGet, "/test/walle.png", nil) | ||
req.Header.Set(echo.HeaderAcceptEncoding, brotliScheme) | ||
rec := httptest.NewRecorder() | ||
e.ServeHTTP(rec, req) | ||
assert.Equal(t, http.StatusOK, rec.Code) | ||
// Data is written out in chunks when Content-Length == "", so only | ||
// validate the content length if it's not set. | ||
if cl := rec.Header().Get("Content-Length"); cl != "" { | ||
assert.Equal(t, cl, rec.Body.Len()) | ||
} | ||
r := brotli.NewReader(rec.Body) | ||
|
||
want, err := ioutil.ReadFile("../_fixture/images/walle.png") | ||
if assert.NoError(t, err) { | ||
buf := new(bytes.Buffer) | ||
buf.ReadFrom(r) | ||
assert.Equal(t, want, buf.Bytes()) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment is not correct. The default value is brotli.DefaultCompression