Skip to content

Commit 3dca795

Browse files
Add backcompat support for top parameter (#9645)
## Plan: Add backward compatibility support for `top` parameter conversion Based on PR #9505 which added conversion from `top` to `maxCount`, implement similar backward compatibility as was done for `maxPageSize` recasing. ### Changes Required: - [x] Understand the issue and existing code - PR #9505 added conversion from "top" to "maxCount" in RestClientProvider.cs (lines 1016-1021) - Similar backward compatibility exists for "maxPageSize" using GetCorrectedPageSizeName (lines 874-895) - Need to add similar backward compatibility for "top" parameter - [x] Implement backward compatibility for top parameter - Created reusable `GetCorrectedParameterName` helper method that takes originalName, updatedName, and client - Both paging parameter corrections (page size and maxCount) call this helper directly - Preserves parameter name if it exists in previous version (LastContractView) - Applies normalization or conversion if no backward compatibility needed - Consolidated both parameter corrections under single InputPagingServiceMethod check - Made updatedName parameter non-nullable since it's always provided by callers - Moved page size normalization logic inside the parameter match check for efficiency - [x] Add/update tests - Updated test `TopParameterPreservedWhenExistsInLastContractView` to validate LastContractView scenario - Created test data file representing previous contract with "top" parameter - Test validates that "top" is preserved when it exists in LastContractView - All 6 tests passing including test for "top" to "maxCount" conversion when no LastContractView - All PageSizeParameter tests pass (6/6) - [x] Run tests and validate changes - Successfully built C# client generator - All ListPageableTests pass (6/6) - All PageSizeParameter tests pass (6/6) - Verified backward compatibility scenario works correctly - Verified no breaking changes for existing code - [x] Update documentation - Updated backward-compatibility.md with the new top parameter conversion scenario - Added Table of Contents entries for the new subsection - Documented both backward compatibility case (preserves "top") and standardization case (converts to "maxCount") - Renamed "Parameter Name Casing" to "Parameter Naming" to better reflect the section content - [x] Refactor to eliminate code duplication - Created single reusable `GetCorrectedParameterName` helper method - Removed wrapper methods `GetCorrectedPageSizeName` and `GetCorrectedMaxCountName` - Both paging parameters now call the helper directly with appropriate arguments - Maintains same functionality with minimal, maintainable code - [x] Improve code organization - Consolidated paging parameter name corrections under single InputPagingServiceMethod check - Split out specific parameter name matching into separate if blocks within - Improved readability and maintainability - [x] Simplify method signature - Made updatedName parameter non-nullable since all callers provide non-null values - Removed unnecessary null coalesce operator - Simplified return statement and comment - [x] Optimize parameter name correction logic - Moved page size normalization check inside the parameter match condition - Only performs normalization when actually needed (when we find a matching parameter) - More efficient as it avoids unnecessary work on every method call <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>Enable backcompat support for `top` parameter</issue_title> > <issue_description>#9505 added a conversion from top to maxCount. We should add similar back compat support as we added for the `maxPageSize` recasing.</issue_description> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #9643 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JoshLove-msft <54595583+JoshLove-msft@users.noreply.github.com> Co-authored-by: jolov <jolov@microsoft.com>
1 parent 7bf4831 commit 3dca795

File tree

4 files changed

+221
-29
lines changed

4 files changed

+221
-29
lines changed

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

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -871,27 +871,25 @@ private static bool TryGetSpecialHeaderParam(InputParameter inputParameter, [Not
871871
: null;
872872
}
873873

874-
private static string GetCorrectedPageSizeName(string originalName, ClientProvider client)
874+
private static void UpdateParameterNameWithBackCompat(InputParameter inputParameter, string proposedName, ClientProvider client)
875875
{
876-
// Check if parameter exists in LastContractView for backward compatibility
876+
// Check if the original parameter name exists in LastContractView for backward compatibility
877877
var existingParam = client.LastContractView?.Methods
878878
?.SelectMany(method => method.Signature.Parameters)
879-
.FirstOrDefault(parameter => string.Equals(parameter.Name, originalName, StringComparison.OrdinalIgnoreCase))
879+
.FirstOrDefault(p => string.Equals(p.Name, inputParameter.Name, StringComparison.OrdinalIgnoreCase))
880880
?.Name;
881881

882882
if (existingParam != null)
883883
{
884-
return existingParam;
884+
// Preserve the exact name (including casing) from the previous contract for backward compatibility
885+
proposedName = existingParam;
885886
}
886887

887-
// Normalize badly-cased "maxpagesize" variants to Camel Case
888-
if (string.Equals(originalName, MaxPageSizeParameterName, StringComparison.OrdinalIgnoreCase))
888+
// Use the updated name
889+
if (!string.Equals(inputParameter.Name, proposedName, StringComparison.Ordinal))
889890
{
890-
return MaxPageSizeParameterName;
891+
inputParameter.Update(name: proposedName);
891892
}
892-
893-
// Keep original name for all other cases
894-
return originalName;
895893
}
896894

897895
private static bool ShouldUpdateReinjectedParameter(InputParameter inputParameter, InputPagingServiceMethod? pagingServiceMethod)
@@ -962,12 +960,6 @@ internal static List<ParameterProvider> GetMethodParameters(
962960

963961
var pageSizeParameterName = GetPageSizeParameterName(serviceMethod as InputPagingServiceMethod);
964962

965-
string? correctedPageSizeName = null;
966-
if (pageSizeParameterName != null)
967-
{
968-
correctedPageSizeName = GetCorrectedPageSizeName(pageSizeParameterName, client);
969-
}
970-
971963
ModelProvider? spreadSource = null;
972964
if (methodType == ScmMethodKind.Convenience)
973965
{
@@ -1013,19 +1005,24 @@ internal static List<ParameterProvider> GetMethodParameters(
10131005
continue;
10141006
}
10151007

1016-
// For paging operations, rename "top" parameter to "maxCount"
1017-
if (serviceMethod is InputPagingServiceMethod &&
1018-
string.Equals(inputParam.Name, TopParameterName, StringComparison.OrdinalIgnoreCase))
1008+
// For paging operations, handle parameter name corrections with backward compatibility
1009+
if (serviceMethod is InputPagingServiceMethod)
10191010
{
1020-
inputParam.Update(name: MaxCountParameterName);
1021-
}
1011+
// Rename "top" parameter to "maxCount" (with backward compatibility)
1012+
if (string.Equals(inputParam.Name, TopParameterName, StringComparison.OrdinalIgnoreCase))
1013+
{
1014+
UpdateParameterNameWithBackCompat(inputParam, MaxCountParameterName, client);
1015+
}
10221016

1023-
// For paging operations, ensure page size parameter uses the correct casing
1024-
if (correctedPageSizeName != null && string.Equals(inputParam.Name, pageSizeParameterName, StringComparison.OrdinalIgnoreCase))
1025-
{
1026-
if (!string.Equals(inputParam.Name, correctedPageSizeName, StringComparison.Ordinal))
1017+
// Ensure page size parameter uses the correct casing (with backward compatibility)
1018+
if (pageSizeParameterName != null && string.Equals(inputParam.Name, pageSizeParameterName, StringComparison.OrdinalIgnoreCase))
10271019
{
1028-
inputParam.Update(name: correctedPageSizeName);
1020+
var updatedPageSizeParameterName = pageSizeParameterName.Equals(MaxPageSizeParameterName, StringComparison.OrdinalIgnoreCase)
1021+
? MaxPageSizeParameterName
1022+
: pageSizeParameterName;
1023+
// For page size parameters, normalize badly-cased "maxpagesize" variants to proper camelCase, but always
1024+
// respect backcompat.
1025+
UpdateParameterNameWithBackCompat(inputParam, updatedPageSizeParameterName, client);
10291026
}
10301027
}
10311028

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/CollectionResultDefinitions/ListPageableTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
using System;
5+
using System.Collections.Generic;
46
using System.Linq;
7+
using System.Threading.Tasks;
58
using Microsoft.TypeSpec.Generator.ClientModel.Providers;
69
using Microsoft.TypeSpec.Generator.Input;
710
using Microsoft.TypeSpec.Generator.Primitives;
11+
using Microsoft.TypeSpec.Generator.Providers;
812
using Microsoft.TypeSpec.Generator.Tests.Common;
913
using NUnit.Framework;
1014

@@ -69,6 +73,67 @@ public void TopParameterRenamedToMaxCountInPagingOperation()
6973
Assert.Contains("maxCount", parameterNames, "Should contain 'maxCount' parameter");
7074
Assert.IsFalse(parameterNames.Contains("top"), "Should not contain 'top' parameter after renaming");
7175
}
76+
77+
[Test]
78+
public async Task TopParameterPreservedWhenExistsInLastContractView()
79+
{
80+
// This test verifies that when a "top" parameter exists in LastContractView,
81+
// it is preserved for backward compatibility instead of being converted to "maxCount"
82+
var topParameter = InputFactory.QueryParameter("top", InputPrimitiveType.Int32, isRequired: false, serializedName: "top");
83+
84+
List<InputParameter> parameters = [topParameter];
85+
List<InputMethodParameter> methodParameters =
86+
[
87+
InputFactory.MethodParameter("top", InputPrimitiveType.Int32, isRequired: false,
88+
location: InputRequestLocation.Query, serializedName: "top"),
89+
];
90+
91+
var inputModel = InputFactory.Model("Item", properties:
92+
[
93+
InputFactory.Property("id", InputPrimitiveType.String, isRequired: true),
94+
]);
95+
96+
var pagingMetadata = new InputPagingServiceMetadata(
97+
["items"],
98+
new InputNextLink(null, ["nextLink"], InputResponseLocation.Body, []),
99+
null,
100+
null);
101+
102+
var response = InputFactory.OperationResponse(
103+
[200],
104+
InputFactory.Model(
105+
"PagedItems",
106+
properties: [
107+
InputFactory.Property("items", InputFactory.Array(inputModel)),
108+
InputFactory.Property("nextLink", InputPrimitiveType.Url)
109+
]));
110+
111+
var operation = InputFactory.Operation("getItems", responses: [response], parameters: parameters);
112+
var inputServiceMethod = InputFactory.PagingServiceMethod(
113+
"getItems",
114+
operation,
115+
pagingMetadata: pagingMetadata,
116+
parameters: methodParameters);
117+
118+
var client = InputFactory.Client("testClient", methods: [inputServiceMethod]);
119+
120+
var generator = await MockHelpers.LoadMockGeneratorAsync(
121+
clients: () => [client],
122+
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
123+
124+
var clientProvider = generator.Object.OutputLibrary.TypeProviders.OfType<ClientProvider>().FirstOrDefault();
125+
Assert.IsNotNull(clientProvider);
126+
Assert.IsNotNull(clientProvider!.LastContractView);
127+
128+
var methodParams = RestClientProvider.GetMethodParameters(inputServiceMethod, ScmMethodKind.Convenience, clientProvider!);
129+
130+
var topParam = methodParams.FirstOrDefault(p =>
131+
string.Equals(p.Name, "top", StringComparison.Ordinal));
132+
133+
Assert.IsNotNull(topParam, "Top parameter should be present in method parameters");
134+
Assert.AreEqual("top", topParam!.Name,
135+
"Parameter name should be 'top' (from LastContractView), not 'maxCount' (conversion should be prevented)");
136+
}
72137

73138
[Test]
74139
public void NoNextLinkOrContinuationTokenOfT()
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 the previous contract with top parameter
12+
public virtual Task<ClientResult> GetItemsAsync(int? top, CancellationToken cancellationToken = default) { return null; }
13+
public virtual ClientResult GetItems(int? top, CancellationToken cancellationToken = default) { return null; }
14+
}
15+
}

packages/http-client-csharp/generator/docs/backward-compatibility.md

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
- [API Version Enum](#api-version-enum)
1212
- [Non-abstract Base Models](#non-abstract-base-models)
1313
- [Model Constructors](#model-constructors)
14-
- [Parameter Name Casing](#parameter-name-casing)
14+
- [Parameter Naming](#parameter-naming)
15+
- [Page Size Parameter Casing Correction](#scenario-page-size-parameter-casing-correction)
16+
- [Top Parameter Conversion to MaxCount](#scenario-top-parameter-conversion-to-maxcount)
1517

1618
## Overview
1719

@@ -361,9 +363,9 @@ public abstract partial class SearchIndexerDataIdentity
361363
- The modifier is changed from `private protected` to `public`
362364
- No additional constructors are generated; only the accessibility is adjusted
363365

364-
### Parameter Name Casing
366+
### Parameter Naming
365367

366-
The generator maintains backward compatibility for parameter names to ensure that existing code continues to compile when parameter name casing is corrected or standardized.
368+
The generator maintains backward compatibility for parameter names to ensure that existing code continues to compile when parameter names are corrected, standardized, or converted to follow naming conventions.
367369

368370
#### Scenario: Page Size Parameter Casing Correction
369371

@@ -475,3 +477,116 @@ public virtual AsyncPageable<Item> GetItemsAsync(int? maxPageSize = null, Cancel
475477
- **Case 2 (Normalization)**: If the parameter does NOT exist in LastContractView, badly-cased variants are normalized to proper camelCase
476478
- The HTTP query parameter always uses the original serialized name from the spec (e.g., `maxpagesize`)
477479
- Existing client code continues to compile without changes
480+
481+
#### Scenario: Top Parameter Conversion to MaxCount
482+
483+
**Description:** For paging operations, the generator converts the `top` parameter to `maxCount` to follow standard naming conventions. However, backward compatibility is maintained in two ways:
484+
485+
1. **If the `top` parameter exists in LastContractView**: The generator preserves the `top` parameter name (with its exact casing) to maintain backward compatibility
486+
2. **If the `top` parameter does NOT exist in LastContractView**: The generator converts `top` to the standardized `maxCount` parameter name
487+
488+
This commonly occurs when:
489+
490+
- Migrating from an older API version or generator that used `top` for pagination limits
491+
- TypeSpec defines paging operations with a `top` parameter
492+
- The generator needs to standardize on `maxCount` while maintaining backward compatibility
493+
494+
**Example:**
495+
496+
**Case 1: Top parameter exists in LastContractView - preserved for backward compatibility**
497+
498+
Previous version had `top` parameter:
499+
500+
```csharp
501+
public virtual AsyncPageable<Item> GetItemsAsync(int? top = null, CancellationToken cancellationToken = default)
502+
{
503+
HttpMessage CreateRequest()
504+
{
505+
var message = pipeline.CreateMessage();
506+
var request = message.Request;
507+
request.Method = RequestMethod.Get;
508+
var uri = new RequestUriBuilder();
509+
uri.Reset(endpoint);
510+
uri.AppendPath("/items", false);
511+
if (top != null)
512+
{
513+
uri.AppendQuery("top", top.Value, true); // Serialized name from spec
514+
}
515+
// ...
516+
}
517+
// ...
518+
}
519+
```
520+
521+
Current TypeSpec still defines `top` parameter:
522+
523+
```typespec
524+
op getItems(@query top?: int32): Page<Item>;
525+
```
526+
527+
**Generated Compatibility Result:**
528+
529+
The generator detects the `top` parameter in LastContractView and preserves it exactly to maintain backward compatibility:
530+
531+
```csharp
532+
public virtual AsyncPageable<Item> GetItemsAsync(int? top = null, CancellationToken cancellationToken = default)
533+
{
534+
HttpMessage CreateRequest()
535+
{
536+
var message = pipeline.CreateMessage();
537+
var request = message.Request;
538+
request.Method = RequestMethod.Get;
539+
var uri = new RequestUriBuilder();
540+
uri.Reset(endpoint);
541+
uri.AppendPath("/items", false);
542+
if (top != null)
543+
{
544+
uri.AppendQuery("top", top.Value, true); // Still uses "top" for backward compatibility
545+
}
546+
// ...
547+
}
548+
// ...
549+
}
550+
```
551+
552+
**Case 2: Top parameter does NOT exist in LastContractView - converted to maxCount**
553+
554+
New paging operation with `top` parameter (no previous version):
555+
556+
```typespec
557+
op getItems(@query top?: int32): Page<Item>;
558+
```
559+
560+
**Generated Result:**
561+
562+
The generator converts the parameter name to standardized `maxCount` since there's no previous version to maintain compatibility with:
563+
564+
```csharp
565+
public virtual AsyncPageable<Item> GetItemsAsync(int? maxCount = null, CancellationToken cancellationToken = default)
566+
{
567+
HttpMessage CreateRequest()
568+
{
569+
var message = pipeline.CreateMessage();
570+
var request = message.Request;
571+
request.Method = RequestMethod.Get;
572+
var uri = new RequestUriBuilder();
573+
uri.Reset(endpoint);
574+
uri.AppendPath("/items", false);
575+
if (maxCount != null)
576+
{
577+
uri.AppendQuery("top", maxCount.Value, true); // Serialized name still uses "top" from spec
578+
}
579+
// ...
580+
}
581+
// ...
582+
}
583+
```
584+
585+
**Key Points:**
586+
587+
- **Case 1 (Backward compatibility)**: If `top` parameter exists in LastContractView, its exact name and casing are preserved
588+
- **Case 2 (Standardization)**: If `top` parameter does NOT exist in LastContractView, it is converted to `maxCount` for consistency
589+
- The HTTP query parameter always uses the original serialized name from the spec (e.g., `top`)
590+
- This conversion is specific to paging operations only
591+
- Existing client code with `top` continues to compile without changes
592+
- New code benefits from the standardized `maxCount` naming convention

0 commit comments

Comments
 (0)