Skip to content

Commit 6275b90

Browse files
Merge main: align with #10319 revert (drop AreNamesEqual on collection back-compat)
Agent-Logs-Url: https://github.com/microsoft/typespec/sessions/1cb571b4-94fe-450a-bda0-73a4c98e8f9f Co-authored-by: JoshLove-msft <54595583+JoshLove-msft@users.noreply.github.com>
1 parent fd9ef6e commit 6275b90

12 files changed

Lines changed: 316 additions & 81 deletions

File tree

packages/http-client-csharp/eng/pipeline/publish.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ extends:
106106
jobs:
107107
- job: CreatePR
108108
timeoutInMinutes: 90
109+
variables:
110+
# Redirect temp and cache directories to Agent.TempDirectory (a separate, larger partition)
111+
# to avoid running out of disk space on the root partition during generation
112+
TMPDIR: $(Agent.TempDirectory)
113+
NUGET_PACKAGES: $(Agent.TempDirectory)/nuget
114+
npm_config_cache: $(Agent.TempDirectory)/npm-cache
109115
steps:
110116
- checkout: self
111117
- pwsh: |
@@ -150,6 +156,7 @@ extends:
150156
workingFile: $(Build.SourcesDirectory)/packages/http-client-csharp/.npmrc
151157

152158
- download: current
159+
artifact: build_artifacts_csharp
153160
displayName: Download pipeline artifacts
154161

