[BUG][CSHARP][GENERICHOST] - Array of string/binary for multi-file uploads generates invalid code
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
- Create the OpenAPI spec file shown above
- Run the generator with the generichost library option
- 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
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
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
@sdukehart-omnesoft @gibiw Can you test the change?
Unfortunately, I have not had the opportunity or ability to either - I have been moved to another project.
@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;
}
}
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.
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
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:
- The code of the api client for
/uploadtest/listand/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) - 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 usesMultipartContentfor the request body. The default constructor ofMultipartContentsets the content type tomultipart/mixed. The api expectsmultipart/form-data. Another cause could be that the code always executesmultipartContentLocalVar.Add(new FormUrlEncodedContent(formParameterLocalVars))(formParameterLocalVarsis empty), but aFormUrlEncodedContentisn'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
}