diff --git a/CHANGELOG.md b/CHANGELOG.md index 90caa73..28dbb37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Convert dictionaries with enum keys to `number` + +### Changed + +- Pin Handlebars.Net dependency to pre-slopped releases +- Bump System.Reflection.MetadataLoadContext from 10.0.10 to 10.0.11 +- Bump Microsoft.NET.Test.SDK from 18.8.1 to 18.9.0 +- Bump xunit.runner.visualstudio from 3.1.5 to 4.0.0 + ## [0.22.0] - 2026-07-31 ### Fixed diff --git a/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs b/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs index 0a86361..4b5af10 100644 --- a/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs +++ b/TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs @@ -244,10 +244,23 @@ public void Handles_Dictionary_With_Nested_Dictionary_Values() result.Should().NotBeNull(); result.Properties.Should().HaveCount(1); var prop = result.Properties!.First(); + prop.ImportType.Should().Be("FormulaDto"); prop.DestinationName.Should().Be("formulas"); prop.DestinationType.Should().Be("{ [key: string]: { [key: string]: FormulaDto[] } }"); } + [Fact] + public void Handles_Dictionary_With_Enum_Keys() + { + var result = Sut.Convert(typeof(EnumKeyedDictionary)); + + result.Should().NotBeNull(); + result.Properties.Should().HaveCount(1); + var prop = result.Properties!.First(); + prop.DestinationName.Should().Be("valuesPerState"); + prop.DestinationType.Should().Be("{ [key: number]: number }"); + } + [Fact] public void Handles_Simple_ValueTuple_Types() { @@ -536,6 +549,18 @@ private class NestedValueDictionary public Dictionary>> Formulas { get; set; } } + private class EnumKeyedDictionary + { + public Dictionary ValuesPerState { get; set; } + } + + private enum State + { + NotSet, + Pending, + Done, + } + private class FormulaDto { public Guid Id { get; set; } diff --git a/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs b/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs index a292639..e1c8c98 100644 --- a/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs +++ b/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs @@ -140,6 +140,23 @@ public void Handles_Dictionary_With_Complex_Values() .And.Contain("formulas: { [key: string]: FormulaDto[] };"); } + [Fact] + public void Handles_Dictionary_With_Enum_Keys() + { + // Arrange + var outputTypes = BuildOutputTypes(typeof(EnumKeyedDictionary)); + + // Act + var result = Sut.Write(outputTypes.First(), outputTypes, false); + + // Assert + var file = File.ReadAllText(result); + file.Should() + .NotBeEmpty() + .And.NotContain("import { ") + .And.Contain("valuesPerState: { [key: number]: number };"); + } + [Fact] public void Handles_Dictionary_With_Nested_Dictionary_Values() { @@ -574,6 +591,18 @@ private class ComplexValueDictionary public Dictionary> Formulas { get; set; } } + private class EnumKeyedDictionary + { + public Dictionary ValuesPerState { get; set; } + } + + private enum State + { + NotSet, + Pending, + Done, + } + private class NestedValueDictionary { public Dictionary>> Formulas { get; set; } diff --git a/TypeContractor/TypeContractor.csproj b/TypeContractor/TypeContractor.csproj index 4e1a195..98e5b83 100644 --- a/TypeContractor/TypeContractor.csproj +++ b/TypeContractor/TypeContractor.csproj @@ -16,7 +16,7 @@ - + diff --git a/TypeContractor/TypeScript/ApiClientWriter.cs b/TypeContractor/TypeScript/ApiClientWriter.cs index 9205e74..8749e61 100644 --- a/TypeContractor/TypeScript/ApiClientWriter.cs +++ b/TypeContractor/TypeScript/ApiClientWriter.cs @@ -50,7 +50,7 @@ public string Write(ApiClient apiClient, IEnumerable allTypes, TypeS var parameterMap = parameters.Select(x => $"{x.ParameterName}{((x.Type?.IsNullable ?? false) && !x.IsOptional ? "?" : "")}: {x.Type?.FullTypeName ?? "any"}{(x.IsOptional ? " | undefined" : "")}").ToList(); var returnType = (endpoint.ReturnType is null ? null - : converter.GetDestinationType(endpoint.ReturnType, endpoint.ReturnType.CustomAttributes, false, TypeChecks.IsNullable(endpoint.ReturnType))?.FullTypeName) ?? "globalThis.Response"; + : converter.GetDestinationType(endpoint.ReturnType, endpoint.ReturnType.CustomAttributes, false, TypeChecks.IsNullable(endpoint.ReturnType), false)?.FullTypeName) ?? "globalThis.Response"; var routeParams = endpoint.Parameters .Where(x => x.FromRoute) @@ -78,7 +78,7 @@ public string Write(ApiClient apiClient, IEnumerable allTypes, TypeS var queryParamsDto = new List(queryParams.Count); foreach (var queryParam in queryParams) { - var destinationType = converter.GetDestinationType(queryParam.ParameterType, queryParam.ParameterType.CustomAttributes, false, TypeChecks.IsNullable(queryParam.ParameterType)); + var destinationType = converter.GetDestinationType(queryParam.ParameterType, queryParam.ParameterType.CustomAttributes, false, TypeChecks.IsNullable(queryParam.ParameterType), false); if (destinationType.IsBuiltin) { queryParamsDto.Add(new QueryParameterTemplateDto(queryParam.Name, destinationType.IsBuiltin, destinationType.IsNullable, destinationType.IsArray, queryParam.IsOptional, null)); @@ -110,7 +110,7 @@ public string Write(ApiClient apiClient, IEnumerable allTypes, TypeS var returnUnparsedResponse = endpoint.UnwrappedReturnType is null && endpoint.ReturnType is null; var targetType = buildZodSchema && endpoint.ReturnType is not null - ? converter.GetDestinationType(endpoint.ReturnType, endpoint.ReturnType.CustomAttributes, false, TypeChecks.IsNullable(endpoint.ReturnType)) + ? converter.GetDestinationType(endpoint.ReturnType, endpoint.ReturnType.CustomAttributes, false, TypeChecks.IsNullable(endpoint.ReturnType), false) : null; var unwrappedReturnSchema = endpoint.UnwrappedReturnType is null ? null @@ -175,7 +175,7 @@ private static (string ParameterName, DestinationType? Type, bool IsOptional) Ma { Log.Instance.LogDebug($"Mapping parameter {parameter.Name} ({parameter.ParameterType.Name})"); - var targetType = converter.GetDestinationType(parameter.ParameterType, parameter.ParameterType.CustomAttributes, false, TypeChecks.IsNullable(parameter.ParameterType)); + var targetType = converter.GetDestinationType(parameter.ParameterType, parameter.ParameterType.CustomAttributes, false, TypeChecks.IsNullable(parameter.ParameterType), false); return (parameter.Name, targetType, parameter.IsOptional); } @@ -188,7 +188,7 @@ private List BuildImports(IEnumerable endpoints, IEnu { var returnType = endpoint.ReturnType is null ? null - : converter.GetDestinationType(endpoint.ReturnType, endpoint.ReturnType.CustomAttributes, false, TypeChecks.IsNullable(endpoint.ReturnType)); + : converter.GetDestinationType(endpoint.ReturnType, endpoint.ReturnType.CustomAttributes, false, TypeChecks.IsNullable(endpoint.ReturnType), false); if (returnType is not null && returnType.IsBuiltin) { diff --git a/TypeContractor/TypeScript/TypeScriptConverter.cs b/TypeContractor/TypeScript/TypeScriptConverter.cs index 327c274..14aeadb 100644 --- a/TypeContractor/TypeScript/TypeScriptConverter.cs +++ b/TypeContractor/TypeScript/TypeScriptConverter.cs @@ -31,7 +31,7 @@ public OutputType Convert(Type type, ContractedType? contractedType = null) type.IsEnum, type.IsGenericType, isConstantsFile, - type.IsGenericType ? ((TypeInfo)type).GenericTypeParameters.Select(x => GetDestinationType(x, [], false, TypeChecks.IsNullable(x))).ToList() : [], + type.IsGenericType ? ((TypeInfo)type).GenericTypeParameters.Select(x => GetDestinationType(x, [], false, TypeChecks.IsNullable(x), false)).ToList() : [], type.IsEnum ? null : (isConstantsFile ? GetConstantProperties(type) : GetProperties(type)).Distinct().ToList(), type.IsEnum ? GetEnumProperties(type) : null ); @@ -84,7 +84,7 @@ private List GetProperties(Type type) var isReadonly = !property.CanWrite || setter is null; var destinationName = GetDestinationName(property.Name); - var destinationType = GetDestinationType(property.PropertyType, property.CustomAttributes, isReadonly, TypeChecks.IsNullable(property.PropertyType)); + var destinationType = GetDestinationType(property.PropertyType, property.CustomAttributes, isReadonly, TypeChecks.IsNullable(property.PropertyType), false); var outputProperty = new OutputProperty( property.Name, property.PropertyType, @@ -153,7 +153,7 @@ private List GetConstantProperties(Type type) } var destinationName = GetDestinationName(field.Name); - var destinationType = GetDestinationType(field.FieldType, field.CustomAttributes, isReadonly: true, TypeChecks.IsNullable(field.FieldType)); + var destinationType = GetDestinationType(field.FieldType, field.CustomAttributes, isReadonly: true, TypeChecks.IsNullable(field.FieldType), false); var outputProperty = new OutputProperty( field.Name, field.FieldType, @@ -196,8 +196,11 @@ private List GetConstantProperties(Type type) public static string GetDestinationName(string name) => name.ToTypeScriptName(); - public DestinationType GetDestinationType(in Type sourceType, IEnumerable customAttributes, bool isReadonly, bool isNullable) + public DestinationType GetDestinationType(in Type sourceType, IEnumerable customAttributes, bool isReadonly, bool isNullable, bool dictionaryKey) { + if (dictionaryKey && sourceType.IsEnum) + return new DestinationType(DestinationTypes.Number, null, true, false, false, false, false, [], null, null, null); + if (!sourceType.IsGenericParameter && !string.IsNullOrEmpty(sourceType.FullName) && 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); @@ -209,9 +212,9 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable GetDestinationType(arg, customAttributes, isReadonly, isNullable)); + var argumentDestinationTypes = arguments.Select(arg => GetDestinationType(arg, customAttributes, isReadonly, isNullable, false)); var isBuiltin = argumentDestinationTypes.All(arg => arg.IsBuiltin); var argumentList = argumentDestinationTypes.Select((arg, idx) => $"item{idx + 1}: {arg.FullTypeName}"); @@ -240,7 +243,7 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable 0) @@ -250,7 +253,7 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable GetDestinationType(x, customAttributes, isReadonly, TypeChecks.IsNullable(x))) + .Select(x => GetDestinationType(x, customAttributes, isReadonly, TypeChecks.IsNullable(x), false)) .ToList(); var importType = genericOutputType.Name.Split('`').First();