Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add attributes to customize the output #110

Merged
merged 6 commits into from
Nov 29, 2024
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Handle name collisions when creationg API clients (#106)
- Add annotations package to further customize output (#107)

## [0.14.0] - 2024-11-17

Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,31 @@ So for example:
while `ExampleApp.Controllers.Subsystem.DataController` collides and
gets turned into `SubsystemDataClient`.

## Further customization using Annotations

To further customize the output of TypeContractor, you can install
the optional package `TypeContractor.Annotations` and start annotating
your controllers.

Available annotations:

* `TypeContractorIgnore`:
If you have a controller that doesn't need a client
generated, you can annotate that controller using `TypeContractorIgnore`
and it will be automatically skipped.
* `TypeContractorName`:
If you have a badly named controller that you can't rename,
you want something custom, or just don't like the default naming
scheme, you can apply this attribute to select a brand new name.

If you have multiple endpoints with the same name and different parameters,
C# handles the overloads perfectly, but not so much in TypeScript.
Use `TypeContractorName` to rename a single endpoint to whatever fits.
* `TypeContractorNullable`:
If your project doesn't support nullable reference types, or you just
feel like you know better, you can mark a property as nullable and
override the automatically detected setting.

## Future improvements

* Kebab-case output files and directories
Expand Down
26 changes: 26 additions & 0 deletions TypeContractor.Annotations/TypeContractor.Annotations.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>

<PropertyGroup>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>TypeContractor.Annotations</PackageId>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageProjectUrl>https://github.com/PerfectlyNormal/TypeContractor</PackageProjectUrl>
<RepositoryUrl>https://github.com/PerfectlyNormal/TypeContractor.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<Authors>Per Christian B. Viken</Authors>
<Description>
Extra annotations to customize TypeContractor output
</Description>
<PackageTags>dotnet-tool TypeContractor TypeScript generator</PackageTags>
</PropertyGroup>

<ItemGroup>
<None Include="..\LICENSE" Pack="true" PackagePath="\" />
<None Include="..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>
14 changes: 14 additions & 0 deletions TypeContractor.Annotations/TypeContractorIgnoreAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;

namespace TypeContractor.Annotations
{
/// <summary>
/// Tells TypeContractor to ignore the given controller when generating
/// automatic API clients. For example a controller that serves static
/// assets for the HTML templates.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class TypeContractorIgnoreAttribute : Attribute
{
}
}
40 changes: 40 additions & 0 deletions TypeContractor.Annotations/TypeContractorNameAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;

namespace TypeContractor.Annotations
{
/// <summary>
/// Rename a generated client or endpoint
///
/// <para>
/// When generating API clients, all applicable controllers will be
/// found and generated automatically. Using this attribute is for
/// providing a custom name in case of multiple controllers with the
/// same name but different namespaces being present in the project.
/// </para>
///
/// <para>
/// When used on a controller endpoint, this will give the endpoint
/// a different name. Useful in case of overloads that doesn't work
/// as well in TypeScript.
/// </para>
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class TypeContractorNameAttribute : Attribute
{
/// <summary>
/// Rename a generated client or endpoint.
///
/// <para>
/// The provided name will be used as-is, with no changes in case,
/// or suffixes added. It must therefore be a valid TypeScript identifier.
/// </para>
/// </summary>
/// <param name="name">The new name it should have, with exact casing.</param>
public TypeContractorNameAttribute(string name)
{
Name = name;
}

public string Name { get; }
}
}
18 changes: 18 additions & 0 deletions TypeContractor.Annotations/TypeContractorNullableAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;

namespace TypeContractor.Annotations
{
/// <summary>
/// Set a property as nullable if the compiler doesn't autodetect it.
///
/// <para>
/// For example a string if your project is targetting netstandard2.0
/// or another target framework that doesn't support nullable reference
/// types, or nullable reference types is not enabled.
/// </para>
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public sealed class TypeContractorNullableAttribute : Attribute
{
}
}
17 changes: 17 additions & 0 deletions TypeContractor.Example/RandomController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using TypeContractor.Annotations;

namespace TypeContractor.Example;

[TypeContractorName("RandomizerClient")]
public class RandomController : ControllerBase
{
private readonly Random _random = new();

[TypeContractorName("randomize")]
public ActionResult<int> GenerateRandomValue(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return _random.Next(0, 256);
}
}
13 changes: 13 additions & 0 deletions TypeContractor.Example/StaticAssetsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc;
using TypeContractor.Annotations;

namespace TypeContractor.Example;

[TypeContractorIgnore]
public class StaticAssetsController : ControllerBase
{
public ActionResult GetSomeAsset()
{
return NotFound();
}
}
7 changes: 6 additions & 1 deletion TypeContractor.Example/TypeContractor.Example.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand All @@ -7,8 +7,13 @@
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ExampleContracts\ExampleContracts.csproj" />
<ProjectReference Include="..\TypeContractor.Annotations\TypeContractor.Annotations.csproj" />
<ProjectReference Include="..\TypeContractor\TypeContractor.csproj" />
</ItemGroup>

Expand Down
79 changes: 79 additions & 0 deletions TypeContractor.Tests/Helpers/ApiHelpersTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Mvc;
using TypeContractor.Annotations;
using TypeContractor.Helpers;

namespace TypeContractor.Tests.Helpers;

public class ApiHelpersTests
{
[Fact]
public void BuildApiClient_Returns_Null_Given_IgnoreAttribute()
{
var client = ApiHelpers.BuildApiClient(typeof(IgnoredController), []);

client.Should().BeNull();
}

[Fact]
public void BuildApiClient_Accepts_ClientAttribute()
{
var client = ApiHelpers.BuildApiClient(typeof(LegacyController), []);

client.Should().NotBeNull();
client!.Name.Should().Be("RenamedClient");
}

[Fact]
public void BuildApiClient_Does_Not_Add_Suffix_With_ClientAttribute()
{
var client = ApiHelpers.BuildApiClient(typeof(RenamedSuffixController), []);

client.Should().NotBeNull();
client!.Name.Should().Be("RenamedApi");
}

[Fact]
public void BuildApiEndpoint_Accepts_NameAttribute()
{
// Arrange
var endpointMethod = typeof(LegacyController).GetMethod(nameof(LegacyController.OverloadEndpoint), [typeof(Guid), typeof(CancellationToken)])!;

// Act
var endpoint = ApiHelpers.BuildApiEndpoint(endpointMethod);

// Assert
endpoint.Should().ContainSingle();
endpoint.First().Name.Should().Be("postWithId");
}

[Fact]
public void BuildApiEndpoint_Generates_Name()
{
// Arrange
var endpointMethod = typeof(LegacyController).GetMethod(nameof(LegacyController.OverloadEndpoint), [typeof(CancellationToken)])!;

// Act
var endpoint = ApiHelpers.BuildApiEndpoint(endpointMethod);

// Assert
endpoint.Should().ContainSingle();
endpoint.First().Name.Should().Be("overloadEndpoint");
}

[TypeContractorIgnore]
internal class IgnoredController : ControllerBase { }

[TypeContractorName("RenamedClient")]
internal class LegacyController : ControllerBase
{
[HttpPost("many-methods")]
[TypeContractorName("postWithId")]
public ActionResult OverloadEndpoint(Guid id, CancellationToken cancellationToken) => NotFound();

[HttpGet("other-route")]
public ActionResult OverloadEndpoint(CancellationToken cancellationToken) => NotFound();
}

[TypeContractorName("RenamedApi")]
internal class RenamedSuffixController : ControllerBase { }
}
2 changes: 1 addition & 1 deletion TypeContractor.Tool/Generator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public Task<int> Execute()
Log.Instance.LogDebug($"Generating endpoints for {controller.FullName}");
var client = ApiHelpers.BuildApiClient(controller, endpoints);

if (client.Endpoints.Any())
if (client?.Endpoints.Any() ?? false)
{
if (clients.Any(x => x.Name == client.Name))
{
Expand Down
6 changes: 6 additions & 0 deletions TypeContractor.sln
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TypeContractor.Example", "T
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TypeContractor.Tool", "TypeContractor.Tool\TypeContractor.Tool.csproj", "{298435BA-89E0-4999-9E0B-9430E82A3907}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TypeContractor.Annotations", "TypeContractor.Annotations\TypeContractor.Annotations.csproj", "{8A6455DF-CE2F-44F7-B1BB-2C1640E1AEC3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -49,6 +51,10 @@ Global
{298435BA-89E0-4999-9E0B-9430E82A3907}.Debug|Any CPU.Build.0 = Debug|Any CPU
{298435BA-89E0-4999-9E0B-9430E82A3907}.Release|Any CPU.ActiveCfg = Release|Any CPU
{298435BA-89E0-4999-9E0B-9430E82A3907}.Release|Any CPU.Build.0 = Release|Any CPU
{8A6455DF-CE2F-44F7-B1BB-2C1640E1AEC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A6455DF-CE2F-44F7-B1BB-2C1640E1AEC3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A6455DF-CE2F-44F7-B1BB-2C1640E1AEC3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A6455DF-CE2F-44F7-B1BB-2C1640E1AEC3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
24 changes: 19 additions & 5 deletions TypeContractor/Helpers/ApiHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Reflection;
using System.Text.RegularExpressions;
using TypeContractor.Annotations;
using TypeContractor.Logger;
using TypeContractor.Output;
using TypeContractor.TypeScript;
Expand All @@ -14,8 +15,15 @@ public static partial class ApiHelpers
[GeneratedRegex("{([A-Za-z]+)(:[[A-Za-z]+)?}")]
private static partial Regex RouteParameterRegexImpl();

public static ApiClient BuildApiClient(Type controller, List<MethodInfo> endpoints)
public static ApiClient? BuildApiClient(Type controller, List<MethodInfo> endpoints)
{
var ignoreAttribute = controller.CustomAttributes.FirstOrDefault(x => x.AttributeType.FullName == typeof(TypeContractorIgnoreAttribute).FullName);
if (ignoreAttribute is not null)
{
Log.Instance.LogDebug($"Controller {controller.Name} marked with Ignore. Skipping.");
return null;
}

// Find route prefix, if any
var prefixAttribute = controller.CustomAttributes.FirstOrDefault(x => x.AttributeType.FullName == "Microsoft.AspNetCore.Mvc.RouteAttribute");
var prefix = prefixAttribute?.ConstructorArguments.First().Value as string;
Expand All @@ -24,7 +32,11 @@ public static ApiClient BuildApiClient(Type controller, List<MethodInfo> endpoin
var obsoleteAttribute = controller.CustomAttributes.FirstOrDefault(x => x.AttributeType.FullName == "System.ObsoleteAttribute");
var obsoleteInfo = obsoleteAttribute is not null ? new ObsoleteInfo(obsoleteAttribute.ConstructorArguments.FirstOrDefault().Value as string) : null;

var clientName = controller.Name.Replace("Controller", "Client");
// Find name of the client
var clientAttribute = controller.CustomAttributes.FirstOrDefault(x => x.AttributeType.FullName == typeof(TypeContractorNameAttribute).FullName);
var clientName = clientAttribute?.ConstructorArguments.FirstOrDefault().Value as string
?? controller.Name.Replace("Controller", "Client");

var client = new ApiClient(clientName, controller.FullName!, prefix, obsoleteInfo);

foreach (var endpoint in endpoints)
Expand All @@ -36,7 +48,7 @@ public static ApiClient BuildApiClient(Type controller, List<MethodInfo> endpoin
return client;
}

private static List<ApiClientEndpoint> BuildApiEndpoint(MethodInfo endpoint)
internal static List<ApiClientEndpoint> BuildApiEndpoint(MethodInfo endpoint)
{
// Find HTTP method
var httpAttributes = endpoint
Expand Down Expand Up @@ -71,9 +83,11 @@ private static List<ApiClientEndpoint> BuildApiEndpoint(MethodInfo endpoint)
.Where(x => x.ParameterType.FullName != "System.Threading.CancellationToken")
.ToList();

Log.Instance.LogDebug($"Found endpoint {endpoint.Name} returning {returnType?.Name ?? "HTTP"} with {parameters.Count} parameters");

var endpointName = endpoint.Name.ToTypeScriptName();
var nameAttribute = endpoint.CustomAttributes.FirstOrDefault(x => x.AttributeType.FullName == typeof(TypeContractorNameAttribute).FullName);
var endpointName = nameAttribute?.ConstructorArguments.FirstOrDefault().Value as string
?? endpoint.Name.ToTypeScriptName();
Log.Instance.LogDebug($"Found endpoint {endpoint.Name}->{endpointName} returning {returnType?.Name ?? "HTTP"} with {parameters.Count} parameters");
var apiEndpoint = new ApiClientEndpoint(endpointName,
route,
httpMethod,
Expand Down
8 changes: 6 additions & 2 deletions TypeContractor/Helpers/TypeChecks.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Reflection;
using TypeContractor.Annotations;

namespace TypeContractor.Helpers;

Expand All @@ -24,13 +25,16 @@ public static bool IsNullable(FieldInfo fieldInfo)
public static bool IsNullable(PropertyInfo propertyInfo)
{
ArgumentNullException.ThrowIfNull(propertyInfo);
return IsNullable(propertyInfo.PropertyType) || _nullabilityContext.Create(propertyInfo).WriteState == NullabilityState.Nullable;
return IsNullable(propertyInfo.PropertyType)
|| propertyInfo.CustomAttributes.Any(x => x.AttributeType.FullName == typeof(TypeContractorNullableAttribute).FullName)
|| _nullabilityContext.Create(propertyInfo).WriteState == NullabilityState.Nullable;
}

public static bool IsNullable(Type sourceType)
{
ArgumentNullException.ThrowIfNull(sourceType);
return Nullable.GetUnderlyingType(sourceType) != null || sourceType.Name == "Nullable`1";
return Nullable.GetUnderlyingType(sourceType) != null
|| sourceType.Name == "Nullable`1";
}

/// <summary>
Expand Down
3 changes: 3 additions & 0 deletions TypeContractor/TypeContractor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TypeContractor.Annotations\TypeContractor.Annotations.csproj" />
</ItemGroup>
</Project>
Loading
Loading