Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
6fa6ca4
Initial plan
Copilot Sep 10, 2026
79a59cb
fix(http-client-csharp): deserialize primitive responses without refl…
Copilot Sep 10, 2026
bea225a
fix(http-client-csharp): preserve BOM handling for primitive responses
Copilot Sep 10, 2026
e5e08e6
test(http-client-csharp): validate complete primitive response methods
Copilot Sep 10, 2026
06ea139
Fix cspell error for FEFF in ExtensibleEnumTests.cs
Copilot Sep 10, 2026
9278405
Cache enum/scalar response value in a variable instead of inline ternary
Copilot Sep 10, 2026
248bdc9
Handle text/plain content type for primitive and enum responses
Copilot Sep 10, 2026
bba90fc
Add nullable enum test coverage for remaining isString/isExtensible c…
Copilot Sep 10, 2026
0e496fe
Simplify plain-text response checks, avoid null-forgiving, fix mixed …
Copilot Sep 10, 2026
c6bbcbe
fix(http-client-csharp): refine plain text response conversion
Copilot Sep 10, 2026
b12eb64
merge: integrate plain-text response feedback
Copilot Sep 10, 2026
d9df7e9
refactor(http-client-csharp): consolidate plain text response helpers
Copilot Sep 10, 2026
755b311
perf(http-client-csharp): cache plain text response content
Copilot Sep 10, 2026
ca77e1d
fix(http-client-csharp): fix unreachable text/plain conversion for ex…
Copilot Sep 10, 2026
26058cc
refactor(http-client-csharp): inline single-use ShouldUseResultConver…
Copilot Sep 10, 2026
f7cd15f
chore(http-client-csharp): regenerate Sample-TypeSpec test project
Copilot Sep 10, 2026
0766471
Merge branch 'main' into copilot/generate-aot-compatible-code
jorgerangel-msft Sep 11, 2026
59cd778
fix(http-client-csharp): restrict raw text response parsing to primit…
Copilot Sep 11, 2026
46fc557
fix(http-client-csharp): allow relative URIs in plain text response p…
Copilot Sep 11, 2026
30b9413
fix(http-client-csharp): return null for unsupported plain text conve…
Copilot Sep 11, 2026
de7abee
refactor(http-client-csharp): resolve plain text parser before content
Copilot Sep 11, 2026
cd41087
test(http-client-csharp): avoid FEFF token in Local extensible enum test
Copilot Sep 11, 2026
2c5ad78
test(http-client-csharp): build BOM prefix from UTF-8 preamble
Copilot Sep 11, 2026
22c6a9d
fix(http-client-csharp): trim plain-text content before null-sentinel…
Copilot Sep 14, 2026
c541f53
fix(http-client-csharp): honor duration wire encoding in plain-text T…
Copilot Sep 14, 2026
d44785e
test(http-client-csharp): expand duration wire-encoding coverage to I…
Copilot Sep 14, 2026
bfd606d
refactor(http-client-csharp): extract ParseNumeric helper and add nul…
Copilot Sep 14, 2026
edecbdf
test(http-client-csharp): add Iso8601 nullable duration case, use nam…
Copilot Sep 14, 2026
c45a141
docs(http-client-csharp): clarify duration float/double parsing inten…
Copilot Sep 14, 2026
feb55b2
refactor(http-client-csharp): use string trim snippet
Copilot Sep 14, 2026
ba43450
refactor(http-client-csharp): unify Int64 duration parsing through Pa…
Copilot Sep 14, 2026
71a36dd
test(http-client-csharp): reorder nullable duration test cases for re…
Copilot Sep 14, 2026
6fc5626
refactor(http-client-csharp): unify numeric duration parsing
Copilot Sep 14, 2026
b479871
test(http-client-csharp): complete full nullable duration wire-encodi…
Copilot Sep 14, 2026
6a50498
fix(http-client-csharp): reject unsupported duration formats
Copilot Sep 14, 2026
8993f86
test(http-client-csharp): add negative test for unsupported duration …
Copilot Sep 14, 2026
b1270a9
test(http-client-csharp): cover JSON BOM primitive responses
Copilot Sep 14, 2026
65f2baf
fix(http-client-csharp): trim BOM before JSON primitive parse
Copilot Sep 14, 2026
2248ca1
test(http-client-csharp): cover JSON primitive BOM responses
Copilot Sep 14, 2026
5415f39
test(http-client-csharp): consolidate JSON BOM coverage
Copilot Sep 14, 2026
89152f8
fix(http-client-csharp): restore BOM-safe JSON parse and stop throwin…
Copilot Sep 14, 2026
5119b68
fix(http-client-csharp): strip BOM in JSON fallback conversion for no…
Copilot Sep 14, 2026
6a987a4
fix(http-client-csharp): make primitive response fallbacks safe
Copilot Sep 14, 2026
0955d59
fix(http-client-csharp): simplify response parse generation
Copilot Sep 14, 2026
1c66d03
test(http-client-csharp): revert local primitive response sample
Copilot Sep 14, 2026
94acf3e
fix(http-client-csharp): avoid duration diagnostics for numeric formats
Copilot Sep 14, 2026
9b2f4e2
fix(http-client-csharp): match text plain media types loosely
Copilot Sep 14, 2026
9c05f25
fix(http-client-csharp): match exact plain-text media types
Copilot Sep 14, 2026
9a84ed9
fix(http-client-csharp): remove BOM response handling
Copilot Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.ServerSentEvents;
Expand Down Expand Up @@ -295,7 +296,8 @@ .. GetStackVariablesForProtocolParamConversion(convenienceBodyParameters, out va
Declare("result", This.Invoke(protocolMethod.Signature, [.. GetProtocolMethodArguments(paramDeclarations)], isAsync).ToApi<ClientResponseApi>(), out ClientResponseApi result),
.. GetStackVariablesForReturnValueConversion(result, responseBodyType, isAsync, out var resultDeclarations),
IsConvertibleFromBinaryData(responseBodyType)
? Return(result.FromValue(GetResultConversion(result, result.GetRawResponse(), responseBodyType, resultDeclarations), result.GetRawResponse()))
|| GetPlainTextParseType(responseBodyType, out _) is not null
? GetResultConversionStatements(result, result.GetRawResponse(), responseBodyType, resultDeclarations)
:
new[]
{
Expand Down Expand Up @@ -634,6 +636,21 @@ private IEnumerable<MethodBodyStatement> GetStackVariablesForReturnValueConversi
out declarations);
}

