openapi-generator icon indicating copy to clipboard operation
openapi-generator copied to clipboard

[BUG][CSHARP][GENERICHOST] - Array of string/binary for multi-file uploads generates invalid code

Open sdukehart-omnesoft opened this issue 6 months ago • 1 comments

Bug Report Checklist

  • [x] Have you provided a full/minimal spec to reproduce the issue?
  • [x] Have you validated the input using an OpenAPI validator?
  • [ ] Have you tested with the latest master to confirm the issue still exists?
  • [x] Have you searched for related issues/PRs?
  • [x] What's the actual output vs expected output?
  • [ ] [Optional] Sponsorship to speed up the bug fix or feature request (example)

Description

When generating a C# client with the generichost library for an endpoint that accepts multiple file uploads (array of binary strings), the generated code is invalid. The generator creates code that attempts to add the entire collection as a single StreamContent instead of iterating through each file and adding them individually to the multipart content.

openapi-generator version

7.13.0

OpenAPI declaration file content or url

openapi: 3.0.0
info:
  title: File Upload API
  version: 1.0.0
  description: A minimal API with file upload functionality
servers:
  - url: http://localhost:8080
    description: Development server
paths:
  /upload:
    post:
      summary: Upload multiple files
      operationId: uploadFiles
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                files:
                  type: array
                  items:
                    type: string
                    format: binary
              required:
                - files
      responses:
        '200':
          description: Files uploaded successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  fileCount:
                    type: integer

Generation Details

Using Docker:
  docker run --rm -v "${PWD}:/app" openapitools/openapi-generator-cli:v7.13.0 generate \
    -i /app/openapi.yaml \
    -g csharp \
    -o /app/generated \
    --additional-properties=targetFramework=net9.0,packageName=FileUploadClient,library=generichost
Or with config file:
  {
    "generatorName": "csharp",
    "outputDir": "./generated",
    "inputSpec": "./openapi.yaml",
    "additionalProperties": {
      "targetFramework": "net9.0",
      "packageName": "FileUploadClient",
      "library": "generichost",
      "nullableReferenceTypes": true,
      "useCollection": true,
      "useDateTimeOffset": true,
      "netCoreProjectFile": true
    }
  }

Steps to reproduce

  1. Create the OpenAPI spec file shown above
  2. Run the generator with the generichost library option
  3. Examine the generated DefaultApi.cs file around line 255

Related issues/PRs

Similar issues with file upload handling:

  • #10912 (RestSharp file upload)
  • #14589 (C# file upload parameters)

Suggest a fix

The issue is in the generated UploadFilesAsync method. The current code generates:

  multipartContentLocalVar.Add(new StreamContent(files));

But it should generate:

  foreach (var file in files)
  {
      var streamContent = new StreamContent(file);
      streamContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
      {
          Name = "\"files\"",
          FileName = "\"file\""
      };
      multipartContentLocalVar.Add(streamContent);
  }

The template that needs modification is likely in modules/openapi-generator/src/main/resources/csharp/libraries/generichost/ - specifically the api template that handles multipart form data with array parameters.

Report generated with the help of Claude

sdukehart-omnesoft avatar Jun 04 '25 19:06 sdukehart-omnesoft

There seem to be similar issues with a single file as well. It looks like the logic within https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/resources/csharp/libraries/generichost/api.mustache#L469-L511:

  • Doesn't allow for providing an explicit name for the upload file in the form data
  • Doesn't deal well with multiple files

sdukehart-omnesoft avatar Jun 05 '25 13:06 sdukehart-omnesoft

This is a code path I don't use, so I need your help to validate this change works. I'm sure its not quite right. https://github.com/OpenAPITools/openapi-generator/pull/21449

devhl-labs avatar Jun 22 '25 20:06 devhl-labs

@sdukehart-omnesoft @gibiw Can you test the change?

devhl-labs avatar Jul 17 '25 01:07 devhl-labs

Unfortunately, I have not had the opportunity or ability to either - I have been moved to another project.