155162
- pwsh: |

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,7 @@ internal static List<ParameterProvider> GetMethodParameters(
10071007
int required = 100;
10081008
int bodyRequired = 200;
10091009
int bodyOptional = 300;
1010+
int contentType = 350;
10101011
int optional = 400;
10111012

10121013
var operation = serviceMethod.Operation;
@@ -1124,7 +1125,12 @@ internal static List<ParameterProvider> GetMethodParameters(
11241125
break;
11251126
case ParameterLocation.Query:
11261127
case ParameterLocation.Header:
1127-
if (parameter.DefaultValue == null)
1128+
if (inputParam is InputHeaderParameter { IsContentType: true }
1129+
&& !HasContentTypeBeforeBodyInLastContract(serviceMethod.Name, client.BackCompatProvider))
1130+
{
1131+
sortedParams.Add(contentType++, parameter);
1132+
}
1133+
else if (parameter.DefaultValue == null)
11281134
{
11291135
sortedParams.Add(required++, parameter);
11301136
}
@@ -1144,7 +1150,7 @@ internal static List<ParameterProvider> GetMethodParameters(
11441150

11451151
if (operation.IsMultipartFormData)
11461152
{
1147-
sortedParams.Add(bodyRequired++, ScmKnownParameters.ContentType);
1153+
sortedParams.Add(contentType++, ScmKnownParameters.ContentType);
11481154
}
11491155

11501156
if (methodType == ScmMethodKind.CreateRequest)
@@ -1159,6 +1165,62 @@ internal static List<ParameterProvider> GetMethodParameters(
11591165
return [.. sortedParams.Values];
11601166
}
11611167

1168+
/// <summary>
1169+
/// Checks if the last contract view contains a method matching the given name where
1170+
/// a "contentType" parameter appears before the body ("content") parameter.
1171+
/// If so, we should preserve that ordering for backward compatibility.
1172+
/// </summary>
1173+
private static bool HasContentTypeBeforeBodyInLastContract(string methodName, TypeProvider backCompatProvider)
1174+
{
1175+
const string contentTypeParamName = "contentType";
1176+
const string contentParamName = "content";
1177+
1178+
var lastContractMethods = backCompatProvider.LastContractView?.Methods;
1179+
if (lastContractMethods == null || lastContractMethods.Count == 0)
1180+
{
1181+
return false;
1182+
}
1183+
1184+
var syncMethodName = methodName;
1185+
var asyncMethodName = methodName + "Async";
1186+
1187+
foreach (var method in lastContractMethods)
1188+
{
1189+
if (!string.Equals(method.Signature.Name, syncMethodName, StringComparison.OrdinalIgnoreCase)
1190+
&& !string.Equals(method.Signature.Name, asyncMethodName, StringComparison.OrdinalIgnoreCase))
1191+
{
1192+
continue;
1193+
}
1194+
1195+
int contentTypeIndex = -1;
1196+
int bodyIndex = -1;
1197+
for (int i = 0; i < method.Signature.Parameters.Count; i++)
1198+
{
1199+
var param = method.Signature.Parameters[i];
1200+
if (string.Equals(param.Name, contentTypeParamName, StringComparison.OrdinalIgnoreCase))
1201+
{
1202+
contentTypeIndex = i;
1203+
}
1204+
else if (string.Equals(param.Name, contentParamName, StringComparison.OrdinalIgnoreCase))
1205+
{
1206+
bodyIndex = i;
1207+
}
1208+
1209+
if (contentTypeIndex >= 0 && bodyIndex >= 0)
1210+
{
1211+
break;
1212+
}
1213+
}
1214+
1215+
if (contentTypeIndex >= 0 && bodyIndex >= 0 && contentTypeIndex < bodyIndex)
1216+
{
1217+
return true;
1218+
}
1219+
}
1220+
1221+
return false;
1222+
}
1223+
11621224
internal static InputModelType GetSpreadParameterModel(InputParameter inputParam)
11631225
{
11641226
if (inputParam.Type is InputModelType model)

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,135 @@ public void TestGetMethodParameters_ProperOrdering()
233233
Assert.AreEqual("b", orderedPathParams[2].Name);
234234
}
235235

236+
[TestCase(true, false)]
237+
[TestCase(false, false)]
238+
[TestCase(true, true)]
239+
[TestCase(false, true)]
240+
public void TestGetMethodParameters_ContentTypeAfterBody(bool isRequired, bool isExtensibleEnum)
241+
{
242+
InputType contentTypeType = isExtensibleEnum
243+
? InputFactory.StringEnum("ContentTypeEnum",
244+
[("application/json", "application/json"), ("application/xml", "application/xml")],
245+
isExtensible: true)
246+
: InputPrimitiveType.String;
247+
var contentTypeHeader = InputFactory.HeaderParameter(
248+
"contentType",
249+
contentTypeType,
250+
isRequired: isRequired,
251+
isContentType: true,
252+
serializedName: "Content-Type",
253+
defaultValue: isRequired ? null : InputFactory.Constant.String("application/json"));
254+
var bodyParam = InputFactory.BodyParameter("body", InputPrimitiveType.String, isRequired: true);
255+
var pathParam = InputFactory.PathParameter("skillId", InputPrimitiveType.String, isRequired: true);
256+
257+
var operation = InputFactory.Operation(
258+
"UpdateSkillDefaultVersion",
259+
parameters: [pathParam, contentTypeHeader, bodyParam]);
260+
261+
var serviceMethod = InputFactory.BasicServiceMethod(
262+
"UpdateSkillDefaultVersion",
263+
operation);
264+
265+
var inputClient = InputFactory.Client("TestClient", methods: [serviceMethod]);
266+
var clientProvider = ScmCodeModelGenerator.Instance.TypeFactory.CreateClient(inputClient);
267+
Assert.IsNotNull(clientProvider);
268+
269+
var methodParameters = RestClientProvider.GetMethodParameters(serviceMethod, ScmMethodKind.Protocol, clientProvider!);
270+
271+
Assert.AreEqual(3, methodParameters.Count);
272+
Assert.AreEqual("skillId", methodParameters[0].Name);
273+
Assert.AreEqual("content", methodParameters[1].Name);
274+
Assert.AreEqual("contentType", methodParameters[2].Name);
275+
}
276+
277+
[Test]
278+
public async Task ContentTypeOrderPreservedFromLastContractView()
279+
{
280+
// Create an operation with a content-type header (union) and body
281+
var contentTypeEnum = InputFactory.StringEnum("ContentTypeEnum",
282+
[("application/json", "application/json"), ("application/xml", "application/xml")],
283+
isExtensible: true);
284+
var contentTypeHeader = InputFactory.HeaderParameter(
285+
"contentType",
286+
contentTypeEnum,
287+
isRequired: true,
288+
isContentType: true,
289+
serializedName: "Content-Type");
290+
var bodyParam = InputFactory.BodyParameter("body", InputPrimitiveType.String, isRequired: true);
291+
var pathParam = InputFactory.PathParameter("skillId", InputPrimitiveType.String, isRequired: true);
292+
293+
var operation = InputFactory.Operation(
294+
"UpdateSkillDefaultVersion",
295+
parameters: [pathParam, contentTypeHeader, bodyParam]);
296+
297+
var serviceMethod = InputFactory.BasicServiceMethod(
298+
"UpdateSkillDefaultVersion",
299+
operation);
300+
301+
var client = InputFactory.Client("TestClient", methods: [serviceMethod]);
302+
303+
// Load with a last contract that has contentType before body
304+
var generator = await MockHelpers.LoadMockGeneratorAsync(
305+
clients: () => [client],
306+
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
307+
308+
var clientProvider = generator.Object.OutputLibrary.TypeProviders.OfType<ClientProvider>().FirstOrDefault();
309+
Assert.IsNotNull(clientProvider);
310+
Assert.IsNotNull(clientProvider!.LastContractView);
311+
312+
var methodParameters = RestClientProvider.GetMethodParameters(serviceMethod, ScmMethodKind.Protocol, clientProvider!);
313+
314+
// When the last contract had contentType before body, the ordering should be preserved
315+
Assert.AreEqual(3, methodParameters.Count);
316+
Assert.AreEqual("skillId", methodParameters[0].Name);
317+
Assert.AreEqual("contentType", methodParameters[1].Name); // contentType stays before body for back-compat
318+
Assert.AreEqual("content", methodParameters[2].Name);
319+
}
320+
321+
[Test]
322+
public async Task ContentTypeAfterBodyInLastContractView()
323+
{
324+
// Create an operation with a content-type header (union) and body
325+
var contentTypeEnum = InputFactory.StringEnum("ContentTypeEnum",
326+
[("application/json", "application/json"), ("application/xml", "application/xml")],
327+
isExtensible: true);
328+
var contentTypeHeader = InputFactory.HeaderParameter(
329+
"contentType",
330+
contentTypeEnum,
331+
isRequired: true,
332+
isContentType: true,
333+
serializedName: "Content-Type");
334+
var bodyParam = InputFactory.BodyParameter("body", InputPrimitiveType.String, isRequired: true);
335+
var pathParam = InputFactory.PathParameter("skillId", InputPrimitiveType.String, isRequired: true);
336+
337+
var operation = InputFactory.Operation(
338+
"UpdateSkillDefaultVersion",
339+
parameters: [pathParam, contentTypeHeader, bodyParam]);
340+
341+
var serviceMethod = InputFactory.BasicServiceMethod(
342+
"UpdateSkillDefaultVersion",
343+
operation);
344+
345+
var client = InputFactory.Client("TestClient", methods: [serviceMethod]);
346+
347+
// Load with a last contract that already has contentType after body
348+
var generator = await MockHelpers.LoadMockGeneratorAsync(
349+
clients: () => [client],
350+
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
351+
352+
var clientProvider = generator.Object.OutputLibrary.TypeProviders.OfType<ClientProvider>().FirstOrDefault();
353+
Assert.IsNotNull(clientProvider);
354+
Assert.IsNotNull(clientProvider!.LastContractView);
355+
356+
var methodParameters = RestClientProvider.GetMethodParameters(serviceMethod, ScmMethodKind.Protocol, clientProvider!);
357+
358+
// When the last contract already had contentType after body, the new ordering is used
359+
Assert.AreEqual(3, methodParameters.Count);
360+
Assert.AreEqual("skillId", methodParameters[0].Name);
361+
Assert.AreEqual("content", methodParameters[1].Name);
362+
Assert.AreEqual("contentType", methodParameters[2].Name); // contentType after body
363+
}
364+
236365
[TestCase(true, true)]
237366
[TestCase(true, false)]
238367
[TestCase(false, true)]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#nullable disable
2+
3+
using System.ClientModel;
4+
using System.ClientModel.Primitives;
5+
using System.Threading.Tasks;
6+
7+
namespace Sample
8+
{
9+
public partial class TestClient
10+
{
11+
// This represents a previous contract where contentType already appears after body (content)
12+
public virtual Task<ClientResult> UpdateSkillDefaultVersionAsync(string skillId, BinaryContent content, string contentType, RequestOptions options = null) { return null; }
13+
public virtual ClientResult UpdateSkillDefaultVersion(string skillId, BinaryContent content, string contentType, RequestOptions options = null) { return null; }
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#nullable disable
2+
3+
using System.ClientModel;
4+
using System.ClientModel.Primitives;
5+
using System.Threading.Tasks;
6+
7+
namespace Sample
8+
{
9+
public partial class TestClient
10+
{
11+
// This represents a previous contract where contentType appears before body (content)
12+
public virtual Task<ClientResult> UpdateSkillDefaultVersionAsync(string skillId, string contentType, BinaryContent content, RequestOptions options = null) { return null; }
13+
public virtual ClientResult UpdateSkillDefaultVersion(string skillId, string contentType, BinaryContent content, RequestOptions options = null) { return null; }
14+
}
15+
}

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelFactoryProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ private static bool TryBuildMethodArgumentsForOverload(
286286
}
287287
else
288288
{
289-
arguments.Add(previousParameter);
289+
arguments.Add(parameter.PositionalReference(previousParameter));
290290
}
291291
}
292292

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,15 +1263,14 @@ _ when type.Equals(_additionalPropsUnknownType, ignoreNullable: true) => type,
12631263
/// source-breaking changes for consumers of the library.
12641264
/// </summary>
12651265
/// <remarks>
1266-
/// The override is applied when the generated and last-contract property types differ but
1267-
/// are logically compatible. Two categories are supported:
1266+
/// The override is applied when the generated and last-contract property types differ.
1267+
/// Two categories are supported:
12681268
/// <list type="bullet">
12691269
/// <item>
12701270
/// <description>
12711271
/// Read-write collection properties (<see cref="CSharpType.IsReadWriteList"/> or
1272-
/// <see cref="CSharpType.IsReadWriteDictionary"/>) whose element/key/value type names
1273-
/// match the last contract. This handles cases such as <c>IReadOnlyList&lt;T&gt;</c>
1274-
/// being regenerated as <c>IList&lt;T&gt;</c>.
1272+
/// <see cref="CSharpType.IsReadWriteDictionary"/>). This handles cases such as
1273+
/// <c>IReadOnlyList&lt;T&gt;</c> being regenerated as <c>IList&lt;T&gt;</c>.
12751274
/// </description>
12761275
/// </item>
12771276
/// <item>
@@ -1318,23 +1317,13 @@ private bool TryGetLastContractPropertyTypeOverride(
13181317
return false;
13191318
}
13201319

1321-
// Read-write collections: allow the wrapper (e.g. IList<T> vs IReadOnlyList<T>) to
1322-
// change as long as the element/key/value type names still match. We compare
1323-
// Arguments by name (not just ElementType) so this covers both list element types
1324-
// and dictionary key/value types. AreNamesEqual is used rather than Equals because
1325-
// the argument types may come from different sources (TypeProvider vs compiled
1326-
// assembly) but represent the same logical type.
1320+
// Read-write collections: preserve the previous type whenever it differs (e.g.
1321+
// IReadOnlyList<T> regenerated as IList<T>). This matches the long-standing
1322+
// behavior in the inline back-compat block.
13271323
if (outputProperty.Type.IsReadWriteList || outputProperty.Type.IsReadWriteDictionary)
13281324
{
1329-
if (outputProperty.Type.Arguments.Count == candidate.Arguments.Count &&
1330-
outputProperty.Type.Arguments.Zip(candidate.Arguments).All(
1331-
pair => pair.First.AreNamesEqual(pair.Second)))
1332-
{
1333-
lastContractPropertyType = candidate;
1334-
return true;
1335-
}
1336-
1337-
return false;
1325+
lastContractPropertyType = candidate;
1326+
return true;
13381327
}
13391328

13401329
// Other properties: require the entire type name (top-level plus any generic

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelFactories/ModelFactoryProviderTests.cs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,54 @@ public async Task BackCompatibility_NewModelPropertyAdded()
193193
Assert.IsNotNull(body);
194194
var result = body!.ToDisplayString();
195195
Assert.AreEqual(
196-
"return PublicModel1(stringProp, modelProp, listProp, dictProp: default);\n",
196+
"return PublicModel1(stringProp: stringProp, modelProp: modelProp, listProp: listProp, dictProp: default);\n",
197+
result);
198+
}
199+
200+
// This test validates that when a new property is added AND the previous contract had a different
201+
// parameter ordering, the backward-compat overload uses named arguments to correctly call the current method.
202+
[Test]
203+
public async Task BackCompatibility_NewPropertyAddedWithDifferentParamOrder()
204+
{
205+
_instance = (await MockHelpers.LoadMockGeneratorAsync(
206+
inputNamespaceName: "Sample.Namespace",
207+
inputModelTypes: ModelList,
208+
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync())).Object;
209+
210+
var modelFactory = _instance!.OutputLibrary.ModelFactory.Value;
211+
Assert.AreEqual("SampleNamespaceModelFactory", modelFactory.Name);
212+
213+
modelFactory.ProcessTypeForBackCompatibility();
214+
215+
var methods = modelFactory.Methods;
216+
// There should be an additional method for backward compatibility
217+
Assert.AreEqual(ModelList.Length - ModelList.Where(m => m.Access == "internal").Count() + 1, methods.Count);
218+
219+
var currentOverloadMethod = methods
220+
.FirstOrDefault(m => m.Signature.Name == "PublicModel1" && m.Signature.Parameters.Any(p => p.Name == "dictProp"));
221+
var backwardCompatibilityMethod = methods
222+
.FirstOrDefault(m => m.Signature.Name == "PublicModel1" && m.Signature.Parameters.All(p => p.Name != "dictProp"));
223+
Assert.IsNotNull(currentOverloadMethod);
224+
Assert.IsNotNull(backwardCompatibilityMethod);
225+
226+
// validate the signature of the backward compatibility method preserves the previous parameter order
227+
var parameters = backwardCompatibilityMethod!.Signature.Parameters;
228+
Assert.AreEqual(3, parameters.Count);
229+
Assert.AreEqual("modelProp", parameters[0].Name);
230+
Assert.AreEqual("stringProp", parameters[1].Name);
231+
Assert.AreEqual("listProp", parameters[2].Name);
232+
foreach (var param in parameters)
233+
{
234+
Assert.IsNull(param.DefaultValue);
235+
}
236+
237+
// validate the previous method body uses named arguments to ensure correct mapping
238+
// even though the parameter order differs between the previous and current methods
239+
var body = backwardCompatibilityMethod!.BodyStatements;
240+
Assert.IsNotNull(body);
241+
var result = body!.ToDisplayString();
242+
Assert.AreEqual(
243+
"return PublicModel1(stringProp: stringProp, modelProp: modelProp, listProp: listProp, dictProp: default);\n",
197244
result);
198245
}
199246

0 commit comments

Comments
 (0)