Skip to content
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Simplified response for generated API clients using the React template (#203)
- Add default maps for unsigned numbers (#180)
- Apply `TypeContractorIgnore` to properties
- Add support for converting classes with constants (#196)
- Add support for specifying additional assemblies to look in

### Fixed

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ TypeScript interfaces and enums
the types, we have the schemas. Let's just make everything work together,
and let TypeContractor handle keeping those pesky API changes in sync.

6. Generate a file with constants to TypeScript

Any class annotated with `[TypeContractorConstants]` will get a TypeScript
version created where all `public const` fields gets converted to a
constant TypeScript object with all the fields and their values.

## Setup and configuration

Expand Down Expand Up @@ -287,6 +292,9 @@ Available annotations:
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.
* `TypeContractorConstants`:
If you have a file with various constants that you want to share and
generate a TypeScript file from that gets kept in sync.

## Future improvements

Expand Down
13 changes: 13 additions & 0 deletions TypeContractor.Annotations/TypeContractorConstantsAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System;

namespace TypeContractor.Annotations
{
/// <summary>
/// Tells TypeContractor to find all constant/static strings and generate
/// a TypeScript equivalent class with the same constants.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class TypeContractorConstantsAttribute : Attribute
{
}
}
25 changes: 24 additions & 1 deletion TypeContractor.Tests/Helpers/TypeChecksTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using System.Collections;
using System.Reflection;
using System.Reflection.Metadata;
using TypeContractor.Annotations;
using TypeContractor.Helpers;

namespace TypeContractor.Tests.Helpers
Expand Down Expand Up @@ -252,6 +252,18 @@ public void UnwrappedParameters_Ignores_AspNetFramework_Types(string methodName,
else
parameterTypes.Should().ContainInOrder(expectedTypes);
}

[Fact]
public void ContainsConstants_Is_True_With_Attribute()
{
TypeChecks.ContainsConstants(typeof(MyConstants)).Should().BeTrue();
}

[Fact]
public void ContainsConstants_Is_False_Without_Attribute()
{
TypeChecks.ContainsConstants(typeof(MyPlainConstants)).Should().BeFalse();
}
}


Expand Down Expand Up @@ -332,5 +344,16 @@ internal class ComplexNestedType
public Guid SequentialId { get; set; }
public string SourceHint { get; set; }
}

[TypeContractorConstants]
internal class MyConstants
{
public const string Test = "hello";
}

internal class MyPlainConstants
{
public const string Test = "hello";
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
}
27 changes: 27 additions & 0 deletions TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,21 @@ public void Ignores_Properties_With_IgnoreAttribute()
.And.Contain(x => x.DestinationName == "dontIgnoreMeBro");
}

[Fact]
public void Finds_Constant_Values()
{
var result = Sut.Convert(typeof(SignalRConstants));

result.Should().NotBeNull();
result.Properties.Should()
.NotBeNull()
.And.NotContain(x => x.DestinationName == "hubName")
.And.NotContain(x => x.DestinationName == "someProperty")
.And.ContainSingle(x => x.DestinationName == "finishedGeneratingReports" && x.Value is string && (string)x.Value == "finishedGeneratingReports")
.And.ContainSingle(x => x.DestinationName == "timeoutInSeconds" && x.Value is int && (int)x.Value == 15)
.And.ContainSingle(x => x.DestinationName == "reconnect" && x.Value is bool && (bool)x.Value == true);
}

#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
private record TopLevelRecord(string Name, SecondStoryRecord? SecondStoryRecord);
private record SecondStoryRecord(string Description, SomeOtherDeeplyNestedRecord? SomeOtherDeeplyNestedRecord);
Expand Down Expand Up @@ -579,6 +594,18 @@ private class ResponseWithIgnoredProperties
public string FeelFreeToIgnoreMe { get; set; }
}

[TypeContractorConstants]
private static class SignalRConstants
{
[TypeContractorIgnore]
public const string HubName = "Notifications";
public static readonly string SomeProperty = "this will be excluded :'(";

public const string FinishedGeneratingReports = "finishedGeneratingReports";
public const int TimeoutInSeconds = 15;
public const bool Reconnect = true;
}

#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.

private MetadataLoadContext BuildMetadataLoadContext()
Expand Down
33 changes: 33 additions & 0 deletions TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Reflection;
using System.Runtime.InteropServices;
using TypeContractor.Annotations;
using TypeContractor.Output;
using TypeContractor.TypeScript;

Expand Down Expand Up @@ -433,6 +434,28 @@ public void Changes_File_Name_According_To_Casing(Casing casing)
}
}

