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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -536,6 +549,18 @@ private class NestedValueDictionary
public Dictionary<Guid, Dictionary<string, IEnumerable<FormulaDto>>> Formulas { get; set; }
}

private class EnumKeyedDictionary
{
public Dictionary<State, int> ValuesPerState { get; set; }
}

private enum State
{
NotSet,
Pending,
Done,
}

private class FormulaDto
{
public Guid Id { get; set; }
Expand Down
29 changes: 29 additions & 0 deletions TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -574,6 +591,18 @@ private class ComplexValueDictionary
public Dictionary<Guid, IEnumerable<FormulaDto>> Formulas { get; set; }
}

private class EnumKeyedDictionary
{
public Dictionary<State, int> ValuesPerState { get; set; }
}

private enum State
{
NotSet,
Pending,
Done,
}

private class NestedValueDictionary
{
public Dictionary<Guid, Dictionary<string, IEnumerable<FormulaDto>>> Formulas { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion TypeContractor/TypeContractor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<EmbeddedResource Include="Templates\react-axios.hbs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="Handlebars.Net" Version="[2.1.6]" />
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
Expand Down
10 changes: 5 additions & 5 deletions TypeContractor/TypeScript/ApiClientWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
[GeneratedRegex(@"([^\$])\{([A-Za-z0-9]+)\}")]
private static partial Regex RouteParameterRegex();

public string Write(ApiClient apiClient, IEnumerable<OutputType> allTypes, TypeScriptConverter converter, bool buildZodSchema, HandlebarsTemplate<object, ApiClientTemplateDto> template, Casing casing)

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant

Check warning on line 27 in TypeContractor/TypeScript/ApiClientWriter.cs

View workflow job for this annotation

GitHub Actions / build

Argument type 'HandlebarsTemplate<object, ApiClientTemplateDto>' is not CLS-compliant
{
var _builder = new StringBuilder();
ArgumentNullException.ThrowIfNull(apiClient);
Expand All @@ -50,7 +50,7 @@
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)
Expand Down Expand Up @@ -78,7 +78,7 @@
var queryParamsDto = new List<QueryParameterTemplateDto>(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));
Expand Down Expand Up @@ -110,7 +110,7 @@

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
Expand Down Expand Up @@ -175,7 +175,7 @@
{
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);
}

Expand All @@ -188,7 +188,7 @@
{
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)
{
Expand Down
23 changes: 13 additions & 10 deletions TypeContractor/TypeScript/TypeScriptConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down Expand Up @@ -84,7 +84,7 @@ private List<OutputProperty> 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,
Expand Down Expand Up @@ -153,7 +153,7 @@ private List<OutputProperty> 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,
Expand Down Expand Up @@ -196,8 +196,11 @@ private List<OutputProperty> GetConstantProperties(Type type)

public static string GetDestinationName(string name) => name.ToTypeScriptName();

public DestinationType GetDestinationType(in Type sourceType, IEnumerable<CustomAttributeData> customAttributes, bool isReadonly, bool isNullable)
public DestinationType GetDestinationType(in Type sourceType, IEnumerable<CustomAttributeData> 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);

Expand All @@ -209,9 +212,9 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable<Custom

if (TypeChecks.ImplementsIDictionary(sourceType))
{
var keyType = GetDestinationType(TypeChecks.GetGenericType(sourceType, 0), customAttributes, isReadonly, false);
var keyType = GetDestinationType(TypeChecks.GetGenericType(sourceType, 0), customAttributes, isReadonly, false, true);
var valueType = TypeChecks.GetGenericType(sourceType, 1);
var valueDestinationType = GetDestinationType(valueType, customAttributes, isReadonly, TypeChecks.IsNullable(valueType));
var valueDestinationType = GetDestinationType(valueType, customAttributes, isReadonly, TypeChecks.IsNullable(valueType), false);

var isBuiltin = keyType.IsBuiltin && valueDestinationType.IsBuiltin;

Expand All @@ -222,14 +225,14 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable<Custom
{
var innerType = TypeChecks.GetGenericType(sourceType);

var (TypeName, FullName, _, IsBuiltin, _, IsReadonly, IsNullable, IsGeneric, _, _, _) = GetDestinationType(innerType, customAttributes, isReadonly, isNullable);
var (TypeName, FullName, _, IsBuiltin, _, IsReadonly, IsNullable, IsGeneric, _, _, _) = GetDestinationType(innerType, customAttributes, isReadonly, isNullable, false);
return new DestinationType(TypeName, FullName, IsBuiltin, true, IsReadonly, IsNullable, IsGeneric, [], innerType, sourceType);
}

if (TypeChecks.IsValueTuple(sourceType))
{
var arguments = sourceType.GenericTypeArguments;
var argumentDestinationTypes = arguments.Select(arg => 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}");
Expand All @@ -240,7 +243,7 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable<Custom

if (TypeChecks.IsNullable(sourceType))
{
return GetDestinationType(sourceType.GenericTypeArguments.First(), customAttributes, isReadonly, true);
return GetDestinationType(sourceType.GenericTypeArguments.First(), customAttributes, isReadonly, true, false);
}

if (sourceType.IsGenericType && sourceType.GenericTypeArguments.Length > 0)
Expand All @@ -250,7 +253,7 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable<Custom
CustomMappedTypes.TryAdd(genericType, genericOutputType);

var genericArguments = sourceType.GenericTypeArguments
.Select(x => GetDestinationType(x, customAttributes, isReadonly, TypeChecks.IsNullable(x)))
.Select(x => GetDestinationType(x, customAttributes, isReadonly, TypeChecks.IsNullable(x), false))
.ToList();

var importType = genericOutputType.Name.Split('`').First();
Expand Down
Loading