diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b5f13..58661b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added + +- Better support for generic types (#123) + ## [0.16.0] - 2024-12-17 ### Added diff --git a/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs b/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs index 83b412c..baaf521 100644 --- a/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs +++ b/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs @@ -343,6 +343,26 @@ public void Handles_Nullable_Records_Inside_Other_Records() second.IsNullable.Should().BeTrue(); } + [Fact] + public void Handles_Generics() + { + var result = Sut.Convert(typeof(ResponseWithOverrides)); + + result.Should().NotBeNull(); + result.Properties.Should().NotBeNull(); + result.Properties!.Should().HaveCount(2); + result.Properties!.First().DestinationType.Should().Be("Overridable"); + result.Properties!.Last().DestinationType.Should().Be("Overridable"); + + Sut.CustomMappedTypes.Should().ContainSingle(); + var overridableType = Sut.CustomMappedTypes.First().Value; + overridableType.Properties.Should().HaveCount(2); + overridableType.Properties!.First().DestinationName.Should().Be("value"); + overridableType.Properties!.First().DestinationType.Should().Be("T"); + overridableType.Properties!.Last().DestinationName.Should().Be("isOverridden"); + overridableType.Properties!.Last().DestinationType.Should().Be("boolean"); + } + #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); @@ -466,6 +486,18 @@ private class TimeOnlyResponse public TimeOnly MeetingTime { get; set; } } + private class Overridable + { + public T? Value { get; set; } + public bool IsOverridden { get; set; } + } + + private class ResponseWithOverrides + { + public Overridable Name { get; set; } + public Overridable SomeBool { get; set; } + } + #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 4b65254..0a1ba6e 100644 --- a/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs +++ b/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs @@ -16,7 +16,7 @@ public class TypeScriptWriterTests : IDisposable public TypeScriptWriterTests() { var assembly = typeof(TypeScriptWriterTests).Assembly; - _outputDirectory = Directory.CreateTempSubdirectory(); + _outputDirectory = new DirectoryInfo("C:\\Users\\per.christian.bechst\\AppData\\Local\\Temp\\gtp5vhmw.mgi\\TypeContractor\\Tests\\TypeScript"); _configuration = TypeContractorConfiguration .WithDefaultConfiguration() .AddAssembly(assembly.FullName!, assembly.Location) @@ -48,6 +48,80 @@ public void Can_Write_Simple_Types() .And.Contain("someObject: any;"); } + [Fact] + public void Can_Write_Generic_Types() + { + // Arrange + var outputTypes = BuildOutputTypes(typeof(ResponseWithOverrides)); + + // Act + var responseResult = Sut.Write(outputTypes.First(x => x.Name == "ResponseWithOverrides"), outputTypes, true); + var overrideResult = Sut.Write(outputTypes.First(x => x.Name == "Overridable"), outputTypes, true); + + // Assert + var responseFile = File.ReadAllLines(responseResult).Select(x => x.TrimStart()); + responseFile.Should() + .NotBeEmpty() + .And.Contain("import { Overridable, OverridableSchema } from './Overridable';") + + .And.Contain("export interface ResponseWithOverrides {") + .And.Contain("name: Overridable;") + .And.Contain("someBool: Overridable;") + + .And.Contain("export const ResponseWithOverridesSchema = z.object({") + .And.Contain("name: OverridableSchema,") + .And.Contain("someBool: OverridableSchema,"); + + var overrideFile = File.ReadAllLines(overrideResult).Select(x => x.TrimStart()); + overrideFile.Should() + .NotBeEmpty() + .And.Contain("export interface Overridable {") + .And.Contain("value?: T;") + .And.Contain("isOverridden: boolean;") + + .And.Contain("export const OverridableSchema = z.object({") + .And.Contain("value: z.any().nullable(),") + .And.Contain("isOverridden: z.boolean(),"); + } + + [Fact] + public void Only_Includes_Relevant_Zod_Schemas() + { + // Arrange + var outputTypes = BuildOutputTypes(typeof(ResponseWithOverridableCustomType)); + + // Act + var responseResult = Sut.Write(outputTypes.First(x => x.Name == "ResponseWithOverridableCustomType"), outputTypes, true); + var overrideResult = Sut.Write(outputTypes.First(x => x.Name == "Overridable"), outputTypes, true); + var requestResult = Sut.Write(outputTypes.First(x => x.Name == "MyCustomRequest"), outputTypes, true); + + // Assert + var responseFile = File.ReadAllLines(responseResult).Select(x => x.TrimStart()); + var overrideFile = File.ReadAllLines(overrideResult).Select(x => x.TrimStart()); + var requestFile = File.ReadAllLines(requestResult).Select(x => x.TrimStart()); + + responseFile.Should() + .NotBeEmpty() + .And.Contain("import { Overridable, OverridableSchema } from './Overridable';") + .And.Contain("import { MyCustomRequest } from './MyCustomRequest';") + + .And.Contain("export interface ResponseWithOverridableCustomType {") + .And.Contain("nestedRequest: Overridable;") + + .And.Contain("export const ResponseWithOverridableCustomTypeSchema = z.object({") + .And.Contain("nestedRequest: OverridableSchema,"); + + overrideFile.Should() + .NotBeEmpty() + .And.Contain("export interface Overridable {") + .And.Contain("value?: T;") + .And.Contain("isOverridden: boolean;") + + .And.Contain("export const OverridableSchema = z.object({") + .And.Contain("value: z.any().nullable(),") + .And.Contain("isOverridden: z.boolean(),"); + } + [Fact] public void Handles_Dictionary_With_Complex_Values() { @@ -479,7 +553,33 @@ private MetadataLoadContext BuildMetadataLoadContext() public void Dispose() { - if (_outputDirectory.Exists) - _outputDirectory.Delete(true); + //if (_outputDirectory.Exists) + // _outputDirectory.Delete(true); } } + +#region Test input +#pragma warning disable CS8618 +public class Overridable +{ + public T? Value { get; set; } + public bool IsOverridden { get; set; } +} + +public class ResponseWithOverrides +{ + public Overridable Name { get; set; } + public Overridable SomeBool { get; set; } +} + +public class ResponseWithOverridableCustomType +{ + public Overridable NestedRequest { get; set; } +} + +public class MyCustomRequest +{ + public string Name { get; set; } +} +#pragma warning restore CS8618 +#endregion diff --git a/TypeContractor/Output/DestinationType.cs b/TypeContractor/Output/DestinationType.cs index e34aded..2acbe9c 100644 --- a/TypeContractor/Output/DestinationType.cs +++ b/TypeContractor/Output/DestinationType.cs @@ -1,8 +1,29 @@ namespace TypeContractor.Output; -public record DestinationType(string TypeName, string? FullName, string ImportType, bool IsBuiltin, bool IsArray, bool IsReadonly, bool IsNullable, Type? InnerType) +public record DestinationType( + string TypeName, + string? FullName, + string ImportType, + bool IsBuiltin, + bool IsArray, + bool IsReadonly, + bool IsNullable, + bool IsGeneric, + ICollection GenericTypeArguments, + Type? SourceType, + Type? InnerType) { - public DestinationType(string typeName, string? fullName, bool isBuiltin, bool isArray, bool isReadonly, bool isNullable, Type? innerType, string? importType = null) : this(typeName, fullName, importType ?? typeName, isBuiltin, isArray, isReadonly, isNullable, innerType) + public DestinationType(string typeName, + string? fullName, + bool isBuiltin, + bool isArray, + bool isReadonly, + bool isNullable, + bool isGeneric, + ICollection genericTypeArguments, + Type? innerType, + Type? sourceType, + string? importType = null) : this(typeName, fullName, importType ?? typeName, isBuiltin, isArray, isReadonly, isNullable, isGeneric, genericTypeArguments, sourceType, innerType) { } diff --git a/TypeContractor/Output/OutputProperty.cs b/TypeContractor/Output/OutputProperty.cs index 39e66d9..173ec8d 100644 --- a/TypeContractor/Output/OutputProperty.cs +++ b/TypeContractor/Output/OutputProperty.cs @@ -1,6 +1,18 @@ namespace TypeContractor.Output; -public class OutputProperty(string sourceName, Type sourceType, Type? innerSourceType, string destinationName, string destinationType, string importType, bool isBuiltin, bool isArray, bool isNullable, bool isReadonly) +public class OutputProperty( + string sourceName, + Type sourceType, + Type? innerSourceType, + string destinationName, + string destinationType, + string importType, + bool isBuiltin, + bool isArray, + bool isNullable, + bool isReadonly, + bool isGeneric, + ICollection genericTypeArguments) { public string SourceName { get; set; } = sourceName; public Type SourceType { get; set; } = sourceType; @@ -12,6 +24,8 @@ public class OutputProperty(string sourceName, Type sourceType, Type? innerSourc public bool IsArray { get; set; } = isArray; public bool IsNullable { get; set; } = isNullable; public bool IsReadonly { get; set; } = isReadonly; + public bool IsGeneric { get; set; } = isGeneric; + public ICollection GenericTypeArguments { get; } = genericTypeArguments; public ObsoleteInfo? Obsolete { get; set; } /// @@ -37,6 +51,8 @@ public override bool Equals(object? obj) IsArray == property.IsArray && IsNullable == property.IsNullable && IsReadonly == property.IsReadonly && + IsGeneric == property.IsGeneric && + GenericTypeArguments.SequenceEqual(property.GenericTypeArguments) && EqualityComparer.Default.Equals(Obsolete, property.Obsolete); } @@ -53,6 +69,7 @@ public override int GetHashCode() hash.Add(IsArray); hash.Add(IsNullable); hash.Add(IsReadonly); + hash.Add(IsGeneric); hash.Add(Obsolete); return hash.ToHashCode(); } diff --git a/TypeContractor/Output/OutputType.cs b/TypeContractor/Output/OutputType.cs index 74205e8..a727c5e 100644 --- a/TypeContractor/Output/OutputType.cs +++ b/TypeContractor/Output/OutputType.cs @@ -1,9 +1,18 @@ -using System.Globalization; +using System.Globalization; using System.Text; namespace TypeContractor.Output; -public record OutputType(string Name, string FullName, string FileName, ContractedType ContractedType, bool IsEnum, ICollection? Properties, ICollection? EnumMembers) +public record OutputType( + string Name, + string FullName, + string FileName, + ContractedType ContractedType, + bool IsEnum, + bool IsGeneric, + ICollection GenericTypeArguments, + ICollection? Properties, + ICollection? EnumMembers) { public override string ToString() { diff --git a/TypeContractor/TypeScript/TypeScriptConverter.cs b/TypeContractor/TypeScript/TypeScriptConverter.cs index cdfcafa..476e4f4 100644 --- a/TypeContractor/TypeScript/TypeScriptConverter.cs +++ b/TypeContractor/TypeScript/TypeScriptConverter.cs @@ -18,12 +18,16 @@ public OutputType Convert(Type type, ContractedType? contractedType = null) { ArgumentNullException.ThrowIfNull(type); + var typeName = type.Name.Split('`').First(); + return new( - type.Name, + typeName, type.FullName!, - CasingHelpers.ToCasing(type.Name.Replace("_", ""), configuration.Casing), - contractedType ?? ContractedType.FromName(type.FullName!, type, configuration), + CasingHelpers.ToCasing(typeName.Replace("_", ""), configuration.Casing), + contractedType ?? ContractedType.FromName(type.FullName ?? typeName, type, configuration), type.IsEnum, + type.IsGenericType, + type.IsGenericType ? ((TypeInfo)type).GenericTypeParameters.Select(x => GetDestinationType(x, [], false, TypeChecks.IsNullable(x))).ToList() : [], type.IsEnum ? null : GetProperties(type).Distinct().ToList(), type.IsEnum ? GetEnumProperties(type) : null ); @@ -71,7 +75,19 @@ private List GetProperties(Type type) var destinationName = GetDestinationName(property.Name); var destinationType = GetDestinationType(property.PropertyType, property.CustomAttributes, isReadonly, TypeChecks.IsNullable(property.PropertyType)); - var outputProperty = new OutputProperty(property.Name, property.PropertyType, destinationType.InnerType, destinationName, destinationType.TypeName, destinationType.ImportType, destinationType.IsBuiltin, destinationType.IsArray, TypeChecks.IsNullable(property), destinationType.IsReadonly); + var outputProperty = new OutputProperty( + property.Name, + property.PropertyType, + destinationType.InnerType, + destinationName, + destinationType.TypeName, + destinationType.ImportType, + destinationType.IsBuiltin, + destinationType.IsArray, + TypeChecks.IsNullable(property), + destinationType.IsReadonly, + destinationType.IsGeneric, + destinationType.GenericTypeArguments); var obsolete = property.CustomAttributes.FirstOrDefault(x => x.AttributeType.FullName == "System.ObsoleteAttribute"); outputProperty.Obsolete = obsolete is not null ? new ObsoleteInfo((string?)obsolete.ConstructorArguments.FirstOrDefault().Value) : null; @@ -94,11 +110,14 @@ private List GetProperties(Type type) public DestinationType GetDestinationType(in Type sourceType, IEnumerable customAttributes, bool isReadonly, bool isNullable) { - if (configuration.TypeMaps.TryGetValue(sourceType.FullName!, out var destType)) - return new DestinationType(destType.Replace("[]", string.Empty), sourceType.FullName, true, destType.Contains("[]"), isReadonly, isNullable || TypeChecks.IsNullable(sourceType), null); + if (!sourceType.IsGenericParameter && configuration.TypeMaps.TryGetValue(sourceType.FullName!, out var destType)) + return new DestinationType(destType.Replace("[]", string.Empty), sourceType.FullName, true, destType.Contains("[]"), isReadonly, isNullable || TypeChecks.IsNullable(sourceType), false, [], null, sourceType); if (CustomMappedTypes.TryGetValue(sourceType, out var customType)) - return new DestinationType(customType.Name, customType.FullName, false, false, isReadonly, TypeChecks.IsNullable(sourceType), null); + return new DestinationType(customType.Name, customType.FullName, false, false, isReadonly, TypeChecks.IsNullable(sourceType), customType.IsGeneric, customType.GenericTypeArguments, null, customType.ContractedType.Type); + + if (sourceType.IsGenericTypeParameter) + return new DestinationType(sourceType.Name, null, true, false, false, isNullable, true, [], null, sourceType, ""); if (TypeChecks.ImplementsIDictionary(sourceType)) { @@ -108,15 +127,15 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable $"item{idx + 1}: {arg.FullTypeName}"); var typeName = $"{{ {string.Join(", ", argumentList)} }}"; - return new DestinationType(typeName, sourceType.FullName, isBuiltin, false, isReadonly, false, null); + return new DestinationType(typeName, sourceType.FullName, isBuiltin, false, isReadonly, false, false, [], null, sourceType); } if (TypeChecks.IsNullable(sourceType)) @@ -136,13 +155,29 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable 0) + { + var genericType = sourceType.GetGenericTypeDefinition(); + var genericOutputType = Convert(genericType); + CustomMappedTypes.TryAdd(genericType, genericOutputType); + + var genericArguments = sourceType.GenericTypeArguments + .Select(x => GetDestinationType(x, customAttributes, isReadonly, TypeChecks.IsNullable(x))) + .ToList(); + + var importType = genericOutputType.Name.Split('`').First(); + var typeName = importType + $"<{string.Join(", ", genericArguments.Select(x => x.TypeName))}>"; + + return new DestinationType(typeName, genericOutputType.FullName, false, false, isReadonly, isNullable, true, genericArguments, null, genericOutputType.ContractedType.Type, importType); + } + if (customAttributes.Any(x => x.AttributeType.FullName == "System.Runtime.CompilerServices.DynamicAttribute")) - return new DestinationType(DestinationTypes.Dynamic, null, true, false, isReadonly, true, null); + return new DestinationType(DestinationTypes.Dynamic, null, true, false, isReadonly, true, false, [], null, null); // FIXME: Check if this is one of our types? var outputType = Convert(sourceType); CustomMappedTypes.Add(sourceType, outputType); - return new DestinationType(outputType.Name, outputType.FullName, false, false, isReadonly, isNullable || TypeChecks.IsNullable(sourceType), null); + return new DestinationType(outputType.Name, outputType.FullName, false, false, isReadonly, isNullable || TypeChecks.IsNullable(sourceType), outputType.IsGeneric, outputType.GenericTypeArguments, null, sourceType); // throw new ArgumentException($"Unexpected type: {sourceType}"); } diff --git a/TypeContractor/TypeScript/TypeScriptWriter.cs b/TypeContractor/TypeScript/TypeScriptWriter.cs index ef65991..351b3c3 100644 --- a/TypeContractor/TypeScript/TypeScriptWriter.cs +++ b/TypeContractor/TypeScript/TypeScriptWriter.cs @@ -48,12 +48,25 @@ private void BuildHeader() private void BuildImports(OutputType type, IEnumerable allTypes, bool buildZodSchema) { - var properties = type.Properties ?? Enumerable.Empty(); + var properties = type.Properties ?? []; var imports = properties .Where(p => !p.IsBuiltin) .DistinctBy(p => p.InnerSourceType ?? p.SourceType) .ToList(); + foreach (var property in properties) + { + if (!property.IsGeneric) continue; + if (property.GenericTypeArguments.Count == 0) continue; + + foreach (var genArg in property.GenericTypeArguments) + { + if (genArg.IsBuiltin) continue; + if (genArg.InnerType is null && genArg.SourceType is null) continue; + imports.Add(new OutputProperty(genArg.TypeName, (genArg.InnerType ?? genArg.SourceType)!, null, "", genArg.TypeName, genArg.ImportType, false, genArg.IsArray, genArg.IsNullable, genArg.IsReadonly, genArg.IsGeneric, genArg.GenericTypeArguments)); + } + } + if (buildZodSchema) _builder.AppendLine(ZodSchemaWriter.LibraryImport); @@ -88,7 +101,8 @@ private void BuildImports(OutputType type, IEnumerable allTypes, boo alreadyImportedTypes.Add(import.ImportType); var importTypes = new List { import.ImportType }; - if (buildZodSchema) + var shouldImportSchema = type.Properties is null || type.Properties.Any(x => x.ImportType == import.ImportType); + if (buildZodSchema && shouldImportSchema) { var zodImport = ZodSchemaWriter.BuildImport(import); if (!string.IsNullOrWhiteSpace(zodImport)) @@ -117,7 +131,14 @@ private void BuildExport(OutputType type) } else { - _builder.AppendLine($"export interface {type.Name} {{"); + var genericPropertyTypes = type.IsGeneric + ? type.GenericTypeArguments ?? [] + : []; + var genericTypeArguments = genericPropertyTypes.Count > 0 + ? $"<{string.Join(", ", genericPropertyTypes.Select(x => x.TypeName))}>" + : ""; + + _builder.AppendLine($"export interface {type.Name}{genericTypeArguments} {{"); } // Body @@ -168,6 +189,11 @@ private static List GetImportedTypes(IEnumerable allType return allTypes.Where(x => x.FullName == keyType.FullName || x.FullName == valueType.FullName).ToList(); } + if (import.IsGeneric && import.GenericTypeArguments.Count > 0) + return allTypes + .Where(x => x.FullName == $"{sourceType.Namespace}.{sourceType.Name}") + .ToList(); + return allTypes.Where(x => x.FullName == sourceType.FullName).ToList(); } } diff --git a/TypeContractor/TypeScript/ZodSchemaWriter.cs b/TypeContractor/TypeScript/ZodSchemaWriter.cs index 6a545bb..83d9779 100644 --- a/TypeContractor/TypeScript/ZodSchemaWriter.cs +++ b/TypeContractor/TypeScript/ZodSchemaWriter.cs @@ -35,7 +35,9 @@ public static void Write(OutputType type, IEnumerable allTypes, Stri public static string? BuildImport(OutputProperty import) { - var sourceType = import.SourceType.IsGenericType ? TypeChecks.GetGenericType(import.SourceType) : import.InnerSourceType ?? import.SourceType; + var sourceType = import.InnerSourceType ?? import.SourceType; + if (TypeChecks.IsNullable(sourceType)) + sourceType = TypeChecks.GetGenericType(sourceType); // We don't currently import any schema for enums if (sourceType.IsEnum) @@ -60,6 +62,7 @@ private static string GetImportType(Type? innerSourceType, Type sourceType) return GetImportType(TypeChecks.GetGenericType(innerSourceType), sourceType); var name = innerSourceType?.Name ?? sourceType.Name; + name = name.Split('`').First(); return name; } @@ -85,6 +88,7 @@ private static string GetImportType(Type? innerSourceType, Type sourceType) else if (!property.IsBuiltin && !property.IsNullable) { var name = property.InnerSourceType?.Name ?? property.SourceType.Name; + name = name.Split('`').First(); output = $"{name}Schema"; } else if (property.IsBuiltin) @@ -94,11 +98,12 @@ private static string GetImportType(Type? innerSourceType, Type sourceType) else if (!property.IsBuiltin && property.IsArray && property.InnerSourceType is not null) { var name = property.InnerSourceType.Name; + name = name.Split('`').First(); output = $"{name}Schema"; } else { - output = $"{property.SourceType.Name}Schema"; + output = $"{property.SourceType.Name.Split('`').First()}Schema"; } if (property.IsArray)