Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions TypeContractor.Tests/Helpers/ApiHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { }

Expand All @@ -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();
}
}
29 changes: 29 additions & 0 deletions TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {")
.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)]
Expand Down
13 changes: 10 additions & 3 deletions TypeContractor/Helpers/ApiHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MethodInfo> endpoints)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading