From c9d12cfd1d68ff27cf25a620eff8461947b0ac3c Mon Sep 17 00:00:00 2001 From: Dmitrii Korolev Date: Wed, 5 Aug 2026 10:23:30 +0200 Subject: [PATCH 1/2] Support multiple response types per status code Group SupportedResponseTypes by status code in SwaggerGenerator and merge the schemas for each content type into an `anyOf` when more than one distinct type is reported. Previously a second response type for the same status code caused OpenApiResponses.Add to throw an ArgumentException (#2413, #2743). This pre-fixes Swashbuckle for .NET 11 C# unions, where the framework can surface multiple response types (or union cases) for a single status code. The single-response path is unchanged, so existing documents are byte for byte identical. Adds unit tests plus a MultipleResponseTypes integration fixture that injects a second response type via a custom IApiDescriptionProvider (the API explorer collapses duplicates on net8-10), with snapshots verifying the generated document and that Kiota and NSwag generate and compile valid clients from the anyOf schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7722de8a-3ea5-40fd-999a-4f647ce8f4f7 --- .../SwaggerGenerator/SwaggerGenerator.cs | 69 +++- .../SwaggerIntegrationTests.cs | 1 + .../VerifyTests.cs | 1 + ....Program_swaggerRequestUri=v1.verified.txt | 82 ++++ ....Program_swaggerRequestUri=v1.verified.txt | 82 ++++ ....Program_swaggerRequestUri=v1.verified.txt | 82 ++++ .../NSwagOpenApiClient.verified.cs | 381 ++++++++++++++++++ .../Animals/AnimalsRequestBuilder.verified.cs | 61 +++ .../AnimalsItemRequestBuilder.verified.cs | 216 ++++++++++ .../KiotaOpenApiClient.verified.cs | 43 ++ .../Models/Cat.verified.cs | 59 +++ .../Models/Dog.verified.cs | 59 +++ .../SwaggerGenerator/SwaggerGeneratorTests.cs | 129 ++++++ .../MultipleResponseTypes.csproj | 16 + .../WebSites/MultipleResponseTypes/Program.cs | 85 ++++ 15 files changed, 1350 insertions(+), 16 deletions(-) create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/10_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/8_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/9_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_653e9faaca680294/NSwagOpenApiClient.verified.cs create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Animals/AnimalsRequestBuilder.verified.cs create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Animals/Item/AnimalsItemRequestBuilder.verified.cs create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/KiotaOpenApiClient.verified.cs create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Models/Cat.verified.cs create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Models/Dog.verified.cs create mode 100644 test/WebSites/MultipleResponseTypes/MultipleResponseTypes.csproj create mode 100644 test/WebSites/MultipleResponseTypes/Program.cs diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs index 023a1d66f8..c467ea4151 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs @@ -1003,10 +1003,17 @@ private OpenApiResponses GenerateResponses( .OrderBy((responseType) => responseType.IsDefaultResponse() ? 1 : 0); var responses = new OpenApiResponses(); - foreach (var responseType in supportedResponseTypes) + + // Multiple response types can be reported for the same status code, e.g. several + // [ProducesResponseType] attributes with the same code, or a C# union return type + // (.NET 11+) which the framework may surface as one entry per case. Group by status + // code and merge them into a single response instead of calling responses.Add twice + // for the same key, which would throw an ArgumentException. + foreach (var responsesForStatusCode in supportedResponseTypes.GroupBy( + (responseType) => responseType.IsDefaultResponse() ? "default" : responseType.StatusCode.ToString())) { - var statusCode = responseType.IsDefaultResponse() ? "default" : responseType.StatusCode.ToString(); - responses.Add(statusCode, GenerateResponse(apiDescription, schemaRepository, statusCode, responseType)); + var statusCode = responsesForStatusCode.Key; + responses.Add(statusCode, GenerateResponse(apiDescription, schemaRepository, statusCode, [.. responsesForStatusCode])); } return responses; } @@ -1015,12 +1022,14 @@ private OpenApiResponse GenerateResponse( ApiDescription apiDescription, SchemaRepository schemaRepository, string statusCode, - ApiResponseType apiResponseType) + IReadOnlyList apiResponseTypes) { string description = null; #if NET10_0_OR_GREATER - description = apiResponseType.Description; + description = apiResponseTypes + .Select((responseType) => responseType.Description) + .FirstOrDefault((value) => !string.IsNullOrEmpty(value)); #endif if (string.IsNullOrEmpty(description)) @@ -1030,18 +1039,54 @@ private OpenApiResponse GenerateResponse( .Value; } - var responseContentTypes = InferResponseContentTypes(apiDescription, apiResponseType); + // Collect the schemas contributed by each response type, grouped by content type and + // preserving the order in which they are encountered. A model type is only emitted once + // per content type so identical response types do not produce duplicate schemas. + var contentTypes = new List(); + var schemasByContentType = new Dictionary>(); + var seenModelTypesByContentType = new Dictionary>(); + + foreach (var apiResponseType in apiResponseTypes) + { + var modelType = apiResponseType.ModelMetadata?.ModelType ?? apiResponseType.Type; + + foreach (var contentType in InferResponseContentTypes(apiDescription, apiResponseType)) + { + if (!schemasByContentType.TryGetValue(contentType, out var schemas)) + { + contentTypes.Add(contentType); + schemas = []; + schemasByContentType[contentType] = schemas; + seenModelTypesByContentType[contentType] = []; + } + + if (seenModelTypesByContentType[contentType].Add(modelType ?? typeof(void))) + { + schemas.Add(GenerateSchema(modelType, schemaRepository)); + } + } + } return new OpenApiResponse { Description = description, - Content = responseContentTypes.ToDictionary( + Content = contentTypes.ToDictionary( (contentType) => contentType, - (contentType) => CreateResponseMediaType(apiResponseType.ModelMetadata?.ModelType ?? apiResponseType.Type, schemaRepository) + (contentType) => new OpenApiMediaType { Schema = CombineResponseSchemas(schemasByContentType[contentType]) } ) }; } + // When more than one distinct schema is reported for the same status code and content type + // (e.g. multiple response types or the cases of a C# union), combine them into an anyOf + // schema. This mirrors how System.Text.Json's JsonSchemaExporter emits union types. + private static IOpenApiSchema CombineResponseSchemas(List schemas) + { + return schemas.Count == 1 + ? schemas[0] + : new OpenApiSchema { AnyOf = [.. schemas] }; + } + private static IEnumerable InferResponseContentTypes(ApiDescription apiDescription, ApiResponseType apiResponseType) { // If there's no associated model type, return an empty list (i.e. no content) @@ -1067,14 +1112,6 @@ private static IEnumerable InferResponseContentTypes(ApiDescription apiD .Distinct()]; } - private OpenApiMediaType CreateResponseMediaType(Type modelType, SchemaRepository schemaRepository) - { - return new OpenApiMediaType - { - Schema = GenerateSchema(modelType, schemaRepository) - }; - } - private static bool IsFromFormAttributeUsedWithIFormFile(ApiParameterDescription apiParameter) { var parameterInfo = apiParameter.ParameterInfo(); diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerIntegrationTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerIntegrationTests.cs index de015fb703..f5f658b79a 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerIntegrationTests.cs +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerIntegrationTests.cs @@ -137,6 +137,7 @@ public async Task SwaggerMiddleware_CanBeConfiguredMultipleTimes( [Theory] [InlineData(typeof(MinimalApp.Program), "/swagger/v1/swagger.json")] [InlineData(typeof(MinimalAppWithNullableEnums.Program), "/swagger/v1/swagger.json")] + [InlineData(typeof(MultipleResponseTypes.Program), "/swagger/v1/swagger.json")] [InlineData(typeof(MvcWithNullable.Program), "/swagger/v1/swagger.json")] [InlineData(typeof(TodoApp.Program), "/swagger/v1/swagger.json")] [InlineData(typeof(TopLevelSwaggerDoc.Program), "/swagger/v1.json")] diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/VerifyTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/VerifyTests.cs index b2d1042e8e..cfdeca0f43 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/VerifyTests.cs +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/VerifyTests.cs @@ -60,6 +60,7 @@ await Verify(swagger) [Theory] [InlineData(typeof(MinimalApp.Program), "/swagger/v1/swagger.json")] [InlineData(typeof(MinimalAppWithNullableEnums.Program), "/swagger/v1/swagger.json")] + [InlineData(typeof(MultipleResponseTypes.Program), "/swagger/v1/swagger.json")] [InlineData(typeof(MvcWithNullable.Program), "/swagger/v1/swagger.json")] [InlineData(typeof(TodoApp.Program), "/swagger/v1/swagger.json")] [InlineData(typeof(TopLevelSwaggerDoc.Program), "/swagger/v1.json")] diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/10_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/10_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt new file mode 100644 index 0000000000..267837597a --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/10_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt @@ -0,0 +1,82 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "MultipleResponseTypes", + "version": "v1" + }, + "paths": { + "/animals/{id}": { + "get": { + "tags": [ + "MultipleResponseTypes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Cat": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "livesLeft": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "Dog": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "goodBoy": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + }, + "tags": [ + { + "name": "MultipleResponseTypes" + } + ] +} \ No newline at end of file diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/8_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/8_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt new file mode 100644 index 0000000000..267837597a --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/8_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt @@ -0,0 +1,82 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "MultipleResponseTypes", + "version": "v1" + }, + "paths": { + "/animals/{id}": { + "get": { + "tags": [ + "MultipleResponseTypes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Cat": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "livesLeft": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "Dog": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "goodBoy": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + }, + "tags": [ + { + "name": "MultipleResponseTypes" + } + ] +} \ No newline at end of file diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/9_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/9_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt new file mode 100644 index 0000000000..267837597a --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/9_0/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MultipleResponseTypes.Program_swaggerRequestUri=v1.verified.txt @@ -0,0 +1,82 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "MultipleResponseTypes", + "version": "v1" + }, + "paths": { + "/animals/{id}": { + "get": { + "tags": [ + "MultipleResponseTypes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Cat": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "livesLeft": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "Dog": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "goodBoy": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + }, + "tags": [ + { + "name": "MultipleResponseTypes" + } + ] +} \ No newline at end of file diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_653e9faaca680294/NSwagOpenApiClient.verified.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_653e9faaca680294/NSwagOpenApiClient.verified.cs new file mode 100644 index 0000000000..d48c5b42ca --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_653e9faaca680294/NSwagOpenApiClient.verified.cs @@ -0,0 +1,381 @@ +//---------------------- +// +// Generated using the NSwag toolchain +// +//---------------------- + +#pragma warning disable 108 // Disable "CS0108 '{derivedDto}.ToJson()' hides inherited member '{dtoBase}.ToJson()'. Use the new keyword if hiding was intended." +#pragma warning disable 114 // Disable "CS0114 '{derivedDto}.RaisePropertyChanged(String)' hides inherited member 'dtoBase.RaisePropertyChanged(String)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword." +#pragma warning disable 472 // Disable "CS0472 The result of the expression is always 'false' since a value of type 'Int32' is never equal to 'null' of type 'Int32?' +#pragma warning disable 612 // Disable "CS0612 '...' is obsolete" +#pragma warning disable 649 // Disable "CS0649 Field is never assigned to, and will always have its default value null" +#pragma warning disable 1573 // Disable "CS1573 Parameter '...' has no matching param tag in the XML comment for ... +#pragma warning disable 1591 // Disable "CS1591 Missing XML comment for publicly visible type or member ..." +#pragma warning disable 8073 // Disable "CS8073 The result of the expression is always 'false' since a value of type 'T' is never equal to 'null' of type 'T?'" +#pragma warning disable 3016 // Disable "CS3016 Arrays as attribute arguments is not CLS-compliant" +#pragma warning disable 8600 // Disable "CS8600 Converting null literal or possible null value to non-nullable type" +#pragma warning disable 8602 // Disable "CS8602 Dereference of a possibly null reference" +#pragma warning disable 8603 // Disable "CS8603 Possible null reference return" +#pragma warning disable 8604 // Disable "CS8604 Possible null reference argument for parameter" +#pragma warning disable 8625 // Disable "CS8625 Cannot convert null literal to non-nullable reference type" +#pragma warning disable 8765 // Disable "CS8765 Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes)." + +namespace Swashbuckle.AspNetCore.IntegrationTests.NSwagTests +{ + using System = global::System; + + [GeneratedCode] + public partial class NSwagOpenApiClient + { + #pragma warning disable 8618 + private string _baseUrl; + #pragma warning restore 8618 + + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private Newtonsoft.Json.JsonSerializerSettings _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public NSwagOpenApiClient(string baseUrl, System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + BaseUrl = baseUrl; + _httpClient = httpClient; + Initialize(); + } + + private static Newtonsoft.Json.JsonSerializerSettings CreateSerializerSettings() + { + var settings = new Newtonsoft.Json.JsonSerializerSettings(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + public string BaseUrl + { + get { return _baseUrl; } + set + { + _baseUrl = value; + if (!string.IsNullOrEmpty(_baseUrl) && !_baseUrl.EndsWith("/")) + _baseUrl += '/'; + } + } + + protected Newtonsoft.Json.JsonSerializerSettings JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(Newtonsoft.Json.JsonSerializerSettings settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task AnimalsAsync(int id) + { + return AnimalsAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task AnimalsAsync(int id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "animals/{id}" + urlBuilder_.Append("animals/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = Newtonsoft.Json.JsonConvert.DeserializeObject(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (Newtonsoft.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + using (var streamReader = new System.IO.StreamReader(responseStream)) + using (var jsonTextReader = new Newtonsoft.Json.JsonTextReader(streamReader)) + { + var serializer = Newtonsoft.Json.JsonSerializer.Create(JsonSerializerSettings); + var typedBody = serializer.Deserialize(jsonTextReader); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (Newtonsoft.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [GeneratedCode] + public partial class Cat + { + + [Newtonsoft.Json.JsonProperty("name", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("livesLeft", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public int LivesLeft { get; set; } + + } + + [GeneratedCode] + public partial class Dog + { + + [Newtonsoft.Json.JsonProperty("name", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public string Name { get; set; } + + [Newtonsoft.Json.JsonProperty("goodBoy", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public bool GoodBoy { get; set; } + + } + + [GeneratedCode] + public partial class Response + { + + private System.Collections.Generic.IDictionary _additionalProperties; + + [Newtonsoft.Json.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + + + [GeneratedCode] + public partial class ApiException : System.Exception + { + public int StatusCode { get; private set; } + + public string Response { get; private set; } + + public System.Collections.Generic.IReadOnlyDictionary> Headers { get; private set; } + + public ApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Exception innerException) + : base(message + "\n\nStatus: " + statusCode + "\nResponse: \n" + ((response == null) ? "(null)" : response.Substring(0, response.Length >= 512 ? 512 : response.Length)), innerException) + { + StatusCode = statusCode; + Response = response; + Headers = headers; + } + + public override string ToString() + { + return string.Format("HTTP Response: \n\n{0}\n\n{1}", Response, base.ToString()); + } + } + + [GeneratedCode] + public partial class ApiException : ApiException + { + public TResult Result { get; private set; } + + public ApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, TResult result, System.Exception innerException) + : base(message, statusCode, response, headers, innerException) + { + Result = result; + } + } + +} + +#pragma warning restore 108 +#pragma warning restore 114 +#pragma warning restore 472 +#pragma warning restore 612 +#pragma warning restore 649 +#pragma warning restore 1573 +#pragma warning restore 1591 +#pragma warning restore 8073 +#pragma warning restore 3016 +#pragma warning restore 8600 +#pragma warning restore 8602 +#pragma warning restore 8603 +#pragma warning restore 8604 +#pragma warning restore 8625 +#pragma warning restore 8765 \ No newline at end of file diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Animals/AnimalsRequestBuilder.verified.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Animals/AnimalsRequestBuilder.verified.cs new file mode 100644 index 0000000000..fcaddfaae4 --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Animals/AnimalsRequestBuilder.verified.cs @@ -0,0 +1,61 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions; +using Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +namespace Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals +{ + /// + /// Builds and executes requests for operations under \animals + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AnimalsRequestBuilder : BaseRequestBuilder + { + /// Gets an item from the Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.animals.item collection + /// Unique identifier of the item + /// A + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder this[int position] + { + get + { + var urlTplParams = new Dictionary(PathParameters); + urlTplParams.Add("id", position); + return new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder(urlTplParams, RequestAdapter); + } + } + /// Gets an item from the Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.animals.item collection + /// Unique identifier of the item + /// A + [Obsolete("This indexer is deprecated and will be removed in the next major version. Use the one with the typed parameter instead.")] + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder this[string position] + { + get + { + var urlTplParams = new Dictionary(PathParameters); + if (!string.IsNullOrWhiteSpace(position)) urlTplParams.Add("id", position); + return new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder(urlTplParams, RequestAdapter); + } + } + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public AnimalsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/animals", pathParameters) + { + } + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public AnimalsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/animals", rawUrl) + { + } + } +} +#pragma warning restore CS0618 diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Animals/Item/AnimalsItemRequestBuilder.verified.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Animals/Item/AnimalsItemRequestBuilder.verified.cs new file mode 100644 index 0000000000..141348fc71 --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Animals/Item/AnimalsItemRequestBuilder.verified.cs @@ -0,0 +1,216 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item +{ + /// + /// Builds and executes requests for operations under \animals\{id} + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AnimalsItemRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public AnimalsItemRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/animals/{id}", pathParameters) + { + } + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public AnimalsItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/animals/{id}", rawUrl) + { + } + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public async Task GetAsAnimalsGetResponseAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { +#nullable restore +#else + public async Task GetAsAnimalsGetResponseAsync(Action> requestConfiguration = default, CancellationToken cancellationToken = default) + { +#endif + var requestInfo = ToGetRequestInformation(requestConfiguration); + return await RequestAdapter.SendAsync(requestInfo, global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder.AnimalsGetResponse.CreateFromDiscriminatorValue, default, cancellationToken).ConfigureAwait(false); + } + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + [Obsolete("This method is obsolete. Use GetAsAnimalsGetResponseAsync instead.")] +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { +#nullable restore +#else + public async Task GetAsync(Action> requestConfiguration = default, CancellationToken cancellationToken = default) + { +#endif + var requestInfo = ToGetRequestInformation(requestConfiguration); + return await RequestAdapter.SendAsync(requestInfo, global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder.AnimalsResponse.CreateFromDiscriminatorValue, default, cancellationToken).ConfigureAwait(false); + } + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { +#nullable restore +#else + public RequestInformation ToGetRequestInformation(Action> requestConfiguration = default) + { +#endif + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/json"); + return requestInfo; + } + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder WithUrl(string rawUrl) + { + return new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder(rawUrl, RequestAdapter); + } + /// + /// Composed type wrapper for classes , + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AnimalsGetResponse : IComposedTypeWrapper, IParsable + { + /// Composed type representation for type +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Cat? Cat { get; set; } +#nullable restore +#else + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Cat Cat { get; set; } +#endif + /// Composed type representation for type +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Dog? Dog { get; set; } +#nullable restore +#else + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Dog Dog { get; set; } +#endif + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder.AnimalsGetResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode)); + var result = new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder.AnimalsGetResponse(); + result.Cat = new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Cat(); + result.Dog = new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Dog(); + return result; + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + if(Cat != null || Dog != null) + { + return ParseNodeHelper.MergeDeserializersForIntersectionWrapper(Cat, Dog); + } + return new Dictionary>(); + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue(null, Cat, Dog); + } + } + /// + /// Configuration for the request such as headers, query parameters, and middleware options. + /// + [Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")] + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AnimalsItemRequestBuilderGetRequestConfiguration : RequestConfiguration + { + } + /// + /// Composed type wrapper for classes , + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AnimalsResponse : IComposedTypeWrapper, IParsable + { + /// Composed type representation for type +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Cat? Cat { get; set; } +#nullable restore +#else + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Cat Cat { get; set; } +#endif + /// Composed type representation for type +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Dog? Dog { get; set; } +#nullable restore +#else + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Dog Dog { get; set; } +#endif + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder.AnimalsResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode)); + var result = new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.Item.AnimalsItemRequestBuilder.AnimalsResponse(); + result.Cat = new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Cat(); + result.Dog = new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Dog(); + return result; + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + if(Cat != null || Dog != null) + { + return ParseNodeHelper.MergeDeserializersForIntersectionWrapper(Cat, Dog); + } + return new Dictionary>(); + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue(null, Cat, Dog); + } + } + } +} +#pragma warning restore CS0618 diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/KiotaOpenApiClient.verified.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/KiotaOpenApiClient.verified.cs new file mode 100644 index 0000000000..e6ae0ff93f --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/KiotaOpenApiClient.verified.cs @@ -0,0 +1,43 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Serialization.Form; +using Microsoft.Kiota.Serialization.Json; +using Microsoft.Kiota.Serialization.Multipart; +using Microsoft.Kiota.Serialization.Text; +using Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +namespace Swashbuckle.AspNetCore.IntegrationTests.KiotaTests +{ + /// + /// The main entry point of the SDK, exposes the configuration and the fluent API. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class KiotaOpenApiClient : BaseRequestBuilder + { + /// The animals property + public global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.AnimalsRequestBuilder Animals + { + get => new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Animals.AnimalsRequestBuilder(PathParameters, RequestAdapter); + } + /// + /// Instantiates a new and sets the default values. + /// + /// The request adapter to use to execute the requests. + public KiotaOpenApiClient(IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}", new Dictionary()) + { + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultDeserializer(); + ApiClientBuilder.RegisterDefaultDeserializer(); + ApiClientBuilder.RegisterDefaultDeserializer(); + } + } +} +#pragma warning restore CS0618 diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Models/Cat.verified.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Models/Cat.verified.cs new file mode 100644 index 0000000000..731a724f59 --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Models/Cat.verified.cs @@ -0,0 +1,59 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class Cat : IParsable + #pragma warning restore CS1591 + { + /// The livesLeft property + public int? LivesLeft { get; set; } + /// The name property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Name { get; set; } +#nullable restore +#else + public string Name { get; set; } +#endif + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Cat CreateFromDiscriminatorValue(IParseNode parseNode) + { + if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode)); + return new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Cat(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "livesLeft", n => { LivesLeft = n.GetIntValue(); } }, + { "name", n => { Name = n.GetStringValue(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer)); + writer.WriteIntValue("livesLeft", LivesLeft); + writer.WriteStringValue("name", Name); + } + } +} +#pragma warning restore CS0618 diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Models/Dog.verified.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Models/Dog.verified.cs new file mode 100644 index 0000000000..2e5eaad5ef --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/code/GeneratesValidClient_9af902faded38cfc/Models/Dog.verified.cs @@ -0,0 +1,59 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class Dog : IParsable + #pragma warning restore CS1591 + { + /// The goodBoy property + public bool? GoodBoy { get; set; } + /// The name property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Name { get; set; } +#nullable restore +#else + public string Name { get; set; } +#endif + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Dog CreateFromDiscriminatorValue(IParseNode parseNode) + { + if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode)); + return new global::Swashbuckle.AspNetCore.IntegrationTests.KiotaTests.Models.Dog(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "goodBoy", n => { GoodBoy = n.GetBoolValue(); } }, + { "name", n => { Name = n.GetStringValue(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer)); + writer.WriteBoolValue("goodBoy", GoodBoy); + writer.WriteStringValue("name", Name); + } + } +} +#pragma warning restore CS0618 diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/SwaggerGeneratorTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/SwaggerGeneratorTests.cs index 69b88d73f5..5892ae2db5 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/SwaggerGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/SwaggerGeneratorTests.cs @@ -1019,6 +1019,135 @@ public void GetSwagger_GeneratesResponses_ForSupportedResponseTypes() Assert.Empty(responseDefault.Content.Keys); } + [Fact] + public void GetSwagger_MergesMultipleResponseTypesForSameStatusCode_IntoAnyOf() + { + var apiDescription = ApiDescriptionFactory.Create( + c => nameof(c.ActionWithReturnValue), + groupName: "v1", + httpMethod: "POST", + relativePath: "resource", + supportedResponseTypes: + [ + new ApiResponseType + { + ApiResponseFormats = [new ApiResponseFormat { MediaType = "application/json" }], + StatusCode = 200, + Type = typeof(TestDto), + }, + new ApiResponseType + { + ApiResponseFormats = [new ApiResponseFormat { MediaType = "application/json" }], + StatusCode = 200, + Type = typeof(int), + }, + ]); + + // Fall back to the explicit Type rather than the action's return type + foreach (var responseType in apiDescription.SupportedResponseTypes) + { + responseType.ModelMetadata = null; + } + + var subject = Subject(apiDescriptions: [apiDescription]); + + var document = subject.GetSwagger("v1"); + + var operation = document.Paths["/resource"].Operations[HttpMethod.Post]; + Assert.Equal(["200"], operation.Responses.Keys); + + var content = Assert.Single(operation.Responses["200"].Content); + Assert.Equal("application/json", content.Key); + + var schema = content.Value.Schema; + Assert.NotNull(schema.AnyOf); + Assert.Equal(2, schema.AnyOf.Count); + Assert.IsType(schema.AnyOf[0]); + Assert.Equal(JsonSchemaTypes.Integer, schema.AnyOf[1].Type); + } + + [Fact] + public void GetSwagger_DeduplicatesResponseSchemas_WhenSameTypeReportedForStatusCode() + { + var apiDescription = ApiDescriptionFactory.Create( + c => nameof(c.ActionWithReturnValue), + groupName: "v1", + httpMethod: "POST", + relativePath: "resource", + supportedResponseTypes: + [ + new ApiResponseType + { + ApiResponseFormats = [new ApiResponseFormat { MediaType = "application/json" }], + StatusCode = 200, + Type = typeof(TestDto), + }, + new ApiResponseType + { + ApiResponseFormats = [new ApiResponseFormat { MediaType = "application/json" }], + StatusCode = 200, + Type = typeof(TestDto), + }, + ]); + + foreach (var responseType in apiDescription.SupportedResponseTypes) + { + responseType.ModelMetadata = null; + } + + var subject = Subject(apiDescriptions: [apiDescription]); + + var document = subject.GetSwagger("v1"); + + var operation = document.Paths["/resource"].Operations[HttpMethod.Post]; + var content = Assert.Single(operation.Responses["200"].Content); + + var schema = content.Value.Schema; + Assert.Null(schema.AnyOf); + Assert.IsType(schema); + } + + [Fact] + public void GetSwagger_IgnoresTypelessResponses_WhenMergingResponseTypesForStatusCode() + { + var apiDescription = ApiDescriptionFactory.Create( + c => nameof(c.ActionWithReturnValue), + groupName: "v1", + httpMethod: "POST", + relativePath: "resource", + supportedResponseTypes: + [ + new ApiResponseType + { + ApiResponseFormats = [new ApiResponseFormat { MediaType = "application/json" }], + StatusCode = 200, + Type = typeof(TestDto), + }, + new ApiResponseType + { + StatusCode = 200, + Type = typeof(void), + }, + ]); + + foreach (var responseType in apiDescription.SupportedResponseTypes) + { + responseType.ModelMetadata = null; + } + + var subject = Subject(apiDescriptions: [apiDescription]); + + var document = subject.GetSwagger("v1"); + + var operation = document.Paths["/resource"].Operations[HttpMethod.Post]; + var content = Assert.Single(operation.Responses["200"].Content); + + // The typeless (void) response contributes no schema, so no anyOf is produced. + var schema = content.Value.Schema; + Assert.Null(schema.AnyOf); + Assert.IsType(schema); + } + [Fact] public void GetSwagger_SetsResponseContentType_WhenActionHasFileResult() { diff --git a/test/WebSites/MultipleResponseTypes/MultipleResponseTypes.csproj b/test/WebSites/MultipleResponseTypes/MultipleResponseTypes.csproj new file mode 100644 index 0000000000..88ba03230f --- /dev/null +++ b/test/WebSites/MultipleResponseTypes/MultipleResponseTypes.csproj @@ -0,0 +1,16 @@ + + + + enable + enable + MultipleResponseTypes + $(DefaultTargetFrameworks) + true + + + + + + + + diff --git a/test/WebSites/MultipleResponseTypes/Program.cs b/test/WebSites/MultipleResponseTypes/Program.cs new file mode 100644 index 0000000000..78ad356749 --- /dev/null +++ b/test/WebSites/MultipleResponseTypes/Program.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection.Extensions; +using MultipleResponseTypes; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => options.SwaggerDoc("v1", new() { Title = "MultipleResponseTypes", Version = "v1" })); + +// Exercises Swashbuckle's multiple-response-types-per-status-code handling end-to-end. +// ASP.NET Core's built-in API explorer collapses duplicate status codes on the currently +// supported runtimes, so a second response type is injected below the default provider to +// mimic what a C# union return type (.NET 11+) will surface. +builder.Services.TryAddEnumerable(ServiceDescriptor.Transient()); + +var app = builder.Build(); + +app.UseSwagger(); + +// A single endpoint that advertises one response type (Cat) via metadata; the injected +// provider adds a second (Dog) for the same 200 status code so the generated document +// contains an "anyOf" of the two schemas. +app.MapGet("/animals/{id}", (int id) => new Cat("Whiskers", 9)) + .Produces(StatusCodes.Status200OK); + +app.Run(); + +namespace MultipleResponseTypes +{ + /// + /// A response type for the GET /animals/{id} endpoint. + /// + public record Cat(string Name, int LivesLeft); + + /// + /// A second response type surfaced for the same status code as . + /// + public record Dog(string Name, bool GoodBoy); + + /// + /// Injects a second for status code 200 after the default + /// API explorer has run, simulating multiple response types for a single status code. + /// + internal sealed class UnionResponseApiDescriptionProvider : IApiDescriptionProvider + { + // Runs after the framework's providers (which use negative Order values) so the + // descriptions are already populated and can be augmented. + public int Order => 0; + + public void OnProvidersExecuting(ApiDescriptionProviderContext context) + { + foreach (var apiDescription in context.Results) + { + if (apiDescription.RelativePath?.StartsWith("animals/", StringComparison.OrdinalIgnoreCase) != true) + { + continue; + } + + var existing = apiDescription.SupportedResponseTypes + .FirstOrDefault((responseType) => responseType.StatusCode == StatusCodes.Status200OK); + + if (existing is null) + { + continue; + } + + apiDescription.SupportedResponseTypes.Add(new ApiResponseType + { + StatusCode = StatusCodes.Status200OK, + Type = typeof(Dog), + ApiResponseFormats = [.. existing.ApiResponseFormats.Select((format) => new ApiResponseFormat { MediaType = format.MediaType, Formatter = format.Formatter })], + }); + } + } + + public void OnProvidersExecuted(ApiDescriptionProviderContext context) + { + } + } + + /// + /// Expose the Program class for use with WebApplicationFactory. + /// + public partial class Program; +} From 6ba8a90be5b4cbaf3b65725280d8b84c376df5bd Mon Sep 17 00:00:00 2001 From: Dmitrii Korolev Date: Wed, 5 Aug 2026 11:16:52 +0200 Subject: [PATCH 2/2] nit --- .../SwaggerGenerator/SwaggerGenerator.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs index c467ea4151..775bfa689f 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs @@ -1006,9 +1006,7 @@ private OpenApiResponses GenerateResponses( // Multiple response types can be reported for the same status code, e.g. several // [ProducesResponseType] attributes with the same code, or a C# union return type - // (.NET 11+) which the framework may surface as one entry per case. Group by status - // code and merge them into a single response instead of calling responses.Add twice - // for the same key, which would throw an ArgumentException. + // (.NET 11+) which the framework may surface as one entry per case. foreach (var responsesForStatusCode in supportedResponseTypes.GroupBy( (responseType) => responseType.IsDefaultResponse() ? "default" : responseType.StatusCode.ToString())) { @@ -1078,8 +1076,8 @@ private OpenApiResponse GenerateResponse( } // When more than one distinct schema is reported for the same status code and content type - // (e.g. multiple response types or the cases of a C# union), combine them into an anyOf - // schema. This mirrors how System.Text.Json's JsonSchemaExporter emits union types. + // (e.g. multiple response types or the cases of a C# union), combine them into an anyOf schema. + // This mirrors how System.Text.Json's JsonSchemaExporter emits union types. private static IOpenApiSchema CombineResponseSchemas(List schemas) { return schemas.Count == 1