diff --git a/README.md b/README.md index 1202e9e..318a6ad 100644 --- a/README.md +++ b/README.md @@ -490,7 +490,7 @@ Exec requests default to backwards-compatible shell mode. Set `mode: "argv"` wit | `GET` | `/sandboxes/{id}/files?path=` | Read a file | | `DELETE` | `/sandboxes/{id}/files?path=` | Delete a file (`recursive=true` for dirs) | | `GET` | `/sandboxes/{id}/files/list?path=` | List a directory | -| `POST` | `/sandboxes/{id}/files/move` | Move/rename | +| `POST` | `/sandboxes/{id}/files/move` | Move/rename (body: `old_path`, `new_path`) | | `POST` | `/sandboxes/{id}/files/chmod` | Change permissions | | `GET` | `/sandboxes/{id}/files/stat?path=` | File metadata | | `GET` | `/sandboxes/{id}/files/glob?pattern=` | Glob pattern matching | diff --git a/internal/api/middleware/logging.go b/internal/api/middleware/logging.go index 9fd6cfb..d4c562c 100644 --- a/internal/api/middleware/logging.go +++ b/internal/api/middleware/logging.go @@ -24,6 +24,21 @@ func (rw *responseWriter) Write(b []byte) (int, error) { return n, err } +// Flush forwards to the underlying writer when it supports flushing. +// Required so streaming handlers (SSE, NDJSON) can flush per chunk after +// the logging middleware has wrapped the writer. +func (rw *responseWriter) Flush() { + if f, ok := rw.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap exposes the underlying writer so http.ResponseController can reach +// optional interfaces (Flusher, Hijacker, etc.) past this wrapper. +func (rw *responseWriter) Unwrap() http.ResponseWriter { + return rw.ResponseWriter +} + func Logging(logger zerolog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/middleware/logging_test.go b/internal/api/middleware/logging_test.go new file mode 100644 index 0000000..142022e --- /dev/null +++ b/internal/api/middleware/logging_test.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/rs/zerolog" +) + +// Regression: the logging wrapper used to swallow http.Flusher so SSE handlers +// hit "streaming not supported". The wrapper must now expose Flush() and +// Unwrap() so both type-assertion and http.ResponseController work. +func TestLoggingWrapperExposesFlusher(t *testing.T) { + var fc flushCapture + + handler := Logging(zerolog.Nop())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := w.(http.Flusher); !ok { + t.Fatalf("wrapped writer does not implement http.Flusher") + } + + rc := http.NewResponseController(w) + if err := rc.Flush(); err != nil { + t.Fatalf("ResponseController.Flush returned error: %v", err) + } + + w.(http.Flusher).Flush() + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil) + handler.ServeHTTP(&fc, req) + + if fc.flushed < 2 { + t.Fatalf("expected at least 2 flushes, got %d", fc.flushed) + } +} + +// flushCapture is a minimal ResponseWriter that records flush invocations. +type flushCapture struct { + header http.Header + flushed int + status int +} + +func (f *flushCapture) Header() http.Header { + if f.header == nil { + f.header = make(http.Header) + } + return f.header +} + +func (f *flushCapture) Write(b []byte) (int, error) { return len(b), nil } +func (f *flushCapture) WriteHeader(status int) { f.status = status } +func (f *flushCapture) Flush() { f.flushed++ }