sdukehart-omnesoft avatar Jul 21 '25 11:07 sdukehart-omnesoft

@devhl-labs This didn’t work for me. I have the following specification:

post:
  operationId: upload-attachment
  tags: [ attachments ]
  summary: Upload attachment
  parameters:
    - $ref: '../parameters/Code.yaml'
  requestBody:
    content:
      multipart/form-data:
        schema:
          type: object
          properties:
            file:
              type: array
              items:
                type: string
                format: binary
  responses:
    200:
      description: An attachments.
      content:
        application/json:
          schema:
            $ref: '../schemas/responses/AttachmentUploadsResponse.yaml'
    400:
      description: Bad Request.
    401:
      description: Unauthorized.
    403:
      description: Forbidden.
    404:
      description: Not Found.
    413:
      description: Payload Too Large.
    429:
      description: Too Many Requests.

I’m using the following code:

public async Task<IUploadAttachmentApiResponse> UploadAttachmentAsync(string code, Option<List<(System.IO.Stream Stream, string FileName)>> file = default, System.Threading.CancellationToken cancellationToken = default)
        {
            UriBuilder uriBuilderLocalVar = new UriBuilder();

            try
            {
                ValidateUploadAttachment(code, file);

                FormatUploadAttachment(ref code, file);

                using (HttpRequestMessage httpRequestMessageLocalVar = new HttpRequestMessage())
                {
                    uriBuilderLocalVar.Host = HttpClient.BaseAddress!.Host;
                    uriBuilderLocalVar.Port = HttpClient.BaseAddress.Port;
                    uriBuilderLocalVar.Scheme = HttpClient.BaseAddress.Scheme;
                    uriBuilderLocalVar.Path = HttpClient.BaseAddress.AbsolutePath == "/"
                        ? "/attachment/{code}"
                        : string.Concat(HttpClient.BaseAddress.AbsolutePath, "/attachment/{code}");
                    uriBuilderLocalVar.Path = uriBuilderLocalVar.Path.Replace("%7Bcode%7D", Uri.EscapeDataString(code.ToString()));

                    MultipartFormDataContent multipartContentLocalVar = new MultipartFormDataContent();

                    httpRequestMessageLocalVar.Content = multipartContentLocalVar;

                    if (file.IsSet && file.Value != null && file.Value.Count > 0)
                    {
                        // Add each file with the correct field name "file"
                        foreach (var (fileStream, fileName) in file.Value)
                        {
                            // Ensure stream is at the beginning
                            if (fileStream.CanSeek)
                            {
                                fileStream.Position = 0;
                            }
                                                        
                            multipartContentLocalVar.Add(new StreamContent(fileStream), "file", fileName);
                        }
                    }

                    List<TokenBase> tokenBaseLocalVars = new List<TokenBase>();
                    ApiKeyToken apiKeyTokenLocalVar1 = (ApiKeyToken) await ApiKeyProvider.GetAsync("Token", cancellationToken).ConfigureAwait(false);
                    tokenBaseLocalVars.Add(apiKeyTokenLocalVar1);
                    apiKeyTokenLocalVar1.UseInHeader(httpRequestMessageLocalVar);

                    httpRequestMessageLocalVar.RequestUri = uriBuilderLocalVar.Uri;

                    // For multipart/form-data, Content-Type is automatically set with boundary
                    // No need to manually set it

                    string[] acceptLocalVars = new string[] {
                        "application/json"
                    };

                    string? acceptLocalVar = ClientUtils.SelectHeaderAccept(acceptLocalVars);

                    if (acceptLocalVar != null)
                        httpRequestMessageLocalVar.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(acceptLocalVar));
                    httpRequestMessageLocalVar.Method = new HttpMethod("POST");

                    DateTime requestedAtLocalVar = DateTime.UtcNow;

                    using (HttpResponseMessage httpResponseMessageLocalVar = await HttpClient.SendAsync(httpRequestMessageLocalVar, cancellationToken).ConfigureAwait(false))
                    {
                        string responseContentLocalVar = await httpResponseMessageLocalVar.Content.ReadAsStringAsync().ConfigureAwait(false);

                        ILogger<UploadAttachmentApiResponse> apiResponseLoggerLocalVar = LoggerFactory.CreateLogger<UploadAttachmentApiResponse>();

                        UploadAttachmentApiResponse apiResponseLocalVar = new UploadAttachmentApiResponse(apiResponseLoggerLocalVar, httpRequestMessageLocalVar, httpResponseMessageLocalVar, responseContentLocalVar, "/attachment/{code}", requestedAtLocalVar, _jsonSerializerOptions);

                        AfterUploadAttachmentDefaultImplementation(apiResponseLocalVar, code, file);

                        Events.ExecuteOnUploadAttachment(apiResponseLocalVar);

                        if (apiResponseLocalVar.StatusCode == (HttpStatusCode) 429)
                            foreach(TokenBase tokenBaseLocalVar in tokenBaseLocalVars)
                                tokenBaseLocalVar.BeginRateLimit();

                        return apiResponseLocalVar;
                    }
                }
            }
            catch(Exception e)
            {
                OnErrorUploadAttachmentDefaultImplementation(e, "/attachment/{code}", uriBuilderLocalVar.Path, code, file);
                Events.ExecuteOnErrorUploadAttachment(e);
                throw;
            }
        }

