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=