Skip to content

Commit a8a668c

Browse files
authored
fix(sse): support streaming responses on Fiber/fasthttp adapters (#1059)
* fix(sse): support streaming responses on Fiber/fasthttp adapters SSE (and other streaming responses) failed on the Fiber adapters with "unable to flush", because Fiber is built on fasthttp, whose RequestCtx does not implement http.Flusher and can't flush the response body synchronously from within the handler; it only streams via SetBodyStreamWriter. Add an internal, unexported streaming hook: the sse package detects an optional bodyStreamer interface on the context (alongside the existing unwrapper/writeDeadliner) and, when present, streams through the adapter's callback. The Fiber v2 and v3 adapters implement it via fasthttp's SetBodyStreamWriter, passing a small writer that satisfies io.Writer, http.Flusher, and the write-deadline interface so the shared send loop treats it uniformly. fasthttp stays an indirect dependency and is not imported by the sse package or the humafiber source. The SSE send loop is extracted into a shared stream() function reused by both the direct-write and streaming paths. Fixes #888. * test(sse): cover the bodyStreamer streaming path Add a white-box test that drives Register through a fake huma.Adapter whose context implements bodyStreamer, exercising the Fiber/fasthttp streaming branch (which codecov can't credit from the adapter tests, since adapters are excluded and coverage is measured per package).
1 parent 6cc787b commit a8a668c

6 files changed

Lines changed: 364 additions & 99 deletions

File tree

adapters/humafiber/humafiber.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package humafiber
22

33
import (
4+
"bufio"
45
"bytes"
56
"context"
67
"crypto/tls"
78
"io"
89
"mime/multipart"
10+
"net"
911
"net/http"
1012
"net/url"
1113
"strings"
@@ -145,6 +147,16 @@ func (c *fiberWrapper) BodyWriter() io.Writer {
145147
return c.orig.RequestCtx()
146148
}
147149

150+
// StreamBody streams the response body via Fiber/fasthttp's stream writer. It
151+
// is the optional streaming hook huma's SSE support uses because fasthttp can't
152+
// flush the response writer synchronously from within the handler.
153+
func (c *fiberWrapper) StreamBody(fn func(io.Writer)) {
154+
rc := c.orig.RequestCtx()
155+
rc.SetBodyStreamWriter(func(bw *bufio.Writer) {
156+
fn(&fiberStreamWriter{bw: bw, conn: rc.Conn()})
157+
})
158+
}
159+
148160
func (c *fiberWrapper) TLS() *tls.ConnectionState {
149161
return c.orig.RequestCtx().TLSConnectionState()
150162
}
@@ -262,3 +274,26 @@ func New(r *fiber.App, config huma.Config) huma.API {
262274
func NewWithGroup(r *fiber.App, g fiber.Router, config huma.Config) huma.API {
263275
return huma.NewAPI(config, &fiberAdapter{tester: r, router: g})
264276
}
277+
278+
// fiberStreamWriter adapts fasthttp's buffered stream writer to the io.Writer,
279+
// http.Flusher, and write-deadline interfaces the streaming code expects. It is
280+
// shared by the Fiber v2 and v3 adapters.
281+
type fiberStreamWriter struct {
282+
bw *bufio.Writer
283+
conn net.Conn
284+
}
285+
286+
func (w *fiberStreamWriter) Write(p []byte) (int, error) {
287+
return w.bw.Write(p)
288+
}
289+
290+
func (w *fiberStreamWriter) Flush() {
291+
_ = w.bw.Flush()
292+
}
293+
294+
func (w *fiberStreamWriter) SetWriteDeadline(t time.Time) error {
295+
if w.conn == nil {
296+
return nil
297+
}
298+
return w.conn.SetWriteDeadline(t)
299+
}

adapters/humafiber/humafiber_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,60 @@ package humafiber
22

33
import (
44
"context"
5+
"io"
56
"net/http"
67
"net/http/httptest"
78
"testing"
89

910
"github.com/danielgtaylor/huma/v2"
11+
"github.com/danielgtaylor/huma/v2/sse"
1012
"github.com/gofiber/fiber/v3"
1113
)
1214

15+
// TestSSE exercises the Fiber-specific streaming path (the StreamBody hook /
16+
// fasthttp SetBodyStreamWriter) so Server-Sent Events flush to the client
17+
// instead of failing with "unable to flush". Regression test for #888.
18+
func TestSSE(t *testing.T) {
19+
app := fiber.New()
20+
api := New(app, huma.DefaultConfig("Test", "1.0.0"))
21+
22+
type Message struct {
23+
Text string `json:"text"`
24+
}
25+
26+
sse.Register(api, huma.Operation{
27+
OperationID: "sse",
28+
Method: http.MethodGet,
29+
Path: "/sse",
30+
}, map[string]any{
31+
"message": Message{},
32+
}, func(ctx context.Context, input *struct{}, send sse.Sender) {
33+
_ = send.Data(Message{Text: "hello"})
34+
_ = send.Comment("heartbeat")
35+
})
36+
37+
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "http://example.com/sse", nil))
38+
if err != nil {
39+
t.Fatal(err)
40+
}
41+
defer resp.Body.Close()
42+
43+
if resp.StatusCode != http.StatusOK {
44+
t.Fatalf("status = %d, want 200", resp.StatusCode)
45+
}
46+
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
47+
t.Fatalf("Content-Type = %q, want text/event-stream", ct)
48+
}
49+
body, err := io.ReadAll(resp.Body)
50+
if err != nil {
51+
t.Fatal(err)
52+
}
53+
want := "data: {\"text\":\"hello\"}\n\n: heartbeat\n\n"
54+
if string(body) != want {
55+
t.Fatalf("body = %q, want %q", body, want)
56+
}
57+
}
58+
1359
// TestNewContext covers the exported NewContext constructor. Unlike the other
1460
// adapters, the Fiber adapter's Handle wraps the context to expose fasthttp
1561
// user values, so it can't call NewContext itself; exercise it directly here.

