Skip to content
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
87 changes: 72 additions & 15 deletions gzip.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ type GzipResponseWriter struct {
ignore bool // If true, then we immediately passthru writes to the underlying ResponseWriter.

contentTypes []parsedContentType // Only compress if the response is one of these content-types. All are accepted if empty.

contentTypeExceptions []parsedContentType // Only compress if the response is not one of these content-types. All are accepted if empty.
}

type GzipResponseWriterWithCloseNotify struct {
Expand Down Expand Up @@ -118,7 +120,7 @@ func (w *GzipResponseWriter) Write(b []byte) (int, error) {
ce = w.Header().Get(contentEncoding)
)
// Only continue if they didn't already choose an encoding or a known unhandled content length or type.
if ce == "" && (cl == 0 || cl >= w.minSize) && (ct == "" || handleContentType(w.contentTypes, ct)) {
if ce == "" && (cl == 0 || cl >= w.minSize) && (ct == "" || handleContentType(w.contentTypes, w.contentTypeExceptions, ct)) {
// If the current buffer is less than minSize and a Content-Length isn't set, then wait until we have more data.
if len(w.buf) < w.minSize && cl == 0 {
return len(b), nil
Expand All @@ -131,7 +133,7 @@ func (w *GzipResponseWriter) Write(b []byte) (int, error) {
w.Header().Set(contentType, ct)
}
// If the Content-Type is acceptable to GZIP, initialize the GZIP writer.
if handleContentType(w.contentTypes, ct) {
if handleContentType(w.contentTypes, w.contentTypeExceptions, ct) {
if err := w.startGzip(); err != nil {
return 0, err
}
Expand Down Expand Up @@ -324,10 +326,11 @@ func GzipHandlerWithOpts(opts ...option) (func(http.Handler) http.Handler, error
w.Header().Add(vary, acceptEncoding)
if acceptsGzip(r) {
gw := &GzipResponseWriter{
ResponseWriter: w,
index: index,
minSize: c.minSize,
contentTypes: c.contentTypes,
ResponseWriter: w,
index: index,
minSize: c.minSize,
contentTypes: c.contentTypes,
contentTypeExceptions: c.contentTypeExceptions,
}
defer gw.Close()

Expand Down Expand Up @@ -376,16 +379,21 @@ func (pct parsedContentType) equals(mediaType string, params map[string]string)

// Used for functional configuration.
type config struct {
minSize int
level int
contentTypes []parsedContentType
minSize int
level int
contentTypes []parsedContentType
contentTypeExceptions []parsedContentType
}

func (c *config) validate() error {
if c.level != gzip.DefaultCompression && (c.level < gzip.BestSpeed || c.level > gzip.BestCompression) {
return fmt.Errorf("invalid compression level requested: %d", c.level)
}

if len(c.contentTypes) > 0 && len(c.contentTypeExceptions) > 0 {
return fmt.Errorf("ContentTypes and ContentTypeExceptions are mutually exclusive")
}

if c.minSize < 0 {
return fmt.Errorf("minimum size must be more than zero")
}
Expand All @@ -411,6 +419,9 @@ func CompressionLevel(level int) option {
// the Content-Type header to before compressing. If none
// match, the response will be returned as-is.
//
// ContentTypes cannot be used with ContentTypeExceptions, the options
// are mutually exclusive.
//
// Content types are compared in a case-insensitive, whitespace-ignored
// manner.
//
Expand All @@ -437,6 +448,39 @@ func ContentTypes(types []string) option {
}
}

// ContentTypeExceptions specifies a list of content types to compare
// the Content-Type header to before compressing. If any
// match, the response will be returned as-is.
//
// Content types are compared in a case-insensitive, whitespace-ignored
// manner.
//
// ContentTypeExceptions cannot be used with ContentTypes, the options
// are mutually exclusive.
//
// A MIME type without any other directive will match a content type
// that has the same MIME type, regardless of that content type's other
// directives. I.e., "text/html" will match both "text/html" and
// "text/html; charset=utf-8".
//
// A MIME type with any other directive will only match a content type
// that has the same MIME type and other directives. I.e.,
// "text/html; charset=utf-8" will only match "text/html; charset=utf-8".
//
// By default, responses are gzipped regardless of
// Content-Type.
func ContentTypeExceptions(types []string) option {
return func(c *config) {
c.contentTypeExceptions = []parsedContentType{}
for _, v := range types {
mediaType, params, err := mime.ParseMediaType(v)
if err == nil {
c.contentTypeExceptions = append(c.contentTypeExceptions, parsedContentType{mediaType, params})
}
}
}
}

// GzipHandler wraps an HTTP handler, to transparently gzip the response body if
// the client supports it (via the Accept-Encoding header). This will compress at
// the default compression level.
Expand All @@ -453,9 +497,11 @@ func acceptsGzip(r *http.Request) bool {
}

// returns true if we've been configured to compress the specific content type.
func handleContentType(contentTypes []parsedContentType, ct string) bool {
// If contentTypes is empty we handle all content types.
if len(contentTypes) == 0 {
func handleContentType(whitelist, blacklist []parsedContentType, ct string) bool {
// If whitelist and blacklist are empty we handle all content types.
whiteLen := len(whitelist)
blackLen := len(blacklist)
if whiteLen == 0 && blackLen == 0 {
return true
}

Expand All @@ -464,13 +510,24 @@ func handleContentType(contentTypes []parsedContentType, ct string) bool {
return false
}

for _, c := range contentTypes {
var listToCheck []parsedContentType
var whitelistMode bool

if whiteLen > 0 {
whitelistMode = true
listToCheck = whitelist
} else {
listToCheck = blacklist
}

var isInList bool
for _, c := range listToCheck {
if c.equals(mediaType, params) {
return true
isInList = true
}
}

return false
return (whitelistMode && isInList) || (!whitelistMode && !isInList)
}

// parseEncodings attempts to parse a list of codings, per RFC 2616, as might
Expand Down
96 changes: 96 additions & 0 deletions gzip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,102 @@ func TestContentTypes(t *testing.T) {
assert.NotEqual(t, "gzip", res.Header.Get("Content-Encoding"), tt.name)
}
}

_, err := GzipHandlerWithOpts(ContentTypes([]string{"foo"}), ContentTypeExceptions([]string{"bar"}))
assert.EqualError(t, err, "ContentTypes and ContentTypeExceptions are mutually exclusive")
}

var contentTypeExceptionTests = []struct {
name string
contentType string
contentTypeExceptions []string
expectedGzip bool
}{
{
name: "Always gzip when content types are empty",
contentType: "",
contentTypeExceptions: []string{},
expectedGzip: true,
},
{
name: "MIME match",
contentType: "application/json",
contentTypeExceptions: []string{"application/json"},
expectedGzip: false,
},
{
name: "MIME no match",
contentType: "text/xml",
contentTypeExceptions: []string{"application/json"},
expectedGzip: true,
},
{
name: "MIME match with no other directive ignores non-MIME directives",
contentType: "application/json; charset=utf-8",
contentTypeExceptions: []string{"application/json"},
expectedGzip: false,
},
{
name: "MIME match with other directives requires all directives be equal, different charset",
contentType: "application/json; charset=ascii",
contentTypeExceptions: []string{"application/json; charset=utf-8"},
expectedGzip: true,
},
{
name: "MIME match with other directives requires all directives be equal, same charset",
contentType: "application/json; charset=utf-8",
contentTypeExceptions: []string{"application/json; charset=utf-8"},
expectedGzip: false,
},
{
name: "MIME match with other directives requires all directives be equal, missing charset",
contentType: "application/json",
contentTypeExceptions: []string{"application/json; charset=ascii"},
expectedGzip: true,
},
{
name: "MIME match case insensitive",
contentType: "Application/Json",
contentTypeExceptions: []string{"application/json"},
expectedGzip: false,
},
{
name: "MIME match ignore whitespace",
contentType: "application/json;charset=utf-8",
contentTypeExceptions: []string{"application/json; charset=utf-8"},
expectedGzip: false,
},
}

func TestContentTypeExceptions(t *testing.T) {
for _, tt := range contentTypeExceptionTests {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", tt.contentType)
io.WriteString(w, testBody)
})

wrapper, err := GzipHandlerWithOpts(ContentTypeExceptions(tt.contentTypeExceptions))
if !assert.Nil(t, err, "NewGzipHandlerWithOpts returned error", tt.name) {
continue
}

req, _ := http.NewRequest("GET", "/whatever", nil)
req.Header.Set("Accept-Encoding", "gzip")
resp := httptest.NewRecorder()
wrapper(handler).ServeHTTP(resp, req)
res := resp.Result()

assert.Equal(t, 200, res.StatusCode)
if tt.expectedGzip {
assert.Equal(t, "gzip", res.Header.Get("Content-Encoding"), tt.name)
} else {
assert.NotEqual(t, "gzip", res.Header.Get("Content-Encoding"), tt.name)
}
}

_, err := GzipHandlerWithOpts(ContentTypes([]string{"foo"}), ContentTypeExceptions([]string{"bar"}))
assert.EqualError(t, err, "ContentTypes and ContentTypeExceptions are mutually exclusive")
}

// --------------------------------------------------------------------
Expand Down