Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions binding/binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
77 changes: 76 additions & 1 deletion context.go
Original file line number Diff line number Diff line change
Expand Up @@ -1342,19 +1342,94 @@ 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()
}
Comment on lines +1360 to +1366

// 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,
Data: message,
})
}

// 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()
}
}
}
Comment on lines +1406 to +1423

// 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 {
Comment on lines 1427 to 1433
select {
case <-clientGone:
Expand Down
106 changes: 97 additions & 9 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment on lines +1452 to +1457

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)
Expand Down Expand Up @@ -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(),
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions docs/doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions ginS/gins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
11 changes: 11 additions & 0 deletions render/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Loading