From 5940399d5869da14fd650174a3ec5c264de2af51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:53:00 +0000 Subject: [PATCH 01/11] Initial plan From ceee14713eddf8bb560cd0d87f61c3fe212d701d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:01:30 +0000 Subject: [PATCH 02/11] fix(http-client-csharp): guard nullable date path formatting Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 7 +++- .../RestClientProviderTests.cs | 31 ++++++++++++++++++ ...rameterIsGuardedBeforeFormatting(False).cs | 32 +++++++++++++++++++ ...arameterIsGuardedBeforeFormatting(True).cs | 28 ++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(True).cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index 747e51bcc2f..7dc6fc57f32 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -942,6 +942,11 @@ private void AddUriSegments( } else { + var nullCheckExpression = valueExpression; + if (type is { IsNullable: true, IsValueType: true }) + { + valueExpression = valueExpression.Property(nameof(Nullable.Value)); + } valueExpression = type?.Equals(typeof(string)) == true ? valueExpression : valueExpression.Invoke(nameof(ToString), toStringParams); @@ -954,7 +959,7 @@ private void AddUriSegments( : [uri.AppendPath(valueExpression, escape).Terminate()]; statement = BuildQueryOrHeaderOrPathParameterNullCheck( type, - valueExpression, + nullCheckExpression, appendPathStatements); } else diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs index 0914920412c..3a1632ca510 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs @@ -1164,6 +1164,37 @@ public void TestBuildCreateRequestMethodWithPathParameters() Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } + [TestCase(true)] + [TestCase(false)] + public void NullableDatePathParameterIsGuardedBeforeFormatting(bool isRequired) + { + var dateType = new InputDateTimeType( + DateTimeKnownEncoding.Rfc7231, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("requestOn", dateType, isRequired: isRequired)], + path: "/things/{requestOn}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation, + parameters: + [ + InputFactory.MethodParameter( + "requestOn", + dateType, + isRequired: isRequired, + location: InputRequestLocation.Path) + ]); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(parameters: isRequired.ToString()), file.Content); + } + // An optional trailing path parameter must not emit a dangling separator when null. // e.g. "/certificates/{certificateName}/{certificateVersion}" with a null version // should produce "/certificates/{name}", not "/certificates/{name}/". diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs new file mode 100644 index 00000000000..6e4a8a5786a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs @@ -0,0 +1,32 @@ +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(global::System.DateTimeOffset? requestOn, global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things", false); + if ((requestOn != null)) + { + uri.AppendPath("/", false); + uri.AppendPath(requestOn.Value.ToString("R"), true); + } + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(True).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(True).cs new file mode 100644 index 00000000000..bd73910a526 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(True).cs @@ -0,0 +1,28 @@ +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(global::System.DateTimeOffset requestOn, global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things/", false); + uri.AppendPath(requestOn.ToString("R"), true); + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} From e919a5c32f73eef3e464c9c4c65dc2a77b56dbf7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:13:21 +0000 Subject: [PATCH 03/11] fix(http-client-csharp): exclude enums from nullable value unwrap, use null-conditional Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 4 +-- .../RestClientProviderTests.cs | 21 +++++++++++++++ ...rameterIsGuardedBeforeFormatting(False).cs | 2 +- ...OptionalEnumPathParameterIsNotUnwrapped.cs | 27 +++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index 7dc6fc57f32..b434b4be1a0 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -943,9 +943,9 @@ private void AddUriSegments( else { var nullCheckExpression = valueExpression; - if (type is { IsNullable: true, IsValueType: true }) + if (type is { IsNullable: true, IsValueType: true, IsEnum: false }) { - valueExpression = valueExpression.Property(nameof(Nullable.Value)); + valueExpression = valueExpression.NullConditional(); } valueExpression = type?.Equals(typeof(string)) == true ? valueExpression diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs index 3a1632ca510..bc79a0af3f5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs @@ -1195,6 +1195,27 @@ public void NullableDatePathParameterIsGuardedBeforeFormatting(bool isRequired) Assert.AreEqual(Helpers.GetExpectedFromFile(parameters: isRequired.ToString()), file.Content); } + [Test] + public void OptionalEnumPathParameterIsNotUnwrapped() + { + var enumType = InputFactory.StringEnum( + "Color", + [("Red", "red"), ("Blue", "blue")], + isExtensible: true); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("color", enumType, isRequired: false, scope: InputParameterScope.Client)], + path: "/things/{color}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + // An optional trailing path parameter must not emit a dangling separator when null. // e.g. "/certificates/{certificateName}/{certificateVersion}" with a null version // should produce "/certificates/{name}", not "/certificates/{name}/". diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs index 6e4a8a5786a..28f3a124b31 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs @@ -21,7 +21,7 @@ public partial class TestClient if ((requestOn != null)) { uri.AppendPath("/", false); - uri.AppendPath(requestOn.Value.ToString("R"), true); + uri.AppendPath(requestOn?.ToString("R"), true); } global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); global::System.ClientModel.Primitives.PipelineRequest request = message.Request; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs new file mode 100644 index 00000000000..c7e4fc86b3a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs @@ -0,0 +1,27 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things", false); + uri.AppendPath(_color?.ToString().ToString(), true); + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} From bd2a68caab5a8501dcfada1f2f8dc267a6327a9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:36:56 +0000 Subject: [PATCH 04/11] fix(http-client-csharp): remove redundant guard unwrap and fix enum double-serialization Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 25 ++++++++++++++++--- ...rameterIsGuardedBeforeFormatting(False).cs | 2 +- ...OptionalEnumPathParameterIsNotUnwrapped.cs | 6 ++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index b434b4be1a0..22e6b828284 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -908,6 +908,9 @@ private void AddUriSegments( */ var isClientParameter = ClientProvider.ClientParameters.Any(p => string.Equals(p.Name, paramName, StringComparison.OrdinalIgnoreCase)) || _inputClient.Parameters.Any(p => p is InputMethodParameter { ParamAlias: string alias } && string.Equals(alias, paramName, StringComparison.OrdinalIgnoreCase)); + bool willEmitNullGuard = inputParamMap.TryGetValue(paramName, out var pathParamForGuard) + && pathParamForGuard.IsRequired == false + && (pathParamForGuard is InputPathParameter || pathParamForGuard is InputEndpointParameter); CSharpType? type; SerializationFormat? serializationFormat; ValueExpression? valueExpression; @@ -942,16 +945,32 @@ private void AddUriSegments( } else { + // The null check must always be performed against the raw, still-nullable value: + // for enums, GetParamInfo has already serialized valueExpression (e.g. via a + // null-conditional ToString call), so capture the check subject before any further + // unwrapping below could make it unconditionally dereference a null value. var nullCheckExpression = valueExpression; if (type is { IsNullable: true, IsValueType: true, IsEnum: false }) { - valueExpression = valueExpression.NullConditional(); + valueExpression = willEmitNullGuard + ? valueExpression.Property(nameof(Nullable.Value)) + : valueExpression.NullConditional(); } - valueExpression = type?.Equals(typeof(string)) == true + else if (type is { IsNullable: true, IsEnum: true } && willEmitNullGuard && paramMap.TryGetValue(paramName, out var enumParamProvider)) + { + // Already inside a null guard: check the raw nullable field directly (instead of + // repeating the null-conditional serialized expression) and unwrap it via .Value + // before serializing, avoiding both a redundant guard invocation and a duplicate + // ToString call. + ValueExpression rawEnumVariable = enumParamProvider.Field is null ? enumParamProvider : enumParamProvider.Field; + nullCheckExpression = rawEnumVariable; + valueExpression = type.ToSerial(rawEnumVariable.Property(nameof(Nullable.Value))); + } + valueExpression = type?.Equals(typeof(string)) == true || type?.IsEnum == true ? valueExpression : valueExpression.Invoke(nameof(ToString), toStringParams); MethodBodyStatement statement; - if (inputParam?.IsRequired == false) + if (willEmitNullGuard) { bool shouldPrependWithPathSeparator = separatorDeferred || (path.Length > 0 && path[^1] != '/'); List appendPathStatements = shouldPrependWithPathSeparator diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs index 28f3a124b31..6e4a8a5786a 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/NullableDatePathParameterIsGuardedBeforeFormatting(False).cs @@ -21,7 +21,7 @@ public partial class TestClient if ((requestOn != null)) { uri.AppendPath("/", false); - uri.AppendPath(requestOn?.ToString("R"), true); + uri.AppendPath(requestOn.Value.ToString("R"), true); } global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); global::System.ClientModel.Primitives.PipelineRequest request = message.Request; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs index c7e4fc86b3a..f63a0a0ac07 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs @@ -17,7 +17,11 @@ public partial class TestClient global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); uri.Reset(_endpoint); uri.AppendPath("/things", false); - uri.AppendPath(_color?.ToString().ToString(), true); + if ((_color != null)) + { + uri.AppendPath("/", false); + uri.AppendPath(_color.Value.ToString(), true); + } global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); global::System.ClientModel.Primitives.PipelineRequest request = message.Request; message.Apply(options); From 306832449f65cccdaa068dde0ddbad0a2d4402ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:59:49 +0000 Subject: [PATCH 05/11] fix(http-client-csharp): guard nullable required path params, remove internal comments Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index 22e6b828284..6577cfed21b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -908,8 +908,7 @@ private void AddUriSegments( */ var isClientParameter = ClientProvider.ClientParameters.Any(p => string.Equals(p.Name, paramName, StringComparison.OrdinalIgnoreCase)) || _inputClient.Parameters.Any(p => p is InputMethodParameter { ParamAlias: string alias } && string.Equals(alias, paramName, StringComparison.OrdinalIgnoreCase)); - bool willEmitNullGuard = inputParamMap.TryGetValue(paramName, out var pathParamForGuard) - && pathParamForGuard.IsRequired == false + bool isPathOrEndpointParam = inputParamMap.TryGetValue(paramName, out var pathParamForGuard) && (pathParamForGuard is InputPathParameter || pathParamForGuard is InputEndpointParameter); CSharpType? type; SerializationFormat? serializationFormat; @@ -935,6 +934,11 @@ private void AddUriSegments( throw new InvalidOperationException($"The location of parameter {inputParam.Name} should be path or uri"); } } + // A guard is needed whenever the value can be null at runtime: either the parameter + // is optional, or its type is nullable even though the parameter itself is required + // (e.g. a required-but-nullable path parameter). + bool willEmitNullGuard = isPathOrEndpointParam + && (pathParamForGuard!.IsRequired == false || type?.IsNullable == true); string? format = serializationFormat?.ToFormatSpecifier(); ValueExpression[] toStringParams = format is null ? [] : [Literal(format)]; InputPathParameter? inputPathParameter = inputParam as InputPathParameter; @@ -945,10 +949,6 @@ private void AddUriSegments( } else { - // The null check must always be performed against the raw, still-nullable value: - // for enums, GetParamInfo has already serialized valueExpression (e.g. via a - // null-conditional ToString call), so capture the check subject before any further - // unwrapping below could make it unconditionally dereference a null value. var nullCheckExpression = valueExpression; if (type is { IsNullable: true, IsValueType: true, IsEnum: false }) { @@ -958,10 +958,6 @@ private void AddUriSegments( } else if (type is { IsNullable: true, IsEnum: true } && willEmitNullGuard && paramMap.TryGetValue(paramName, out var enumParamProvider)) { - // Already inside a null guard: check the raw nullable field directly (instead of - // repeating the null-conditional serialized expression) and unwrap it via .Value - // before serializing, avoiding both a redundant guard invocation and a duplicate - // ToString call. ValueExpression rawEnumVariable = enumParamProvider.Field is null ? enumParamProvider : enumParamProvider.Field; nullCheckExpression = rawEnumVariable; valueExpression = type.ToSerial(rawEnumVariable.Property(nameof(Nullable.Value))); From 4795fb43f45e169ad54a33a20d5898d791615d82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:15:08 +0000 Subject: [PATCH 06/11] test(http-client-csharp): cover nullable path guard branches Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 3 +- .../RestClientProviderTests.cs | 77 +++++++++++++++++++ ...ePathParameterIsGuardedBeforeFormatting.cs | 31 ++++++++ ...MethodEnumPathParameterIsSerializedOnce.cs | 31 ++++++++ ...redNullableStringPathParameterIsGuarded.cs | 31 ++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientDatePathParameterIsGuardedBeforeFormatting.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalMethodEnumPathParameterIsSerializedOnce.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsGuarded.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index 6577cfed21b..9661b5f1b99 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -897,7 +897,8 @@ private void AddUriSegments( bool separatorDeferred = false; if (pathLiteral.EndsWith('/') && inputParamMap.TryGetValue(paramName, out var optionalCheckParam) - && optionalCheckParam is InputPathParameter { IsRequired: false }) + && optionalCheckParam is InputPathParameter or InputEndpointParameter + && (optionalCheckParam.IsRequired == false || optionalCheckParam.Type is InputNullableType)) { pathLiteral = pathLiteral.Substring(0, pathLiteral.Length - 1); separatorDeferred = true; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs index bc79a0af3f5..023327b5f12 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs @@ -1216,6 +1216,83 @@ public void OptionalEnumPathParameterIsNotUnwrapped() Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } + [Test] + public void OptionalMethodEnumPathParameterIsSerializedOnce() + { + var enumType = InputFactory.StringEnum( + "Color", + [("Red", "red"), ("Blue", "blue")], + isExtensible: true); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("color", enumType, isRequired: false)], + path: "/things/{color}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation, + parameters: + [ + InputFactory.MethodParameter( + "color", + enumType, + isRequired: false, + location: InputRequestLocation.Path) + ]); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + + [Test] + public void RequiredNullableStringPathParameterIsGuarded() + { + var nullableString = new InputNullableType(InputPrimitiveType.String); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("name", nullableString, isRequired: true)], + path: "/things/{name}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation, + parameters: + [ + InputFactory.MethodParameter( + "name", + nullableString, + isRequired: true, + location: InputRequestLocation.Path) + ]); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + + [Test] + public void OptionalClientDatePathParameterIsGuardedBeforeFormatting() + { + var dateType = new InputDateTimeType( + DateTimeKnownEncoding.Rfc7231, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("requestOn", dateType, isRequired: false, scope: InputParameterScope.Client)], + path: "/things/{requestOn}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + // An optional trailing path parameter must not emit a dangling separator when null. // e.g. "/certificates/{certificateName}/{certificateVersion}" with a null version // should produce "/certificates/{name}", not "/certificates/{name}/". diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientDatePathParameterIsGuardedBeforeFormatting.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientDatePathParameterIsGuardedBeforeFormatting.cs new file mode 100644 index 00000000000..5765ad6d73b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientDatePathParameterIsGuardedBeforeFormatting.cs @@ -0,0 +1,31 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things", false); + if ((_requestOn != null)) + { + uri.AppendPath("/", false); + uri.AppendPath(_requestOn.Value.ToString("R"), true); + } + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalMethodEnumPathParameterIsSerializedOnce.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalMethodEnumPathParameterIsSerializedOnce.cs new file mode 100644 index 00000000000..b205e84100e --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalMethodEnumPathParameterIsSerializedOnce.cs @@ -0,0 +1,31 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(string color, global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things", false); + if ((color != null)) + { + uri.AppendPath("/", false); + uri.AppendPath(color, true); + } + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsGuarded.cs new file mode 100644 index 00000000000..2b507f667c4 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsGuarded.cs @@ -0,0 +1,31 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(string name, global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things", false); + if ((name != null)) + { + uri.AppendPath("/", false); + uri.AppendPath(name, true); + } + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} From baead841107446ea730304c0b8d45ce56acdbcdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:56:11 +0000 Subject: [PATCH 07/11] fix(http-client-csharp): guard and defer separator for required-nullable collection path params Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 14 +++++++- .../RestClientProviderTests.cs | 26 +++++++++++++++ ...ullableCollectionPathParameterIsGuarded.cs | 32 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsGuarded.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index 4ffaa914713..f68287a1866 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -967,7 +967,19 @@ private void AddUriSegments( bool escape = !inputPathParameter?.SkipUrlEncoding ?? true; if (type?.OutputType.IsCollection == true) { - statements.Add(uri.AppendPathDelimited(valueExpression, GetFormatEnumValue(serializationFormat), escape).Terminate()); + MethodBodyStatement collectionStatement = uri.AppendPathDelimited(valueExpression, GetFormatEnumValue(serializationFormat), escape).Terminate(); + if (willEmitNullGuard) + { + bool shouldPrependWithPathSeparator = separatorDeferred || (path.Length > 0 && path[^1] != '/'); + List appendPathStatements = shouldPrependWithPathSeparator + ? [uri.AppendPath(Literal("/"), false).Terminate(), collectionStatement] + : [collectionStatement]; + collectionStatement = BuildQueryOrHeaderOrPathParameterNullCheck( + type, + valueExpression, + appendPathStatements); + } + statements.Add(collectionStatement); } else { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs index ec94b821990..391af799ad0 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs @@ -1436,6 +1436,32 @@ public void RequiredNullableStringPathParameterIsGuarded() Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } + [Test] + public void RequiredNullableCollectionPathParameterIsGuarded() + { + var nullableArray = new InputNullableType(InputFactory.Array(InputPrimitiveType.String)); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("ids", nullableArray, isRequired: true)], + path: "/things/{ids}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation, + parameters: + [ + InputFactory.MethodParameter( + "ids", + nullableArray, + isRequired: true, + location: InputRequestLocation.Path) + ]); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + [Test] public void OptionalClientDatePathParameterIsGuardedBeforeFormatting() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsGuarded.cs new file mode 100644 index 00000000000..a3b81a192fe --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsGuarded.cs @@ -0,0 +1,32 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; +using System.Collections.Generic; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(global::System.Collections.Generic.IEnumerable ids, global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things", false); + if (((ids != null) && !((ids is global::Sample.ChangeTrackingList changeTrackingList) && changeTrackingList.IsUndefined))) + { + uri.AppendPath("/", false); + uri.AppendPathDelimited(ids, ",", escape: true); + } + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} From e8ad7b095f3db0b33183a5602c110b9d919a7bcd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:01:53 +0000 Subject: [PATCH 08/11] fix(http-client-csharp): use resolved path parameter's input name for enum lookup Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 2 +- .../RestClientProviderTests.cs | 21 +++++++++++++ ...eterWithDistinctSerializedNameIsGuarded.cs | 31 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientEnumPathParameterWithDistinctSerializedNameIsGuarded.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index f68287a1866..4f739916f4f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -990,7 +990,7 @@ private void AddUriSegments( ? valueExpression.Property(nameof(Nullable.Value)) : valueExpression.NullConditional(); } - else if (type is { IsNullable: true, IsEnum: true } && willEmitNullGuard && paramMap.TryGetValue(paramName, out var enumParamProvider)) + else if (type is { IsNullable: true, IsEnum: true } && willEmitNullGuard && paramMap.TryGetValue(inputParam?.Name ?? paramName, out var enumParamProvider)) { ValueExpression rawEnumVariable = enumParamProvider.Field is null ? enumParamProvider : enumParamProvider.Field; nullCheckExpression = rawEnumVariable; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs index 391af799ad0..84f65701f32 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs @@ -1410,6 +1410,27 @@ public void OptionalMethodEnumPathParameterIsSerializedOnce() Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } + [Test] + public void OptionalClientEnumPathParameterWithDistinctSerializedNameIsGuarded() + { + var enumType = InputFactory.StringEnum( + "Color", + [("Red", "red"), ("Blue", "blue")], + isExtensible: true); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("color", enumType, isRequired: false, serializedName: "colour", scope: InputParameterScope.Client)], + path: "/things/{colour}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + [Test] public void RequiredNullableStringPathParameterIsGuarded() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientEnumPathParameterWithDistinctSerializedNameIsGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientEnumPathParameterWithDistinctSerializedNameIsGuarded.cs new file mode 100644 index 00000000000..f63a0a0ac07 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientEnumPathParameterWithDistinctSerializedNameIsGuarded.cs @@ -0,0 +1,31 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things", false); + if ((_color != null)) + { + uri.AppendPath("/", false); + uri.AppendPath(_color.Value.ToString(), true); + } + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} From c4c78a924782b2739c5ae513c0f3d7819a7108ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:26:32 +0000 Subject: [PATCH 09/11] fix(http-client-csharp): keep path separator for required nullable enum params Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 15 +++------ .../RestClientProviderTests.cs | 30 ++++++++++++++++++ ...uiredNullableEnumPathParameterIsGuarded.cs | 31 +++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsGuarded.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index 4f739916f4f..13cd4ab4e4d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -914,12 +914,14 @@ private void AddUriSegments( * literal so it is only written together with the parameter value inside * the null check below. */ + bool hasPathOrEndpointParam = inputParamMap.TryGetValue(paramName, out var pathParamForGuard) + && pathParamForGuard is InputPathParameter or InputEndpointParameter; + bool willEmitNullGuard = hasPathOrEndpointParam + && (pathParamForGuard!.IsRequired == false || pathParamForGuard.Type is InputNullableType); var pathLiteral = path.ToString(); bool separatorDeferred = false; if (pathLiteral.EndsWith('/') - && inputParamMap.TryGetValue(paramName, out var optionalCheckParam) - && optionalCheckParam is InputPathParameter or InputEndpointParameter - && (optionalCheckParam.IsRequired == false || optionalCheckParam.Type is InputNullableType)) + && willEmitNullGuard) { pathLiteral = pathLiteral.Substring(0, pathLiteral.Length - 1); separatorDeferred = true; @@ -930,8 +932,6 @@ private void AddUriSegments( */ var isClientParameter = ClientProvider.ClientParameters.Any(p => string.Equals(p.Name, paramName, StringComparison.OrdinalIgnoreCase)) || _inputClient.Parameters.Any(p => p is InputMethodParameter { ParamAlias: string alias } && string.Equals(alias, paramName, StringComparison.OrdinalIgnoreCase)); - bool isPathOrEndpointParam = inputParamMap.TryGetValue(paramName, out var pathParamForGuard) - && (pathParamForGuard is InputPathParameter || pathParamForGuard is InputEndpointParameter); CSharpType? type; SerializationFormat? serializationFormat; ValueExpression? valueExpression; @@ -956,11 +956,6 @@ private void AddUriSegments( throw new InvalidOperationException($"The location of parameter {inputParam.Name} should be path or uri"); } } - // A guard is needed whenever the value can be null at runtime: either the parameter - // is optional, or its type is nullable even though the parameter itself is required - // (e.g. a required-but-nullable path parameter). - bool willEmitNullGuard = isPathOrEndpointParam - && (pathParamForGuard!.IsRequired == false || type?.IsNullable == true); string? format = serializationFormat?.ToFormatSpecifier(); ValueExpression[] toStringParams = format is null ? [] : [Literal(format)]; InputPathParameter? inputPathParameter = inputParam as InputPathParameter; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs index 84f65701f32..e3be08b5ec8 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs @@ -1457,6 +1457,36 @@ public void RequiredNullableStringPathParameterIsGuarded() Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } + [Test] + public void RequiredNullableEnumPathParameterIsGuarded() + { + var enumType = InputFactory.StringEnum( + "Color", + [("Red", "red"), ("Blue", "blue")], + isExtensible: true); + var nullableEnum = new InputNullableType(enumType); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("color", nullableEnum, isRequired: true)], + path: "/things/{color}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation, + parameters: + [ + InputFactory.MethodParameter( + "color", + nullableEnum, + isRequired: true, + location: InputRequestLocation.Path) + ]); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + [Test] public void RequiredNullableCollectionPathParameterIsGuarded() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsGuarded.cs new file mode 100644 index 00000000000..b205e84100e --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsGuarded.cs @@ -0,0 +1,31 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(string color, global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things", false); + if ((color != null)) + { + uri.AppendPath("/", false); + uri.AppendPath(color, true); + } + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} From 91c227bfc7e374a77e438ed7b9a48d3707d0339a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:30:19 +0000 Subject: [PATCH 10/11] chore(http-client-csharp): remove stale inline comments in path guard logic Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index 13cd4ab4e4d..98dc9db56af 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -907,13 +907,6 @@ private void AddUriSegments( var paramEndIndex = pathSpan.IndexOf('}'); var paramName = pathSpan.Slice(0, paramEndIndex).ToString(); - /* An optional path parameter that is null must not leave a dangling - * path separator behind. For example "/foo/{bar}/{baz}" with an absent - * optional "baz" should produce "/foo/{bar}", not "/foo/{bar}/". When the - * upcoming parameter is optional, defer the trailing '/' of the preceding - * literal so it is only written together with the parameter value inside - * the null check below. - */ bool hasPathOrEndpointParam = inputParamMap.TryGetValue(paramName, out var pathParamForGuard) && pathParamForGuard is InputPathParameter or InputEndpointParameter; bool willEmitNullGuard = hasPathOrEndpointParam @@ -927,9 +920,6 @@ private void AddUriSegments( separatorDeferred = true; } AppendLiteralSegment(uri, pathLiteral, statements); - /* when the parameter is in operation.uri, it is client parameter - * It is not operation parameter and not in inputParamHash list. - */ var isClientParameter = ClientProvider.ClientParameters.Any(p => string.Equals(p.Name, paramName, StringComparison.OrdinalIgnoreCase)) || _inputClient.Parameters.Any(p => p is InputMethodParameter { ParamAlias: string alias } && string.Equals(alias, paramName, StringComparison.OrdinalIgnoreCase)); CSharpType? type; From be4eef7069e77d883c7d8b421d036490039f8b28 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Wed, 26 Aug 2026 18:03:27 -0500 Subject: [PATCH 11/11] fix(http-client-csharp): only guard optional path parameters and null-check raw values Address review feedback on the path parameter null guard: - Restore the separator-deferral and client-parameter comments that exist on main. - Guard only optional path/endpoint parameters. A required parameter that is null is left to fail loudly in AppendPath rather than silently targeting a different resource. One predicate now drives both separator deferral and guard emission. - Have GetParamInfo return the raw parameter/field expression and whether the value was already serialized. Null checks are written against the raw value instead of the serialized form, and the duplicate paramMap lookup with its compile-breaking fallthrough is gone. - Replace the over-broad `type?.IsEnum == true` ToString suppression with the isSerialized flag set where serialization is actually applied. Test coverage: rename OptionalEnumPathParameterIsNotUnwrapped to reflect that the value is guarded then unwrapped, re-record the required-nullable snapshots as unguarded, and add cases for a client-scoped required-nullable enum, a plain optional collection, and an optional endpoint parameter in a server template. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Providers/RestClientProvider.cs | 66 ++++++++---- .../RestClientProviderTests.cs | 102 +++++++++++++++++- ...numPathParameterIsGuardedThenUnwrapped.cs} | 0 ...tionalCollectionPathParameterIsGuarded.cs} | 0 ...ointParameterInServerTemplateIsGuarded.cs} | 8 +- ...ableClientEnumPathParameterIsNotGuarded.cs | 27 +++++ ...ableCollectionPathParameterIsNotGuarded.cs | 28 +++++ ...dNullableEnumPathParameterIsNotGuarded.cs} | 8 +- ...NullableStringPathParameterIsNotGuarded.cs | 27 +++++ 9 files changed, 232 insertions(+), 34 deletions(-) rename packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/{OptionalEnumPathParameterIsNotUnwrapped.cs => OptionalClientEnumPathParameterIsGuardedThenUnwrapped.cs} (100%) rename packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/{RequiredNullableCollectionPathParameterIsGuarded.cs => OptionalCollectionPathParameterIsGuarded.cs} (100%) rename packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/{RequiredNullableStringPathParameterIsGuarded.cs => OptionalEndpointParameterInServerTemplateIsGuarded.cs} (86%) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableClientEnumPathParameterIsNotGuarded.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsNotGuarded.cs rename packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/{RequiredNullableEnumPathParameterIsGuarded.cs => RequiredNullableEnumPathParameterIsNotGuarded.cs} (85%) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsNotGuarded.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index 98dc9db56af..8f197c6b458 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -500,7 +500,7 @@ private IEnumerable AppendHeaderParameters(HttpRequestApi r CSharpType? type; SerializationFormat? serializationFormat; ValueExpression? valueExpression; - GetParamInfo(paramMap, operation, inputHeaderParameter, out type, out serializationFormat, out valueExpression); + GetParamInfo(paramMap, operation, inputHeaderParameter, out type, out serializationFormat, out valueExpression, out _, out _); if (valueExpression == null) { continue; @@ -576,7 +576,7 @@ private List AppendQueryParameters(ScopedApi uri, InputOper InputOperation operation, bool isNextLinkRequest = false) { - GetParamInfo(paramMap, operation, inputQueryParameter, out var paramType, out var serializationFormat, out var valueExpression); + GetParamInfo(paramMap, operation, inputQueryParameter, out var paramType, out var serializationFormat, out var valueExpression, out _, out _); if (valueExpression == null) { return null; @@ -907,10 +907,9 @@ private void AddUriSegments( var paramEndIndex = pathSpan.IndexOf('}'); var paramName = pathSpan.Slice(0, paramEndIndex).ToString(); - bool hasPathOrEndpointParam = inputParamMap.TryGetValue(paramName, out var pathParamForGuard) - && pathParamForGuard is InputPathParameter or InputEndpointParameter; - bool willEmitNullGuard = hasPathOrEndpointParam - && (pathParamForGuard!.IsRequired == false || pathParamForGuard.Type is InputNullableType); + bool willEmitNullGuard = inputParamMap.TryGetValue(paramName, out var optionalCheckParam) + && optionalCheckParam is InputPathParameter or InputEndpointParameter + && !optionalCheckParam.IsRequired; var pathLiteral = path.ToString(); bool separatorDeferred = false; if (pathLiteral.EndsWith('/') @@ -920,22 +919,27 @@ private void AddUriSegments( separatorDeferred = true; } AppendLiteralSegment(uri, pathLiteral, statements); + /* when the parameter is in operation.uri, it is client parameter + * It is not operation parameter and not in inputParamHash list. + */ var isClientParameter = ClientProvider.ClientParameters.Any(p => string.Equals(p.Name, paramName, StringComparison.OrdinalIgnoreCase)) || _inputClient.Parameters.Any(p => p is InputMethodParameter { ParamAlias: string alias } && string.Equals(alias, paramName, StringComparison.OrdinalIgnoreCase)); CSharpType? type; SerializationFormat? serializationFormat; ValueExpression? valueExpression; + ValueExpression? rawValueExpression; + bool isSerialized; InputParameter? inputParam = null; if (isClientParameter) { - GetParamInfo(paramMap[paramName], out type, out serializationFormat, out valueExpression); + GetParamInfo(paramMap[paramName], out type, out serializationFormat, out valueExpression, out rawValueExpression, out isSerialized); } else { inputParam = inputParamMap[paramName]; - if (inputParam is InputPathParameter || inputParam is InputEndpointParameter) + if (inputParam is InputPathParameter or InputEndpointParameter) { - GetParamInfo(paramMap, operation, inputParam, out type, out serializationFormat, out valueExpression); + GetParamInfo(paramMap, operation, inputParam, out type, out serializationFormat, out valueExpression, out rawValueExpression, out isSerialized); if (valueExpression == null) { break; @@ -950,6 +954,11 @@ private void AddUriSegments( ValueExpression[] toStringParams = format is null ? [] : [Literal(format)]; InputPathParameter? inputPathParameter = inputParam as InputPathParameter; bool escape = !inputPathParameter?.SkipUrlEncoding ?? true; + /* The null check must always test the raw parameter or field, never the + * serialized form: `x.ToString("R") != null` is vacuously true and + * double-evaluates the serialization. + */ + ValueExpression nullCheckExpression = rawValueExpression ?? valueExpression; if (type?.OutputType.IsCollection == true) { MethodBodyStatement collectionStatement = uri.AppendPathDelimited(valueExpression, GetFormatEnumValue(serializationFormat), escape).Terminate(); @@ -961,27 +970,29 @@ private void AddUriSegments( : [collectionStatement]; collectionStatement = BuildQueryOrHeaderOrPathParameterNullCheck( type, - valueExpression, + nullCheckExpression, appendPathStatements); } statements.Add(collectionStatement); } else { - var nullCheckExpression = valueExpression; if (type is { IsNullable: true, IsValueType: true, IsEnum: false }) { valueExpression = willEmitNullGuard ? valueExpression.Property(nameof(Nullable.Value)) : valueExpression.NullConditional(); } - else if (type is { IsNullable: true, IsEnum: true } && willEmitNullGuard && paramMap.TryGetValue(inputParam?.Name ?? paramName, out var enumParamProvider)) + else if (type is { IsNullable: true, IsEnum: true } && willEmitNullGuard) { - ValueExpression rawEnumVariable = enumParamProvider.Field is null ? enumParamProvider : enumParamProvider.Field; - nullCheckExpression = rawEnumVariable; - valueExpression = type.ToSerial(rawEnumVariable.Property(nameof(Nullable.Value))); + /* GetParamInfo serialized the nullable enum through a null-conditional + * access. The guard makes that redundant, so re-serialize from the + * unwrapped value instead. + */ + valueExpression = type.ToSerial(nullCheckExpression.Property(nameof(Nullable.Value))); + isSerialized = true; } - valueExpression = type?.Equals(typeof(string)) == true || type?.IsEnum == true + valueExpression = isSerialized || type?.Equals(typeof(string)) == true ? valueExpression : valueExpression.Invoke(nameof(ToString), toStringParams); MethodBodyStatement statement; @@ -1045,12 +1056,18 @@ private static void AppendLiteralSegment(ScopedApi uri, string literal, List + /// The unformatted parameter or field access backing . Null checks must be + /// written against this rather than , which may already be serialized. + /// + /// Whether has already had serialization applied. + private void GetParamInfo(ParameterProviderMap paramMap, InputOperation operation, InputParameter inputParam, out CSharpType? type, out SerializationFormat? serializationFormat, out ValueExpression? valueExpression, out ValueExpression? rawValueExpression, out bool isSerialized) { type = IsContentTypeParameter(inputParam, includeInputHeaderParameter: false) ? null : ScmCodeModelGenerator.Instance.TypeFactory.CreateCSharpType(inputParam.Type); serializationFormat = null; + isSerialized = false; if (inputParam.IsApiVersion && ClientProvider.IsMultiServiceClient) { @@ -1060,6 +1077,7 @@ private void GetParamInfo(ParameterProviderMap paramMap, InputOperation operatio type = apiVersionField.Type; serializationFormat = apiVersionField.WireInfo?.SerializationFormat; valueExpression = apiVersionField; + rawValueExpression = apiVersionField; return; } } @@ -1067,45 +1085,53 @@ private void GetParamInfo(ParameterProviderMap paramMap, InputOperation operatio if (inputParam.Scope == InputParameterScope.Constant && !(operation.IsMultipartFormData && inputParam is InputHeaderParameter headerParameter && headerParameter.IsContentType)) { valueExpression = Literal((inputParam.Type as InputLiteralType)?.Value); + rawValueExpression = valueExpression; serializationFormat = ScmCodeModelGenerator.Instance.TypeFactory.GetSerializationFormat(inputParam.Type); } else if (TryGetAcceptHeaderWithMultipleContentTypes(inputParam, operation, out var contentTypes)) { string joinedContentTypes = string.Join(", ", contentTypes); valueExpression = Literal(joinedContentTypes); + rawValueExpression = valueExpression; serializationFormat = ScmCodeModelGenerator.Instance.TypeFactory.GetSerializationFormat(inputParam.Type); } else if (TryGetSpecialHeaderParam(inputParam, out var parameterProvider)) { valueExpression = parameterProvider.DefaultValue!; + rawValueExpression = valueExpression; serializationFormat = ScmCodeModelGenerator.Instance.TypeFactory.GetSerializationFormat(inputParam.Type); } else { if (paramMap.TryGetValue(inputParam.Name, out var paramProvider)) { - GetParamInfo(paramProvider, out type, out serializationFormat, out valueExpression); + GetParamInfo(paramProvider, out type, out serializationFormat, out valueExpression, out var raw, out isSerialized); + rawValueExpression = raw; } else { type = null; valueExpression = null; + rawValueExpression = null; } } } - private static void GetParamInfo(ParameterProvider paramProvider, out CSharpType? type, out SerializationFormat? serializationFormat, out ValueExpression valueExpression) + private static void GetParamInfo(ParameterProvider paramProvider, out CSharpType? type, out SerializationFormat? serializationFormat, out ValueExpression valueExpression, out ValueExpression rawValueExpression, out bool isSerialized) { type = paramProvider.Field is null ? paramProvider.Type : paramProvider.Field.Type; + rawValueExpression = paramProvider.Field is null ? paramProvider : paramProvider.Field; if (type.IsEnum) { valueExpression = type.ToSerial(paramProvider); serializationFormat = SerializationFormat.Default; + isSerialized = true; } else { - valueExpression = paramProvider.Field is null ? paramProvider : paramProvider.Field; + valueExpression = rawValueExpression; serializationFormat = paramProvider.WireInfo.SerializationFormat; + isSerialized = false; } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs index e3be08b5ec8..b5863307f4b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs @@ -1361,7 +1361,7 @@ public void NullableDatePathParameterIsGuardedBeforeFormatting(bool isRequired) } [Test] - public void OptionalEnumPathParameterIsNotUnwrapped() + public void OptionalClientEnumPathParameterIsGuardedThenUnwrapped() { var enumType = InputFactory.StringEnum( "Color", @@ -1431,8 +1431,14 @@ public void OptionalClientEnumPathParameterWithDistinctSerializedNameIsGuarded() Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } + /* A required path parameter is never guarded, even when its type is nullable. + * Skipping the segment would silently target a different resource + * (e.g. "/things" instead of "/things/{name}"), so a null value is deliberately + * left to fail loudly inside ClientUriBuilder.AppendPath. Only optional path + * parameters are omitted from the URL when absent. + */ [Test] - public void RequiredNullableStringPathParameterIsGuarded() + public void RequiredNullableStringPathParameterIsNotGuarded() { var nullableString = new InputNullableType(InputPrimitiveType.String); var operation = InputFactory.Operation( @@ -1458,7 +1464,7 @@ public void RequiredNullableStringPathParameterIsGuarded() } [Test] - public void RequiredNullableEnumPathParameterIsGuarded() + public void RequiredNullableEnumPathParameterIsNotGuarded() { var enumType = InputFactory.StringEnum( "Color", @@ -1487,8 +1493,32 @@ public void RequiredNullableEnumPathParameterIsGuarded() Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } + // The client-scoped variant resolves to the nullable enum field rather than the + // flattened serialized primitive, so it exercises a different code path. [Test] - public void RequiredNullableCollectionPathParameterIsGuarded() + public void RequiredNullableClientEnumPathParameterIsNotGuarded() + { + var enumType = InputFactory.StringEnum( + "Color", + [("Red", "red"), ("Blue", "blue")], + isExtensible: true); + var nullableEnum = new InputNullableType(enumType); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("color", nullableEnum, isRequired: true, scope: InputParameterScope.Client)], + path: "/things/{color}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + + [Test] + public void RequiredNullableCollectionPathParameterIsNotGuarded() { var nullableArray = new InputNullableType(InputFactory.Array(InputPrimitiveType.String)); var operation = InputFactory.Operation( @@ -1513,6 +1543,34 @@ public void RequiredNullableCollectionPathParameterIsGuarded() Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } + // An optional collection path parameter previously had its separator deferred + // without a matching guard, producing "/thingsa,b" when the value was present. + [Test] + public void OptionalCollectionPathParameterIsGuarded() + { + var arrayType = InputFactory.Array(InputPrimitiveType.String); + var operation = InputFactory.Operation( + "GetThing", + parameters: [InputFactory.PathParameter("ids", arrayType, isRequired: false)], + path: "/things/{ids}"); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation, + parameters: + [ + InputFactory.MethodParameter( + "ids", + arrayType, + isRequired: false, + location: InputRequestLocation.Path) + ]); + var client = InputFactory.Client("TestClient", methods: [serviceMethod]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + [Test] public void OptionalClientDatePathParameterIsGuardedBeforeFormatting() { @@ -1535,6 +1593,42 @@ public void OptionalClientDatePathParameterIsGuardedBeforeFormatting() Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } + // The guard covers InputEndpointParameter as well as InputPathParameter; a missing + // or stray separator in a server URL template is especially easy to overlook. + [Test] + public void OptionalEndpointParameterInServerTemplateIsGuarded() + { + const string serverTemplate = "{endpoint}/{region}"; + var regionParameter = InputFactory.EndpointParameter( + "region", + InputPrimitiveType.String, + isRequired: false, + isEndpoint: false, + serverUrlTemplate: serverTemplate, + scope: InputParameterScope.Client); + var endpointParameter = InputFactory.EndpointParameter( + "endpoint", + InputPrimitiveType.String, + isRequired: true, + serverUrlTemplate: serverTemplate); + var operation = InputFactory.Operation( + "GetThing", + uri: serverTemplate, + path: "/things", + parameters: [endpointParameter, regionParameter]); + var serviceMethod = InputFactory.BasicServiceMethod( + "GetThing", + operation); + var client = InputFactory.Client( + "TestClient", + methods: [serviceMethod], + parameters: [endpointParameter, regionParameter]); + var restClient = new ClientProvider(client).RestClient; + + var file = new TypeProviderWriter(restClient).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + // An optional trailing path parameter must not emit a dangling separator when null. // e.g. "/certificates/{certificateName}/{certificateVersion}" with a null version // should produce "/certificates/{name}", not "/certificates/{name}/". diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientEnumPathParameterIsGuardedThenUnwrapped.cs similarity index 100% rename from packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEnumPathParameterIsNotUnwrapped.cs rename to packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalClientEnumPathParameterIsGuardedThenUnwrapped.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalCollectionPathParameterIsGuarded.cs similarity index 100% rename from packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsGuarded.cs rename to packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalCollectionPathParameterIsGuarded.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEndpointParameterInServerTemplateIsGuarded.cs similarity index 86% rename from packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsGuarded.cs rename to packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEndpointParameterInServerTemplateIsGuarded.cs index 2b507f667c4..b3750c5c1e4 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsGuarded.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/OptionalEndpointParameterInServerTemplateIsGuarded.cs @@ -12,16 +12,16 @@ public partial class TestClient private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); - internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(string name, global::System.ClientModel.Primitives.RequestOptions options) + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(global::System.ClientModel.Primitives.RequestOptions options) { global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); uri.Reset(_endpoint); - uri.AppendPath("/things", false); - if ((name != null)) + if ((_region != null)) { uri.AppendPath("/", false); - uri.AppendPath(name, true); + uri.AppendPath(_region, true); } + uri.AppendPath("/things", false); global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); global::System.ClientModel.Primitives.PipelineRequest request = message.Request; message.Apply(options); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableClientEnumPathParameterIsNotGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableClientEnumPathParameterIsNotGuarded.cs new file mode 100644 index 00000000000..1414333d973 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableClientEnumPathParameterIsNotGuarded.cs @@ -0,0 +1,27 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things/", false); + uri.AppendPath(_color.ToString(), true); + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsNotGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsNotGuarded.cs new file mode 100644 index 00000000000..24c1b08440e --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableCollectionPathParameterIsNotGuarded.cs @@ -0,0 +1,28 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; +using System.Collections.Generic; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(global::System.Collections.Generic.IEnumerable ids, global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things/", false); + uri.AppendPathDelimited(ids, ",", escape: true); + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsNotGuarded.cs similarity index 85% rename from packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsGuarded.cs rename to packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsNotGuarded.cs index b205e84100e..510157fdb9d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsGuarded.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableEnumPathParameterIsNotGuarded.cs @@ -16,12 +16,8 @@ public partial class TestClient { global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); uri.Reset(_endpoint); - uri.AppendPath("/things", false); - if ((color != null)) - { - uri.AppendPath("/", false); - uri.AppendPath(color, true); - } + uri.AppendPath("/things/", false); + uri.AppendPath(color, true); global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); global::System.ClientModel.Primitives.PipelineRequest request = message.Request; message.Apply(options); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsNotGuarded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsNotGuarded.cs new file mode 100644 index 00000000000..65c06f4fc5a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/RequiredNullableStringPathParameterIsNotGuarded.cs @@ -0,0 +1,27 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + private static global::System.ClientModel.Primitives.PipelineMessageClassifier _pipelineMessageClassifier200; + + private static global::System.ClientModel.Primitives.PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 ??= global::System.ClientModel.Primitives.PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetThingRequest(string name, global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/things/", false); + uri.AppendPath(name, true); + global::System.ClientModel.Primitives.PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "GET", PipelineMessageClassifier200); + global::System.ClientModel.Primitives.PipelineRequest request = message.Request; + message.Apply(options); + return message; + } + } +}