[Fact]
public void Writes_A_File_With_Constants()
{
// Arrange
var outputTypes = BuildOutputTypes(typeof(MyConstants));

// Act
var result = Sut.Write(outputTypes.First(), outputTypes, buildZodSchema: false);
var file = File.ReadAllText(result);

// Assert
file.Should()
.NotBeEmpty()
.And.Contain("export const MyConstants = {")
.And.Contain(" finishedGeneratingReports: \"FinishedGeneratingReports\",")
.And.NotContain(" startingToGenerate: \"StartingToGenerate\",")
.And.NotContain(" failedToGenerate: \"FailedToGenerate\",")
.And.Contain(" useTabsInsteadOfSpaces: true")
.And.Contain(" retries: 5")
.And.Contain("}");
}

private List<OutputType> BuildOutputTypes(Type type, Casing casing = Casing.Pascal)
{
var oldCasing = _configuration.Casing;
Expand Down Expand Up @@ -581,5 +604,15 @@ public class MyCustomRequest
{
public string Name { get; set; }
}

[TypeContractorConstants]
public static class MyConstants
{
public const string FinishedGeneratingReports = "FinishedGeneratingReports";
public static readonly string StartingToGenerate = "StartingToGenerate";
public static string FailedToGenerate { get; } = "FailedToGenerate";
public const bool UseTabsInsteadOfSpaces = true;
public const int Retries = 5;
}
#pragma warning restore CS8618
#endregion
34 changes: 29 additions & 5 deletions TypeContractor.Tool/Generator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ internal class Generator
private readonly string[] _replacements;
private readonly string[] _strip;
private readonly string[] _customMaps;
private readonly string[] _alsoLookIn;
private readonly string[] _constantsTypes;
private readonly string _packPath;
private readonly int _dotnetVersion;
private readonly bool _buildZodSchemas;
Expand All @@ -23,12 +25,14 @@ internal class Generator
private readonly Casing? _casing;

public Generator(string assemblyPath,
string[] alsoLookIn,
string output,
string? relativeRoot,
CleanMethod cleanMethod,
string[] replacements,
string[] strip,
string[] customMaps,
string[] constantsTypes,
string packsPath,
int dotnetVersion,
bool buildZodSchemas,
Expand All @@ -37,12 +41,14 @@ public Generator(string assemblyPath,
Casing? casing)
{
_assemblyPath = assemblyPath;
_alsoLookIn = alsoLookIn;
_output = output;
_relativeRoot = relativeRoot;
_cleanMethod = cleanMethod;
_replacements = replacements;
_strip = strip;
_customMaps = customMaps;
_constantsTypes = constantsTypes;
_packPath = packsPath;
_dotnetVersion = dotnetVersion;
_buildZodSchemas = buildZodSchemas;
Expand Down Expand Up @@ -74,14 +80,26 @@ public Task<int> Execute(CancellationToken cancellationToken)
try
{
Log.Instance.LogDebug($"Going to load assembly {_assemblyPath}");
var assembly = context.LoadFromAssemblyPath(_assemblyPath);
var controllers = assembly.GetTypes()
.Where(IsController).ToList();
var clients = new List<ApiClient>();
var assembly = context.LoadFromAssemblyPath(_assemblyPath);
var assemblyTypes = assembly.GetExportedTypes();
foreach (var assPath in _alsoLookIn)
{
var extraAssembly = context.LoadFromAssemblyPath(assPath);
assemblyTypes = [.. assemblyTypes, .. extraAssembly.GetExportedTypes()];
}

var controllers = assemblyTypes.Where(IsController).ToList();

if (controllers.Count == 0)
var extraConstants = (_constantsTypes ?? [])
.SelectMany(x => context.GetAssemblies().Select(ass => ass.GetType(x)))
.Where(x => x is not null)
.Cast<Type>();
var constants = assemblyTypes.Where(ContainsConstants).Concat(extraConstants).Distinct().ToList();

if (controllers.Count == 0 && constants.Count == 0)
{
Log.Instance.LogError("Unable to find any controllers.");
Log.Instance.LogError("Unable to find anything to generate types from.");
return Task.FromResult(1);
}

Expand Down Expand Up @@ -136,6 +154,12 @@ public Task<int> Execute(CancellationToken cancellationToken)
}
}

foreach (var constant in constants)
{
typesToLoad.TryAdd(constant.Assembly, []);
typesToLoad[constant.Assembly].Add(constant);
}

if (typesToLoad.Count == 0)
{
Log.Instance.LogWarning("Unable to find any types to convert that matches the expected format.");
Expand Down
18 changes: 18 additions & 0 deletions TypeContractor.Tool/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
Required = true,
};

var extraAssemblyOptions = new Option<string[]>("--also-look-in")
{
DefaultValueFactory = (arg) => config.GetStrings("also-look-in"),
Description = "Path to additional assemblies to look for relevant types in. Can be repeated",
};

var outputOption = new Option<string>("--output")
{
DefaultValueFactory = (arg) => config.TryGetString("output") ?? "",
Expand Down Expand Up @@ -53,6 +59,12 @@
Description = "Provide a custom type map in the form '<from>:<to>'. Can be repeated",
};

var constantOptions = new Option<string[]>("--generate-constants")
{
DefaultValueFactory = (arg) => config.GetStrings("generate-constants"),
Description = "Treat the provided type name as containing constants to convert. Can be repeated",
};

var packsOptions = new Option<string>("--packs-path")
{
DefaultValueFactory = (arg) => config.GetStringWithFallback("packs-path", @"C:\Program Files\dotnet\packs\"),
Expand Down Expand Up @@ -95,12 +107,14 @@
};

rootCommand.Options.Add(assemblyOption);
rootCommand.Options.Add(extraAssemblyOptions);
rootCommand.Options.Add(outputOption);
rootCommand.Options.Add(relativeRootOption);
rootCommand.Options.Add(cleanOption);
rootCommand.Options.Add(replaceOptions);
rootCommand.Options.Add(stripOptions);
rootCommand.Options.Add(mapOptions);
rootCommand.Options.Add(constantOptions);
rootCommand.Options.Add(packsOptions);
rootCommand.Options.Add(dotnetVersionOptions);
rootCommand.Options.Add(logLevelOptions);
Expand Down Expand Up @@ -132,12 +146,14 @@
rootCommand.SetAction(async (parseResult, cancellationToken) =>
{
var assemblyOptionValue = parseResult.GetValue(assemblyOption)!;
var extraAssembliesValue = parseResult.GetValue(extraAssemblyOptions) ?? [];
var outputValue = parseResult.GetValue(outputOption)!;
var relativeRootValue = parseResult.GetValue(relativeRootOption);
var cleanValue = parseResult.GetValue(cleanOption);
var replacementsValue = parseResult.GetValue(replaceOptions) ?? [];
var stripValue = parseResult.GetValue(stripOptions) ?? [];
var customMapsValue = parseResult.GetValue(mapOptions) ?? [];
var constantsValue = parseResult.GetValue(constantOptions) ?? [];
var packsPathValue = parseResult.GetValue(packsOptions)!;
var dotnetVersionValue = parseResult.GetValue(dotnetVersionOptions);
var logLevelValue = parseResult.GetValue(logLevelOptions);
Expand All @@ -148,12 +164,14 @@

Log.Instance = new ConsoleLogger(logLevelValue);
var generator = new Generator(assemblyOptionValue,
extraAssembliesValue,
outputValue,
relativeRootValue,
cleanValue,
replacementsValue,
stripValue,
customMapsValue,
constantsValue,
packsPathValue,
dotnetVersionValue,
buildZodSchemasValue,
Expand Down
3 changes: 3 additions & 0 deletions TypeContractor/Helpers/TypeChecks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ public static bool IsController(Type type)
return false;
}

public static bool ContainsConstants(Type type)
=> type.HasCustomAttribute(typeof(TypeContractorConstantsAttribute).FullName!);

public static bool ReturnsActionResult(MethodInfo methodInfo)
{
ArgumentNullException.ThrowIfNull(methodInfo, nameof(methodInfo));
Expand Down
3 changes: 2 additions & 1 deletion TypeContractor/Output/OutputProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public class OutputProperty(
public bool IsGeneric { get; set; } = isGeneric;
public ICollection<DestinationType> GenericTypeArguments { get; } = genericTypeArguments;
public ObsoleteInfo? Obsolete { get; set; }
public object? Value { get; set; }

/// <summary>
/// Returns the <see cref="DestinationType"/> and array brackets if the type is an array
Expand All @@ -35,7 +36,7 @@ public class OutputProperty(

public override string ToString()
{
return $"{(IsReadonly ? "readonly" : "")}{DestinationName}{(IsNullable ? "?" : "")}: {FullDestinationType} (import {ImportType} from {SourceType}, {(IsBuiltin ? "builtin" : "custom")})";
return $"{(IsReadonly ? "readonly " : "")}{DestinationName}{(IsNullable ? "?" : "")}: {FullDestinationType} (import {ImportType} from {SourceType}, {(IsBuiltin ? "builtin" : "custom")})";
}

public override bool Equals(object? obj)
Expand Down
1 change: 1 addition & 0 deletions TypeContractor/Output/OutputType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public record OutputType(
ContractedType ContractedType,
bool IsEnum,
bool IsGeneric,
bool IsConstantsFile,
ICollection<DestinationType> GenericTypeArguments,
ICollection<OutputProperty>? Properties,
ICollection<OutputEnumMember>? EnumMembers)
Expand Down
Loading
Loading