if ((IsConvertibleFromBinaryData(responseBodyType) || GetPlainTextParseType(responseBodyType, out _) is not null)
&& (responseBodyType.IsFrameworkType || responseBodyType.IsEnum)
&& !responseBodyType.Equals(typeof(BinaryData))
Comment thread
jorgerangel-msft marked this conversation as resolved.
&& !HasOnlyPlainTextContentType())
{
var data = result.GetRawResponse().Content();
var statements = new MethodBodyStatement[]
{
UsingDeclare("document", data.Parse(), out var document)
};
declarations["data"] = data;
declarations["document"] = document;
return statements;
}

return [];
}

Expand Down Expand Up @@ -837,6 +854,47 @@ private MethodBodyStatement AddElement(ValueExpression? dictKey, ValueExpression
return scopedApi.Add(element);
}

private MethodBodyStatement[] GetResultConversionStatements(ClientResponseApi result, HttpResponseApi response, CSharpType responseBodyType, Dictionary<string, ValueExpression> declarations)
{
var plainTextParseType = GetPlainTextParseType(responseBodyType, out var enumType);
if (!responseBodyType.Equals(typeof(string)) && plainTextParseType is not null && HasOnlyPlainTextContentType())
{
return
[
Declare("value", responseBodyType, GetPlainTextValueConversion(responseBodyType, plainTextParseType, enumType, response.Content().InvokeToString()), out var value),
Comment thread
jorgerangel-msft marked this conversation as resolved.
Return(result.FromValue(value, response))
];
}

var isSpecialCaseType = responseBodyType.Equals(typeof(BinaryData))
|| responseBodyType.IsCollection
|| (responseBodyType.Equals(typeof(string)) && HasOnlyPlainTextContentType());

if (!isSpecialCaseType && (responseBodyType.IsFrameworkType || responseBodyType.IsEnum))
{
var element = declarations["document"].As<JsonDocument>().RootElement();
var deserializedValue = ScmCodeModelGenerator.Instance.TypeFactory.DeserializeJsonValue(
responseBodyType.WithNullable(false),
element,
declarations["data"].As<BinaryData>(),
ScmCodeModelGenerator.Instance.ModelSerializationExtensionsDefinition.WireOptionsField.As<ModelReaderWriterOptions>(),
responseBodyType.Equals(typeof(TimeSpan)) || responseBodyType.Equals(typeof(TimeSpan?))
? SerializationFormat.Duration_Constant
: SerializationFormat.Default);
var valueExpression = responseBodyType.IsNullable
? new TernaryConditionalExpression(element.ValueKindEqualsNull(), Null.CastTo(responseBodyType), deserializedValue)
: deserializedValue;

return
[
Declare("value", responseBodyType, valueExpression, out var value),
Return(result.FromValue(value, response))
];
}

return [Return(result.FromValue(GetResultConversion(result, response, responseBodyType, declarations), response))];
}

