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" } }