Skip to content

Commit

Permalink
Support interop against System.Text.Json types on NET8+ (#1826)
Browse files Browse the repository at this point in the history
  • Loading branch information
lahma authored Apr 1, 2024
1 parent 5656b1d commit 198f4f7
Show file tree
Hide file tree
Showing 8 changed files with 127 additions and 46 deletions.
16 changes: 13 additions & 3 deletions Jint.Tests.PublicInterface/InteropTests.SystemTextJson.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Reflection;
using System.Text.Json.Nodes;
using Jint.Native;
using Jint.Runtime.Interop;
Expand All @@ -8,6 +7,12 @@ namespace Jint.Tests.PublicInterface;

public sealed class SystemTextJsonValueConverter : IObjectConverter
{
public static readonly SystemTextJsonValueConverter Instance = new();

private SystemTextJsonValueConverter()
{
}

public bool TryConvert(Engine engine, object value, out JsValue result)
{
if (value is JsonValue jsonValue)
Expand Down Expand Up @@ -90,6 +95,9 @@ public void AccessingJsonNodeShouldWork()

var engine = new Engine(options =>
{
#if !NET8_0_OR_GREATER
// Jint doesn't know about the types statically as they are not part of the out-of-the-box experience
// make JsonArray behave like JS array
options.Interop.WrapObjectHandler = static (e, target, type) =>
{
Expand All @@ -103,13 +111,14 @@ public void AccessingJsonNodeShouldWork()
return ObjectWrapper.Create(e, target);
};
options.AddObjectConverter(new SystemTextJsonValueConverter());
options.AddObjectConverter(SystemTextJsonValueConverter.Instance);
// we cannot access this[string] with anything else than JsonObject, otherwise itw will throw
options.Interop.TypeResolver = new TypeResolver
{
MemberFilter = static info =>
{
if (info.ReflectedType != typeof(JsonObject) && info.Name == "Item" && info is PropertyInfo p)
if (info.ReflectedType != typeof(JsonObject) && info.Name == "Item" && info is System.Reflection.PropertyInfo p)
{
var parameters = p.GetIndexParameters();
return parameters.Length != 1 || parameters[0].ParameterType != typeof(string);
Expand All @@ -118,6 +127,7 @@ public void AccessingJsonNodeShouldWork()
return true;
}
};
#endif
});

engine
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<PackageReference Include="MongoDB.Bson.signed" />
<PackageReference Include="NodaTime" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="System.Text.Json" />
<PackageReference Include="System.Text.Json" Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net8.0'))" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
Expand Down
5 changes: 5 additions & 0 deletions Jint/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,11 @@ public class InteropOptions
/// Defaults to <see cref="System.DateTimeKind.Utc"/>.
/// </summary>
public DateTimeKind DateTimeKind { get; set; } = DateTimeKind.Utc;

/// <summary>
/// Should the Array prototype be attached instead of Object prototype to the wrapped interop objects when type looks suitable. Defaults to true.
/// </summary>
public bool AttachArrayPrototype { get; set; } = true;
}

public class ConstraintOptions
Expand Down
26 changes: 26 additions & 0 deletions Jint/Runtime/Interop/DefaultObjectConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ public static bool TryConvert(Engine engine, object value, Type? type, [NotNullW
}
#endif

#if NET8_0_OR_GREATER
if (value is System.Text.Json.Nodes.JsonValue jsonValue)
{
result = ConvertSystemTextJsonValue(engine, jsonValue);
return result is not null;
}
#endif

var t = value.GetType();

if (!engine.Options.Interop.AllowSystemReflection
Expand Down Expand Up @@ -148,6 +156,24 @@ public static bool TryConvert(Engine engine, object value, Type? type, [NotNullW
return result is not null;
}

#if NET8_0_OR_GREATER
private static JsValue? ConvertSystemTextJsonValue(Engine engine, System.Text.Json.Nodes.JsonValue value)
{
return value.GetValueKind() switch
{
System.Text.Json.JsonValueKind.Object => JsValue.FromObject(engine, value),
System.Text.Json.JsonValueKind.Array => JsValue.FromObject(engine, value),
System.Text.Json.JsonValueKind.String => JsString.Create(value.ToString()),
System.Text.Json.JsonValueKind.Number => value.TryGetValue<double>(out var doubleValue) ? JsNumber.Create(doubleValue) : JsValue.Undefined,
System.Text.Json.JsonValueKind.True => JsBoolean.True,
System.Text.Json.JsonValueKind.False => JsBoolean.False,
System.Text.Json.JsonValueKind.Undefined => JsValue.Undefined,
System.Text.Json.JsonValueKind.Null => JsValue.Null,
_ => null
};
}
#endif

private static bool TryConvertConvertible(Engine engine, IConvertible convertible, [NotNullWhen(true)] out JsValue? result)
{
result = convertible.GetTypeCode() switch
Expand Down
3 changes: 2 additions & 1 deletion Jint/Runtime/Interop/DefaultTypeConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ public DefaultTypeConverter(Engine engine)
return converted;
}

public virtual bool TryConvert(object? value,
public virtual bool TryConvert(
object? value,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields)] Type type,
IFormatProvider formatProvider,
[NotNullWhen(true)] out object? converted)
Expand Down
4 changes: 1 addition & 3 deletions Jint/Runtime/Interop/MethodInfoFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,7 @@ private JsValue[] ProcessParamsArrays(JsValue[] jsArguments, MethodDescriptor me
return jsArguments;
}

var jsArray = Engine.Realm.Intrinsics.Array.Construct(Arguments.Empty);
Engine.Realm.Intrinsics.Array.PrototypeObject.Push(jsArray, argsToTransform);

var jsArray = new JsArray(_engine, argsToTransform);
var newArgumentsCollection = new JsValue[nonParamsArgumentsCount + 1];
for (var j = 0; j < nonParamsArgumentsCount; ++j)
{
Expand Down
20 changes: 19 additions & 1 deletion Jint/Runtime/Interop/ObjectWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,19 @@ public ObjectWrapper(
Target = obj;
ClrType = GetClrType(obj, type);
_typeDescriptor = TypeDescriptor.Get(ClrType);

if (_typeDescriptor.LengthProperty is not null)
{
// create a forwarder to produce length from Count or Length if one of them is present
var functionInstance = new ClrFunction(engine, "length", GetLength);
var descriptor = new GetSetPropertyDescriptor(functionInstance, Undefined, PropertyFlag.Configurable);
SetProperty(KnownKeys.Length, descriptor);

if (_typeDescriptor.IsArrayLike && engine.Options.Interop.AttachArrayPrototype)
{
// if we have array-like object, we can attach array prototype
SetPrototypeOf(engine.Intrinsics.Array.PrototypeObject);
}
}
}

Expand All @@ -45,7 +52,18 @@ public ObjectWrapper(
/// </summary>
public static ObjectInstance Create(Engine engine, object obj, Type? type = null)
#pragma warning disable CS0618 // Type or member is obsolete
=> new ObjectWrapper(engine, obj, type);
{

#if NET8_0_OR_GREATER
if (type == typeof(System.Text.Json.Nodes.JsonNode))
{
// we need to always expose the actual type instead of the type nodes provide
type = obj.GetType();
}
#endif

return new ObjectWrapper(engine, obj, type);
}
#pragma warning restore CS0618 // Type or member is obsolete

public object Target { get; }
Expand Down
97 changes: 60 additions & 37 deletions Jint/Runtime/Interop/Reflection/IndexerAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,46 +48,13 @@ internal static bool TryFindIndexer(
integerKey = intKeyTemp;
}

IndexerAccessor? ComposeIndexerFactory(PropertyInfo candidate, Type paramType)
{
object? key = null;
// int key is quite common case
if (paramType == typeof(int) && integerKey is not null)
{
key = integerKey;
}
else
{
engine.TypeConverter.TryConvert(propertyName, paramType, CultureInfo.InvariantCulture, out key);
}

if (key is not null)
{
// the key can be converted for this indexer
var indexerProperty = candidate;
// get contains key method to avoid index exception being thrown in dictionaries
paramTypeArray[0] = paramType;
var containsKeyMethod = targetType.GetMethod(nameof(IDictionary<string, string>.ContainsKey), paramTypeArray);
if (containsKeyMethod is null && targetType.IsAssignableFrom(typeof(IDictionary)))
{
paramTypeArray[0] = typeof(object);
containsKeyMethod = targetType.GetMethod(nameof(IDictionary.Contains), paramTypeArray);
}

return new IndexerAccessor(indexerProperty, containsKeyMethod, key);
}

// the key type doesn't work for this indexer
return null;
}

var filter = new Func<MemberInfo, bool>(m => engine.Options.Interop.TypeResolver.Filter(engine, m));

// default indexer wins
var descriptor = TypeDescriptor.Get(targetType);
if (descriptor.IntegerIndexerProperty is not null && filter(descriptor.IntegerIndexerProperty))
if (descriptor.IntegerIndexerProperty is not null && !filter(descriptor.IntegerIndexerProperty))
{
indexerAccessor = ComposeIndexerFactory(descriptor.IntegerIndexerProperty, typeof(int));
indexerAccessor = ComposeIndexerFactory(engine, targetType, descriptor.IntegerIndexerProperty, paramType: typeof(int), propertyName, integerKey, paramTypeArray);
if (indexerAccessor != null)
{
indexer = descriptor.IntegerIndexerProperty;
Expand All @@ -113,7 +80,7 @@ internal static bool TryFindIndexer(
if (candidate.GetGetMethod() != null || candidate.GetSetMethod() != null)
{
var paramType = indexParameters[0].ParameterType;
indexerAccessor = ComposeIndexerFactory(candidate, paramType);
indexerAccessor = ComposeIndexerFactory(engine, targetType, candidate, paramType, propertyName, integerKey, paramTypeArray);
if (indexerAccessor != null)
{
if (paramType != typeof(string) || integerKey is null)
Expand Down Expand Up @@ -145,6 +112,62 @@ internal static bool TryFindIndexer(
return false;
}

private static IndexerAccessor? ComposeIndexerFactory(
Engine engine,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicProperties)] Type targetType,
PropertyInfo candidate,
Type paramType,
string propertyName,
int? integerKey,
Type[] paramTypeArray)
{
// check for known incompatible types
#if NET8_0_OR_GREATER
if (typeof(System.Text.Json.Nodes.JsonNode).IsAssignableFrom(targetType)
&& (targetType != typeof(System.Text.Json.Nodes.JsonArray) || paramType != typeof(int))
&& (targetType != typeof(System.Text.Json.Nodes.JsonObject) || paramType != typeof(string)))
{
// we cannot access this[string] with anything else than JsonObject, otherwise itw will throw
// we cannot access this[int] with anything else than JsonArray, otherwise itw will throw
return null;
}
#endif

object? key = null;
// int key is quite common case
if (paramType == typeof(int))
{
if (integerKey is not null)
{
key = integerKey;
}
}
else
{
engine.TypeConverter.TryConvert(propertyName, paramType, CultureInfo.InvariantCulture, out key);
}

if (key is not null)
{
// the key can be converted for this indexer
var indexerProperty = candidate;
// get contains key method to avoid index exception being thrown in dictionaries
paramTypeArray[0] = paramType;
var containsKeyMethod = targetType.GetMethod(nameof(IDictionary<string, string>.ContainsKey), paramTypeArray);
if (containsKeyMethod is null && targetType.IsAssignableFrom(typeof(IDictionary)))
{
paramTypeArray[0] = typeof(object);
containsKeyMethod = targetType.GetMethod(nameof(IDictionary.Contains), paramTypeArray);
}

return new IndexerAccessor(indexerProperty, containsKeyMethod, key);
}

// the key type doesn't work for this indexer
return null;
}


public override bool Readable => _indexer.CanRead;

public override bool Writable => _indexer.CanWrite;
Expand All @@ -157,7 +180,7 @@ internal static bool TryFindIndexer(
return null;
}

object[] parameters = { _key };
object[] parameters = [_key];

if (_containsKey != null)
{
Expand Down

0 comments on commit 198f4f7

Please sign in to comment.