adapters/humafiber/humafiber_v2.go

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

33
import (
4+
"bufio"
45
"bytes"
56
"context"
67
"crypto/tls"
@@ -144,6 +145,16 @@ func (c *fiberV2Wrapper) BodyWriter() io.Writer {
144145
return c.orig.Context()
145146
}
146147

148+
// StreamBody streams the response body via Fiber/fasthttp's stream writer. It
149+
// is the optional streaming hook huma's SSE support uses because fasthttp can't
150+
// flush the response writer synchronously from within the handler.
151+
func (c *fiberV2Wrapper) StreamBody(fn func(io.Writer)) {
152+
rc := c.orig.Context()
153+
rc.SetBodyStreamWriter(func(bw *bufio.Writer) {
154+
fn(&fiberStreamWriter{bw: bw, conn: rc.Conn()})
155+
})
156+
}
157+
147158
func (c *fiberV2Wrapper) TLS() *tls.ConnectionState {
148159
return c.orig.Context().TLSConnectionState()
149160
}

adapters/humafiber/humafiber_v2_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,45 @@ import (
99
"testing"
1010

1111
"github.com/danielgtaylor/huma/v2"
12+
"github.com/danielgtaylor/huma/v2/sse"
1213
fiberV2 "github.com/gofiber/fiber/v2"
1314
"github.com/stretchr/testify/require"
1415
)
1516

17+
// TestSSEV2 exercises the Fiber v2 streaming path (the StreamBody hook /
18+
// fasthttp SetBodyStreamWriter) so Server-Sent Events flush to the client.
19+
// Regression test for #888.
20+
func TestSSEV2(t *testing.T) {
21+
app := fiberV2.New()
22+
api := NewV2(app, huma.DefaultConfig("Test", "1.0.0"))
23+
24+
type Message struct {
25+
Text string `json:"text"`
26+
}
27+
28+
sse.Register(api, huma.Operation{
29+
OperationID: "sse",
30+
Method: http.MethodGet,
31+
Path: "/sse",
32+
}, map[string]any{
33+
"message": Message{},
34+
}, func(ctx context.Context, input *struct{}, send sse.Sender) {
35+
_ = send.Data(Message{Text: "hello"})
36+
_ = send.Comment("heartbeat")
37+
})
38+
39+
req, _ := http.NewRequest(http.MethodGet, "http://example.com/sse", nil)
40+
resp, err := app.Test(req)
41+
require.NoError(t, err)
42+
defer resp.Body.Close()
43+
44+
require.Equal(t, http.StatusOK, resp.StatusCode)
45+
require.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
46+
body, err := io.ReadAll(resp.Body)
47+
require.NoError(t, err)
48+
require.Equal(t, "data: {\"text\":\"hello\"}\n\n: heartbeat\n\n", string(body))
49+
}
50+
1651
// TestFiberV2EachHeaderAndCookie ensures EachHeader yields one callback per
1752
// header value (not one per byte) so header-based helpers like huma.ReadCookie
1853
// continue to work under the Fiber v2 adapter. Regression test for #1055.

0 commit comments

Comments
 (0)