Skip to content

Commit

Permalink
feat: add /websocket/echo endpoint (#155)
Browse files Browse the repository at this point in the history
Here we add a new `/websocket/echo` endpoint, which implements a basic
WebSocket echo service.

The endpoint is powered by our own basic, zero-dependency WebSocket
implementation, which passes _almost_ every test in the invaluable
[Autobahn Testsuite](https://github.com/crossbario/autobahn-testsuite)
"fuzzingclient" set of integration tests, which will be run
automatically as part of our continuous integration tests going forward.

Closes #151.
  • Loading branch information
mccutchen authored Nov 25, 2023
1 parent c440f9a commit 1c61db6
Show file tree
Hide file tree
Showing 12 changed files with 1,184 additions and 36 deletions.
23 changes: 1 addition & 22 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,27 +1,6 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so

# Folders
_obj
_test

# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out

*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*

_testmain.go

.*
*.exe
*.test
*.prof

dist/*
coverage.txt
113 changes: 113 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Development

## Local development

For interactive local development, use `make run` to build and run go-httpbin
or `make watch` to automatically re-build and re-run go-httpbin on every
change:

make run
make watch

By default, the server will listen on `http://127.0.0.1:8080`, but the host,
port, or any other [configuration option][config] may be overridden by
specifying the relevant environment variables:

make run PORT=9999
make run PORT=9999 MAX_DURATION=60s
make watch HOST=0.0.0.0 PORT=8888

## Testing

Run `make test` to run unit tests, using `TEST_ARGS` to pass arguments through
to `go test`:

make test
make test TEST_ARGS="-v -race -run ^TestDelay"

### Integration tests

go-httpbin includes its own minimal WebSocket echo server implementation, and
we use the incredibly helpful [Autobahn Testsuite][] to ensure that the
implementation conforms to the spec.

These tests can be slow to run (~40 seconds on my machine), so they are not run
by default when using `make test`.

They are run automatically as part of our extended "CI" test suite, which is
run on every pull request:

make testci

### WebSocket development

When working on the WebSocket implementation, it can also be useful to run
those integration tests directly, like so:

make testautobahn

Use the `AUTOBAHN_CASES` var to run a specific subset of the Autobahn tests,
which may or may not include wildcards:

make testautobahn AUTOBAHN_CASES=6.*
make testautobahn AUTOBAHN_CASES=6.5.*
make testautobahn AUTOBAHN_CASES=6.5.4


### Test coverage

We use [Codecov][] to measure and track test coverage as part of our continuous
integration test suite. While we strive for as much coverage as possible and
the Codecov CI check is configured with fairly strict requirements, 100% test
coverage is not an explicit goal or requirement for all contributions.

To view test coverage locally, use

make testcover

which will run the full suite of unit and integration tests and pop open a web
browser to view coverage results.


## Linting and code style

Run `make lint` to run our suite of linters and formatters, which include
gofmt, [revive][], and [staticcheck][]:

make lint


## Docker images

To build a docker image locally:

make image

To build a docker image an push it to a remote repository:

make imagepush

By default, images will be tagged as `mccutchen/go-httpbin:${COMMIT}` with the
current HEAD commit hash.

Use `VERSION` to override the tag value

make imagepush VERSION=v1.2.3

or `DOCKER_TAG` to override the remote repo and version at once:

make imagepush DOCKER_TAG=my-org/my-fork:v1.2.3

### Automated docker image builds

When a new release is created, the [Release][] GitHub Actions workflow
automatically builds and pushes new Docker images for both linux/amd64 and
linux/arm64 architectures.


[config]: /README.md#configuration
[revive]: https://github.com/mgechev/revive
[staticcheck]: https://staticcheck.dev/
[Release]: /.github/workflows/release.yaml
[Codecov]: https://app.codecov.io/gh/mccutchen/go-httpbin
[Autobahn Testsuite]: https://github.com/crossbario/autobahn-testsuite
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ buildtests:
.PHONY: buildtests

clean:
rm -rf $(DIST_PATH) $(COVERAGE_PATH)
rm -rf $(DIST_PATH) $(COVERAGE_PATH) .integrationtests
.PHONY: clean


Expand All @@ -53,14 +53,18 @@ test:
# based on codecov.io's documentation:
# https://github.com/codecov/example-go/blob/b85638743b972bd0bd2af63421fe513c6f968930/README.md
testci: build buildexamples
go test $(TEST_ARGS) $(COVERAGE_ARGS) ./...
git diff --exit-code
AUTOBAHN_TESTS=1 go test $(TEST_ARGS) $(COVERAGE_ARGS) ./...
.PHONY: testci

testcover: testci
go tool cover -html=$(COVERAGE_PATH)
.PHONY: testcover

# Run the autobahn fuzzingclient test suite
testautobahn:
AUTOBAHN_TESTS=1 AUTOBAHN_OPEN_REPORT=1 go test -v -run ^TestWebSocketServer$$ $(TEST_ARGS) ./...
.PHONY: autobahntests

lint:
test -z "$$(gofmt -d -s -e .)" || (echo "Error: gofmt failed"; gofmt -d -s -e . ; exit 1)
go vet ./...
Expand Down
13 changes: 2 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,17 +169,7 @@ public internet, consider tuning it appropriately:

## Development

```bash
# local development
make
make test
make testcover
make run

# building & pushing docker images
make image
make imagepush
```
See [DEVELOPMENT.md][].

## Motivation & prior art

Expand Down Expand Up @@ -218,3 +208,4 @@ Compared to [ahmetb/go-httpbin][ahmet]:
[Observer]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Observer
[Production considerations]: #production-considerations
[zerolog]: https://github.com/rs/zerolog
[DEVELOPMENT.md]: ./DEVELOPMENT.md
49 changes: 49 additions & 0 deletions httpbin/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"time"

"github.com/mccutchen/go-httpbin/v2/httpbin/digest"
"github.com/mccutchen/go-httpbin/v2/httpbin/websocket"
)

var nilValues = url.Values{}
Expand Down Expand Up @@ -1112,3 +1113,51 @@ func (h *HTTPBin) Hostname(w http.ResponseWriter, _ *http.Request) {
Hostname: h.hostname,
})
}

// WebSocketEcho - simple websocket echo server, where the max fragment size
// and max message size can be controlled by clients.
func (h *HTTPBin) WebSocketEcho(w http.ResponseWriter, r *http.Request) {
var (
maxFragmentSize = h.MaxBodySize / 2
maxMessageSize = h.MaxBodySize
q = r.URL.Query()
err error
)

if userMaxFragmentSize := q.Get("max_fragment_size"); userMaxFragmentSize != "" {
maxFragmentSize, err = strconv.ParseInt(userMaxFragmentSize, 10, 32)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid max_fragment_size: %w", err))
return
} else if maxFragmentSize < 1 || maxFragmentSize > h.MaxBodySize {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid max_fragment_size: %d not in range [1, %d]", maxFragmentSize, h.MaxBodySize))
return
}
}

if userMaxMessageSize := q.Get("max_message_size"); userMaxMessageSize != "" {
maxMessageSize, err = strconv.ParseInt(userMaxMessageSize, 10, 32)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid max_message_size: %w", err))
return
} else if maxMessageSize < 1 || maxMessageSize > h.MaxBodySize {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid max_message_size: %d not in range [1, %d]", maxMessageSize, h.MaxBodySize))
return
}
}

if maxFragmentSize > maxMessageSize {
writeError(w, http.StatusBadRequest, fmt.Errorf("max_fragment_size %d must be less than or equal to max_message_size %d", maxFragmentSize, maxMessageSize))
return
}

ws := websocket.New(w, r, websocket.Limits{
MaxFragmentSize: int(maxFragmentSize),
MaxMessageSize: int(maxMessageSize),
})
if err := ws.Handshake(); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
ws.Serve(websocket.EchoHandler)
}
72 changes: 72 additions & 0 deletions httpbin/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2911,6 +2911,78 @@ func TestHostname(t *testing.T) {
})
}

func TestWebSocketEcho(t *testing.T) {
// ========================================================================
// Note: Here we only test input validation for the websocket endpoint.
//
// See websocket/*_test.go for in-depth integration tests of the actual
// websocket implementation.
// ========================================================================

handshakeHeaders := map[string]string{
"Connection": "upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version": "13",
}

t.Run("handshake ok", func(t *testing.T) {
t.Parallel()

req := newTestRequest(t, http.MethodGet, "/websocket/echo")
for k, v := range handshakeHeaders {
req.Header.Set(k, v)
}

resp, err := client.Do(req)
assert.NilError(t, err)
assert.StatusCode(t, resp, http.StatusSwitchingProtocols)
})

t.Run("handshake failed", func(t *testing.T) {
t.Parallel()
req := newTestRequest(t, http.MethodGet, "/websocket/echo")
resp, err := client.Do(req)
assert.NilError(t, err)
assert.StatusCode(t, resp, http.StatusBadRequest)
})

paramTests := []struct {
query string
wantStatus int
}{
// ok
{"max_fragment_size=1&max_message_size=2", http.StatusSwitchingProtocols},
{fmt.Sprintf("max_fragment_size=%d&max_message_size=%d", app.MaxBodySize, app.MaxBodySize), http.StatusSwitchingProtocols},

// bad max_framgent_size
{"max_fragment_size=-1&max_message_size=2", http.StatusBadRequest},
{"max_fragment_size=0&max_message_size=2", http.StatusBadRequest},
{"max_fragment_size=3&max_message_size=2", http.StatusBadRequest},
{"max_fragment_size=foo&max_message_size=2", http.StatusBadRequest},
{fmt.Sprintf("max_fragment_size=%d&max_message_size=2", app.MaxBodySize+1), http.StatusBadRequest},

// bad max_message_size
{"max_fragment_size=1&max_message_size=0", http.StatusBadRequest},
{"max_fragment_size=1&max_message_size=-1", http.StatusBadRequest},
{"max_fragment_size=1&max_message_size=bar", http.StatusBadRequest},
{fmt.Sprintf("max_fragment_size=1&max_message_size=%d", app.MaxBodySize+1), http.StatusBadRequest},
}
for _, tc := range paramTests {
tc := tc
t.Run(tc.query, func(t *testing.T) {
t.Parallel()
req := newTestRequest(t, http.MethodGet, "/websocket/echo?"+tc.query)
for k, v := range handshakeHeaders {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
assert.NilError(t, err)
assert.StatusCode(t, resp, tc.wantStatus)
})
}
}

func newTestServer(handler http.Handler) (*httptest.Server, *http.Client) {
srv := httptest.NewServer(handler)
client := srv.Client()
Expand Down
2 changes: 2 additions & 0 deletions httpbin/httpbin.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ func (h *HTTPBin) Handler() http.Handler {

mux.HandleFunc("/dump/request", h.DumpRequest)

mux.HandleFunc("/websocket/echo", h.WebSocketEcho)

// existing httpbin endpoints that we do not support
mux.HandleFunc("/brotli", notImplementedHandler)

Expand Down
6 changes: 6 additions & 0 deletions httpbin/middleware.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package httpbin

import (
"bufio"
"fmt"
"log"
"net"
"net/http"
"time"
)
Expand Down Expand Up @@ -123,6 +125,10 @@ func (mw *metaResponseWriter) Size() int64 {
return mw.size
}

func (mw *metaResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return mw.w.(http.Hijacker).Hijack()
}

func observe(o Observer, h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mw := &metaResponseWriter{w: w}
Expand Down
1 change: 1 addition & 0 deletions httpbin/static/index.html

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

Loading

0 comments on commit 1c61db6

Please sign in to comment.