gibiw avatar Jul 22 '25 19:07 gibiw

The following spec works fine from a client like Scalar or SwaggerUI, but the generated c# code is wrong and the server throws an error.

"/images/{id}/upload": {
      "put": {
        "tags": [
          "Images"
        ],
        "operationId": "UploadImage",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "required": [
                  "file"
                ],
                "type": "object",
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              },
              "encoding": {
                "file": {
                  "style": "form"
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Image uploaded successfully."
          },
          "403": {
            "description": "Readonly configuration",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "Not Found",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        },
        "security": [
          {
            "Bearer": [ ]
          }
        ]
      }
    }

The error:

{"errors":{"":["Failed to read the request form. Missing content-type boundary."]},"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-4b78a16c299eaea88608b67b1228686b-a0584e743d41b00f-01"}

I suspect this is the offending part:

MultipartContent multipartContentLocalVar = new MultipartContent();

httpRequestMessageLocalVar.Content = multipartContentLocalVar;

List<KeyValuePair<string?, string?>> formParameterLocalVars = new List<KeyValuePair<string?, string?>>();

multipartContentLocalVar.Add(new FormUrlEncodedContent(formParameterLocalVars));                    multipartContentLocalVar.Add(new StreamContent(file));

List<TokenBase> tokenBaseLocalVars = new List<TokenBase>();
httpRequestMessageLocalVar.RequestUri = uriBuilderLocalVar.Uri;

BearerToken bearerTokenLocalVar1 = (BearerToken) await BearerTokenProvider.GetAsync(cancellation: cancellationToken).ConfigureAwait(false);

tokenBaseLocalVars.Add(bearerTokenLocalVar1);

bearerTokenLocalVar1.UseInHeader(httpRequestMessageLocalVar, "");

string[] contentTypes = new string[] {
    "multipart/form-data"
};

string? contentTypeLocalVar = ClientUtils.SelectHeaderContentType(contentTypes);

if (contentTypeLocalVar != null && httpRequestMessageLocalVar.Content != null)
    httpRequestMessageLocalVar.Content.Headers.ContentType = new MediaTypeHeaderValue(contentTypeLocalVar); // <------- this overrides the content-type from the default generated one, that already has the boundary set

This code correctly sets the content type header:

MultipartContent multipartContentLocalVar = new MultipartContent();
httpRequestMessageLocalVar.Content = multipartContentLocalVar;

With the boundary in it, so the override of the content type is what breaks it. In my case the ctor should be new MultipartFormDataContent() subtype, because the default is mixed.

For my case the following code would send in the correct format:

MultipartFormDataContent multipartContentLocalVar = new MultipartFormDataContent();

