Skip to content

Commit 1c61db6

Browse files
authored
feat: add /websocket/echo endpoint (#155)
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.
1 parent c440f9a commit 1c61db6

File tree

12 files changed

+1184
-36
lines changed

12 files changed

+1184
-36
lines changed

.gitignore

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,6 @@
1-
# Compiled Object files, Static and Dynamic libs (Shared Objects)
2-
*.o
3-
*.a
4-
*.so
5-
6-
# Folders
7-
_obj
8-
_test
9-
10-
# Architecture specific extensions/prefixes
11-
*.[568vq]
12-
[568vq].out
13-
14-
*.cgo1.go
15-
*.cgo2.c
16-
_cgo_defun.c
17-
_cgo_gotypes.go
18-
_cgo_export.*
19-
20-
_testmain.go
21-
1+
.*
222
*.exe
233
*.test
244
*.prof
25-
265
dist/*
276
coverage.txt

DEVELOPMENT.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Development
2+
3+
## Local development
4+
5+
For interactive local development, use `make run` to build and run go-httpbin
6+
or `make watch` to automatically re-build and re-run go-httpbin on every
7+
change:
8+
9+
make run
10+
make watch
11+
12+
By default, the server will listen on `http://127.0.0.1:8080`, but the host,
13+
port, or any other [configuration option][config] may be overridden by
14+
specifying the relevant environment variables:
15+
16+
make run PORT=9999
17+
make run PORT=9999 MAX_DURATION=60s
18+
make watch HOST=0.0.0.0 PORT=8888
19+
20+
## Testing
21+
22+
Run `make test` to run unit tests, using `TEST_ARGS` to pass arguments through
23+
to `go test`:
24+
25+
make test
26+
make test TEST_ARGS="-v -race -run ^TestDelay"
27+
28+
### Integration tests
29+
30+
go-httpbin includes its own minimal WebSocket echo server implementation, and
31+
we use the incredibly helpful [Autobahn Testsuite][] to ensure that the
32+
implementation conforms to the spec.
33+
34+
These tests can be slow to run (~40 seconds on my machine), so they are not run
35+
by default when using `make test`.
36+
37+
They are run automatically as part of our extended "CI" test suite, which is
38+
run on every pull request:
39+
40+
make testci
41+
42+
### WebSocket development
43+
44+
When working on the WebSocket implementation, it can also be useful to run
45+
those integration tests directly, like so:
46+
47+
make testautobahn
48+
49+
Use the `AUTOBAHN_CASES` var to run a specific subset of the Autobahn tests,
50+
which may or may not include wildcards:
51+
52+
make testautobahn AUTOBAHN_CASES=6.*
53+
make testautobahn AUTOBAHN_CASES=6.5.*
54+
make testautobahn AUTOBAHN_CASES=6.5.4
55+
56+
57+
### Test coverage
58+
59+
We use [Codecov][] to measure and track test coverage as part of our continuous
60+
integration test suite. While we strive for as much coverage as possible and
61+
the Codecov CI check is configured with fairly strict requirements, 100% test
62+
coverage is not an explicit goal or requirement for all contributions.
63+
64+
To view test coverage locally, use
65+
66+
make testcover
67+
68+
which will run the full suite of unit and integration tests and pop open a web
69+
browser to view coverage results.
70+
71+
72+
## Linting and code style
73+
74+
Run `make lint` to run our suite of linters and formatters, which include
75+
gofmt, [revive][], and [staticcheck][]:
76+
77+
make lint
78+
79+
80+
## Docker images
81+
82+
To build a docker image locally:
83+
84+
make image
85+
86+
To build a docker image an push it to a remote repository:
87+
88+
make imagepush
89+
90+
By default, images will be tagged as `mccutchen/go-httpbin:${COMMIT}` with the
91+
current HEAD commit hash.
92+
93+
Use `VERSION` to override the tag value
94+
95+
make imagepush VERSION=v1.2.3
96+
97+
or `DOCKER_TAG` to override the remote repo and version at once:
98+
99+
make imagepush DOCKER_TAG=my-org/my-fork:v1.2.3
100+
101+
### Automated docker image builds
102+
103+
When a new release is created, the [Release][] GitHub Actions workflow
104+
automatically builds and pushes new Docker images for both linux/amd64 and
105+
linux/arm64 architectures.
106+
107+
108+
[config]: /README.md#configuration
109+
[revive]: https://github.com/mgechev/revive
110+
[staticcheck]: https://staticcheck.dev/
111+
[Release]: /.github/workflows/release.yaml
112+
[Codecov]: https://app.codecov.io/gh/mccutchen/go-httpbin
113+
[Autobahn Testsuite]: https://github.com/crossbario/autobahn-testsuite

Makefile

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ buildtests:
3838
.PHONY: buildtests
3939

4040
clean:
41-
rm -rf $(DIST_PATH) $(COVERAGE_PATH)
41+
rm -rf $(DIST_PATH) $(COVERAGE_PATH) .integrationtests
4242
.PHONY: clean
4343

4444

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

6059
testcover: testci
6160
go tool cover -html=$(COVERAGE_PATH)
6261
.PHONY: testcover
6362

63+
# Run the autobahn fuzzingclient test suite
64+
testautobahn:
65+
AUTOBAHN_TESTS=1 AUTOBAHN_OPEN_REPORT=1 go test -v -run ^TestWebSocketServer$$ $(TEST_ARGS) ./...
66+
.PHONY: autobahntests
67+
6468
lint:
6569
test -z "$$(gofmt -d -s -e .)" || (echo "Error: gofmt failed"; gofmt -d -s -e . ; exit 1)
6670
go vet ./...

README.md

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -169,17 +169,7 @@ public internet, consider tuning it appropriately:
169169

170170
## Development
171171

172-
```bash
173-
# local development
174-
make
175-
make test
176-
make testcover
177-
make run
178-
179-
# building & pushing docker images
180-
make image
181-
make imagepush
182-
```
172+
See [DEVELOPMENT.md][].
183173

184174
## Motivation & prior art
185175

@@ -218,3 +208,4 @@ Compared to [ahmetb/go-httpbin][ahmet]:
218208
[Observer]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Observer
219209
[Production considerations]: #production-considerations
220210
[zerolog]: https://github.com/rs/zerolog
211+
[DEVELOPMENT.md]: ./DEVELOPMENT.md

httpbin/handlers.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"time"
1616

1717
"github.com/mccutchen/go-httpbin/v2/httpbin/digest"
18+
"github.com/mccutchen/go-httpbin/v2/httpbin/websocket"
1819
)
1920

2021
var nilValues = url.Values{}
@@ -1112,3 +1113,51 @@ func (h *HTTPBin) Hostname(w http.ResponseWriter, _ *http.Request) {
11121113
Hostname: h.hostname,
11131114
})
11141115
}
1116+
1117+
// WebSocketEcho - simple websocket echo server, where the max fragment size
1118+
// and max message size can be controlled by clients.
1119+
func (h *HTTPBin) WebSocketEcho(w http.ResponseWriter, r *http.Request) {
1120+
var (
1121+
maxFragmentSize = h.MaxBodySize / 2
1122+
maxMessageSize = h.MaxBodySize
1123+
q = r.URL.Query()
1124+
err error
1125+
)
1126+
1127+
if userMaxFragmentSize := q.Get("max_fragment_size"); userMaxFragmentSize != "" {
1128+
maxFragmentSize, err = strconv.ParseInt(userMaxFragmentSize, 10, 32)
1129+
if err != nil {
1130+
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid max_fragment_size: %w", err))
1131+
return
1132+
} else if maxFragmentSize < 1 || maxFragmentSize > h.MaxBodySize {
1133+
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid max_fragment_size: %d not in range [1, %d]", maxFragmentSize, h.MaxBodySize))
1134+
return
1135+
}
1136+
}
1137+
1138+
if userMaxMessageSize := q.Get("max_message_size"); userMaxMessageSize != "" {
1139+
maxMessageSize, err = strconv.ParseInt(userMaxMessageSize, 10, 32)
1140+
if err != nil {
1141+
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid max_message_size: %w", err))
1142+
return
1143+
} else if maxMessageSize < 1 || maxMessageSize > h.MaxBodySize {
1144+
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid max_message_size: %d not in range [1, %d]", maxMessageSize, h.MaxBodySize))
1145+
return
1146+
}
1147+
}
1148+
1149+
if maxFragmentSize > maxMessageSize {
1150+
writeError(w, http.StatusBadRequest, fmt.Errorf("max_fragment_size %d must be less than or equal to max_message_size %d", maxFragmentSize, maxMessageSize))
1151+
return
1152+
}
1153+
1154+
ws := websocket.New(w, r, websocket.Limits{
1155+
MaxFragmentSize: int(maxFragmentSize),
1156+
MaxMessageSize: int(maxMessageSize),
1157+
})
1158+
if err := ws.Handshake(); err != nil {
1159+
writeError(w, http.StatusBadRequest, err)
1160+
return
1161+
}
1162+
ws.Serve(websocket.EchoHandler)
1163+
}

httpbin/handlers_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2911,6 +2911,78 @@ func TestHostname(t *testing.T) {
29112911
})
29122912
}
29132913

2914+
func TestWebSocketEcho(t *testing.T) {
2915+
// ========================================================================
2916+
// Note: Here we only test input validation for the websocket endpoint.
2917+
//
2918+
// See websocket/*_test.go for in-depth integration tests of the actual
2919+
// websocket implementation.
2920+
// ========================================================================
2921+
2922+
handshakeHeaders := map[string]string{
2923+
"Connection": "upgrade",
2924+
"Upgrade": "websocket",
2925+
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
2926+
"Sec-WebSocket-Version": "13",
2927+
}
2928+
2929+
t.Run("handshake ok", func(t *testing.T) {
2930+
t.Parallel()
2931+
2932+
req := newTestRequest(t, http.MethodGet, "/websocket/echo")
2933+
for k, v := range handshakeHeaders {
2934+
req.Header.Set(k, v)
2935+
}
2936+
2937+
resp, err := client.Do(req)
2938+
assert.NilError(t, err)
2939+
assert.StatusCode(t, resp, http.StatusSwitchingProtocols)
2940+
})
2941+
2942+
t.Run("handshake failed", func(t *testing.T) {
2943+
t.Parallel()
2944+
req := newTestRequest(t, http.MethodGet, "/websocket/echo")
2945+
resp, err := client.Do(req)
2946+
assert.NilError(t, err)
2947+
assert.StatusCode(t, resp, http.StatusBadRequest)
2948+
})
2949+
2950+
paramTests := []struct {
2951+
query string
2952+
wantStatus int
2953+
}{
2954+
// ok
2955+
{"max_fragment_size=1&max_message_size=2", http.StatusSwitchingProtocols},
2956+
{fmt.Sprintf("max_fragment_size=%d&max_message_size=%d", app.MaxBodySize, app.MaxBodySize), http.StatusSwitchingProtocols},
2957+
2958+
// bad max_framgent_size
2959+
{"max_fragment_size=-1&max_message_size=2", http.StatusBadRequest},
2960+
{"max_fragment_size=0&max_message_size=2", http.StatusBadRequest},
2961+
{"max_fragment_size=3&max_message_size=2", http.StatusBadRequest},
2962+
{"max_fragment_size=foo&max_message_size=2", http.StatusBadRequest},
2963+
{fmt.Sprintf("max_fragment_size=%d&max_message_size=2", app.MaxBodySize+1), http.StatusBadRequest},
2964+
2965+
// bad max_message_size
2966+
{"max_fragment_size=1&max_message_size=0", http.StatusBadRequest},
2967+
{"max_fragment_size=1&max_message_size=-1", http.StatusBadRequest},
2968+
{"max_fragment_size=1&max_message_size=bar", http.StatusBadRequest},
2969+
{fmt.Sprintf("max_fragment_size=1&max_message_size=%d", app.MaxBodySize+1), http.StatusBadRequest},
2970+
}
2971+
for _, tc := range paramTests {
2972+
tc := tc
2973+
t.Run(tc.query, func(t *testing.T) {
2974+
t.Parallel()
2975+
req := newTestRequest(t, http.MethodGet, "/websocket/echo?"+tc.query)
2976+
for k, v := range handshakeHeaders {
2977+
req.Header.Set(k, v)
2978+
}
2979+
resp, err := client.Do(req)
2980+
assert.NilError(t, err)
2981+
assert.StatusCode(t, resp, tc.wantStatus)
2982+
})
2983+
}
2984+
}
2985+
29142986
func newTestServer(handler http.Handler) (*httptest.Server, *http.Client) {
29152987
srv := httptest.NewServer(handler)
29162988
client := srv.Client()

httpbin/httpbin.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ func (h *HTTPBin) Handler() http.Handler {
153153

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

156+
mux.HandleFunc("/websocket/echo", h.WebSocketEcho)
157+
156158
// existing httpbin endpoints that we do not support
157159
mux.HandleFunc("/brotli", notImplementedHandler)
158160

httpbin/middleware.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package httpbin
22

33
import (
4+
"bufio"
45
"fmt"
56
"log"
7+
"net"
68
"net/http"
79
"time"
810
)
@@ -123,6 +125,10 @@ func (mw *metaResponseWriter) Size() int64 {
123125
return mw.size
124126
}
125127

128+
func (mw *metaResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
129+
return mw.w.(http.Hijacker).Hijack()
130+
}
131+
126132
func observe(o Observer, h http.Handler) http.Handler {
127133
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
128134
mw := &metaResponseWriter{w: w}

httpbin/static/index.html

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)