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; + } + } +}