Skip to content

Commit

Permalink
Add support for comparing by MIME. (#69)
Browse files Browse the repository at this point in the history
* zero API change; don't break current usage.
* no change in time or space usage using benchmarks
  • Loading branch information
meirf authored and jprobinson committed Feb 27, 2018
1 parent 2600fb1 commit 5032c88
Show file tree
Hide file tree
Showing 2 changed files with 104 additions and 20 deletions.
74 changes: 65 additions & 9 deletions gzip.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"compress/gzip"
"fmt"
"io"
"mime"
"net"
"net/http"
"strconv"
Expand All @@ -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
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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})
}
}
}
}
Expand All @@ -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
}
}
Expand Down
50 changes: 39 additions & 11 deletions gzip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 5032c88

Please sign in to comment.