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
123 changes: 122 additions & 1 deletion src/typechat.schema/TypeEx.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,21 @@ public static bool IsBoolean(this Type type)

public static bool IsDateTime(this Type type)
{
return type == typeof(DateTime) || type == typeof(TimeSpan);
// These are value types that System.Text.Json serializes to/from a JSON *string* rather than
// to an object. They must be treated as the 'string' primitive by the exporter (see
// Typescript.Types.ToPrimitive); otherwise they would be exported as (mostly empty) interfaces.
if (type == typeof(DateTime) || type == typeof(TimeSpan) || type == typeof(DateTimeOffset))
{
return true;
}
#if NET6_0_OR_GREATER
// DateOnly/TimeOnly only exist on net6.0+, but Json serializes them as strings too.
if (type == typeof(DateOnly) || type == typeof(TimeOnly))
{
return true;
}
#endif
return false;
}

public static bool IsNumber(this Type type)
Expand Down Expand Up @@ -149,6 +163,113 @@ internal static bool IsTask(this Type type)
return (args.IsNullOrEmpty()) ? null : args[0];
}

/// <summary>
/// Is the given type one that serializes to a JSON array? This includes arrays and any type
/// that implements IEnumerable{T} (List{T}, IList{T}, ICollection{T}, HashSet{T}...).
/// Strings (which are IEnumerable{char}) and dictionaries are deliberately excluded: they do
/// not serialize to JSON arrays.
/// </summary>
/// <param name="type">type to inspect</param>
/// <param name="elementType">the type of the items in the array</param>
/// <returns>true if the type is array-like</returns>
internal static bool IsArrayLike(this Type type, out Type elementType)
{
elementType = null;

// Strings are IEnumerable<char> but must be exported as scalar strings, not char arrays
if (type.IsString())
{
return false;
}
// Dictionaries are IEnumerable<KeyValuePair<,>> but must be exported as maps, not arrays.
// Use a structural check so that even a self-mapping dictionary is excluded here.
if (type.IsDictionaryShaped())
{
return false;
}
if (type.IsArray)
{
elementType = type.GetElementType();
return true;
}

Type? enumerableType = FindGenericInterface(type, typeof(IEnumerable<>));
if (enumerableType is not null)
{
Type genericArg = enumerableType.GetGenericArguments()[0];
// Guard against a type that enumerates itself (e.g. class Node : IEnumerable<Node>):
// there is no finite element type to unwrap to, so treat it as a plain object instead.
if (genericArg != type)
{
elementType = genericArg;
return true;
}
return false;
}
// Non-generic IEnumerable (e.g. ArrayList): the element type is unknown, so treat it as 'any'
if (typeof(IEnumerable).IsAssignableFrom(type))
{
elementType = typeof(object);
return true;
}
return false;
}

/// <summary>
/// Is the given type one that serializes to a JSON object/map? This includes any type that
/// implements IDictionary{K,V} or IReadOnlyDictionary{K,V}, as well as the non-generic IDictionary.
/// </summary>
/// <param name="type">type to inspect</param>
/// <param name="keyType">the type of the map's keys</param>
/// <param name="valueType">the type of the map's values</param>
/// <returns>true if the type is a dictionary</returns>
internal static bool IsDictionary(this Type type, out Type keyType, out Type valueType)
{
keyType = typeof(string);
valueType = typeof(object);

Type? dictionaryType = FindGenericInterface(type, typeof(IDictionary<,>)) ??
FindGenericInterface(type, typeof(IReadOnlyDictionary<,>));
if (dictionaryType is not null)
{
Type[] args = dictionaryType.GetGenericArguments();
// Guard against a type that maps to itself (e.g. class D : IDictionary<string, D>):
// there is no finite value type to unwrap to, so treat it as a plain object instead.
if (args[1] != type)
{
keyType = args[0];
valueType = args[1];
return true;
}
return false;
}
// Non-generic IDictionary (e.g. Hashtable): keys and values are untyped
return typeof(IDictionary).IsAssignableFrom(type);
}

private static Type? FindGenericInterface(Type type, Type genericInterface)
{
if (type.IsGenericType && type.GetGenericTypeDefinition() == genericInterface)
{
return type;
}
foreach (Type iface in type.GetInterfaces())
{
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == genericInterface)
{
return iface;
}
}
return null;
}

