Skip to content
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

Add support for Brotli and Zstandard compression #1082

Merged
merged 3 commits into from
Jul 23, 2019
Merged
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
35 changes: 35 additions & 0 deletions Gopkg.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Gopkg.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,11 @@
[[prune.project]]
name = "github.com/spf13/cobra"
unused-packages = false

[[constraint]]
name = "github.com/klauspost/compress"
version = "1.7.2"

[[constraint]]
branch = "master"
name = "github.com/andybalholm/brotli"
33 changes: 33 additions & 0 deletions js/modules/k6/http/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ import (
"testing"
"time"

"github.com/andybalholm/brotli"
"github.com/dop251/goja"
"github.com/klauspost/compress/zstd"
"github.com/loadimpact/k6/js/common"
"github.com/loadimpact/k6/lib"
"github.com/loadimpact/k6/lib/metrics"
Expand Down Expand Up @@ -314,6 +316,24 @@ func TestRequestAndBatch(t *testing.T) {
`))
assert.NoError(t, err)
})
t.Run("zstd", func(t *testing.T) {
_, err := common.RunString(rt, sr(`
let res = http.get("HTTPSBIN_IP_URL/zstd");
if (res.json()['compression'] != 'zstd') {
throw new Error("unexpected body data: " + res.json()['compression'])
}
`))
assert.NoError(t, err)
})
t.Run("brotli", func(t *testing.T) {
_, err := common.RunString(rt, sr(`
let res = http.get("HTTPSBIN_IP_URL/brotli");
if (res.json()['compression'] != 'br') {
throw new Error("unexpected body data: " + res.json()['compression'])
}
`))
assert.NoError(t, err)
})
})
t.Run("CompressionWithAcceptEncodingHeader", func(t *testing.T) {
t.Run("gzip", func(t *testing.T) {
Expand Down Expand Up @@ -1226,6 +1246,9 @@ func TestRequestCompression(t *testing.T) {

var decompress = func(algo string, input io.Reader) io.Reader {
switch algo {
case "br":
w := brotli.NewReader(input)
return w
case "gzip":
w, err := gzip.NewReader(input)
if err != nil {
Expand All @@ -1238,6 +1261,12 @@ func TestRequestCompression(t *testing.T) {
t.Fatal(err)
}
return w
case "zstd":
w, err := zstd.NewReader(input)
if err != nil {
t.Fatal(err)
}
return w
default:
t.Fatal("unknown algorithm " + algo)
}
Expand Down Expand Up @@ -1287,6 +1316,10 @@ func TestRequestCompression(t *testing.T) {
{compression: "deflate"},
{compression: "deflate, gzip"},
{compression: "gzip,deflate, gzip"},
{compression: "zstd"},
{compression: "zstd, gzip, deflate"},
{compression: "br"},
{compression: "br, gzip, deflate"},
{
compression: "George",
expectedError: `unknown compression algorithm George`,
Expand Down
13 changes: 8 additions & 5 deletions lib/netext/httpext/compression_type_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 58 additions & 6 deletions lib/netext/httpext/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import (

ntlmssp "github.com/Azure/go-ntlmssp"
digest "github.com/Soontao/goHttpDigestClient"
"github.com/andybalholm/brotli"
"github.com/klauspost/compress/zstd"
"github.com/loadimpact/k6/lib"
"github.com/loadimpact/k6/stats"
log "github.com/sirupsen/logrus"
Expand Down Expand Up @@ -86,7 +88,11 @@ const (
CompressionTypeGzip CompressionType = iota
// CompressionTypeDeflate compresses through flate
CompressionTypeDeflate
// TODO: add compress(lzw), brotli maybe bzip2 and others listed at
// CompressionTypeZstd compresses through zstd
CompressionTypeZstd
// CompressionTypeBr compresses through brotli
CompressionTypeBr
// TODO: add compress(lzw), maybe bzip2 and others listed at
// https://en.wikipedia.org/wiki/HTTP_compression#Content-Encoding_tokens
)

Expand Down Expand Up @@ -115,6 +121,27 @@ type ParsedHTTPRequest struct {
Tags map[string]string
}

// Matches non-compliant io.Closer implementations (e.g. zstd.Decoder)
type ncloser interface {
Close()
}

type readCloser struct {
io.Reader
}

// Close readers with differing Close() implementations
func (r readCloser) Close() error {
var err error
switch v := r.Reader.(type) {
case io.Closer:
err = v.Close()
case ncloser:
v.Close()
}
return err
}

func stdCookiesToHTTPRequestCookies(cookies []*http.Cookie) map[string][]*HTTPRequestCookie {
var result = make(map[string][]*HTTPRequestCookie, len(cookies))
for _, cookie := range cookies {
Expand Down Expand Up @@ -144,6 +171,10 @@ func compressBody(algos []CompressionType, body io.ReadCloser) (io.Reader, int64
w = gzip.NewWriter(buf)
case CompressionTypeDeflate:
w = zlib.NewWriter(buf)
case CompressionTypeZstd:
w, _ = zstd.NewWriter(buf)
case CompressionTypeBr:
w = brotli.NewWriter(buf)
default:
return nil, 0, "", fmt.Errorf("unknown compressionType %s", compressionType)
}
Expand Down Expand Up @@ -179,15 +210,33 @@ func readResponseBody(
return nil, respErr
}

// Transperently decompress the body if it's has a content-encoding we
rc := &readCloser{resp.Body}
// Ensure that the entire response body is read and closed, e.g. in case of decoding errors
defer func(respBody io.ReadCloser) {
_, _ = io.Copy(ioutil.Discard, respBody)
_ = respBody.Close()
}(resp.Body)

// Transparently decompress the body if it's has a content-encoding we
// support. If not, simply return it as it is.
contentEncoding := strings.TrimSpace(resp.Header.Get("Content-Encoding"))
if compression, err := CompressionTypeString(contentEncoding); err == nil {
var decoder io.ReadCloser
switch compression {
case CompressionTypeDeflate:
resp.Body, respErr = zlib.NewReader(resp.Body)
decoder, respErr = zlib.NewReader(resp.Body)
rc = &readCloser{decoder}
case CompressionTypeGzip:
resp.Body, respErr = gzip.NewReader(resp.Body)
decoder, respErr = gzip.NewReader(resp.Body)
rc = &readCloser{decoder}
case CompressionTypeZstd:
var zstdecoder *zstd.Decoder
zstdecoder, respErr = zstd.NewReader(resp.Body)
rc = &readCloser{zstdecoder}
case CompressionTypeBr:
var brdecoder *brotli.Reader
brdecoder = brotli.NewReader(resp.Body)
rc = &readCloser{brdecoder}
default:
// We have not implemented a compression ... :(
respErr = fmt.Errorf(
Expand All @@ -200,8 +249,11 @@ func readResponseBody(
buf := state.BPool.Get()
defer state.BPool.Put(buf)
buf.Reset()
_, err := io.Copy(buf, resp.Body)
_ = resp.Body.Close()
_, err := io.Copy(buf, rc.Reader)
if err != nil {
respErr = err
}
err = rc.Close()
if err != nil {
respErr = err
}
Expand Down
50 changes: 50 additions & 0 deletions lib/testutils/httpmultibin.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
Expand All @@ -35,9 +37,13 @@ import (
"testing"
"time"

"github.com/andybalholm/brotli"
"github.com/gorilla/websocket"
"github.com/klauspost/compress/zstd"
"github.com/loadimpact/k6/lib/netext"
"github.com/loadimpact/k6/lib/netext/httpext"
"github.com/mccutchen/go-httpbin/httpbin"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/http2"
Expand Down Expand Up @@ -89,6 +95,11 @@ type HTTPMultiBin struct {
Cleanup func()
}

type jsonBody struct {
Header http.Header `json:"headers"`
Compression string `json:"compression"`
}

func getWebsocketEchoHandler(t testing.TB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
t.Logf("[%p %s] Upgrading to websocket connection...", req, req.URL)
Expand Down Expand Up @@ -116,12 +127,51 @@ func getWebsocketCloserHandler(t testing.TB) http.Handler {
})
}

func writeJSON(w io.Writer, v interface{}) error {
e := json.NewEncoder(w)
e.SetIndent("", " ")
return errors.Wrap(e.Encode(v), "failed to encode JSON")
}

func getEncodedHandler(t testing.TB, compressionType httpext.CompressionType) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
var (
encoding string
err error
encw io.WriteCloser
)

switch compressionType {
case httpext.CompressionTypeBr:
encw = brotli.NewWriter(rw)
encoding = "br"
case httpext.CompressionTypeZstd:
encw, _ = zstd.NewWriter(rw)
encoding = "zstd"
}

rw.Header().Set("Content-Type", "application/json")
rw.Header().Add("Content-Encoding", encoding)
data := jsonBody{
Header: req.Header,
Compression: encoding,
}
err = writeJSON(encw, data)
_ = encw.Close()
if !assert.NoError(t, err) {
return
}
})
}

// NewHTTPMultiBin returns a fully configured and running HTTPMultiBin
func NewHTTPMultiBin(t testing.TB) *HTTPMultiBin {
// Create a http.ServeMux and set the httpbin handler as the default
mux := http.NewServeMux()
mux.Handle("/brotli", getEncodedHandler(t, httpext.CompressionTypeBr))
mux.Handle("/ws-echo", getWebsocketEchoHandler(t))
mux.Handle("/ws-close", getWebsocketCloserHandler(t))
mux.Handle("/zstd", getEncodedHandler(t, httpext.CompressionTypeZstd))
mux.Handle("/", httpbin.New().Handler())

// Initialize the HTTP server and get its details
Expand Down
Loading