mirror of
https://github.com/gin-gonic/gin.git
synced 2026-06-06 12:08:20 +08:00
Merge 7886b23a1750144f71fb9f99c98b189bc930ccb5 into d75fcd4c9ab260e5225de590f1f0f8c0e0e12d11
This commit is contained in:
commit
471730ed92
@ -1432,3 +1432,23 @@ func requestWithBody(method, path, body string) (req *http.Request) {
|
|||||||
req, _ = http.NewRequest(method, path, bytes.NewBufferString(body))
|
req, _ = http.NewRequest(method, path, bytes.NewBufferString(body))
|
||||||
return
|
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
context.go
77
context.go
@ -1342,7 +1342,34 @@ func (c *Context) FileAttachment(filepath, filename string) {
|
|||||||
http.ServeFile(c.Writer, c.Request, filepath)
|
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.
|
// 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) {
|
func (c *Context) SSEvent(name string, message any) {
|
||||||
c.Render(-1, sse.Event{
|
c.Render(-1, sse.Event{
|
||||||
Event: name,
|
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
|
// Stream sends a streaming response and returns a boolean
|
||||||
// indicates "Is client disconnected in middle of stream"
|
// indicates "Is client disconnected in middle of stream"
|
||||||
func (c *Context) Stream(step func(w io.Writer) bool) bool {
|
func (c *Context) Stream(step func(w io.Writer) bool) bool {
|
||||||
w := c.Writer
|
w := c.Writer
|
||||||
clientGone := w.CloseNotify()
|
var clientGone <-chan struct{}
|
||||||
|
if c.Request != nil {
|
||||||
|
clientGone = c.Request.Context().Done()
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-clientGone:
|
case <-clientGone:
|
||||||
|
|||||||
106
context_test.go
106
context_test.go
@ -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", " ", ""))
|
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) {
|
func TestContextRenderFile(t *testing.T) {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := CreateTestContext(w)
|
c, _ := CreateTestContext(w)
|
||||||
@ -3090,10 +3159,6 @@ func (r *TestResponseRecorder) CloseNotify() <-chan bool {
|
|||||||
return r.closeChannel
|
return r.closeChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *TestResponseRecorder) closeClient() {
|
|
||||||
r.closeChannel <- true
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateTestResponseRecorder() *TestResponseRecorder {
|
func CreateTestResponseRecorder() *TestResponseRecorder {
|
||||||
return &TestResponseRecorder{
|
return &TestResponseRecorder{
|
||||||
httptest.NewRecorder(),
|
httptest.NewRecorder(),
|
||||||
@ -3104,6 +3169,7 @@ func CreateTestResponseRecorder() *TestResponseRecorder {
|
|||||||
func TestContextStream(t *testing.T) {
|
func TestContextStream(t *testing.T) {
|
||||||
w := CreateTestResponseRecorder()
|
w := CreateTestResponseRecorder()
|
||||||
c, _ := CreateTestContext(w)
|
c, _ := CreateTestContext(w)
|
||||||
|
c.Request, _ = http.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
stopStream := true
|
stopStream := true
|
||||||
c.Stream(func(w io.Writer) bool {
|
c.Stream(func(w io.Writer) bool {
|
||||||
@ -3124,20 +3190,42 @@ func TestContextStreamWithClientGone(t *testing.T) {
|
|||||||
w := CreateTestResponseRecorder()
|
w := CreateTestResponseRecorder()
|
||||||
c, _ := CreateTestContext(w)
|
c, _ := CreateTestContext(w)
|
||||||
|
|
||||||
c.Stream(func(writer io.Writer) bool {
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer func() {
|
defer cancel()
|
||||||
w.closeClient()
|
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"))
|
_, err := writer.Write([]byte("test"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
cancel() // simulate client disconnect
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
assert.True(t, result)
|
||||||
assert.Equal(t, "test", w.Body.String())
|
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) {
|
func TestContextResetInHandler(t *testing.T) {
|
||||||
w := CreateTestResponseRecorder()
|
w := CreateTestResponseRecorder()
|
||||||
c, _ := CreateTestContext(w)
|
c, _ := CreateTestContext(w)
|
||||||
|
|||||||
30
docs/doc.md
30
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
|
### HTML rendering
|
||||||
|
|
||||||
Using LoadHTMLGlob() or LoadHTMLFiles() or LoadHTMLFS()
|
Using LoadHTMLGlob() or LoadHTMLFiles() or LoadHTMLFS()
|
||||||
|
|||||||
@ -244,3 +244,44 @@ func TestStaticFS(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
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
2
go.mod
@ -12,7 +12,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20
|
github.com/mattn/go-isatty v0.0.20
|
||||||
github.com/modern-go/reflect2 v1.0.2
|
github.com/modern-go/reflect2 v1.0.2
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
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/stretchr/testify v1.11.1
|
||||||
github.com/ugorji/go/codec v1.3.1
|
github.com/ugorji/go/codec v1.3.1
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -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/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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
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.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
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=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|||||||
@ -805,3 +805,14 @@ func TestRenderWriteError(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Equal(t, "write error", err.Error())
|
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…
x
Reference in New Issue
Block a user