diff --git a/CHANGELOG.md b/CHANGELOG.md index 06bea82..6d2d59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- Handle optional route parameters with constraints (#222) + ### Changed - Bump System.Reflection.MetadataLoadContext from 10.0.8 to 10.0.9 (#221) diff --git a/TypeContractor.Tests/Helpers/ApiHelpersTests.cs b/TypeContractor.Tests/Helpers/ApiHelpersTests.cs index b0cae8f..418e356 100644 --- a/TypeContractor.Tests/Helpers/ApiHelpersTests.cs +++ b/TypeContractor.Tests/Helpers/ApiHelpersTests.cs @@ -73,6 +73,27 @@ public void BuildApiEndpoint_Skips_Ignored_Methods() endpoint.Should().BeEmpty(); } + [Fact] + public void BuildApiEndpoint_Handles_Optional_Route_Part() + { + // Arrange + var endpointMethod = typeof(RouteController).GetMethod(nameof(RouteController.DeleteWithOptionalPart), [typeof(Guid), typeof(Guid), typeof(Guid), typeof(Guid), typeof(Guid?), typeof(CancellationToken)])!; + + // Act + var endpoints = ApiHelpers.BuildApiEndpoint(endpointMethod); + + // Assert + endpoints.Should().ContainSingle(); + var endpoint = endpoints.First(); + + endpoint.Route.Should().Be("deletemember/{id}/{referenceId}/{certificationGroupId?}"); + endpoint.Parameters.Should() + .HaveCount(3) + .And.Contain(x => x.FromRoute && x.Name == "id" && !x.IsOptional) + .And.Contain(x => x.FromRoute && x.Name == "referenceId" && !x.IsOptional) + .And.Contain(x => x.FromRoute && x.Name == "certificationGroupId" && x.IsOptional); + } + [TypeContractorIgnore] internal class IgnoredController : ControllerBase { } @@ -93,4 +114,10 @@ internal class LegacyController : ControllerBase [TypeContractorName("RenamedApi")] internal class RenamedSuffixController : ControllerBase { } + + internal class RouteController : ControllerBase + { + [HttpDelete("deletemember/{id:Guid}/{referenceId:Guid}/{certificationGroupId:Guid?}")] + public ActionResult DeleteWithOptionalPart([FromHeader] Guid organizationId, [FromHeader] Guid customerId, Guid id, Guid referenceId, Guid? certificationGroupId, CancellationToken cancellationToken) => NotFound(); + } } diff --git a/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs b/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs index c2067d0..4b5ac97 100644 --- a/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs +++ b/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs @@ -359,6 +359,35 @@ public void Handles_Optional_Route_Parameter() .And.NotContain("url.searchParams.append("); } + [Fact] + public void Handles_Nullable_Route_Parameter() + { + // Arrange + var apiClient = new ApiClient("TestClient", "TestController", "test", null); + apiClient.AddEndpoint(new ApiClientEndpoint("getLatest", "latest/{id}/{referenceId}/{groupId?}", EndpointMethod.GET, null, typeof(Guid), false, [ + new EndpointParameter("id", typeof(Guid), null, false, false, true, false, false, false, false, false), + new EndpointParameter("referenceId", typeof(Guid), null, false, false, true, false, false, false, false, false), + new EndpointParameter("groupId", typeof(Guid?), typeof(Guid), false, false, true, false, false, false, false, true), + ], null)); + + // Act + var result = Sut.Write(apiClient, [], _converter, true, _templateFn, Casing.Pascal); + + // Assert + var file = File.ReadAllText(result).Trim(); + file.Should() + .NotBeEmpty() + .And.Contain("import { z } from 'zod';") + .And.Contain("export class TestClient {") + .And.Contain("public async getLatest(id: string, referenceId: string, groupId: string | undefined, cancellationToken: AbortSignal = null): Promise {") + .And.Contain("const url = new URL(`test/latest/${id}/${referenceId}/{groupId?}`, window.location.origin);") + .And.Contain("if (groupId != undefined)") + .And.Contain("url.pathname = url.pathname.replace('{groupId?}', groupId.toString());") + .And.Contain("else") + .And.Contain("url.pathname = url.pathname.replace('/{groupId?}', '');") + .And.NotContain("url.searchParams.append("); + } + [Theory] [InlineData(Casing.Camel)] [InlineData(Casing.Pascal)] diff --git a/TypeContractor/Helpers/ApiHelpers.cs b/TypeContractor/Helpers/ApiHelpers.cs index 0b95e04..764dad3 100644 --- a/TypeContractor/Helpers/ApiHelpers.cs +++ b/TypeContractor/Helpers/ApiHelpers.cs @@ -12,7 +12,7 @@ public static partial class ApiHelpers { private static readonly Regex _routeParameterRegex = RouteParameterRegexImpl(); - [GeneratedRegex("{([A-Za-z]+)(:[[A-Za-z]+)?}")] + [GeneratedRegex("{([A-Za-z]+)(:[[A-Za-z?]+)?}")] private static partial Regex RouteParameterRegexImpl(); public static ApiClient? BuildApiClient(Type controller, List endpoints) @@ -121,7 +121,8 @@ private static (string Route, EndpointMethod HttpMethod) DetermineRoute(MethodIn { if (!match.Success) continue; if (match.Groups.Count < 3) continue; - finalRoute = finalRoute.Replace(match.Value, $"{{{match.Groups[1].Value}}}"); + var optional = match.Groups[2].Value.EndsWith('?') ? "?" : ""; + finalRoute = finalRoute.Replace(match.Value, $"{{{match.Groups[1].Value}{optional}}}"); } var httpMethod = method.AttributeType.Name switch @@ -152,7 +153,13 @@ private static bool ParameterIsOptional(ParameterInfo parameterInfo, string fina if (!ParameterIsFromRoute(parameterInfo, finalRoute)) return false; - return finalRoute.Contains($"{{{parameterInfo.Name}?}}"); + if (finalRoute.Contains($"{{{parameterInfo.Name}?}}")) + return true; + + if (IsNullable(parameterInfo.ParameterType)) + return true; + + return false; } private static bool ParameterIsFromQuery(ParameterInfo parameterInfo, EndpointMethod httpMethod, string finalRoute)