From 3f81e315adc041cbd342e66a636590d61a63742f Mon Sep 17 00:00:00 2001 From: Meir Fischer Date: Mon, 26 Feb 2018 00:22:56 -0500 Subject: [PATCH] Add support for comparing by MIME. * zero API change; don't break current usage. * no change in time or space usage using benchmarks --- gzip.go | 74 +++++++++++++++++++++++++++++++++++++++++++++------- gzip_test.go | 50 +++++++++++++++++++++++++++-------- 2 files changed, 104 insertions(+), 20 deletions(-) diff --git a/gzip.go b/gzip.go index f91dcfa..028b553 100644 --- a/gzip.go +++ b/gzip.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "fmt" "io" + "mime" "net" "net/http" "strconv" @@ -28,8 +29,8 @@ const ( // The examples seem to indicate that it is. DefaultQValue = 1.0 - // 1500 bytes is the MTU size for the internet since that is the largest size allowed at the network layer. - // If you take a file that is 1300 bytes and compress it to 800 bytes, it’s still transmitted in that same 1500 byte packet regardless, so you’ve gained nothing. + // 1500 bytes is the MTU size for the internet since that is the largest size allowed at the network layer. + // If you take a file that is 1300 bytes and compress it to 800 bytes, it’s still transmitted in that same 1500 byte packet regardless, so you’ve gained nothing. // That being the case, you should restrict the gzip compression to files with a size greater than a single packet, 1400 bytes (1.4KB) is a safe value. DefaultMinSize = 1400 ) @@ -82,7 +83,7 @@ type GzipResponseWriter struct { minSize int // Specifed the minimum response size to gzip. If the response length is bigger than this value, it is compressed. buf []byte // Holds the first part of the write before reaching the minSize or the end of the write. - contentTypes []string // Only compress if the response is one of these content-types. All are accepted if empty. + contentTypes []parsedContentType // Only compress if the response is one of these content-types. All are accepted if empty. } type GzipResponseWriterWithCloseNotify struct { @@ -296,11 +297,40 @@ func GzipHandlerWithOpts(opts ...option) (func(http.Handler) http.Handler, error }, nil } +// Parsed representation of one of the inputs to ContentTypes. +// See https://golang.org/pkg/mime/#ParseMediaType +type parsedContentType struct { + mediaType string + params map[string]string +} + +// equals returns whether this content type matches another content type. +func (pct parsedContentType) equals(mediaType string, params map[string]string) bool { + if pct.mediaType != mediaType { + return false + } + // if pct has no params, don't care about other's params + if len(pct.params) == 0 { + return true + } + + // if pct has any params, they must be identical to other's. + if len(pct.params) != len(params) { + return false + } + for k, v := range pct.params { + if w, ok := params[k]; !ok || v != w { + return false + } + } + return true +} + // Used for functional configuration. type config struct { minSize int level int - contentTypes []string + contentTypes []parsedContentType } func (c *config) validate() error { @@ -329,11 +359,32 @@ func CompressionLevel(level int) option { } } +// ContentTypes specifies a list of content types to compare +// the Content-Type header to before compressing. If none +// match, the response will be returned as-is. +// +// Content types are compared in a case-insensitive, whitespace-ignored +// manner. +// +// 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 ContentTypes(types []string) option { return func(c *config) { - c.contentTypes = []string{} + c.contentTypes = []parsedContentType{} for _, v := range types { - c.contentTypes = append(c.contentTypes, strings.ToLower(v)) + mediaType, params, err := mime.ParseMediaType(v) + if err == nil { + c.contentTypes = append(c.contentTypes, parsedContentType{mediaType, params}) + } } } } @@ -354,15 +405,20 @@ func acceptsGzip(r *http.Request) bool { } // returns true if we've been configured to compress the specific content type. -func handleContentType(contentTypes []string, w http.ResponseWriter) bool { +func handleContentType(contentTypes []parsedContentType, w http.ResponseWriter) bool { // If contentTypes is empty we handle all content types. if len(contentTypes) == 0 { return true } - ct := strings.ToLower(w.Header().Get(contentType)) + ct := w.Header().Get(contentType) + mediaType, params, err := mime.ParseMediaType(ct) + if err != nil { + return false + } + for _, c := range contentTypes { - if c == ct { + if c.equals(mediaType, params) { return true } } diff --git a/gzip_test.go b/gzip_test.go index 64a032a..615ddca 100644 --- a/gzip_test.go +++ b/gzip_test.go @@ -345,7 +345,7 @@ func TestFlushBeforeWrite(t *testing.T) { func TestImplementCloseNotifier(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/", nil) request.Header.Set(acceptEncoding, "gzip") - GzipHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request){ + GzipHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { _, ok := rw.(http.CloseNotifier) assert.True(t, ok, "response writer must implement http.CloseNotifier") })).ServeHTTP(&mockRWCloseNotify{}, request) @@ -354,7 +354,7 @@ func TestImplementCloseNotifier(t *testing.T) { func TestImplementFlusherAndCloseNotifier(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/", nil) request.Header.Set(acceptEncoding, "gzip") - GzipHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request){ + GzipHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { _, okCloseNotifier := rw.(http.CloseNotifier) assert.True(t, okCloseNotifier, "response writer must implement http.CloseNotifier") _, okFlusher := rw.(http.Flusher) @@ -365,13 +365,12 @@ func TestImplementFlusherAndCloseNotifier(t *testing.T) { func TestNotImplementCloseNotifier(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/", nil) request.Header.Set(acceptEncoding, "gzip") - GzipHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request){ + GzipHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { _, ok := rw.(http.CloseNotifier) assert.False(t, ok, "response writer must not implement http.CloseNotifier") })).ServeHTTP(httptest.NewRecorder(), request) } - type mockRWCloseNotify struct{} func (m *mockRWCloseNotify) CloseNotify() <-chan bool { @@ -390,7 +389,6 @@ func (m *mockRWCloseNotify) WriteHeader(int) { panic("implement me") } - func TestIgnoreSubsequentWriteHeader(t *testing.T) { handler := GzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) @@ -444,23 +442,53 @@ var contentTypeTests = []struct { expectedGzip: true, }, { - name: "Exact content-type match", + name: "MIME match", contentType: "application/json", acceptedContentTypes: []string{"application/json"}, expectedGzip: true, }, { - name: "Case insensitive content-type matching", - contentType: "Application/Json", + name: "MIME no match", + contentType: "text/xml", acceptedContentTypes: []string{"application/json"}, - expectedGzip: true, + expectedGzip: false, }, { - name: "Non-matching content-type", - contentType: "text/xml", + name: "MIME match with no other directive ignores non-MIME directives", + contentType: "application/json; charset=utf-8", acceptedContentTypes: []string{"application/json"}, + expectedGzip: true, + }, + { + name: "MIME match with other directives requires all directives be equal, different charset", + contentType: "application/json; charset=ascii", + acceptedContentTypes: []string{"application/json; charset=utf-8"}, expectedGzip: false, }, + { + name: "MIME match with other directives requires all directives be equal, same charset", + contentType: "application/json; charset=utf-8", + acceptedContentTypes: []string{"application/json; charset=utf-8"}, + expectedGzip: true, + }, + { + name: "MIME match with other directives requires all directives be equal, missing charset", + contentType: "application/json", + acceptedContentTypes: []string{"application/json; charset=ascii"}, + expectedGzip: false, + }, + { + name: "MIME match case insensitive", + contentType: "Application/Json", + acceptedContentTypes: []string{"application/json"}, + expectedGzip: true, + }, + { + name: "MIME match ignore whitespace", + contentType: "application/json;charset=utf-8", + acceptedContentTypes: []string{"application/json; charset=utf-8"}, + expectedGzip: true, + }, } func TestContentTypes(t *testing.T) {