From 5c1093cd0fea846748d70a4ed404eddb4d0f93ea Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:08:01 +0100 Subject: [PATCH 1/2] Fix compile error in generated C# client when media type contains quotes --- .../CodeGenerationTests.cs | 52 +++ ...ns_quotes_can_generate_client.verified.txt | 300 ++++++++++++++++++ .../Models/OperationModelBase.cs | 15 +- 3 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 src/NSwag.CodeGeneration.CSharp.Tests/Snapshots/CodeGenerationTests.When_media_type_contains_quotes_can_generate_client.verified.txt diff --git a/src/NSwag.CodeGeneration.CSharp.Tests/CodeGenerationTests.cs b/src/NSwag.CodeGeneration.CSharp.Tests/CodeGenerationTests.cs index 57f1c24cf..926a00521 100644 --- a/src/NSwag.CodeGeneration.CSharp.Tests/CodeGenerationTests.cs +++ b/src/NSwag.CodeGeneration.CSharp.Tests/CodeGenerationTests.cs @@ -354,6 +354,58 @@ public async Task When_path_starts_with_numeric_can_generate_client() CSharpCompiler.AssertCompile(code); } + [Fact] + public async Task When_media_type_contains_quotes_can_generate_client() + { + // Arrange + const string mediaType = "application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic https://www.jsonapi.net/ext/openapi\""; + string mediaTypeJsonEscaped = mediaType.Replace("\"", "\\\""); + + string json = + $$""" + { + "openapi": "3.0.1", + "paths": { + "/Test": { + "post": { + "requestBody": { + "content": { + "{{mediaTypeJsonEscaped}}": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "{{mediaTypeJsonEscaped}}": { + "schema": { + "type": "object" + } + } + } + } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(json); + var codeGenerator = new CSharpClientGenerator(document, new CSharpClientGeneratorSettings()); + + // Act + var code = codeGenerator.GenerateFile(); + + // Assert + await VerifyHelper.Verify(code); + CSharpCompiler.AssertCompile(code); + } + private static OpenApiDocument CreateDocument() { var document = new OpenApiDocument(); diff --git a/src/NSwag.CodeGeneration.CSharp.Tests/Snapshots/CodeGenerationTests.When_media_type_contains_quotes_can_generate_client.verified.txt b/src/NSwag.CodeGeneration.CSharp.Tests/Snapshots/CodeGenerationTests.When_media_type_contains_quotes_can_generate_client.verified.txt new file mode 100644 index 000000000..caeecfc14 --- /dev/null +++ b/src/NSwag.CodeGeneration.CSharp.Tests/Snapshots/CodeGenerationTests.When_media_type_contains_quotes_can_generate_client.verified.txt @@ -0,0 +1,300 @@ + + +namespace MyNamespace +{ + using System = global::System; + + public partial class Client + { + #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 Client(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); + + public virtual System.Threading.Tasks.Task TestAsync(object body) + { + return TestAsync(body, System.Threading.CancellationToken.None); + } + + public virtual async System.Threading.Tasks.Task TestAsync(object body, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = Newtonsoft.Json.JsonConvert.SerializeObject(body, JsonSerializerSettings); + var content_ = new System.Net.Http.StringContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic https://www.jsonapi.net/ext/openapi\""); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic https://www.jsonapi.net/ext/openapi\"")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "Test" + urlBuilder_.Append("Test"); + + 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; + } + } + + + + + + 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()); + } + } + + 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; + } + } + +} diff --git a/src/NSwag.CodeGeneration/Models/OperationModelBase.cs b/src/NSwag.CodeGeneration/Models/OperationModelBase.cs index 0a6dee04f..5f10b234b 100644 --- a/src/NSwag.CodeGeneration/Models/OperationModelBase.cs +++ b/src/NSwag.CodeGeneration/Models/OperationModelBase.cs @@ -275,9 +275,8 @@ public string Consumes return "application/json"; } - return actualConsumes?.FirstOrDefault() - ?? _operation.ActualRequestBody?._content.FirstOrDefault().Key - ?? "application/json"; + var consumes = EscapeQuotes(actualConsumes?.FirstOrDefault() ?? _operation.ActualRequestBody?._content.FirstOrDefault().Key); + return consumes ?? "application/json"; } } @@ -286,17 +285,19 @@ public string Produces { get { - if (_operation.ActualProducesCollection?.Contains("application/json") == true) + var actualProduces = _operation.ActualProducesCollection; + if (actualProduces?.Contains("application/json") == true) { return "application/json"; } - return _operation.ActualProducesCollection?.FirstOrDefault() - ?? SuccessResponse?.Produces - ?? "application/json"; + var produces = EscapeQuotes(actualProduces?.FirstOrDefault() ?? SuccessResponse?.Produces); + return produces ?? "application/json"; } } + private static string EscapeQuotes(string value) => value?.Replace("\"", "\\\""); + /// Gets a value indicating whether a file response is expected from one of the responses. public bool IsFile => _operation.HasActualResponse((_, response) => response.IsBinary(_operation)); From 655ac3990e3168d5ef75047605f2a6b3dd0ebc40 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:56:51 +0100 Subject: [PATCH 2/2] Fix broken build --- .../ControllerGenerationFormatTests.cs | 4 ++-- src/NSwag.Commands/PathUtilities.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/NSwag.CodeGeneration.CSharp.Tests/ControllerGenerationFormatTests.cs b/src/NSwag.CodeGeneration.CSharp.Tests/ControllerGenerationFormatTests.cs index c77202228..4b9123fe2 100644 --- a/src/NSwag.CodeGeneration.CSharp.Tests/ControllerGenerationFormatTests.cs +++ b/src/NSwag.CodeGeneration.CSharp.Tests/ControllerGenerationFormatTests.cs @@ -427,10 +427,10 @@ public async Task When_controllertarget_aspnet_and_multiple_controllers_then_onl var code = codeGen.GenerateFile(); // Assert - var fromHeaderCustomAttributeCount = Regex.Matches(code, "public class FromHeaderAttribute :").Count; + var fromHeaderCustomAttributeCount = Regex.Count(code, "public class FromHeaderAttribute :"); Assert.Equal(1, fromHeaderCustomAttributeCount); - var fromHeaderCustomBindingCount = Regex.Matches(code, "public class FromHeaderBinding :").Count; + var fromHeaderCustomBindingCount = Regex.Count(code, "public class FromHeaderBinding :"); Assert.Equal(1, fromHeaderCustomBindingCount); await VerifyHelper.Verify(code); diff --git a/src/NSwag.Commands/PathUtilities.cs b/src/NSwag.Commands/PathUtilities.cs index 538fb178a..c787a76da 100644 --- a/src/NSwag.Commands/PathUtilities.cs +++ b/src/NSwag.Commands/PathUtilities.cs @@ -72,7 +72,7 @@ public static IEnumerable FindWildcardMatches(string selector, IEnumerab .Replace("__starstar__", "(.*?)") .Replace("__star__", "([^" + escapedDelimiter + "]*?)") + "$"); - return items.Where(i => regex.Match(i.Replace("\\", "/")).Success); + return items.Where(i => regex.IsMatch(i.Replace("\\", "/"))); } /// Converts a relative path to an absolute path.