Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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
@@ -1,12 +1,15 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

// cspell:ignore FEFF

using System;
using System.ClientModel;
using System.ClientModel.Primitives;
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 @@ -294,8 +297,13 @@ .. GetStackVariablesForProtocolParamConversion(convenienceBodyParameters, out va
.. GetStackVariablesForProtocolParamConversion(convenienceBodyParameters, out var paramDeclarations),
Declare("result", This.Invoke(protocolMethod.Signature, [.. GetProtocolMethodArguments(paramDeclarations)], isAsync).ToApi<ClientResponseApi>(), out ClientResponseApi result),
.. GetStackVariablesForReturnValueConversion(result, responseBodyType, isAsync, out var resultDeclarations),
// Text/plain primitive and enum responses are parsed directly from the response content, bypassing the
// BinaryData/JsonDocument conversion path entirely. Route them through GetResultConversionStatements even
// when the response body type isn't in the IsConvertibleFromBinaryData allow-list (e.g. Guid, Uri, or the
// byte/short/unsigned integer types), otherwise they would incorrectly fall back to JSON parsing.
IsConvertibleFromBinaryData(responseBodyType)
? Return(result.FromValue(GetResultConversion(result, result.GetRawResponse(), responseBodyType, resultDeclarations), result.GetRawResponse()))
|| (IsPlainTextParsableType(responseBodyType) && HasOnlyPlainTextContentType())
? GetResultConversionStatements(result, result.GetRawResponse(), responseBodyType, resultDeclarations)
:
new[]
{
Expand Down Expand Up @@ -634,6 +642,23 @@ private IEnumerable<MethodBodyStatement> GetStackVariablesForReturnValueConversi
out declarations);
}

if (IsConvertibleFromBinaryData(responseBodyType)
Comment thread
jorgerangel-msft marked this conversation as resolved.
&& (responseBodyType.IsFrameworkType || responseBodyType.IsEnum)
&& !responseBodyType.Equals(typeof(BinaryData))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot why do we need the responseBodyType.Equals(typeof(BinaryData)) check if we are already checked IsConvertibleFromBinaryData ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsConvertibleFromBinaryData is a broad capability check (it intentionally returns true for BinaryData), but this specific branch builds JSON-document conversion scaffolding. Without !responseBodyType.Equals(typeof(BinaryData)), BinaryData responses would go through the JSON parse setup instead of preserving the raw payload path. So the extra check keeps BinaryData on its existing direct-content conversion path.

&& !HasOnlyPlainTextContentType())
{
var data = result.GetRawResponse().Content();
// The stream overload preserves UTF-8 BOM handling from ToObjectFromJson.
var statements = new MethodBodyStatement[]
{
UsingDeclare("stream", data.ToStream(), out var stream),
UsingDeclare("document", JsonDocumentSnippets.Parse(stream), out var document)
};
declarations["data"] = data;
declarations["document"] = document;
return statements;
}

return [];
}

Expand Down Expand Up @@ -837,6 +862,49 @@ 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())
{
var contentExpression = response.Content().InvokeToString().Invoke(nameof(string.TrimStart), Literal('\uFEFF')).As<string>();
return
[
Declare("content", typeof(string), contentExpression, out var content),
Declare("value", responseBodyType, GetPlainTextValueConversion(responseBodyType, plainTextParseType, enumType, content), out var value),
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 +923,100 @@ 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) => content.As<string>().ParseTimeSpan(Literal(SerializationFormat.Duration_Constant.ToFormatSpecifier() ?? throw new InvalidOperationException())),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Honor the duration's wire encoding in the plain-text parser. InputDurationType also maps to TimeSpan, so forcing "c" here makes a seconds-encoded response body 60 deserialize as 60 days instead of one minute, and makes the default ISO 8601 body PT1H throw (the duration encoding mappings are in generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs:449-474). Select parsing from the response encoding, retaining constant-format parsing for the existing plain-time/constant case, and add ISO 8601 and seconds/milliseconds response coverage.

--generated by Copilot

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)
{
deserializedValue = enumType.ToEnum(deserializedValue);
}

return responseBodyType.IsNullable
? new TernaryConditionalExpression(content.Equal(Literal("null")), Null.CastTo(responseBodyType), deserializedValue)
: deserializedValue;
Comment on lines +953 to +955
}

private static bool IsPlainTextParsableType(CSharpType responseBodyType)
=> GetPlainTextParseType(responseBodyType, out _) is not null;

/// <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 })
{
return response.Content().ToObjectFromJson(responseBodyType);
enumType = typeToParse;
typeToParse = underlyingEnumType;
}
if (responseBodyType.IsEnum)

if (!typeToParse.IsFrameworkType)
{
return responseBodyType.ToEnum(response.Content().ToObjectFromJson(responseBodyType.UnderlyingEnumType));
return null;
}
return result.CastTo(responseBodyType);

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(contentType => contentType == "text/plain");
Comment thread
jorgerangel-msft marked this conversation as resolved.
}

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));

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,11 @@
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.IO.Stream stream = result.GetRawResponse().Content.ToStream();
using global::System.Text.Json.JsonDocument document = global::System.Text.Json.JsonDocument.Parse(stream);
string value = document.RootElement.GetString();
return global::System.ClientModel.ClientResult.FromValue(value, result.GetRawResponse());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
public virtual global::System.ClientModel.ClientResult<string> UpdateResource(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 = this.UpdateResource(content, param2, param3, cancellationToken.ToRequestOptions());
using global::System.IO.Stream stream = result.GetRawResponse().Content.ToStream();
using global::System.Text.Json.JsonDocument document = global::System.Text.Json.JsonDocument.Parse(stream);
string value = document.RootElement.GetString();
return global::System.ClientModel.ClientResult.FromValue(value, result.GetRawResponse());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
public virtual async global::System.Threading.Tasks.Task<global::System.ClientModel.ClientResult<string>> GetDataAsync(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.GetDataAsync(param3, param2, content, cancellationToken.ToRequestOptions()).ConfigureAwait(false);
using global::System.IO.Stream stream = result.GetRawResponse().Content.ToStream();
using global::System.Text.Json.JsonDocument document = global::System.Text.Json.JsonDocument.Parse(stream);
string value = document.RootElement.GetString();
return global::System.ClientModel.ClientResult.FromValue(value, result.GetRawResponse());
}
Loading
Loading