List<KeyValuePair<string?, string?>> formParameterLocalVars = new List<KeyValuePair<string?, string?>>();

multipartContentLocalVar.Add(new FormUrlEncodedContent(formParameterLocalVars));                    
var streamContent = new StreamContent(file);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
multipartContentLocalVar.Add(streamContent, "file", "somename");
//                                              ^ these two are absolutely needed, both the name and the filename, otherwise the server will not parse the form-data

There is still the issue of the filename not being available from the Stream parameter syntax, but I can live with that.

alexaka1 avatar Aug 07 '25 11:08 alexaka1

How do I know it is mixed or something else? I don't see mixed in CodegenParameters

edit - oh i see, it comes from multipart/mixed https://swagger.io/specification/#:~:text=on%20percent%2Ddecoding.-,Encoding%20multipart%20Media%20Types,-It%20is%20common but your spec has multipart/form-data not multipart/mixed

devhl-labs avatar Aug 23 '25 19:08 devhl-labs

I also have problems sending/uploading file(s) with the generated api client using the generichost library and OpenApiGenerator v7.16.0. The generated code for the following spec seems to have multiple problems:

  1. The code of the api client for /uploadtest/list and /uploadtest/list/{myGuid} doesn't even compile, because it tries to add a list of streams as a single StreamContent to the request (see the first post)
  2. The request for the single file upload (/uploadtest/single-w-params) fails, because the request has the wrong form. This might be, because the code uses MultipartContent for the request body. The default constructor of MultipartContent sets the content type to multipart/mixed. The api expects multipart/form-data. Another cause could be that the code always executes multipartContentLocalVar.Add(new FormUrlEncodedContent(formParameterLocalVars)) (formParameterLocalVars is empty), but a FormUrlEncodedContent isn't in the spec.

Example Spec:

{
  "openapi": "3.0.1",
  "info": {
    "title": "Example API",
    "description": "Example",
    "version": "v1"
  },
  "paths": {
    "/uploadtest/list": {
      "post": {
        "tags": [
          "UploadTest"
        ],
        "operationId": "UploadList",
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "required": [
                  "receivedFormFiles"
                ],
                "type": "object",
                "properties": {
                  "receivedFormFiles": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "binary"
                    }
                  }
                }
              },
              "encoding": {
                "receivedFormFiles": {
                  "style": "form"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                }
              }
            }
          }
        }
      }
    },
    "/uploadtest/list/{myGuid}": {
      "post": {
        "tags": [
          "UploadTest"
        ],
        "operationId": "UploadListWithGuid",
        "parameters": [
          {
            "name": "myGuid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "required": [
                  "receivedFormFiles"
                ],
                "type": "object",
                "properties": {
                  "receivedFormFiles": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "binary"
                    }
                  }
                }
              },
              "encoding": {
                "receivedFormFiles": {
                  "style": "form"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                }
              }
            }
          }
        }
      }
    },
    "/uploadtest/single-w-params": {
      "post": {
        "tags": [
          "UploadTest"
        ],
        "operationId": "UploadSingleWithParams",
        "parameters": [
          {
            "name": "myGuid",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "myParameter",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "required": [
                  "content"
                ],
                "type": "object",
                "properties": {
                  "content": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              },
              "encoding": {
                "content": {
                  "style": "form"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "UploadResponse": {
        "type": "object",
        "properties": {
          "isSuccess": {
            "type": "boolean"
          }
        },
        "additionalProperties": false
      }
    }
  }
}

For the generation I used

docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli:v7.16.0 generate \
-i /local/example-spec.json \
-g csharp \
-o /local/generated \
-c /local/config.json \
--additional-properties=targetFramework=${net9.0}

with these configs:

{
	"packageName": "Example.Generated",
	"library": "generichost",
	"apiName": "ExampleApi",
	"nullableReferenceTypes": false,
	"optionalMethodArgument": true,
	"useSourceGeneration": false
}

CakeC24 avatar Oct 28 '25 18:10 CakeC24