private ValueExpression GetResultConversion(ClientResponseApi result, HttpResponseApi response, CSharpType responseBodyType, Dictionary<string, ValueExpression> declarations)
{
if (responseBodyType.Equals(typeof(BinaryData)))
Expand All @@ -855,19 +913,157 @@ private ValueExpression GetResultConversion(ClientResponseApi result, HttpRespon
{
return declarations["value"].CastTo(new CSharpType(responseBodyType.OutputType.FrameworkType, responseBodyType.Arguments[0], responseBodyType.Arguments[1]));
}
if (responseBodyType.Equals(typeof(string)) && ServiceMethod.Operation.Responses.Any(r => r.IsErrorResponse is false && r.ContentTypes.Contains("text/plain")))
if (responseBodyType.Equals(typeof(string)) && HasOnlyPlainTextContentType())
{
return response.Content().InvokeToString();
}
if (responseBodyType.IsFrameworkType)
return result.CastTo(responseBodyType);
}

private ValueExpression GetPlainTextValueConversion(CSharpType responseBodyType, Type parseType, CSharpType? enumType, ValueExpression content)
{
var invariantCulture = new MemberExpression(typeof(CultureInfo), nameof(CultureInfo.InvariantCulture));
var deserializedValue = parseType switch
{
Type t when t == typeof(string) => content,
Type t when t == typeof(bool) => Static<bool>().Invoke(nameof(bool.Parse), content).As<bool>(),
Type t when t == typeof(Guid) => Static<Guid>().Invoke(nameof(Guid.Parse), content).As<Guid>(),
Type t when t == typeof(Uri) => New.Instance<Uri>(content, FrameworkEnumValue(UriKind.RelativeOrAbsolute)),
Type t when t == typeof(TimeSpan) => GetPlainTextTimeSpanConversion(content, invariantCulture),
Type t when t == typeof(DateTimeOffset) => content.As<string>().ParseDateTimeOffset(Literal(GetResponseSerializationFormat().ToFormatSpecifier())),
// The remaining supported types are numeric and all expose a static Parse(string, IFormatProvider) method.
_ => Static(parseType).Invoke(nameof(int.Parse), [content, invariantCulture]).As(parseType)
};

if (enumType is not null)
{
return response.Content().ToObjectFromJson(responseBodyType);
deserializedValue = enumType.ToEnum(deserializedValue);
}
if (responseBodyType.IsEnum)

return responseBodyType.IsNullable
? new TernaryConditionalExpression(content.As<string>().Trim().Equal(Literal("null")), Null.CastTo(responseBodyType), deserializedValue)
: deserializedValue;
}

/// <summary>
/// Builds the raw-text conversion for a <see cref="TimeSpan"/> response, honoring the response body's wire
/// encoding. Numeric duration encodings (seconds/milliseconds) parse the content as a number and construct
/// the <see cref="TimeSpan"/> from it, matching <see cref="MrwSerializationTypeDefinition"/>'s JSON handling;
/// all other encodings (ISO 8601, constant, plain time) parse the content directly using the corresponding
/// format specifier.
/// </summary>
private ValueExpression GetPlainTextTimeSpanConversion(ValueExpression content, ValueExpression invariantCulture)
{
var format = GetResponseSerializationFormat();
switch (format)
{
case SerializationFormat.Duration_Seconds:
return TimeSpanSnippets.FromSeconds(ParseNumeric<int>(content, invariantCulture));
case SerializationFormat.Duration_Seconds_Int64:
return TimeSpanSnippets.FromSeconds(ParseNumeric<long>(content, invariantCulture));
case SerializationFormat.Duration_Seconds_Float:
case SerializationFormat.Duration_Seconds_Double:
// Float and Double wire encodings are intentionally collapsed to a single double.Parse,
// matching MrwSerializationTypeDefinition's JSON path, which uses GetDouble() for both.
return TimeSpanSnippets.FromSeconds(ParseNumeric<double>(content, invariantCulture));
case SerializationFormat.Duration_Milliseconds:
return TimeSpanSnippets.FromMilliseconds(ParseNumeric<int>(content, invariantCulture));
case SerializationFormat.Duration_Milliseconds_Int64:
return TimeSpanSnippets.FromMilliseconds(ParseNumeric<long>(content, invariantCulture));
case SerializationFormat.Duration_Milliseconds_Float:
case SerializationFormat.Duration_Milliseconds_Double:
// See the Duration_Seconds_Float/Double comment above.
return TimeSpanSnippets.FromMilliseconds(ParseNumeric<double>(content, invariantCulture));
}

var formatSpecifier = format.ToFormatSpecifier();
if (formatSpecifier is null)
{
return responseBodyType.ToEnum(response.Content().ToObjectFromJson(responseBodyType.UnderlyingEnumType));
ScmCodeModelGenerator.Instance.Emitter.ReportDiagnostic(
DiagnosticCodes.UnsupportedSerialization,
$"Unsupported duration serialization format: {format}. Falling back to constant duration format.",
ServiceMethod.Operation.CrossLanguageDefinitionId);
formatSpecifier = SerializationFormat.Duration_Constant.ToFormatSpecifier()!;
}
Comment thread
Copilot marked this conversation as resolved.
return result.CastTo(responseBodyType);

// ISO 8601 ("P"), constant ("c") and plain time ("T") encodings all parse the content directly.
return content.As<string>().ParseTimeSpan(Literal(formatSpecifier));
}

/// <summary>
/// Builds a <c>T.Parse(content, invariantCulture)</c> invocation for the given numeric <typeparamref name="T"/>.
/// </summary>
private static ScopedApi<T> ParseNumeric<T>(ValueExpression content, ValueExpression invariantCulture)
where T : struct
{
// Static members on a generic type parameter cannot be referenced by nameof.
return Static<T>().Invoke("Parse", [content, invariantCulture]).As<T>();
}

/// <summary>
/// Gets the framework type that a raw text response body is parsed into, or <c>null</c> when the response body
/// type isn't a primitive or enum that can be parsed from raw text. Types such as <see cref="BinaryData"/>,
/// collections and generated models keep their existing conversion.
/// </summary>
private static Type? GetPlainTextParseType(CSharpType responseBodyType, out CSharpType? enumType)
{
enumType = null;
var typeToParse = responseBodyType.WithNullable(false);
if (typeToParse is { IsEnum: true, UnderlyingEnumType: { } underlyingEnumType })
{
enumType = typeToParse;
typeToParse = underlyingEnumType;
}

if (!typeToParse.IsFrameworkType)
{
return null;
}

var frameworkType = typeToParse.FrameworkType;
return frameworkType switch
{
Type t when t == typeof(string)
|| t == typeof(bool)
|| t == typeof(Guid)
|| t == typeof(Uri)
|| t == typeof(TimeSpan)
|| t == typeof(DateTimeOffset)
|| t == typeof(byte)
|| t == typeof(sbyte)
|| t == typeof(short)
|| t == typeof(ushort)
|| t == typeof(int)
|| t == typeof(uint)
|| t == typeof(long)
|| t == typeof(ulong)
|| t == typeof(float)
|| t == typeof(double)
|| t == typeof(decimal) => frameworkType,
_ => null
};
}

private bool HasOnlyPlainTextContentType()
{
var contentTypes = ServiceMethod.Operation.Responses
.Where(r => r.IsErrorResponse is false)
.SelectMany(r => r.ContentTypes);
return contentTypes.Any() && contentTypes.All(IsPlainTextContentType);
}

private static bool IsPlainTextContentType(string contentType)
{
return contentType.Split(';')[0].Trim().Equals("text/plain", StringComparison.OrdinalIgnoreCase);
}

private SerializationFormat GetResponseSerializationFormat()
{
var responseBodyType = ServiceMethod.Operation.Responses
.FirstOrDefault(r => r.IsErrorResponse is false)?.BodyType;
return responseBodyType is null
? SerializationFormat.Default
: ScmCodeModelGenerator.Instance.TypeFactory.GetSerializationFormat(responseBodyType);
}

private static bool ShouldBuildStackVarForFrameworkType(CSharpType type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3049,13 +3049,9 @@ public async Task BackCompatibility_ConvenienceMethodParamOrderChanged()
var body = syncConvenienceMethod!.BodyStatements;
Assert.IsNotNull(body);

var result = body!.ToDisplayString();
Assert.AreEqual(
"global::Sample.Argument.AssertNotNullOrEmpty(param1, nameof(param1));\n\n" +
"using global::System.ClientModel.BinaryContent content = global::System.ClientModel.BinaryContent.Create(global::System.BinaryData.FromString(param1));\n" +
"global::System.ClientModel.ClientResult result = this.GetData(param3, param2, content, cancellationToken.ToRequestOptions());\n" +
"return global::System.ClientModel.ClientResult.FromValue(result.GetRawResponse().Content.ToObjectFromJson<string>(), result.GetRawResponse());\n",
result);
using var syncWriter = new CodeWriter();
syncWriter.WriteMethod(syncConvenienceMethod);
Assert.AreEqual(Helpers.GetExpectedFromFile("Sync"), syncWriter.ToString(false));
Comment thread
jorgerangel-msft marked this conversation as resolved.

var asyncConvenienceMethod = convenienceMethods
.FirstOrDefault(m => m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Async));
Expand All @@ -3064,13 +3060,9 @@ public async Task BackCompatibility_ConvenienceMethodParamOrderChanged()
body = asyncConvenienceMethod!.BodyStatements;
Assert.IsNotNull(body);

result = body!.ToDisplayString();
Assert.AreEqual(
"global::Sample.Argument.AssertNotNullOrEmpty(param1, nameof(param1));\n\n" +
"using global::System.ClientModel.BinaryContent content = global::System.ClientModel.BinaryContent.Create(global::System.BinaryData.FromString(param1));\n" +
"global::System.ClientModel.ClientResult result = await this.GetDataAsync(param3, param2, content, cancellationToken.ToRequestOptions()).ConfigureAwait(false);\n" +
"return global::System.ClientModel.ClientResult.FromValue(result.GetRawResponse().Content.ToObjectFromJson<string>(), result.GetRawResponse());\n",
result);
using var asyncWriter = new CodeWriter();
asyncWriter.WriteMethod(asyncConvenienceMethod);
Assert.AreEqual(Helpers.GetExpectedFromFile("Async"), asyncWriter.ToString(false));
}

