Merge 7886b23a1750144f71fb9f99c98b189bc930ccb5 into d75fcd4c9ab260e5225de590f1f0f8c0e0e12d11

This commit is contained in:
shahariaz ahammed 2026-06-05 16:54:02 +06:00 committed by GitHub
commit 471730ed92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 278 additions and 13 deletions

View File

@ -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))
}

View File

@ -1342,7 +1342,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,
@ -1350,11 +1377,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:

View File

@ -1442,6 +1442,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)
@ -3090,10 +3159,6 @@ func (r *TestResponseRecorder) CloseNotify() <-chan bool {
return r.closeChannel
}
func (r *TestResponseRecorder) closeClient() {
r.closeChannel <- true
}
func CreateTestResponseRecorder() *TestResponseRecorder {
return &TestResponseRecorder{
httptest.NewRecorder(),
@ -3104,6 +3169,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 {
@ -3124,20 +3190,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)

View File

@ -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()

View File

@ -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)
}

2
go.mod
View File

@ -12,7 +12,7 @@ require (
github.com/mattn/go-isatty v0.0.20
github.com/modern-go/reflect2 v1.0.2
github.com/pelletier/go-toml/v2 v2.2.4
github.com/quic-go/quic-go v0.59.0
github.com/quic-go/quic-go v0.59.1
github.com/stretchr/testify v1.11.1
github.com/ugorji/go/codec v1.3.1
go.mongodb.org/mongo-driver/v2 v2.5.0

4
go.sum
View File

@ -52,8 +52,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@ -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"))
}