private static bool IsDictionaryShaped(this Type type)
{
return FindGenericInterface(type, typeof(IDictionary<,>)) is not null ||
FindGenericInterface(type, typeof(IReadOnlyDictionary<,>)) is not null ||
typeof(IDictionary).IsAssignableFrom(type);
}

internal static IEnumerable<Type> Subclasses(this Type type)
{
if (!type.IsClass)
Expand Down
10 changes: 9 additions & 1 deletion src/typechat.schema/Typescript.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,15 @@ public static class Types
}
else if (type.IsDateTime())
{
return String; // Json does not have a primitive DateTime
return String; // Json has no DateTime primitive: these value types serialize as strings
}
else if (type == typeof(Guid))
{
// Guid is a value type, but System.Text.Json writes it as a JSON string (e.g.
// "3f2504e0-4f89-11d3-9a0c-0305e82c3301") and it exposes no public fields/properties.
// Without this mapping it would fall through to the object path and be exported as an
// empty "interface Guid {}", which a model could never populate correctly.
return String;
}
else if (type.IsObject())
{
Expand Down
87 changes: 68 additions & 19 deletions src/typechat.schema/TypescriptExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,29 +175,36 @@ public override void ExportPending()

public override void ExportType(Type type)
{
if (type.IsArray)
if (type.IsDictionary(out _, out Type valueType))
{
AddPending(type.GetElementType());
AddPending(valueType);
}
else if (type.IsArrayLike(out Type elementType))
{
AddPending(elementType);
}
else if (type.IsEnum)
{
ExportEnum(type);
}
else
{
if (type.IsEnum)
{
ExportEnum(type);
}
else
{
ExportClass(type);
}
ExportClass(type);
}
}

public TypescriptExporter ExportClass(Type type)
{
ArgumentVerify.ThrowIfNull(type, nameof(type));
if (!type.IsClass)
// Export both reference types (classes) and non-enum value types (structs) as interfaces:
// System.Text.Json serializes a struct's public fields/properties into a JSON object exactly
// like a class (e.g. KeyValuePair<K,V> => { Key, Value }, a custom Money => { Amount, Currency }).
// Enums are routed to ExportEnum before they reach here, and scalar value types that Json writes
// as a single value (Guid, DateTime, DateTimeOffset, ...) are mapped to primitives in
// Typescript.Types.ToPrimitive, so only genuine object-like structs arrive at this point.
if (!type.IsClass && (!type.IsValueType || type.IsPrimitive || type.IsEnum))
{
ArgumentVerify.Throw($"{type.Name} must be a class");
ArgumentVerify.Throw($"{type.Name} must be a class or struct");
}

if (IsExported(type))
Expand Down Expand Up @@ -505,7 +512,7 @@ private TypescriptExporter ExportVariable(MemberInfo member, Type type)
_writer.Variable(
member.PropertyName(),
DataType(actualType),
type.IsArray,
actualType.IsArrayLike(out _),
isNullable
);
}
Expand Down Expand Up @@ -534,7 +541,7 @@ private TypescriptExporter ExportParameter(ParameterInfo parameter, int i, int c
DataType(type),
i,
count,
type.IsArray,
type.IsArrayLike(out _),
isNullable
);
AddPending(type);
Expand Down Expand Up @@ -658,9 +665,20 @@ private string DataType(Type type)
type = type.GetGenericType() ?? typeof(void);
}

if (type.IsArray)
Type? nullableValueType = type.GetNullableValueType();
if (nullableValueType is not null)
{
type = nullableValueType;
}

if (type.IsDictionary(out Type keyType, out Type valueType))
{
return DataTypeMap(keyType, valueType);
}

if (type.IsArrayLike(out Type elementType))
{
return DataType(type.GetElementType());
return DataType(elementType);
}

string? typeName = null;
Expand All @@ -680,6 +698,23 @@ private string DataType(Type type)
return typeName;
}

private string DataTypeMap(Type keyType, Type valueType)
{
string valueDataType = DataType(valueType);
if (valueType.IsArrayLike(out _))
{
valueDataType += Typescript.Punctuation.Array;
}
return $"Record<{MapKeyType(keyType)}, {valueDataType}>";
}

