Skip to content

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

Closed
wants to merge 1 commit into from
Closed
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/labstack/echo/v4
go 1.14

require (
github.com/andybalholm/brotli v1.0.0
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/labstack/gommon v0.3.0
github.com/mattn/go-colorable v0.1.6 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4=
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM=
Expand Down
121 changes: 121 additions & 0 deletions middleware/compress_brotli.go
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.
Copy link
Contributor

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

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The 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})
Copy link
Contributor

Choose a reason for hiding this comment

The 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()
}
144 changes: 144 additions & 0 deletions middleware/compress_brotli_test.go
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"))
Copy link
Contributor

Choose a reason for hiding this comment

The 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"))
Copy link
Contributor

Choose a reason for hiding this comment

The 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())
}
}
File renamed without changes.
File renamed without changes.