[Test]
Expand Down Expand Up @@ -3146,13 +3138,9 @@ public async Task BackCompatibility_BothMethodsParamOrderChanged()
var body = syncConvenienceMethod!.BodyStatements;
Assert.IsNotNull(body);

var result = body!.ToDisplayString();
Assert.AreEqual(
"global::Sample.Argument.AssertNotNullOrEmpty(param1, nameof(param1));\n\n" +
"using global::System.ClientModel.BinaryContent content = global::System.ClientModel.BinaryContent.Create(global::System.BinaryData.FromString(param1));\n" +
"global::System.ClientModel.ClientResult result = this.UpdateResource(content, param2, param3, cancellationToken.ToRequestOptions());\n" +
"return global::System.ClientModel.ClientResult.FromValue(result.GetRawResponse().Content.ToObjectFromJson<string>(), result.GetRawResponse());\n",
result);
using var syncWriter = new CodeWriter();
syncWriter.WriteMethod(syncConvenienceMethod);
Assert.AreEqual(Helpers.GetExpectedFromFile("Sync"), syncWriter.ToString(false));

var asyncConvenienceMethod = convenienceMethods
.FirstOrDefault(m => m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Async));
Expand All @@ -3161,13 +3149,9 @@ public async Task BackCompatibility_BothMethodsParamOrderChanged()
body = asyncConvenienceMethod!.BodyStatements;
Assert.IsNotNull(body);

