diff --git a/binding/binding_test.go b/binding/binding_test.go index f90488cdd3..78302fdf75 100644 --- a/binding/binding_test.go +++ b/binding/binding_test.go @@ -1432,3 +1432,23 @@ func requestWithBody(method, path, body string) (req *http.Request) { req, _ = http.NewRequest(method, path, bytes.NewBufferString(body)) return } + +func TestPlainBindingBody(t *testing.T) { + p := Plain + + var s string + require.NoError(t, p.BindBody([]byte("hello body"), &s)) + assert.Equal(t, "hello body", s) + + var bs []byte + require.NoError(t, p.BindBody([]byte("bytes body"), &bs)) + assert.Equal(t, []byte("bytes body"), bs) + + var i int + require.Error(t, p.BindBody([]byte("fail"), &i)) + + require.NoError(t, p.BindBody([]byte(""), nil)) + + var ptr *string + require.NoError(t, p.BindBody([]byte(""), ptr)) +} diff --git a/context.go b/context.go index 5174033eb3..48c75e0fa0 100644 --- a/context.go +++ b/context.go @@ -1315,7 +1315,34 @@ func (c *Context) FileAttachment(filepath, filename string) { http.ServeFile(c.Writer, c.Request, filepath) } +// InitSSE prepares the response for a Server-Sent Events stream by setting the +// required HTTP headers: Content-Type is set to "text/event-stream", +// Cache-Control to "no-cache", and Connection to "keep-alive". +// The headers are flushed to the client immediately so that the browser opens +// the stream before the first event is sent. +// +// Call this once at the beginning of your SSE handler, before any SSEvent call: +// +// router.GET("/stream", func(c *gin.Context) { +// c.InitSSE() +// for i := range 5 { +// c.SSEvent("message", i) +// c.Writer.Flush() +// } +// }) +func (c *Context) InitSSE() { + c.Writer.Header().Set("Content-Type", sse.ContentType) + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.WriteHeaderNow() + c.Writer.Flush() +} + // SSEvent writes a Server-Sent Event into the body stream. +// It sets Content-Type and Cache-Control headers on the first call if they have +// not already been set (e.g. by InitSSE). The writer is NOT flushed automatically; +// call c.Writer.Flush() after each event to push it to the client immediately. +// To include the optional id or retry fields use c.Render(-1, sse.Event{…}) directly. func (c *Context) SSEvent(name string, message any) { c.Render(-1, sse.Event{ Event: name, @@ -1323,11 +1350,59 @@ func (c *Context) SSEvent(name string, message any) { }) } +// SSEStream initializes an SSE response and calls step in a loop to send events +// until either the client disconnects or step returns false. +// +// It returns true when the client disconnected (c.Request.Context() was cancelled) +// and false when step returned false (normal end-of-stream). +// +// The writer is flushed automatically after every successful step call. +// step receives the current Context so it can call c.SSEvent, c.Render, or +// inspect c.Request.Context().Done() for its own blocking select: +// +// router.GET("/events", func(c *gin.Context) { +// ch := make(chan string) +// go produce(ch) +// c.SSEStream(func(c *gin.Context) bool { +// select { +// case msg, ok := <-ch: +// if !ok { +// return false // channel closed → end stream normally +// } +// c.SSEvent("message", msg) +// return true +// case <-c.Request.Context().Done(): +// return false // client gone → end stream +// } +// }) +// }) +func (c *Context) SSEStream(step func(c *Context) bool) bool { + c.InitSSE() + var done <-chan struct{} + if c.Request != nil { + done = c.Request.Context().Done() + } + for { + select { + case <-done: + return true + default: + if !step(c) { + return false + } + c.Writer.Flush() + } + } +} + // Stream sends a streaming response and returns a boolean // indicates "Is client disconnected in middle of stream" func (c *Context) Stream(step func(w io.Writer) bool) bool { w := c.Writer - clientGone := w.CloseNotify() + var clientGone <-chan struct{} + if c.Request != nil { + clientGone = c.Request.Context().Done() + } for { select { case <-clientGone: diff --git a/context_test.go b/context_test.go index ef60379d77..5a8cff6480 100644 --- a/context_test.go +++ b/context_test.go @@ -1441,6 +1441,75 @@ func TestContextRenderSSE(t *testing.T) { assert.Equal(t, strings.ReplaceAll(w.Body.String(), " ", ""), strings.ReplaceAll("event:float\ndata:1.5\n\nid:123\ndata:text\n\nevent:chat\ndata:{\"bar\":\"foo\",\"foo\":\"bar\"}\n\n", " ", "")) } +func TestContextInitSSE(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) + + c.InitSSE() + + assert.Equal(t, sse.ContentType, w.Header().Get("Content-Type")) + assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) + assert.Equal(t, "keep-alive", w.Header().Get("Connection")) + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, w.Flushed) +} + +func TestContextSSEStreamNormalEnd(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) + + count := 0 + disconnected := c.SSEStream(func(c *Context) bool { + count++ + c.SSEvent("ping", count) + return count < 3 + }) + + assert.False(t, disconnected) + assert.Equal(t, 3, count) + assert.Equal(t, sse.ContentType, w.Header().Get("Content-Type")) + assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) + assert.Equal(t, "keep-alive", w.Header().Get("Connection")) + assert.Contains(t, w.Body.String(), "event:ping") +} + +func TestContextSSEStreamNilRequest(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + // c.Request is intentionally left nil to verify no panic + + count := 0 + assert.NotPanics(t, func() { + disconnected := c.SSEStream(func(c *Context) bool { + count++ + return count < 2 + }) + assert.False(t, disconnected) + }) + assert.Equal(t, 2, count) +} + +func TestContextSSEStreamClientDisconnect(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c.Request, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/", nil) + + // step cancels the context and returns true (keep streaming). + // On the next loop iteration SSEStream's outer select sees ctx.Done() + // is closed and returns true, indicating client disconnected. + result := c.SSEStream(func(c *Context) bool { + cancel() // simulate client disconnect + return true + }) + + assert.True(t, result) +} + func TestContextRenderFile(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) @@ -3030,10 +3099,6 @@ func (r *TestResponseRecorder) CloseNotify() <-chan bool { return r.closeChannel } -func (r *TestResponseRecorder) closeClient() { - r.closeChannel <- true -} - func CreateTestResponseRecorder() *TestResponseRecorder { return &TestResponseRecorder{ httptest.NewRecorder(), @@ -3044,6 +3109,7 @@ func CreateTestResponseRecorder() *TestResponseRecorder { func TestContextStream(t *testing.T) { w := CreateTestResponseRecorder() c, _ := CreateTestContext(w) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) stopStream := true c.Stream(func(w io.Writer) bool { @@ -3064,20 +3130,42 @@ func TestContextStreamWithClientGone(t *testing.T) { w := CreateTestResponseRecorder() c, _ := CreateTestContext(w) - c.Stream(func(writer io.Writer) bool { - defer func() { - w.closeClient() - }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c.Request, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/", nil) + // step cancels the context and returns true (keep streaming). + // On the next loop iteration Stream's outer select sees clientGone + // is closed and returns true, indicating client disconnected. + result := c.Stream(func(writer io.Writer) bool { _, err := writer.Write([]byte("test")) require.NoError(t, err) - + cancel() // simulate client disconnect return true }) + assert.True(t, result) assert.Equal(t, "test", w.Body.String()) } +func TestContextStreamNilRequest(t *testing.T) { + w := CreateTestResponseRecorder() + c, _ := CreateTestContext(w) + // c.Request is intentionally left nil to verify no panic + + count := 0 + assert.NotPanics(t, func() { + disconnected := c.Stream(func(writer io.Writer) bool { + count++ + _, err := writer.Write([]byte("x")) + require.NoError(t, err) + return count < 2 + }) + assert.False(t, disconnected) + }) + assert.Equal(t, 2, count) +} + func TestContextResetInHandler(t *testing.T) { w := CreateTestResponseRecorder() c, _ := CreateTestContext(w) diff --git a/docs/doc.md b/docs/doc.md index d1c33b8762..d974cd24b6 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -1879,6 +1879,36 @@ func main() { } ``` +### Server-Sent Events (SSE) + +Use `c.InitSSE()` to set the required headers, then `c.SSEvent()` + `c.Writer.Flush()` to push events: + +```go +router.GET("/stream", func(c *gin.Context) { + c.InitSSE() + for i := range 5 { + c.SSEvent("message", gin.H{"count": i}) + c.Writer.Flush() + } +}) +``` + +For a long-running stream that stops when the client disconnects, use `c.SSEStream()`: + +```go +router.GET("/stream", func(c *gin.Context) { + i := 0 + c.SSEStream(func(c *gin.Context) bool { + i++ + c.SSEvent("message", gin.H{"count": i}) + return i < 10 // return false to end the stream normally + }) +}) +``` + +`SSEStream` returns `true` if the client disconnected mid-stream, `false` if the step +function ended the stream by returning `false`. + ### HTML rendering Using LoadHTMLGlob() or LoadHTMLFiles() or LoadHTMLFS() diff --git a/ginS/gins_test.go b/ginS/gins_test.go index ffde85d22e..995c6da1f8 100644 --- a/ginS/gins_test.go +++ b/ginS/gins_test.go @@ -244,3 +244,44 @@ func TestStaticFS(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } + +func TestLoadHTMLGlob(t *testing.T) { + assert.NotPanics(t, func() { + LoadHTMLGlob("../testdata/template/*.tmpl") + }) + assert.NotNil(t, engine()) +} + +func TestLoadHTMLFiles(t *testing.T) { + assert.NotPanics(t, func() { + LoadHTMLFiles("../testdata/template/hello.tmpl", "../testdata/template/raw.tmpl") + }) + assert.NotNil(t, engine()) +} + +func TestLoadHTMLFS(t *testing.T) { + assert.NotPanics(t, func() { + LoadHTMLFS(http.Dir("../testdata/template"), "*.tmpl") + }) + assert.NotNil(t, engine()) +} + +func TestRunError(t *testing.T) { + err := Run("not-valid-address") + assert.Error(t, err) +} + +func TestRunTLSError(t *testing.T) { + err := RunTLS(":0", "/nonexistent.cert", "/nonexistent.key") + assert.Error(t, err) +} + +func TestRunUnixError(t *testing.T) { + err := RunUnix("/nonexistent/deep/path/gin-test.sock") + assert.Error(t, err) +} + +func TestRunFdError(t *testing.T) { + err := RunFd(99999) + assert.Error(t, err) +} diff --git a/render/render_test.go b/render/render_test.go index f63878b966..0236abca85 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -805,3 +805,14 @@ func TestRenderWriteError(t *testing.T) { require.Error(t, err) assert.Equal(t, "write error", err.Error()) } + +func TestRedirectWriteContentType(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + r := Redirect{Code: http.StatusFound, Request: req, Location: "/new"} + // WriteContentType is a no-op for Redirect; verify it doesn't panic + assert.NotPanics(t, func() { + r.WriteContentType(w) + }) + assert.Empty(t, w.Header().Get("Content-Type")) +}