diff --git a/CHANGELOG.md b/CHANGELOG.md
index e31d965..009f1b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index c51200d..17ff748 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
diff --git a/TypeContractor.Annotations/TypeContractorConstantsAttribute.cs b/TypeContractor.Annotations/TypeContractorConstantsAttribute.cs
new file mode 100644
index 0000000..8336094
--- /dev/null
+++ b/TypeContractor.Annotations/TypeContractorConstantsAttribute.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace TypeContractor.Annotations
+{
+ ///
+ /// Tells TypeContractor to find all constant/static strings and generate
+ /// a TypeScript equivalent class with the same constants.
+ ///
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
+ public sealed class TypeContractorConstantsAttribute : Attribute
+ {
+ }
+}
diff --git a/TypeContractor.Tests/Helpers/TypeChecksTests.cs b/TypeContractor.Tests/Helpers/TypeChecksTests.cs
index 57b6280..ccc2275 100644
--- a/TypeContractor.Tests/Helpers/TypeChecksTests.cs
+++ b/TypeContractor.Tests/Helpers/TypeChecksTests.cs
@@ -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
@@ -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();
+ }
}
@@ -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.
}
diff --git a/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs b/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs
index 2cd229f..db983e2 100644
--- a/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs
+++ b/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs
@@ -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);
@@ -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()
diff --git a/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs b/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs
index b736c48..2c0c14e 100644
--- a/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs
+++ b/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs
@@ -1,5 +1,6 @@
using System.Reflection;
using System.Runtime.InteropServices;
+using TypeContractor.Annotations;
using TypeContractor.Output;
using TypeContractor.TypeScript;
@@ -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 BuildOutputTypes(Type type, Casing casing = Casing.Pascal)
{
var oldCasing = _configuration.Casing;
@@ -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
diff --git a/TypeContractor.Tool/Generator.cs b/TypeContractor.Tool/Generator.cs
index b8b68b3..5cd5308 100644
--- a/TypeContractor.Tool/Generator.cs
+++ b/TypeContractor.Tool/Generator.cs
@@ -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;
@@ -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,
@@ -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;
@@ -74,14 +80,26 @@ public Task 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();
+ 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();
+ 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);
}
@@ -136,6 +154,12 @@ public Task 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.");
diff --git a/TypeContractor.Tool/Program.cs b/TypeContractor.Tool/Program.cs
index 99421f6..7af0500 100644
--- a/TypeContractor.Tool/Program.cs
+++ b/TypeContractor.Tool/Program.cs
@@ -16,6 +16,12 @@
Required = true,
};
+var extraAssemblyOptions = new Option("--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("--output")
{
DefaultValueFactory = (arg) => config.TryGetString("output") ?? "",
@@ -53,6 +59,12 @@
Description = "Provide a custom type map in the form ':'. Can be repeated",
};
+var constantOptions = new Option("--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("--packs-path")
{
DefaultValueFactory = (arg) => config.GetStringWithFallback("packs-path", @"C:\Program Files\dotnet\packs\"),
@@ -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);
@@ -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);
@@ -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,
diff --git a/TypeContractor/Helpers/TypeChecks.cs b/TypeContractor/Helpers/TypeChecks.cs
index 09177a6..847ecba 100644
--- a/TypeContractor/Helpers/TypeChecks.cs
+++ b/TypeContractor/Helpers/TypeChecks.cs
@@ -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));
diff --git a/TypeContractor/Output/OutputProperty.cs b/TypeContractor/Output/OutputProperty.cs
index 173ec8d..560d092 100644
--- a/TypeContractor/Output/OutputProperty.cs
+++ b/TypeContractor/Output/OutputProperty.cs
@@ -27,6 +27,7 @@ public class OutputProperty(
public bool IsGeneric { get; set; } = isGeneric;
public ICollection GenericTypeArguments { get; } = genericTypeArguments;
public ObsoleteInfo? Obsolete { get; set; }
+ public object? Value { get; set; }
///
/// Returns the and array brackets if the type is an array
@@ -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)
diff --git a/TypeContractor/Output/OutputType.cs b/TypeContractor/Output/OutputType.cs
index a727c5e..66389c5 100644
--- a/TypeContractor/Output/OutputType.cs
+++ b/TypeContractor/Output/OutputType.cs
@@ -10,6 +10,7 @@ public record OutputType(
ContractedType ContractedType,
bool IsEnum,
bool IsGeneric,
+ bool IsConstantsFile,
ICollection GenericTypeArguments,
ICollection? Properties,
ICollection? EnumMembers)
diff --git a/TypeContractor/TypeScript/TypeScriptConverter.cs b/TypeContractor/TypeScript/TypeScriptConverter.cs
index 24b16e9..327c274 100644
--- a/TypeContractor/TypeScript/TypeScriptConverter.cs
+++ b/TypeContractor/TypeScript/TypeScriptConverter.cs
@@ -21,6 +21,7 @@ public OutputType Convert(Type type, ContractedType? contractedType = null)
ArgumentNullException.ThrowIfNull(type);
var typeName = type.Name.Split('`').First();
+ var isConstantsFile = TypeChecks.ContainsConstants(type);
return new(
typeName,
@@ -29,8 +30,9 @@ public OutputType Convert(Type type, ContractedType? contractedType = null)
contractedType ?? ContractedType.FromName(type.FullName ?? typeName, type, configuration),
type.IsEnum,
type.IsGenericType,
+ isConstantsFile,
type.IsGenericType ? ((TypeInfo)type).GenericTypeParameters.Select(x => GetDestinationType(x, [], false, TypeChecks.IsNullable(x))).ToList() : [],
- type.IsEnum ? null : GetProperties(type).Distinct().ToList(),
+ type.IsEnum ? null : (isConstantsFile ? GetConstantProperties(type) : GetProperties(type)).Distinct().ToList(),
type.IsEnum ? GetEnumProperties(type) : null
);
}
@@ -114,6 +116,84 @@ private List GetProperties(Type type)
return outputProperties;
}
+ private List GetConstantProperties(Type type)
+ {
+ var outputProperties = new List();
+
+ // Find all properties
+ var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Static);
+
+ // Evaluate type of property
+ foreach (var property in properties)
+ {
+ if (property.HasCustomAttribute(typeof(TypeContractorIgnoreAttribute).FullName!))
+ {
+ Log.Instance.LogTrace($"Property {type.FullName ?? type.Name}.{property.Name} is ignored by attribute");
+ continue;
+ }
+
+ // Need to have a getter
+ if (!property.CanRead) continue;
+
+ // Getter has to be public
+ var getter = property.GetGetMethod(false);
+ if (getter is null) continue;
+
+ Log.Instance.LogWarning($"Ignoring {type.FullName ?? type.Name}.{property.Name}. {property.Name} is a property, but must be a constant field to be included");
+ }
+
+ // Check fields
+ var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static);
+ foreach (var field in fields)
+ {
+ if (field.HasCustomAttribute(typeof(TypeContractorIgnoreAttribute).FullName!))
+ {
+ Log.Instance.LogTrace($"Field {type.FullName ?? type.Name}.{field.Name} is ignored by attribute");
+ continue;
+ }
+
+ var destinationName = GetDestinationName(field.Name);
+ var destinationType = GetDestinationType(field.FieldType, field.CustomAttributes, isReadonly: true, TypeChecks.IsNullable(field.FieldType));
+ var outputProperty = new OutputProperty(
+ field.Name,
+ field.FieldType,
+ destinationType.InnerType,
+ destinationName,
+ destinationType.TypeName,
+ destinationType.ImportType,
+ destinationType.IsBuiltin,
+ destinationType.IsArray,
+ TypeChecks.IsNullable(field),
+ destinationType.IsReadonly,
+ destinationType.IsGeneric,
+ destinationType.GenericTypeArguments);
+
+ var obsolete = field.GetCustomAttribute("System.ObsoleteAttribute");
+ outputProperty.Obsolete = obsolete is not null ? new ObsoleteInfo((string?)obsolete.ConstructorArguments.FirstOrDefault().Value) : null;
+
+ try
+ {
+ if (field.IsLiteral)
+ outputProperty.Value = field.GetRawConstantValue();
+ else if (field.IsInitOnly)
+ {
+ Log.Instance.LogWarning($"Ignoring {type.FullName ?? type.Name}.{field.Name}. {field.Name} is a non-constant field");
+ continue;
+ }
+ else
+ outputProperty.Value = field.GetValue(type);
+ }
+ catch (InvalidOperationException ex)
+ {
+ Log.Instance.LogError(ex, $"Unable to get constant value of {type.FullName ?? type.Name}.{field.Name}");
+ }
+
+ outputProperties.Add(outputProperty);
+ }
+
+ return outputProperties;
+ }
+
public static string GetDestinationName(string name) => name.ToTypeScriptName();
public DestinationType GetDestinationType(in Type sourceType, IEnumerable customAttributes, bool isReadonly, bool isNullable)
diff --git a/TypeContractor/TypeScript/TypeScriptWriter.cs b/TypeContractor/TypeScript/TypeScriptWriter.cs
index 351b3c3..f41b542 100644
--- a/TypeContractor/TypeScript/TypeScriptWriter.cs
+++ b/TypeContractor/TypeScript/TypeScriptWriter.cs
@@ -129,6 +129,10 @@ private void BuildExport(OutputType type)
{
_builder.AppendLine($"export enum {type.Name} {{");
}
+ else if (type.IsConstantsFile)
+ {
+ _builder.AppendLine($"export const {type.Name} = {{");
+ }
else
{
var genericPropertyTypes = type.IsGeneric
@@ -144,12 +148,31 @@ private void BuildExport(OutputType type)
// Body
foreach (var property in type.Properties ?? Enumerable.Empty())
{
- var nullable = property.IsNullable ? "?" : "";
- var array = property.IsArray ? "[]" : "";
- var isReadonly = property.IsReadonly ? "readonly " : "";
-
_builder.AppendDeprecationComment(property.Obsolete);
- _builder.AppendFormat(" {4}{0}{1}: {2}{3};\r\n", property.DestinationName, nullable, property.DestinationType, array, isReadonly);
+
+ if (type.IsConstantsFile)
+ {
+ if (property.Value is null)
+ continue;
+
+ var value = property.Value switch
+ {
+ string => $"\"{property.Value}\"",
+ short or int or long or ushort or uint or ulong => $"{property.Value}",
+ bool => $"{property.Value}".ToLowerInvariant(),
+ _ => $"{property.Value}",
+ };
+
+ _builder.AppendFormat(" {0}: {1},\r\n", property.DestinationName, value);
+ }
+ else
+ {
+ var nullable = property.IsNullable ? "?" : "";
+ var array = property.IsArray ? "[]" : "";
+ var isReadonly = property.IsReadonly ? "readonly " : "";
+
+ _builder.AppendFormat(" {4}{0}{1}: {2}{3};\r\n", property.DestinationName, nullable, property.DestinationType, array, isReadonly);
+ }
}
foreach (var member in type.EnumMembers ?? Enumerable.Empty())
diff --git a/global.json b/global.json
index 058bafa..ed07ad8 100644
--- a/global.json
+++ b/global.json
@@ -1,5 +1,6 @@
{
"sdk": {
- "version": "10.0.103"
+ "version": "10.0.103",
+ "rollForward": "latestFeature"
}
}