result = body!.ToDisplayString();
Assert.AreEqual(
"global::Sample.Argument.AssertNotNullOrEmpty(param1, nameof(param1));\n\n" +
"using global::System.ClientModel.BinaryContent content = global::System.ClientModel.BinaryContent.Create(global::System.BinaryData.FromString(param1));\n" +
"global::System.ClientModel.ClientResult result = await this.UpdateResourceAsync(content, param2, param3, cancellationToken.ToRequestOptions()).ConfigureAwait(false);\n" +
"return global::System.ClientModel.ClientResult.FromValue(result.GetRawResponse().Content.ToObjectFromJson<string>(), result.GetRawResponse());\n",
result);
using var asyncWriter = new CodeWriter();
asyncWriter.WriteMethod(asyncConvenienceMethod);
Assert.AreEqual(Helpers.GetExpectedFromFile("Async"), asyncWriter.ToString(false));
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
public virtual async global::System.Threading.Tasks.Task<global::System.ClientModel.ClientResult<string>> UpdateResourceAsync(string param1, int param2, bool param3, global::System.Threading.CancellationToken cancellationToken = default)
{
global::Sample.Argument.AssertNotNullOrEmpty(param1, nameof(param1));

using global::System.ClientModel.BinaryContent content = global::System.ClientModel.BinaryContent.Create(global::System.BinaryData.FromString(param1));
global::System.ClientModel.ClientResult result = await this.UpdateResourceAsync(content, param2, param3, cancellationToken.ToRequestOptions()).ConfigureAwait(false);
using global::System.Text.Json.JsonDocument document = global::System.Text.Json.JsonDocument.Parse(result.GetRawResponse().Content);
string value = document.RootElement.GetString();
return global::System.ClientModel.ClientResult.FromValue(value, result.GetRawResponse());
}
Loading
Loading