From 46c04e075133d715baa1e4ba1c5fd6c9346c5483 Mon Sep 17 00:00:00 2001 From: JoshLove-msft Date: Thu, 25 Jun 2026 22:01:16 -0700 Subject: [PATCH 1/2] Fix trailing path separator for optional path parameter When a route ends with an optional path parameter (e.g. /items/{name}/{version} with optional version), the separator '/' preceding the optional segment was emitted unconditionally, producing /items/{name}/ when the value was null instead of /items/{name}. Defer the trailing separator into the parameter's null check so it is only written when the optional value is present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-param-trailing-slash-2026-6-25-21-50-0.md | 7 +++++ .../src/Providers/RestClientProvider.cs | 21 ++++++++++++-- .../ClientProviders/ClientProviderTests.cs | 6 ++-- .../RestClientProviderTests.cs | 28 +++++++++++++++++++ ...eRequestMethodWithOptionalPathParameter.cs | 28 +++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 .chronus/changes/fix-optional-path-param-trailing-slash-2026-6-25-21-50-0.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/TestBuildCreateRequestMethodWithOptionalPathParameter.cs diff --git a/.chronus/changes/fix-optional-path-param-trailing-slash-2026-6-25-21-50-0.md b/.chronus/changes/fix-optional-path-param-trailing-slash-2026-6-25-21-50-0.md new file mode 100644 index 00000000000..46d14b6151b --- /dev/null +++ b/.chronus/changes/fix-optional-path-param-trailing-slash-2026-6-25-21-50-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-csharp" +--- + +Fix trailing path separator emitted for an optional path parameter when its value is null. A route like `/items/{name}/{version}` with an optional `version` now produces `/items/{name}` instead of `/items/{name}/` when `version` is not provided. 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 38cae05f21a..5d4b8b9b26a 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 @@ -717,10 +717,27 @@ private void AddUriSegments( } var path = pathSpan.Slice(0, paramIndex); - AppendLiteralSegment(uri, path.ToString(), statements); pathSpan = pathSpan.Slice(paramIndex + 1); 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. + */ + var pathLiteral = path.ToString(); + bool separatorDeferred = false; + if (pathLiteral.EndsWith('/') + && inputParamMap.TryGetValue(paramName, out var optionalCheckParam) + && optionalCheckParam is InputPathParameter { IsRequired: false }) + { + pathLiteral = pathLiteral.Substring(0, pathLiteral.Length - 1); + 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. */ @@ -766,7 +783,7 @@ private void AddUriSegments( MethodBodyStatement statement; if (inputParam?.IsRequired == false) { - bool shouldPrependWithPathSeparator = path.Length > 0 && path[^1] != '/'; + bool shouldPrependWithPathSeparator = separatorDeferred || (path.Length > 0 && path[^1] != '/'); List appendPathStatements = shouldPrependWithPathSeparator ? [uri.AppendPath(Literal("/"), false).Terminate(), uri.AppendPath(valueExpression, escape).Terminate()] : [uri.AppendPath(valueExpression, escape).Terminate()]; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderTests.cs index 960e2ee5f59..5e47ec0a1ad 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderTests.cs @@ -3589,8 +3589,8 @@ public void ServerTemplateWithPathParameter_OnlyAppendsSegmentsAfterEndpoint() MockHelpers.LoadMockGenerator(); var serverTemplate = "{endpoint}/{apiVersion}"; - var apiVersionParam = InputFactory.PathParameter("apiVersion", InputPrimitiveType.String, serverUrlTemplate: serverTemplate); - var userIdParam = InputFactory.PathParameter("userId", InputPrimitiveType.String); + var apiVersionParam = InputFactory.PathParameter("apiVersion", InputPrimitiveType.String, isRequired: true, serverUrlTemplate: serverTemplate); + var userIdParam = InputFactory.PathParameter("userId", InputPrimitiveType.String, isRequired: true); var operation = InputFactory.Operation( name: "GetUser", @@ -3633,7 +3633,7 @@ public void ServerTemplateWithMultipleSegments_HandlesCorrectly() MockHelpers.LoadMockGenerator(); var serverTemplate = "{endpoint}/v1/services"; - var operationIdParam = InputFactory.PathParameter("operationId", InputPrimitiveType.String); + var operationIdParam = InputFactory.PathParameter("operationId", InputPrimitiveType.String, isRequired: true); var operation = InputFactory.Operation( name: "GetOperation", 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 de041fdffdb..7e637e07363 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 @@ -819,6 +819,34 @@ public void TestBuildCreateRequestMethodWithPathParameters() 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}/". + [Test] + public void TestBuildCreateRequestMethodWithOptionalPathParameter() + { + List parameters = + [ + InputFactory.PathParameter("certificateName", InputPrimitiveType.String, isRequired: true), + InputFactory.PathParameter("certificateVersion", InputPrimitiveType.String, isRequired: false), + ]; + var operation = InputFactory.Operation( + "getCertificate", + parameters: parameters, + uri: "/certificates/{certificateName}/{certificateVersion}"); + + var client = InputFactory.Client( + "TestClient", + methods: [InputFactory.BasicServiceMethod("Test", operation)]); + + var clientProvider = new ClientProvider(client); + var restClientProvider = new MockClientProvider(client, clientProvider); + + var writer = new TypeProviderWriter(restClientProvider); + var file = writer.Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + [Test] public void TestBuildCreateRequestMethodWithQueryInPath() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/TestBuildCreateRequestMethodWithOptionalPathParameter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/TestBuildCreateRequestMethodWithOptionalPathParameter.cs new file mode 100644 index 00000000000..b20563de827 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/TestBuildCreateRequestMethodWithOptionalPathParameter.cs @@ -0,0 +1,28 @@ +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestClient + { + internal global::System.ClientModel.Primitives.PipelineMessage CreateGetCertificateRequest(string certificateName, string certificateVersion, global::System.ClientModel.Primitives.RequestOptions options) + { + global::Sample.ClientUriBuilder uri = new global::Sample.ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/certificates/", false); + uri.AppendPath(certificateName, true); + if ((certificateVersion != null)) + { + uri.AppendPath("/", false); + uri.AppendPath(certificateVersion, 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 d425885979b592e20e49a4798690ac6471cc7e6f Mon Sep 17 00:00:00 2001 From: JoshLove-msft Date: Thu, 25 Jun 2026 22:02:12 -0700 Subject: [PATCH 2/2] Remove changelog entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...optional-path-param-trailing-slash-2026-6-25-21-50-0.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .chronus/changes/fix-optional-path-param-trailing-slash-2026-6-25-21-50-0.md diff --git a/.chronus/changes/fix-optional-path-param-trailing-slash-2026-6-25-21-50-0.md b/.chronus/changes/fix-optional-path-param-trailing-slash-2026-6-25-21-50-0.md deleted file mode 100644 index 46d14b6151b..00000000000 --- a/.chronus/changes/fix-optional-path-param-trailing-slash-2026-6-25-21-50-0.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -changeKind: fix -packages: - - "@typespec/http-client-csharp" ---- - -Fix trailing path separator emitted for an optional path parameter when its value is null. A route like `/items/{name}/{version}` with an optional `version` now produces `/items/{name}` instead of `/items/{name}/` when `version` is not provided.