From 99db5c2a00b6f2eb8fe515b484d38cf36378c5d2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=A4=D0=B8=D0=BB=D0=B8=D0=BC=D0=BE=D0=BD=D0=B5=D0=BD?=
=?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=9F=D0=B0=D0=B2=D0=B5=D0=BB=20=D0=90?=
=?UTF-8?q?=D0=BD=D0=B0=D1=82=D0=BE=D0=BB=D1=8C=D0=B5=D0=B2=D0=B8=D1=87?=
Date: Fri, 24 Jul 2026 13:49:14 +0300
Subject: [PATCH 1/4] feat: add streaming multipart file-server playground
---
.golangci.yml | 4 +
file-server/README.md | 32 ++++++--
file-server/restapi/configure_file_upload.go | 35 ++++++---
file-server/restapi/embedded_spec.go | 6 +-
.../uploads/upload_file_parameters.go | 73 ++++++++++++-------
file-server/swagger.yml | 1 +
go.mod | 2 +
go.sum | 4 +-
8 files changed, 107 insertions(+), 50 deletions(-)
diff --git a/.golangci.yml b/.golangci.yml
index de6c26a9..da214fd3 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -50,6 +50,10 @@ linters:
goconst:
min-len: 2
min-occurrences: 3
+ gomoddirectives:
+ # Allow the experimental runtime fork used by the multipart streaming playground.
+ replace-allow-list:
+ - github.com/go-openapi/runtime
gosec:
excludes:
- G706 # logs in examples are deliberately verbose and revealing of the internals
diff --git a/file-server/README.md b/file-server/README.md
index c709d04f..d6a134f7 100644
--- a/file-server/README.md
+++ b/file-server/README.md
@@ -52,17 +52,35 @@ paths:
## Server side
-The handler receives a `io.ReadCloser` as the file to consume.
-
-Under the hood, the runtime builds this with a `*runtime.File`, which provides access to some header information, such as:
+The handler receives a `*runtime.StreamedFile`, which reads the payload directly
+from the HTTP request body. The filename and MIME headers are available before
+the payload is consumed:
```go
- if namedFile, ok := params.File.(*runtime.File); ok {
- log.Printf("received file name: %s", namedFile.Header.Filename)
- log.Printf("received file size: %d", namedFile.Header.Size)
- }
+ log.Printf("received file name: %s", params.File.Filename)
+ log.Printf("received content type: %s", params.File.Header.Get(runtime.HeaderContentType))
```
+The complete file size is not known before the stream is consumed.
+
+## Experimental server-side streaming
+
+This branch demonstrates the intended server binding for a file parameter with
+`x-go-server-streaming: true`.
+
+The generated binder is adapted manually until go-swagger supports the
+extension. It uses `runtime.MultipartFormStream` from go-openapi/runtime#507 and
+exposes the file payload before the complete multipart request has arrived.
+The handler owns the multipart stream and must either:
+
+- call `Drain` after consuming the file to process the remaining parts and close
+ the request body; or
+- call `Close` to abort multipart processing.
+
+The request size is limited outside the binder with `http.MaxBytesHandler`.
+The runtime stream's own body limit is disabled so that `*http.MaxBytesError`
+from the outer middleware is propagated through file reads and draining.
+
## Client side
The local file is handled as a `runtime.NamedReadCloser` (that is, a `io.ReadCloser` plus the `Name() string` method).
diff --git a/file-server/restapi/configure_file_upload.go b/file-server/restapi/configure_file_upload.go
index 2db53567..2b53c564 100644
--- a/file-server/restapi/configure_file_upload.go
+++ b/file-server/restapi/configure_file_upload.go
@@ -46,9 +46,6 @@ func configureAPI(api *operations.FileUploadAPI) http.Handler {
api.JSONProducer = runtime.JSONProducer()
- // You may change here the memory limit for this multipart form parser. Below is the default (32 MB).
- // uploads.UploadFileMaxParseMemory = 32 << 20
-
uploadFolder, err := os.MkdirTemp(".", "upload")
if err != nil {
panic("could not create upload folder")
@@ -60,14 +57,15 @@ func configureAPI(api *operations.FileUploadAPI) http.Handler {
if params.File == nil {
return middleware.Error(http.StatusNotFound, stderrors.New("no file provided"))
}
+ if params.MultipartForm == nil {
+ return middleware.Error(http.StatusInternalServerError, stderrors.New("multipart stream is not initialized"))
+ }
defer func() {
- _ = params.File.Close()
+ _ = params.MultipartForm.Close()
}()
- if namedFile, ok := params.File.(*runtime.File); ok {
- log.Printf("received file name: %s", namedFile.Header.Filename)
- log.Printf("received file size: %d", namedFile.Header.Size)
- }
+ log.Printf("received file name: %s", params.File.Filename)
+ log.Printf("received content type: %s", params.File.Header.Get(runtime.HeaderContentType))
// uploads file and save it locally
filename := path.Join(uploadFolder, fmt.Sprintf("uploaded_file_%d.dat", uploadCounter))
@@ -77,13 +75,19 @@ func configureAPI(api *operations.FileUploadAPI) http.Handler {
return middleware.Error(http.StatusInternalServerError, stderrors.New("could not create file on server"))
}
+ defer func() {
+ _ = f.Close()
+ }()
+
n, err := io.Copy(f, params.File)
if err != nil {
- return middleware.Error(http.StatusInternalServerError, stderrors.New("could not upload file on server"))
+ return uploadError(err)
+ }
+ if err := params.MultipartForm.Drain(); err != nil {
+ return uploadError(err)
}
log.Printf("copied bytes %d", n)
-
log.Printf("file uploaded copied as %s", filename)
return uploads.NewUploadFileOK()
@@ -122,5 +126,14 @@ func setupMiddlewares(handler http.Handler) http.Handler {
// The middleware configuration happens before anything, this middleware also applies to serving the swagger.json document.
// So this is a good place to plug in a panic handling middleware, logging and metrics.
func setupGlobalMiddleware(handler http.Handler) http.Handler {
- return handler
+ return http.MaxBytesHandler(handler, uploads.UploadFileMaxBodySize)
+}
+
+func uploadError(err error) middleware.Responder {
+ var maxBytesErr *http.MaxBytesError
+ if stderrors.As(err, &maxBytesErr) {
+ return middleware.Error(http.StatusRequestEntityTooLarge, err)
+ }
+
+ return middleware.Error(http.StatusInternalServerError, stderrors.New("could not upload file on server"))
}
diff --git a/file-server/restapi/embedded_spec.go b/file-server/restapi/embedded_spec.go
index f8341ec5..e563150c 100644
--- a/file-server/restapi/embedded_spec.go
+++ b/file-server/restapi/embedded_spec.go
@@ -47,7 +47,8 @@ func init() {
"type": "file",
"name": "file",
"in": "formData",
- "required": true
+ "required": true,
+ "x-go-server-streaming": true
}
],
"responses": {
@@ -92,7 +93,8 @@ func init() {
"type": "file",
"name": "file",
"in": "formData",
- "required": true
+ "required": true,
+ "x-go-server-streaming": true
}
],
"responses": {
diff --git a/file-server/restapi/operations/uploads/upload_file_parameters.go b/file-server/restapi/operations/uploads/upload_file_parameters.go
index 6f2c8618..becd71f0 100644
--- a/file-server/restapi/operations/uploads/upload_file_parameters.go
+++ b/file-server/restapi/operations/uploads/upload_file_parameters.go
@@ -1,10 +1,13 @@
// Code generated by go-swagger; DO NOT EDIT.
+//
+// This file contains an experimental manual adaptation for
+// x-go-server-streaming until go-swagger code generation supports it.
package uploads
import (
+ stderrors "errors"
"io"
- "mime/multipart"
"net/http"
"github.com/go-openapi/errors"
@@ -12,13 +15,6 @@ import (
"github.com/go-openapi/runtime/middleware"
)
-// UploadFileMaxParseMemory sets the maximum size in bytes for
-// the multipart form parser for this operation.
-//
-// The default value is 32 MB.
-// The multipart parser stores up to this + 10MB.
-var UploadFileMaxParseMemory int64 = 32 << 20
-
// UploadFileMaxBodySize caps the size of the form body.
//
// The default value is 32 MB. Larger bodies will error with http status 413.
@@ -39,9 +35,14 @@ func NewUploadFileParams() UploadFileParams {
type UploadFileParams struct {
// HTTP Request Object
HTTPRequest *http.Request `json:"-"`
+
+ // MultipartForm owns the request-body stream. The handler must call Drain
+ // after successfully consuming File, or Close to abort processing.
+ MultipartForm *runtime.MultipartFormStream `json:"-"`
+
// Required: true
// In: formData
- File io.ReadCloser
+ File *runtime.StreamedFile
}
// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
@@ -49,33 +50,49 @@ type UploadFileParams struct {
//
// To ensure default values, the struct must have been initialized with NewUploadFileParams() beforehand.
func (o *UploadFileParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
- var res []error
-
o.HTTPRequest = r
- isBlocking, err := runtime.BindForm(r,
- runtime.BindFormMaxParseMemory(UploadFileMaxParseMemory),
- runtime.BindFormMaxBody(UploadFileMaxBodySize),
- runtime.BindFormFile("file", true, o.bindFile),
+
+ stream, err := runtime.NewMultipartFormStream(
+ r,
+ // The example applies the request-size limit with http.MaxBytesHandler.
+ runtime.MultipartFormStreamMaxBody(-1),
)
if err != nil {
- if isBlocking {
- return err
- }
-
- res = append(res, err)
+ return err
}
+ o.MultipartForm = stream
- if len(res) > 0 {
- return errors.CompositeValidationError(res...)
+ file, err := nextStreamingFile(stream, "file")
+ if err != nil {
+ return closeMultipartStream(stream, err)
}
+ o.File = file
+
return nil
}
-// bindFile validates file parameter File1 and assigns it as a *runtime.File on success.
-//
-// The only supported validations on files are MinLength and MaxLength
-func (o *UploadFileParams) bindFile(file multipart.File, header *multipart.FileHeader) error {
+func nextStreamingFile(stream *runtime.MultipartFormStream, fieldName string) (*runtime.StreamedFile, error) {
+ for {
+ file, err := stream.NextFile()
+ if stderrors.Is(err, io.EOF) {
+ return nil, errors.Required(fieldName, "formData", nil)
+ }
+ if err != nil {
+ return nil, err
+ }
+ if file.FieldName == fieldName {
+ return file, nil
+ }
+ if err := file.Close(); err != nil {
+ return nil, err
+ }
+ }
+}
- o.File = &runtime.File{Data: file, Header: header}
- return nil
+func closeMultipartStream(stream *runtime.MultipartFormStream, err error) error {
+ if closeErr := stream.Close(); closeErr != nil {
+ return stderrors.Join(err, closeErr)
+ }
+
+ return err
}
diff --git a/file-server/swagger.yml b/file-server/swagger.yml
index 9626f1ab..79f15ed4 100644
--- a/file-server/swagger.yml
+++ b/file-server/swagger.yml
@@ -26,6 +26,7 @@ paths:
in: formData
type: file
required: true
+ x-go-server-streaming: true
# endsnippet:upload-path
responses:
"200":
diff --git a/go.mod b/go.mod
index 9400d416..c2aed640 100644
--- a/go.mod
+++ b/go.mod
@@ -71,3 +71,5 @@ require (
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+
+replace github.com/go-openapi/runtime => github.com/fpawel/openapi-runtime v0.0.0-pr507.1
diff --git a/go.sum b/go.sum
index 45246c79..ee163996 100644
--- a/go.sum
+++ b/go.sum
@@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/fpawel/openapi-runtime v0.0.0-pr507.1 h1:L1q09GpEEHDjYohyaCgGywe0Nm/Gvrs+Z8C903sw8Ms=
+github.com/fpawel/openapi-runtime v0.0.0-pr507.1/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
@@ -32,8 +34,6 @@ github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkr
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI=
github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8=
-github.com/go-openapi/runtime v0.32.6 h1:hrcTTF8P7ZZr2Majzq11I65QtL/s85o7Q+zJf+AvFN4=
-github.com/go-openapi/runtime v0.32.6/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw=
github.com/go-openapi/runtime/server-middleware v0.32.6 h1:IGTYzybyFrUeSqQEwwO1y/9KnOk4QsabFNtAQtIHxDE=
github.com/go-openapi/runtime/server-middleware v0.32.6/go.mod h1:OQHTBqMGquJShXhPYQ62yAqDMtC1rYpsEwldNWjYKhA=
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
From 6623e90b0752ae4bf41cfc72feb004ff7bec88ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=A4=D0=B8=D0=BB=D0=B8=D0=BC=D0=BE=D0=BD=D0=B5=D0=BD?=
=?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=9F=D0=B0=D0=B2=D0=B5=D0=BB=20=D0=90?=
=?UTF-8?q?=D0=BD=D0=B0=D1=82=D0=BE=D0=BB=D1=8C=D0=B5=D0=B2=D0=B8=D1=87?=
Date: Fri, 24 Jul 2026 16:54:53 +0300
Subject: [PATCH 2/4] refactor: move multipart traversal to handler
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Keep generated streaming binding limited to constructing and passing MultipartFormStream.
Let the handler own multipart traversal, required-file checks and application-specific validation, and demonstrate discovered field and file state in the black-box streaming test.
Signed-off-by: Филимоненков Павел Анатольевич
---
file-server/README.md | 37 ++--
file-server/restapi/configure_file_upload.go | 68 ++++---
.../uploads/upload_file_parameters.go | 54 +-----
.../streaming_upload_integration_test.go | 174 ++++++++++++++++++
go.mod | 2 +-
5 files changed, 256 insertions(+), 79 deletions(-)
create mode 100644 file-server/restapi/streaming_upload_integration_test.go
diff --git a/file-server/README.md b/file-server/README.md
index d6a134f7..08419661 100644
--- a/file-server/README.md
+++ b/file-server/README.md
@@ -52,29 +52,44 @@ paths:
## Server side
-The handler receives a `*runtime.StreamedFile`, which reads the payload directly
-from the HTTP request body. The filename and MIME headers are available before
-the payload is consumed:
+For `x-go-server-streaming: true`, generated binding only constructs a
+`*runtime.MultipartFormStream` and passes ownership to the handler. It does not
+consume parts or try to populate generated file and form fields.
+
+The handler traverses the multipart body sequentially:
```go
- log.Printf("received file name: %s", params.File.Filename)
- log.Printf("received content type: %s", params.File.Header.Get(runtime.HeaderContentType))
+ for {
+ file, err := params.MultipartForm.NextFile()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ // Validate file.FieldName and consume file here.
+ }
```
-The complete file size is not known before the stream is consumed.
+Each `runtime.StreamedFile` reads directly from the HTTP request body. The
+filename and MIME headers are available before the payload is consumed, but the
+complete file size is not known in advance.
## Experimental server-side streaming
-This branch demonstrates the intended server binding for a file parameter with
+This branch demonstrates the intended server binding for an operation with
`x-go-server-streaming: true`.
The generated binder is adapted manually until go-swagger supports the
-extension. It uses `runtime.MultipartFormStream` from go-openapi/runtime#507 and
-exposes the file payload before the complete multipart request has arrived.
+extension. It deliberately leaves traversal, required-field checks,
+multiplicity and application-specific validation to the handler so mixed and
+repeated multipart parts remain usable.
+
+`MultipartFormStream.Fields()` and `MultipartFormStream.Files()` return
+snapshots of ordinary fields and file metadata discovered so far. They do not
+read ahead: trailing fields become visible only after the active file is
+consumed or closed and the stream advances.
+
The handler owns the multipart stream and must either:
-- call `Drain` after consuming the file to process the remaining parts and close
- the request body; or
+- call `Drain` to process all remaining parts and close the request body; or
- call `Close` to abort multipart processing.
The request size is limited outside the binder with `http.MaxBytesHandler`.
diff --git a/file-server/restapi/configure_file_upload.go b/file-server/restapi/configure_file_upload.go
index 2b53c564..338897fb 100644
--- a/file-server/restapi/configure_file_upload.go
+++ b/file-server/restapi/configure_file_upload.go
@@ -54,9 +54,6 @@ func configureAPI(api *operations.FileUploadAPI) http.Handler {
// snippet:upload-handler
api.UploadsUploadFileHandler = uploads.UploadFileHandlerFunc(func(params uploads.UploadFileParams) middleware.Responder {
- if params.File == nil {
- return middleware.Error(http.StatusNotFound, stderrors.New("no file provided"))
- }
if params.MultipartForm == nil {
return middleware.Error(http.StatusInternalServerError, stderrors.New("multipart stream is not initialized"))
}
@@ -64,31 +61,48 @@ func configureAPI(api *operations.FileUploadAPI) http.Handler {
_ = params.MultipartForm.Close()
}()
- log.Printf("received file name: %s", params.File.Filename)
- log.Printf("received content type: %s", params.File.Header.Get(runtime.HeaderContentType))
-
- // uploads file and save it locally
- filename := path.Join(uploadFolder, fmt.Sprintf("uploaded_file_%d.dat", uploadCounter))
- uploadCounter++
- f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
- if err != nil {
- return middleware.Error(http.StatusInternalServerError, stderrors.New("could not create file on server"))
+ uploadedFiles := 0
+ for {
+ file, err := params.MultipartForm.NextFile()
+ if stderrors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return uploadError(err)
+ }
+
+ if file.FieldName != "file" {
+ if err := file.Close(); err != nil {
+ return uploadError(err)
+ }
+
+ continue
+ }
+
+ log.Printf("received file name: %s", file.Filename)
+ log.Printf("received content type: %s", file.Header.Get(runtime.HeaderContentType))
+
+ filename := path.Join(uploadFolder, fmt.Sprintf("uploaded_file_%d.dat", uploadCounter))
+ uploadCounter++
+ n, err := saveStreamedFile(filename, file)
+ if err != nil {
+ return uploadError(err)
+ }
+ uploadedFiles++
+
+ log.Printf("copied bytes %d", n)
+ log.Printf("file uploaded copied as %s", filename)
}
- defer func() {
- _ = f.Close()
- }()
-
- n, err := io.Copy(f, params.File)
- if err != nil {
- return uploadError(err)
+ if uploadedFiles == 0 {
+ return middleware.Error(http.StatusBadRequest, stderrors.New("no file provided"))
}
if err := params.MultipartForm.Drain(); err != nil {
return uploadError(err)
}
- log.Printf("copied bytes %d", n)
- log.Printf("file uploaded copied as %s", filename)
+ log.Printf("discovered multipart fields: %v", params.MultipartForm.Fields())
+ log.Printf("discovered multipart files: %d", len(params.MultipartForm.Files()))
return uploads.NewUploadFileOK()
})
@@ -129,6 +143,18 @@ func setupGlobalMiddleware(handler http.Handler) http.Handler {
return http.MaxBytesHandler(handler, uploads.UploadFileMaxBodySize)
}
+func saveStreamedFile(filename string, file io.Reader) (int64, error) {
+ f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
+ if err != nil {
+ return 0, fmt.Errorf("create upload file: %w", err)
+ }
+
+ n, copyErr := io.Copy(f, file)
+ closeErr := f.Close()
+
+ return n, stderrors.Join(copyErr, closeErr)
+}
+
func uploadError(err error) middleware.Responder {
var maxBytesErr *http.MaxBytesError
if stderrors.As(err, &maxBytesErr) {
diff --git a/file-server/restapi/operations/uploads/upload_file_parameters.go b/file-server/restapi/operations/uploads/upload_file_parameters.go
index becd71f0..446e0478 100644
--- a/file-server/restapi/operations/uploads/upload_file_parameters.go
+++ b/file-server/restapi/operations/uploads/upload_file_parameters.go
@@ -6,11 +6,8 @@
package uploads
import (
- stderrors "errors"
- "io"
"net/http"
- "github.com/go-openapi/errors"
"github.com/go-openapi/runtime"
"github.com/go-openapi/runtime/middleware"
)
@@ -24,7 +21,6 @@ var UploadFileMaxBodySize int64 = 32 << 20
//
// There are no default values defined in the spec.
func NewUploadFileParams() UploadFileParams {
-
return UploadFileParams{}
}
@@ -36,20 +32,17 @@ type UploadFileParams struct {
// HTTP Request Object
HTTPRequest *http.Request `json:"-"`
- // MultipartForm owns the request-body stream. The handler must call Drain
- // after successfully consuming File, or Close to abort processing.
+ // MultipartForm owns the request-body stream. Generated binding deliberately
+ // does not consume or validate file and field parts: the handler owns
+ // traversal, validation, draining and closing.
MultipartForm *runtime.MultipartFormStream `json:"-"`
-
- // Required: true
- // In: formData
- File *runtime.StreamedFile
}
-// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
-// for simple values it will use straight method calls.
+// BindRequest binds the request-level multipart stream without consuming parts.
//
-// To ensure default values, the struct must have been initialized with NewUploadFileParams() beforehand.
-func (o *UploadFileParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
+// Streaming operations intentionally leave file and field validation to the
+// handler because multipart parts are ordered and may be mixed or repeated.
+func (o *UploadFileParams) BindRequest(r *http.Request, _ *middleware.MatchedRoute) error {
o.HTTPRequest = r
stream, err := runtime.NewMultipartFormStream(
@@ -60,39 +53,8 @@ func (o *UploadFileParams) BindRequest(r *http.Request, route *middleware.Matche
if err != nil {
return err
}
- o.MultipartForm = stream
- file, err := nextStreamingFile(stream, "file")
- if err != nil {
- return closeMultipartStream(stream, err)
- }
- o.File = file
+ o.MultipartForm = stream
return nil
}
-
-func nextStreamingFile(stream *runtime.MultipartFormStream, fieldName string) (*runtime.StreamedFile, error) {
- for {
- file, err := stream.NextFile()
- if stderrors.Is(err, io.EOF) {
- return nil, errors.Required(fieldName, "formData", nil)
- }
- if err != nil {
- return nil, err
- }
- if file.FieldName == fieldName {
- return file, nil
- }
- if err := file.Close(); err != nil {
- return nil, err
- }
- }
-}
-
-func closeMultipartStream(stream *runtime.MultipartFormStream, err error) error {
- if closeErr := stream.Close(); closeErr != nil {
- return stderrors.Join(err, closeErr)
- }
-
- return err
-}
diff --git a/file-server/restapi/streaming_upload_integration_test.go b/file-server/restapi/streaming_upload_integration_test.go
new file mode 100644
index 00000000..6a6b2337
--- /dev/null
+++ b/file-server/restapi/streaming_upload_integration_test.go
@@ -0,0 +1,174 @@
+// SPDX-FileCopyrightText: Copyright 2015-2026 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+package restapi
+
+import (
+ stderrors "errors"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-openapi/loads"
+ "github.com/go-openapi/runtime"
+ "github.com/go-openapi/runtime/middleware"
+ "github.com/go-openapi/testify/v2/assert"
+ "github.com/go-openapi/testify/v2/require"
+
+ "github.com/go-swagger/examples/file-server/restapi/operations"
+ "github.com/go-swagger/examples/file-server/restapi/operations/uploads"
+)
+
+type uploadHTTPResult struct {
+ statusCode int
+ err error
+}
+
+type streamingHandlerProgress struct {
+ firstChunk string
+ fields url.Values
+ files []runtime.MultipartFileInfo
+}
+
+type streamingHandlerResult struct {
+ fields url.Values
+ files []runtime.MultipartFileInfo
+ err error
+}
+
+func TestStreamingUploadReachesHandlerBeforeRequestBodyCompletes(t *testing.T) {
+ const (
+ firstChunk = "first"
+ secondChunk = "second"
+ )
+
+ swaggerSpec, err := loads.Embedded(SwaggerJSON, FlatSwaggerJSON)
+ require.NoError(t, err)
+
+ handlerRead := make(chan streamingHandlerProgress, 1)
+ handlerDone := make(chan streamingHandlerResult, 1)
+
+ api := operations.NewFileUploadAPI(swaggerSpec)
+ api.MultipartformConsumer = runtime.DiscardConsumer
+ api.UploadsUploadFileHandler = uploads.UploadFileHandlerFunc(func(params uploads.UploadFileParams) middleware.Responder {
+ defer func() {
+ _ = params.MultipartForm.Close()
+ }()
+
+ file, handlerErr := params.MultipartForm.NextFile()
+ if handlerErr == nil {
+ first := make([]byte, len(firstChunk))
+ _, handlerErr = io.ReadFull(file, first)
+ if handlerErr == nil {
+ handlerRead <- streamingHandlerProgress{
+ firstChunk: string(first),
+ fields: params.MultipartForm.Fields(),
+ files: params.MultipartForm.Files(),
+ }
+ _, handlerErr = io.Copy(io.Discard, file)
+ }
+ }
+ if handlerErr == nil {
+ _, handlerErr = params.MultipartForm.NextFile()
+ if stderrors.Is(handlerErr, io.EOF) {
+ handlerErr = nil
+ }
+ }
+ if handlerErr == nil {
+ handlerErr = params.MultipartForm.Drain()
+ }
+
+ handlerDone <- streamingHandlerResult{
+ fields: params.MultipartForm.Fields(),
+ files: params.MultipartForm.Files(),
+ err: handlerErr,
+ }
+ if handlerErr != nil {
+ return middleware.Error(http.StatusInternalServerError, handlerErr)
+ }
+
+ return uploads.NewUploadFileOK()
+ })
+
+ server := httptest.NewServer(api.Serve(nil))
+ defer server.Close()
+
+ bodyReader, bodyWriter := io.Pipe()
+ defer func() {
+ _ = bodyReader.Close()
+ }()
+ defer func() {
+ _ = bodyWriter.Close()
+ }()
+
+ multipartWriter := multipart.NewWriter(bodyWriter)
+ request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/upload", bodyReader)
+ require.NoError(t, err)
+ request.Header.Set(runtime.HeaderContentType, multipartWriter.FormDataContentType())
+
+ responseDone := make(chan uploadHTTPResult, 1)
+ go func() {
+ response, requestErr := server.Client().Do(request)
+ if requestErr != nil {
+ responseDone <- uploadHTTPResult{err: requestErr}
+
+ return
+ }
+ defer func() {
+ _ = response.Body.Close()
+ }()
+
+ responseDone <- uploadHTTPResult{statusCode: response.StatusCode}
+ }()
+
+ require.NoError(t, multipartWriter.WriteField("before", "one"))
+ part, err := multipartWriter.CreateFormFile("file", "payload.bin")
+ require.NoError(t, err)
+ _, err = io.WriteString(part, firstChunk)
+ require.NoError(t, err)
+
+ select {
+ case progress := <-handlerRead:
+ assert.EqualT(t, firstChunk, progress.firstChunk)
+ assert.EqualT(t, "one", progress.fields.Get("before"))
+ assert.Empty(t, progress.fields.Get("after"))
+ require.Len(t, progress.files, 1)
+ assert.EqualT(t, "file", progress.files[0].FieldName)
+ assert.EqualT(t, "payload.bin", progress.files[0].Filename)
+ case result := <-responseDone:
+ require.NoError(t, result.err)
+ t.Fatal("request completed before the multipart body was resumed")
+ case <-time.After(time.Second):
+ t.Fatal("handler did not receive the first file chunk while the request body was still open")
+ }
+
+ _, err = io.WriteString(part, secondChunk)
+ require.NoError(t, err)
+ require.NoError(t, multipartWriter.WriteField("after", "two"))
+ require.NoError(t, multipartWriter.Close())
+ require.NoError(t, bodyWriter.Close())
+
+ var result uploadHTTPResult
+ select {
+ case result = <-responseDone:
+ case <-time.After(time.Second):
+ t.Fatal("request did not complete after the multipart body was closed")
+ }
+ require.NoError(t, result.err)
+ assert.EqualT(t, http.StatusOK, result.statusCode)
+
+ select {
+ case handlerResult := <-handlerDone:
+ require.NoError(t, handlerResult.err)
+ assert.EqualT(t, "one", handlerResult.fields.Get("before"))
+ assert.EqualT(t, "two", handlerResult.fields.Get("after"))
+ require.Len(t, handlerResult.files, 1)
+ assert.EqualT(t, "payload.bin", handlerResult.files[0].Filename)
+ case <-time.After(time.Second):
+ t.Fatal("upload handler did not complete")
+ }
+}
diff --git a/go.mod b/go.mod
index c2aed640..6c2eb265 100644
--- a/go.mod
+++ b/go.mod
@@ -72,4 +72,4 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
)
-replace github.com/go-openapi/runtime => github.com/fpawel/openapi-runtime v0.0.0-pr507.1
+replace github.com/go-openapi/runtime => github.com/fpawel/openapi-runtime v0.0.0-pr507.2
From 7706b67f51fff497674d88fed4d7da0549c0c4d4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=A4=D0=B8=D0=BB=D0=B8=D0=BC=D0=BE=D0=BD=D0=B5=D0=BD?=
=?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=9F=D0=B0=D0=B2=D0=B5=D0=BB=20=D0=90?=
=?UTF-8?q?=D0=BD=D0=B0=D1=82=D0=BE=D0=BB=D1=8C=D0=B5=D0=B2=D0=B8=D1=87?=
Date: Sat, 25 Jul 2026 22:56:02 +0300
Subject: [PATCH 3/4] feat(file-server): demonstrate streaming multipart
uploads
---
.DS_Store | Bin 0 -> 6148 bytes
.golangci.yml | 4 ---
file-server/README.md | 22 +++++-------
file-server/restapi/configure_file_upload.go | 3 ++
file-server/restapi/embedded_spec.go | 8 ++---
.../uploads/upload_file_parameters.go | 34 +++++++++---------
go.mod | 4 +--
go.sum | 4 +--
8 files changed, 36 insertions(+), 43 deletions(-)
create mode 100644 .DS_Store
diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6
GIT binary patch
literal 6148
zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3
zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ
zLs35+`xjp>T0 0 {
+ return errors.CompositeValidationError(res...)
+ }
+
+ multipartForm, err := runtime.NewMultipartFormStream(
r,
- // The example applies the request-size limit with http.MaxBytesHandler.
- runtime.MultipartFormStreamMaxBody(-1),
+ runtime.MultipartFormStreamMaxBody(UploadFileMaxBodySize),
)
if err != nil {
return err
}
-
- o.MultipartForm = stream
-
+ o.MultipartForm = multipartForm
return nil
}
diff --git a/go.mod b/go.mod
index 6c2eb265..5ae7fec6 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
github.com/coreos/go-oidc/v3 v3.20.0
github.com/go-openapi/errors v0.22.8
github.com/go-openapi/loads v0.25.0
- github.com/go-openapi/runtime v0.32.6
+ github.com/go-openapi/runtime v0.32.7-0.20260724174133-f44e6731289f
github.com/go-openapi/spec v0.22.9
github.com/go-openapi/strfmt v0.27.0
github.com/go-openapi/swag/cmdutils v0.27.3
@@ -71,5 +71,3 @@ require (
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
-
-replace github.com/go-openapi/runtime => github.com/fpawel/openapi-runtime v0.0.0-pr507.2
diff --git a/go.sum b/go.sum
index ee163996..7525a861 100644
--- a/go.sum
+++ b/go.sum
@@ -11,8 +11,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/fpawel/openapi-runtime v0.0.0-pr507.1 h1:L1q09GpEEHDjYohyaCgGywe0Nm/Gvrs+Z8C903sw8Ms=
-github.com/fpawel/openapi-runtime v0.0.0-pr507.1/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
@@ -34,6 +32,8 @@ github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkr
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI=
github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8=
+github.com/go-openapi/runtime v0.32.7-0.20260724174133-f44e6731289f h1:NITRGLRjdL0p1Qcgj7wmPYjQrsrm1egITXS3C9a7ie8=
+github.com/go-openapi/runtime v0.32.7-0.20260724174133-f44e6731289f/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw=
github.com/go-openapi/runtime/server-middleware v0.32.6 h1:IGTYzybyFrUeSqQEwwO1y/9KnOk4QsabFNtAQtIHxDE=
github.com/go-openapi/runtime/server-middleware v0.32.6/go.mod h1:OQHTBqMGquJShXhPYQ62yAqDMtC1rYpsEwldNWjYKhA=
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
From 9d8b6abe87dff7d85c0968f92bc67148106853f8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=A4=D0=B8=D0=BB=D0=B8=D0=BC=D0=BE=D0=BD=D0=B5=D0=BD?=
=?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=9F=D0=B0=D0=B2=D0=B5=D0=BB=20=D0=90?=
=?UTF-8?q?=D0=BD=D0=B0=D1=82=D0=BE=D0=BB=D1=8C=D0=B5=D0=B2=D0=B8=D1=87?=
Date: Mon, 27 Jul 2026 19:29:43 +0300
Subject: [PATCH 4/4] feat: upgrade github.com/go-openapi/runtime
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index 5ae7fec6..b8fdab2a 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
github.com/coreos/go-oidc/v3 v3.20.0
github.com/go-openapi/errors v0.22.8
github.com/go-openapi/loads v0.25.0
- github.com/go-openapi/runtime v0.32.7-0.20260724174133-f44e6731289f
+ github.com/go-openapi/runtime v0.33.0
github.com/go-openapi/spec v0.22.9
github.com/go-openapi/strfmt v0.27.0
github.com/go-openapi/swag/cmdutils v0.27.3
diff --git a/go.sum b/go.sum
index 7525a861..d1616796 100644
--- a/go.sum
+++ b/go.sum
@@ -32,8 +32,8 @@ github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkr
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI=
github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8=
-github.com/go-openapi/runtime v0.32.7-0.20260724174133-f44e6731289f h1:NITRGLRjdL0p1Qcgj7wmPYjQrsrm1egITXS3C9a7ie8=
-github.com/go-openapi/runtime v0.32.7-0.20260724174133-f44e6731289f/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw=
+github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU=
+github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw=
github.com/go-openapi/runtime/server-middleware v0.32.6 h1:IGTYzybyFrUeSqQEwwO1y/9KnOk4QsabFNtAQtIHxDE=
github.com/go-openapi/runtime/server-middleware v0.32.6/go.mod h1:OQHTBqMGquJShXhPYQ62yAqDMtC1rYpsEwldNWjYKhA=
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=