feat: add HTTP migration bridges and extensible API binding
This commit is contained in:
60
httpx/body.go
Normal file
60
httpx/body.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// LimitBody is useful for handwritten routes. Built-in App.HandleWithBodyLimit
|
||||
// additionally installs the reader cap before global middleware can read it.
|
||||
func LimitBody(maxBytes int64) Middleware {
|
||||
if maxBytes <= 0 || maxBytes > 1<<40 {
|
||||
panic("httpx: body limit must be 1..1 TiB")
|
||||
}
|
||||
return func(next Handler) Handler {
|
||||
return func(c *Context) error {
|
||||
r := c.Request()
|
||||
if r.ContentLength > maxBytes {
|
||||
return bodyTooLarge()
|
||||
}
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(c.Raw(), r.Body, maxBytes)
|
||||
}
|
||||
defer CleanupMultipart(r)
|
||||
err := next(c)
|
||||
var limitErr *http.MaxBytesError
|
||||
if errors.As(err, &limitErr) {
|
||||
return bodyTooLarge()
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
func bodyTooLarge() error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }
|
||||
|
||||
// CleanupMultipart removes disk spill files created by ParseMultipartForm.
|
||||
// Callers still close individual multipart files. Streaming MultipartReader
|
||||
// handlers own any files they create themselves.
|
||||
func CleanupMultipart(r *http.Request) {
|
||||
if r != nil && r.MultipartForm != nil {
|
||||
_ = r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}
|
||||
|
||||
type BodyLimitRegistrar interface {
|
||||
HandleWithBodyLimit(string, string, int64, response.Encoder, Handler, ...Middleware)
|
||||
}
|
||||
|
||||
func HandleWithBodyLimit(router interface {
|
||||
Handle(string, string, Handler, ...Middleware)
|
||||
}, method, path string, maxBytes int64, encoder response.Encoder, handler Handler, middleware ...Middleware) {
|
||||
if r, ok := router.(BodyLimitRegistrar); ok {
|
||||
r.HandleWithBodyLimit(method, path, maxBytes, encoder, handler, middleware...)
|
||||
return
|
||||
}
|
||||
// Custom registrars without the early hook cap at route middleware entry.
|
||||
all := append([]Middleware{LimitBody(maxBytes)}, middleware...)
|
||||
HandleWithResponse(router, method, path, encoder, handler, all...)
|
||||
}
|
||||
80
httpx/bridge.go
Normal file
80
httpx/bridge.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type bridgeState struct{ parent *Context }
|
||||
|
||||
// WrapHandler runs an existing standard handler inside the App middleware chain.
|
||||
// It preserves the original response, including downloads and streaming output.
|
||||
func WrapHandler(handler http.Handler) Handler {
|
||||
if handler == nil {
|
||||
panic("httpx: nil HTTP handler")
|
||||
}
|
||||
return func(c *Context) error {
|
||||
r := bridgeRequest(c)
|
||||
defer CleanupMultipart(r)
|
||||
handler.ServeHTTP(c.Raw(), r)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WrapMiddleware adapts synchronous standard HTTP middleware. The middleware is
|
||||
// constructed once per chain, preserving its shared state across requests.
|
||||
// Errors from next are encoded before unwinding so response-transforming
|
||||
// middleware sees them. Preserve the incoming request context (derive with
|
||||
// WithContext), and never call next after ServeHTTP returns, as with net/http.
|
||||
func WrapMiddleware(middleware func(http.Handler) http.Handler) Middleware {
|
||||
if middleware == nil {
|
||||
panic("httpx: nil HTTP middleware")
|
||||
}
|
||||
return func(next Handler) Handler {
|
||||
// Each nested bridge has its own key, including repeated use of one adapter.
|
||||
key := &bridgeState{}
|
||||
wrapped := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := r.Context().Value(key).(*bridgeState)
|
||||
if !ok {
|
||||
panic("httpx: standard middleware must preserve the incoming request context")
|
||||
}
|
||||
c := state.parent
|
||||
child := c.Clone(r.Context(), w)
|
||||
child.request = r
|
||||
if c.Responded() {
|
||||
child.writer.(interface{ inheritCommit(int) }).inheritCommit(c.Status())
|
||||
}
|
||||
defer CleanupMultipart(r)
|
||||
defer func() { c.values = child.values; c.encoder = child.encoder }()
|
||||
if err := next(child); err != nil && !c.Responded() {
|
||||
child.WriteError(err)
|
||||
}
|
||||
}))
|
||||
if wrapped == nil {
|
||||
panic("httpx: HTTP middleware returned nil handler")
|
||||
}
|
||||
return func(c *Context) error {
|
||||
r := bridgeRequest(c).WithContext(context.WithValue(c.Context(), key, &bridgeState{parent: c}))
|
||||
defer CleanupMultipart(r)
|
||||
wrapped.ServeHTTP(c.Raw(), r)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WrapFuncMiddleware supports middleware shaped as func(http.HandlerFunc) http.HandlerFunc.
|
||||
func WrapFuncMiddleware(middleware func(http.HandlerFunc) http.HandlerFunc) Middleware {
|
||||
if middleware == nil {
|
||||
panic("httpx: nil HTTP middleware")
|
||||
}
|
||||
return WrapMiddleware(func(next http.Handler) http.Handler { return middleware(next.ServeHTTP) })
|
||||
}
|
||||
|
||||
// Populate Go 1.22 path values without mutating a parent router's request maps.
|
||||
func bridgeRequest(c *Context) *http.Request {
|
||||
r := c.Request().Clone(c.Context())
|
||||
for name, value := range c.params {
|
||||
r.SetPathValue(name, value)
|
||||
}
|
||||
return r
|
||||
}
|
||||
363
httpx/bridge_test.go
Normal file
363
httpx/bridge_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package httpx_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestLegacyResponseTrackingAndMiddleware(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
closeLog, err := logx.Setup(logx.Config{Writer: &logs, Format: "json"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeLog()
|
||||
app := omni.New(omni.WithLogger(true))
|
||||
app.GET("/legacy", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })))
|
||||
app.GET("/late", func(c *httpx.Context) error {
|
||||
c.Raw().WriteHeader(404)
|
||||
_, _ = c.Raw().Write([]byte("original"))
|
||||
return errors.New("late error")
|
||||
})
|
||||
app.GET("/panic", func(c *httpx.Context) error { _, _ = c.Raw().Write([]byte("partial")); panic("late panic") })
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
status int
|
||||
body string
|
||||
}{{"/legacy", 404, "404 page not found\n"}, {"/late", 404, "original"}, {"/panic", 200, "partial"}} {
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
|
||||
if w.Code != tc.status || w.Body.String() != tc.body {
|
||||
t.Fatalf("%s: %d %q", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
count := 0
|
||||
for _, line := range strings.Split(logs.String(), "\n") {
|
||||
var e map[string]any
|
||||
if json.Unmarshal([]byte(line), &e) != nil || e["msg"] != "http.request" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if e["path"] != "/panic" && e["status"] != float64(404) {
|
||||
t.Fatalf("wrong log: %s", line)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("access logs: %d", count)
|
||||
}
|
||||
|
||||
type key struct{}
|
||||
app = omni.New()
|
||||
app.Use(httpx.WrapFuncMiddleware(func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Before", "yes")
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), key{}, "identity")))
|
||||
}
|
||||
}))
|
||||
app.GET("/context", func(c *httpx.Context) error {
|
||||
if c.Context().Value(key{}) != "identity" || c.RequestID() == "" {
|
||||
t.Error("lost context")
|
||||
}
|
||||
return omni.NotFound("MISSING", "missing")
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/context", nil))
|
||||
if w.Code != 404 || w.Header().Get("X-Before") != "yes" || strings.Count(w.Body.String(), "MISSING") != 1 {
|
||||
t.Fatalf("middleware response: %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
type plainWriter struct {
|
||||
header http.Header
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *plainWriter) Header() http.Header { return w.header }
|
||||
func (w *plainWriter) WriteHeader(status int) { w.status = status }
|
||||
func (w *plainWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func TestWriterCapabilitiesAndInformationalStatus(t *testing.T) {
|
||||
w := &plainWriter{header: http.Header{}}
|
||||
c := httpx.NewContext(httptest.NewRequest("GET", "/", nil), w, nil)
|
||||
if _, ok := c.Raw().(http.Flusher); ok {
|
||||
t.Fatal("invented Flusher")
|
||||
}
|
||||
if _, ok := c.Raw().(http.Hijacker); ok {
|
||||
t.Fatal("invented Hijacker")
|
||||
}
|
||||
if err := http.NewResponseController(c.Raw()).Flush(); !errors.Is(err, http.ErrNotSupported) {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
c.Raw().WriteHeader(103)
|
||||
if c.Responded() {
|
||||
t.Fatal("103 committed final response")
|
||||
}
|
||||
c.Raw().WriteHeader(201)
|
||||
c.Raw().WriteHeader(500)
|
||||
if !c.Responded() || c.Status() != 201 || w.status != 201 {
|
||||
t.Fatal("first final status not preserved")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
c = httpx.NewContext(httptest.NewRequest("GET", "/", nil), recorder, nil)
|
||||
if err := http.NewResponseController(c.Raw()).Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !c.Responded() || c.Status() != 200 || !recorder.Flushed {
|
||||
t.Fatal("flush not tracked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyStreamingAcrossTransports(t *testing.T) {
|
||||
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
app := omni.New(omni.WithTransport(transport))
|
||||
app.GET("/stream", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = io.WriteString(w, "first\n")
|
||||
if err := http.NewResponseController(w).Flush(); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
})))
|
||||
app.GET("/file", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="data.bin"`)
|
||||
_, _ = io.Copy(w, strings.NewReader(strings.Repeat("x", 2<<20)))
|
||||
})))
|
||||
app.HandleWithBodyLimit("POST", "/bounded", 32, nil, func(c *httpx.Context) error {
|
||||
_, err := io.Copy(io.Discard, c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.NoContent()
|
||||
})
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- app.ServeContext(ctx, listener) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Error("shutdown timed out")
|
||||
}
|
||||
}()
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for _, body := range []io.Reader{strings.NewReader(strings.Repeat("x", 33)), io.NopCloser(strings.NewReader(strings.Repeat("x", 33)))} {
|
||||
res, err := client.Post("http://"+listener.Addr().String()+"/bounded", "application/octet-stream", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
res.Body.Close()
|
||||
if res.StatusCode != 413 {
|
||||
t.Fatalf("real transport body limit: %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
res, err := client.Get("http://" + listener.Addr().String() + "/stream")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := make([]byte, 6)
|
||||
_, err = io.ReadFull(res.Body, first)
|
||||
res.Body.Close()
|
||||
if err != nil || string(first) != "first\n" {
|
||||
t.Fatalf("stream was buffered: %s %v", first, err)
|
||||
}
|
||||
res, err = client.Get("http://" + listener.Addr().String() + "/file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
size, err := io.Copy(io.Discard, res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil || size != 2<<20 || res.Header.Get("Content-Disposition") == "" {
|
||||
t.Fatalf("file: %d %v", size, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardMiddlewareStateAndShortCircuit(t *testing.T) {
|
||||
constructions, calls := 0, 0
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
constructions++
|
||||
count := 0
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count++
|
||||
if count == 2 {
|
||||
http.Error(w, "denied", 401)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}))
|
||||
app.GET("/", func(c *httpx.Context) error { calls++; return c.NoContent() })
|
||||
for _, status := range []int{204, 401, 204} {
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
if w.Code != status {
|
||||
t.Fatalf("got %d want %d", w.Code, status)
|
||||
}
|
||||
}
|
||||
if constructions != 1 || calls != 2 {
|
||||
t.Fatalf("middleware lifecycle: %d constructions, %d calls", constructions, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardMiddlewareTransformsErrorAndPreservesCancellation(t *testing.T) {
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// A legacy middleware may buffer its own small response. The bridge must
|
||||
// report its eventual physical status, and encode errors before it unwinds.
|
||||
temp := httptest.NewRecorder()
|
||||
next.ServeHTTP(temp, r)
|
||||
if temp.Code != 404 {
|
||||
t.Errorf("inner error not encoded: %d", temp.Code)
|
||||
}
|
||||
w.WriteHeader(409)
|
||||
_, _ = io.WriteString(w, strings.ToUpper(temp.Body.String()))
|
||||
})
|
||||
}))
|
||||
app.GET("/", func(c *httpx.Context) error {
|
||||
if !errors.Is(c.Context().Err(), context.Canceled) {
|
||||
t.Error("cancellation lost")
|
||||
}
|
||||
return omni.NotFound("missing", "missing")
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil).WithContext(ctx))
|
||||
if w.Code != 409 || !strings.Contains(w.Body.String(), "MISSING") {
|
||||
t.Fatalf("%d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteBodyLimitBeforeMiddleware(t *testing.T) {
|
||||
for _, length := range []int64{-1, 33} {
|
||||
t.Run(fmt.Sprint(length), func(t *testing.T) {
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
read := 0
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
body, err := io.ReadAll(c.Request().Body)
|
||||
read = len(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
app.Group("/api").HandleWithBodyLimit("POST", "/upload", 32, nil, func(c *httpx.Context) error { t.Error("called oversized handler"); return nil })
|
||||
r := httptest.NewRequest("POST", "/api/upload", strings.NewReader(strings.Repeat("x", 33)))
|
||||
r.ContentLength = length
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 413 || read > 32 {
|
||||
t.Fatalf("limit: status=%d read=%d", w.Code, read)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartCleanupOnPanic(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
t.Setenv("TMPDIR", temp)
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
part, err := form.CreateFormFile("file", "x.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = part.Write(bytes.Repeat([]byte("x"), 4096))
|
||||
_ = form.Close()
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.HandleWithBodyLimit("POST", "/", 8192, nil, httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
panic("after disk spill")
|
||||
})))
|
||||
r := httptest.NewRequest("POST", "/", &body)
|
||||
r.Header.Set("Content-Type", form.FormDataContentType())
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
entries, err := os.ReadDir(temp)
|
||||
if w.Code != 500 || err != nil || len(entries) != 0 {
|
||||
t.Fatalf("cleanup: %d %v %v", w.Code, entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardMiddlewareCommitsBeforeCallingNext(t *testing.T) {
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(202)
|
||||
_, _ = io.WriteString(w, "accepted")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}))
|
||||
app.GET("/", func(c *httpx.Context) error {
|
||||
if !c.Responded() || c.Status() != 202 {
|
||||
t.Error("lost middleware commit")
|
||||
}
|
||||
return errors.New("late failure")
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
if w.Code != 202 || w.Body.String() != "accepted" {
|
||||
t.Fatalf("late append: %d %q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeStandardPathValues(t *testing.T) {
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PathValue("itemId") != "abc" {
|
||||
t.Error("middleware path value lost")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}))
|
||||
app.GET("/items/:itemId", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, r.PathValue("itemId")) })))
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/items/abc", nil))
|
||||
if w.Body.String() != "abc" {
|
||||
t.Fatal("handler path value lost")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package httpx
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
@@ -101,12 +102,15 @@ func NewContext(request *http.Request, writer http.ResponseWriter, params map[st
|
||||
if request == nil {
|
||||
request = &http.Request{}
|
||||
}
|
||||
return &Context{
|
||||
c := &Context{
|
||||
request: request,
|
||||
writer: writer,
|
||||
params: params,
|
||||
values: make(map[string]any),
|
||||
}
|
||||
if writer != nil {
|
||||
c.writer = trackWriter(writer, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) Request() *http.Request { return c.request }
|
||||
@@ -120,8 +124,8 @@ func (c *Context) Context() context.Context {
|
||||
return c.request.Context()
|
||||
}
|
||||
|
||||
// Raw returns the response writer supplied by the active network adapter.
|
||||
// Prefer the transport-neutral methods on Context for portable handlers.
|
||||
// Raw returns a streaming response writer that tracks commits and status.
|
||||
// Optional HTTP capabilities are preserved only when the underlying writer supports them.
|
||||
func (c *Context) Raw() http.ResponseWriter { return c.writer }
|
||||
|
||||
// Clone gives a buffering middleware its own response state and values map.
|
||||
@@ -146,7 +150,6 @@ func (c *Context) WriteResponse(status int, headers http.Header, body []byte) er
|
||||
for key, values := range headers {
|
||||
c.writer.Header()[key] = append([]string(nil), values...)
|
||||
}
|
||||
c.responded, c.status = true, status
|
||||
c.writer.WriteHeader(status)
|
||||
if len(body) > 0 {
|
||||
if _, err := c.writer.Write(body); err != nil {
|
||||
@@ -216,8 +219,6 @@ func (c *Context) JSON(status int, value any) error {
|
||||
if err != nil {
|
||||
return apierrors.Internal(err)
|
||||
}
|
||||
c.responded = true
|
||||
c.status = status
|
||||
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.writer.WriteHeader(status)
|
||||
if _, err := c.writer.Write(append(data, '\n')); err != nil {
|
||||
@@ -243,8 +244,6 @@ func (c *Context) NoContent() error {
|
||||
if c.writer == nil {
|
||||
return apierrors.Internal(nil)
|
||||
}
|
||||
c.responded = true
|
||||
c.status = http.StatusNoContent
|
||||
c.writer.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
@@ -271,12 +270,15 @@ func (c *Context) Status() int {
|
||||
func (c *Context) Responded() bool { return c.responded }
|
||||
|
||||
// WriteError writes a stable public error response. Internal causes are not
|
||||
// serialized. Responses committed through Context methods are preserved;
|
||||
// direct Raw writes are outside this response-tracking contract.
|
||||
// serialized. Responses committed through Context methods or Raw are preserved.
|
||||
func (c *Context) WriteError(err error) {
|
||||
if c.responded || err == nil {
|
||||
return
|
||||
}
|
||||
var bodyErr *http.MaxBytesError
|
||||
if errors.As(err, &bodyErr) {
|
||||
err = bodyTooLarge()
|
||||
}
|
||||
apiErr := apierrors.From(err)
|
||||
if writeErr := c.writeResult(response.Result{
|
||||
Status: apiErr.Status,
|
||||
|
||||
315
httpx/trackingWriter.go
Normal file
315
httpx/trackingWriter.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// trackingWriter does not buffer response bodies. Each wrapper has its own
|
||||
// commit flag, so a standard middleware may safely wrap an already tracked writer.
|
||||
type trackingWriter struct {
|
||||
http.ResponseWriter
|
||||
context *Context
|
||||
committed bool
|
||||
}
|
||||
|
||||
func (w *trackingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
func (w *trackingWriter) inheritCommit(status int) { w.mark(status) }
|
||||
func (w *trackingWriter) mark(status int) {
|
||||
w.committed = true
|
||||
w.context.responded = true
|
||||
w.context.status = status
|
||||
}
|
||||
func (w *trackingWriter) WriteHeader(status int) {
|
||||
if w.committed {
|
||||
return
|
||||
}
|
||||
// Let the server validate the status before updating the observation.
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
if status == 101 || status >= 200 {
|
||||
w.mark(status)
|
||||
}
|
||||
}
|
||||
func (w *trackingWriter) Write(p []byte) (int, error) {
|
||||
if !w.committed {
|
||||
w.mark(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
type flushWriter struct{ *trackingWriter }
|
||||
|
||||
func (w flushWriter) Flush() { _ = w.FlushError() }
|
||||
func (w flushWriter) FlushError() error {
|
||||
if !w.committed {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return http.NewResponseController(w.ResponseWriter).Flush()
|
||||
}
|
||||
|
||||
type hijackWriter struct{ *trackingWriter }
|
||||
|
||||
func (w hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
conn, rw, err := w.ResponseWriter.(http.Hijacker).Hijack()
|
||||
if err == nil {
|
||||
w.context.responded = true
|
||||
w.committed = true
|
||||
}
|
||||
return conn, rw, err
|
||||
}
|
||||
|
||||
type pushWriter struct{ *trackingWriter }
|
||||
|
||||
func (w pushWriter) Push(target string, options *http.PushOptions) error {
|
||||
return w.ResponseWriter.(http.Pusher).Push(target, options)
|
||||
}
|
||||
|
||||
type readFromWriter struct{ *trackingWriter }
|
||||
|
||||
func (w readFromWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
// A writer-only view makes every write observable, including partial failures,
|
||||
// while io.Copy retains bounded streaming memory and source WriterTo support.
|
||||
return io.Copy(struct{ io.Writer }{w.trackingWriter}, r)
|
||||
}
|
||||
|
||||
type closeNotifyWriter struct{ *trackingWriter }
|
||||
|
||||
func (w closeNotifyWriter) CloseNotify() <-chan bool {
|
||||
return w.ResponseWriter.(http.CloseNotifier).CloseNotify()
|
||||
}
|
||||
|
||||
func trackWriter(writer http.ResponseWriter, c *Context) http.ResponseWriter {
|
||||
w := &trackingWriter{ResponseWriter: writer, context: c}
|
||||
mask := 0
|
||||
if _, ok := writer.(http.Flusher); ok {
|
||||
mask |= 1
|
||||
} else if _, ok := writer.(interface{ FlushError() error }); ok {
|
||||
mask |= 1
|
||||
}
|
||||
if _, ok := writer.(http.Hijacker); ok {
|
||||
mask |= 2
|
||||
}
|
||||
if _, ok := writer.(http.Pusher); ok {
|
||||
mask |= 4
|
||||
}
|
||||
if _, ok := writer.(io.ReaderFrom); ok {
|
||||
mask |= 8
|
||||
}
|
||||
if _, ok := writer.(http.CloseNotifier); ok {
|
||||
mask |= 16
|
||||
}
|
||||
switch mask {
|
||||
case 1:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
}{w, flushWriter{w}}
|
||||
case 2:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
}{w, hijackWriter{w}}
|
||||
case 3:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
}{w, flushWriter{w}, hijackWriter{w}}
|
||||
case 4:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Pusher
|
||||
}{w, pushWriter{w}}
|
||||
case 5:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Pusher
|
||||
}{w, flushWriter{w}, pushWriter{w}}
|
||||
case 6:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
}{w, hijackWriter{w}, pushWriter{w}}
|
||||
case 7:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}}
|
||||
case 8:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
io.ReaderFrom
|
||||
}{w, readFromWriter{w}}
|
||||
case 9:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
io.ReaderFrom
|
||||
}{w, flushWriter{w}, readFromWriter{w}}
|
||||
case 10:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
io.ReaderFrom
|
||||
}{w, hijackWriter{w}, readFromWriter{w}}
|
||||
case 11:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
io.ReaderFrom
|
||||
}{w, flushWriter{w}, hijackWriter{w}, readFromWriter{w}}
|
||||
case 12:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
}{w, pushWriter{w}, readFromWriter{w}}
|
||||
case 13:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
}{w, flushWriter{w}, pushWriter{w}, readFromWriter{w}}
|
||||
case 14:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
}{w, hijackWriter{w}, pushWriter{w}, readFromWriter{w}}
|
||||
case 15:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}, readFromWriter{w}}
|
||||
case 16:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.CloseNotifier
|
||||
}{w, closeNotifyWriter{w}}
|
||||
case 17:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, closeNotifyWriter{w}}
|
||||
case 18:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.CloseNotifier
|
||||
}{w, hijackWriter{w}, closeNotifyWriter{w}}
|
||||
case 19:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, hijackWriter{w}, closeNotifyWriter{w}}
|
||||
case 20:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Pusher
|
||||
http.CloseNotifier
|
||||
}{w, pushWriter{w}, closeNotifyWriter{w}}
|
||||
case 21:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Pusher
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, pushWriter{w}, closeNotifyWriter{w}}
|
||||
case 22:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
http.CloseNotifier
|
||||
}{w, hijackWriter{w}, pushWriter{w}, closeNotifyWriter{w}}
|
||||
case 23:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}, closeNotifyWriter{w}}
|
||||
case 24:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 25:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 26:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, hijackWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 27:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, hijackWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 28:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 29:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 30:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, hijackWriter{w}, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 31:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
type flushCapability interface {
|
||||
Flush()
|
||||
FlushError() error
|
||||
}
|
||||
Reference in New Issue
Block a user