private static string MapKeyType(Type keyType)
{
// JSON object keys are always strings; only numeric keys are represented as 'number'
string? primitive = Typescript.Types.ToPrimitive(keyType);
return primitive == Typescript.Types.Number ? Typescript.Types.Number : Typescript.Types.String;
}

private string InterfaceName(Type type, bool useMapper = true)
{
string? typeName = null;
Expand Down Expand Up @@ -756,12 +791,26 @@ protected override bool ShouldExport(Type type, out Type typeToExport)
return false;
}

if (type.IsArray)
// Unwrap arrays, collections and dictionaries down to the underlying element/value type
while (true)
{
type = type.GetElementType();
if (type.IsDictionary(out _, out Type valueType))
{
type = valueType;
}
else if (type.IsArrayLike(out Type elementType))
{
type = elementType;
}
else
{
break;
}
}

typeToExport = type;
return !type.IsPrimitive && !_nonExportTypes.Contains(type);
return !type.IsPrimitive &&
!type.IsNullableValueType() &&
!_nonExportTypes.Contains(type);
}
}
10 changes: 0 additions & 10 deletions src/typechat/OpenAIConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,6 @@ public static class VariableNames
/// </summary>
public const string OPENAI_EMBEDDINGMODEL = "OPENAI_EMBEDDINGMODEL";

/// <summary>
/// The embedding endpoint to use
/// </summary>
public const string OPENAI_EMBEDDING_ENDPOINT = "OPENAI_EMBEDDING_ENDPOINT";

/// <summary>
/// Api key to use for Azure OpenAI service
/// </summary>
Expand Down Expand Up @@ -149,11 +144,6 @@ public static OpenAIConfig FromEnvironment(bool isEmbedding = false)
}
if (isEmbedding)
{
if (Environment.GetEnvironmentVariable(VariableNames.OPENAI_EMBEDDING_ENDPOINT) != null)
{
config.Endpoint = Environment.GetEnvironmentVariable(VariableNames.OPENAI_EMBEDDING_ENDPOINT);
}

config.Model = Environment.GetEnvironmentVariable(VariableNames.OPENAI_EMBEDDINGMODEL);
}
else
Expand Down
63 changes: 63 additions & 0 deletions tests/TypeChat.TestLib/Schemas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,69 @@ public class FriendsOfPerson
public Name[] FriendNames { get; set; }
}

// For testing that IEnumerable<T> exports as a JSON array (string[]) and not as a
// leaky List_String interface. See https://github.com/microsoft/typechat.net/issues/218
public sealed class Pizza
{
public IEnumerable<string> Toppings { get; set; }
public string Size { get; set; }
}

// For testing that the various collection and dictionary types all export as JSON arrays/maps
public class CollectionsObj
{
public IEnumerable<string> Tags { get; set; }
public List<Name> Names { get; set; }
public IList<int> Scores { get; set; }
public ICollection<Location> Locations { get; set; }
public IReadOnlyList<double> Ratings { get; set; }
public HashSet<string> UniqueTags { get; set; }
public string[] Aliases { get; set; }
public Dictionary<string, int> Counts { get; set; }
public IDictionary<string, Location> LocationsByCity { get; set; }
public Dictionary<string, Name> NamesById { get; set; }
public Dictionary<string, List<int>> Buckets { get; set; }
}

// A type that enumerates itself (composite pattern): it implements IEnumerable<Composite>,
// so it has no finite "array of..." representation. Schema generation must still terminate.
public class Composite : IEnumerable<Composite>
{
public string Name { get; set; }
public IEnumerator<Composite> GetEnumerator() => Enumerable.Empty<Composite>().GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}

public class CompositeHolder
{
public List<Composite> Nodes { get; set; }
public Composite Root { get; set; }
}

// For testing that structs export as interfaces: Json serializes a struct's public members as an
// object, just like a class.
public struct Money
{
public decimal Amount { get; set; }
public string Currency { get; set; }
}

public struct Coordinates
{
public double Latitude { get; set; }
public double Longitude { get; set; }
}

public class StructHolder
{
public Money Price { get; set; }
public Coordinates Location { get; set; }
public KeyValuePair<string, int> Pair { get; set; }
// Scalar value types that Json writes as a single string - must NOT become empty interfaces
public Guid Id { get; set; }
public DateTimeOffset When { get; set; }
}

// For testing Generics
public class Child<T>
{
Expand Down
Loading
Loading