Skip to content

Commit fc5ffb5

Browse files
fix(lib): Handle optional route parameters with constraints
1 parent 43a26b5 commit fc5ffb5

4 files changed

Lines changed: 70 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Fixed
11+
12+
- Handle optional route parameters with constraints (#222)
13+
1014
### Changed
1115

1216
- Bump System.Reflection.MetadataLoadContext from 10.0.8 to 10.0.9 (#221)

TypeContractor.Tests/Helpers/ApiHelpersTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,27 @@ public void BuildApiEndpoint_Skips_Ignored_Methods()
7373
endpoint.Should().BeEmpty();
7474
}
7575

76+
[Fact]
77+
public void BuildApiEndpoint_Handles_Optional_Route_Part()
78+
{
79+
// Arrange
80+
var endpointMethod = typeof(RouteController).GetMethod(nameof(RouteController.DeleteWithOptionalPart), [typeof(Guid), typeof(Guid), typeof(Guid), typeof(Guid), typeof(Guid?), typeof(CancellationToken)])!;
81+
82+
// Act
83+
var endpoints = ApiHelpers.BuildApiEndpoint(endpointMethod);
84+
85+
// Assert
86+
endpoints.Should().ContainSingle();
87+
var endpoint = endpoints.First();
88+
89+
endpoint.Route.Should().Be("deletemember/{id}/{referenceId}/{certificationGroupId?}");
90+
endpoint.Parameters.Should()
91+
.HaveCount(3)
92+
.And.Contain(x => x.FromRoute && x.Name == "id" && !x.IsOptional)
93+
.And.Contain(x => x.FromRoute && x.Name == "referenceId" && !x.IsOptional)
94+
.And.Contain(x => x.FromRoute && x.Name == "certificationGroupId" && x.IsOptional);
95+
}
96+
7697
[TypeContractorIgnore]
7798
internal class IgnoredController : ControllerBase { }
7899

@@ -93,4 +114,10 @@ internal class LegacyController : ControllerBase
93114

94115
[TypeContractorName("RenamedApi")]
95116
internal class RenamedSuffixController : ControllerBase { }
117+
118+
internal class RouteController : ControllerBase
119+
{
120+
[HttpDelete("deletemember/{id:Guid}/{referenceId:Guid}/{certificationGroupId:Guid?}")]
121+
public ActionResult DeleteWithOptionalPart([FromHeader] Guid organizationId, [FromHeader] Guid customerId, Guid id, Guid referenceId, Guid? certificationGroupId, CancellationToken cancellationToken) => NotFound();
122+
}
96123
}

TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,35 @@ public void Handles_Optional_Route_Parameter()
359359
.And.NotContain("url.searchParams.append(");
360360
}
361361

362+
[Fact]
363+
public void Handles_Nullable_Route_Parameter()
364+
{
365+
// Arrange
366+
var apiClient = new ApiClient("TestClient", "TestController", "test", null);
367+
apiClient.AddEndpoint(new ApiClientEndpoint("getLatest", "latest/{id}/{referenceId}/{groupId?}", EndpointMethod.GET, null, typeof(Guid), false, [
368+
new EndpointParameter("id", typeof(Guid), null, false, false, true, false, false, false, false, false),
369+
new EndpointParameter("referenceId", typeof(Guid), null, false, false, true, false, false, false, false, false),
370+
new EndpointParameter("groupId", typeof(Guid?), typeof(Guid), false, false, true, false, false, false, false, true),
371+
], null));
372+
373+
// Act
374+
var result = Sut.Write(apiClient, [], _converter, true, _templateFn, Casing.Pascal);
375+
376+
// Assert
377+
var file = File.ReadAllText(result).Trim();
378+
file.Should()
379+
.NotBeEmpty()
380+
.And.Contain("import { z } from 'zod';")
381+
.And.Contain("export class TestClient {")
382+
.And.Contain("public async getLatest(id: string, referenceId: string, groupId: string | undefined, cancellationToken: AbortSignal = null): Promise<string> {")
383+
.And.Contain("const url = new URL(`test/latest/${id}/${referenceId}/{groupId?}`, window.location.origin);")
384+
.And.Contain("if (groupId != undefined)")
385+
.And.Contain("url.pathname = url.pathname.replace('{groupId?}', groupId.toString());")
386+
.And.Contain("else")
387+
.And.Contain("url.pathname = url.pathname.replace('/{groupId?}', '');")
388+
.And.NotContain("url.searchParams.append(");
389+
}
390+
362391
[Theory]
363392
[InlineData(Casing.Camel)]
364393
[InlineData(Casing.Pascal)]

TypeContractor/Helpers/ApiHelpers.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public static partial class ApiHelpers
1212
{
1313
private static readonly Regex _routeParameterRegex = RouteParameterRegexImpl();
1414

15-
[GeneratedRegex("{([A-Za-z]+)(:[[A-Za-z]+)?}")]
15+
[GeneratedRegex("{([A-Za-z]+)(:[[A-Za-z?]+)?}")]
1616
private static partial Regex RouteParameterRegexImpl();
1717

1818
public static ApiClient? BuildApiClient(Type controller, List<MethodInfo> endpoints)
@@ -121,7 +121,8 @@ private static (string Route, EndpointMethod HttpMethod) DetermineRoute(MethodIn
121121
{
122122
if (!match.Success) continue;
123123
if (match.Groups.Count < 3) continue;
124-
finalRoute = finalRoute.Replace(match.Value, $"{{{match.Groups[1].Value}}}");
124+
var optional = match.Groups[2].Value.EndsWith('?') ? "?" : "";
125+
finalRoute = finalRoute.Replace(match.Value, $"{{{match.Groups[1].Value}{optional}}}");
125126
}
126127

127128
var httpMethod = method.AttributeType.Name switch
@@ -152,7 +153,13 @@ private static bool ParameterIsOptional(ParameterInfo parameterInfo, string fina
152153
if (!ParameterIsFromRoute(parameterInfo, finalRoute))
153154
return false;
154155

155-
return finalRoute.Contains($"{{{parameterInfo.Name}?}}");
156+
if (finalRoute.Contains($"{{{parameterInfo.Name}?}}"))
157+
return true;
158+
159+
if (IsNullable(parameterInfo.ParameterType))
160+
return true;
161+
162+
return false;
156163
}
157164

158165
private static bool ParameterIsFromQuery(ParameterInfo parameterInfo, EndpointMethod httpMethod, string finalRoute)

0 commit comments

Comments
 (0)