Skip to content
Open
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
103 changes: 94 additions & 9 deletions tools/DocGen/JsonRpcGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Nethermind.JsonRpc.Modules.Subscribe;
using Nethermind.Serialization.Json;
using Spectre.Console;
using System.Buffers;
using System.Net;
using System.Numerics;
using System.Reflection;
Expand Down Expand Up @@ -322,9 +323,9 @@ private static void WriteExpandedType(StreamWriter file, Type type, int indentat
if (IsOpaqueJson(type))
return;

foreach ((string name, Type memberType) in GetSerializedMembers(type))
foreach ((string name, Type memberType, Type? memberConverter) in GetSerializedMembers(type))
{
string memberJsonType = GetJsonTypeName(memberType);
string memberJsonType = GetJsonTypeName(memberType, memberConverter);

file.WriteLine($"{Indent(indentation + 2)}- `{name}`: {memberJsonType}");

Expand Down Expand Up @@ -352,15 +353,28 @@ private static void WriteFromFile(StreamWriter file, string fileName)
}
}

private static string GetJsonTypeName(Type type)
private static string GetJsonTypeName(Type type, Type? converterType = null)
{
if (type.IsByRef && type.GetElementType() is { } elementType)
type = elementType;

Type? underlyingType = Nullable.GetUnderlyingType(type);
type = Nullable.GetUnderlyingType(type) ?? type;

// A member can pin its own converter, and the CLR type alone cannot tell a hex-quantity string
// from a raw JSON number (e.g. two `ulong` fields, one with a raw-number converter). Probing that
// converter's actual output is the only reliable signal, and it works for any converter, not a
// hard-coded list. This is scoped to explicit member converters: probing a type's default
// serialization is unsound for value-dependent unions (e.g. `eth_syncing` returns `false` or an
// object), so those keep the editorial mapping below.
if (converterType is not null && TryProbeScalarKind(converterType, out JsonTokenType token))
{
if (token is JsonTokenType.Number)
return IsFloatingPoint(type) ? "_number_" : "_integer_";
if (token is JsonTokenType.True or JsonTokenType.False)
return "_boolean_";

if (underlyingType is not null)
return GetJsonTypeName(underlyingType);
// A string result keeps its flavour (hex data, hash, ...) from the editorial mapping below
}

if (_knownTypeNames.TryGetValue(type, out string? knownName))
return knownName;
Expand Down Expand Up @@ -389,6 +403,69 @@ private static string GetJsonTypeName(Type type)
return _objectTypeName;
}

private static bool IsFloatingPoint(Type type) =>
type == typeof(double) || type == typeof(float) || type == typeof(decimal);

// Serializes a sample value through the converter and reports the JSON token it produces, so
// number-vs-string-vs-boolean is read from the serializer rather than guessed from the CLR type.
private static bool TryProbeScalarKind(Type converterType, out JsonTokenType token)
{
token = JsonTokenType.None;

// The converter's own JsonConverter<T> base is the source of truth for its value type: a
// nullable-aware converter declares Write against `T?`, and a JsonConverterFactory declares
// no Write at all (null here), so both are handled without relying on member-type coincidence.
Type? valueType = ConverterValueType(converterType);

if (valueType is null)
return false;

try
{
// A non-null sample of the underlying value so a nullable converter writes its value, not null
object? sample = Activator.CreateInstance(Nullable.GetUnderlyingType(valueType) ?? valueType);

if (sample is null)
return false;

ArrayBufferWriter<byte> buffer = new();

using (Utf8JsonWriter writer = new(buffer))
InvokeConverterWrite(converterType, valueType, sample, writer);

Utf8JsonReader reader = new(buffer.WrittenSpan);
reader.Read();
token = reader.TokenType;

return token is JsonTokenType.Number or JsonTokenType.String or JsonTokenType.True or JsonTokenType.False;
}
// A converter that cannot serialize the sample (no default value, rejects zero, complex output)
// yields no usable token; the caller falls back to the editorial mapping.
catch (Exception)
{
return false;
}
}

private static Type? ConverterValueType(Type converterType)
{
for (Type? t = converterType; t is not null; t = t.BaseType)
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(JsonConverter<>))
return t.GetGenericArguments()[0];

return null;
}

private static void InvokeConverterWrite(Type converterType, Type valueType, object sample, Utf8JsonWriter writer)
Comment thread
wurdum marked this conversation as resolved.
{
JsonConverter converter = (JsonConverter)Activator.CreateInstance(converterType)!;
MethodInfo write = converterType.GetMethod(
nameof(JsonConverter<object>.Write),
[typeof(Utf8JsonWriter), valueType, typeof(JsonSerializerOptions)])!;

write.Invoke(converter, [writer, sample, EthereumJsonSerializer.JsonOptions]);
}

private static Type GetReturnType(Type type)
{
Type returnType = type.IsGenericType
Expand Down Expand Up @@ -417,14 +494,14 @@ private static bool IsOpaqueJson(Type type) =>
}
}

private static IEnumerable<(string Name, Type Type)> GetSerializedMembers(Type type)
private static IEnumerable<(string Name, Type Type, Type? Converter)> GetSerializedMembers(Type type)
{
JsonTypeInfo? contract = GetContract(type);

if (contract?.Kind is JsonTypeInfoKind.Object)
return contract.Properties
.Where(p => p.Get is not null)
.Select(p => (Name: p.Name, Type: p.PropertyType))
.Select(p => (Name: p.Name, Type: p.PropertyType, Converter: MemberConverter(p.AttributeProvider)))
.OrderBy(m => m.Name, StringComparer.Ordinal);

// A hand-rolled converter exposes no contract members, leaving the CLR shape as the only guess
Expand All @@ -436,10 +513,18 @@ private static bool IsOpaqueJson(Type type) =>
return type.GetProperties(memberFlags).Select(p => (Member: (MemberInfo)p, Type: p.PropertyType))
.Concat(type.GetFields(memberFlags).Select(f => (Member: (MemberInfo)f, Type: f.FieldType)))
.Where(m => m.Member.GetCustomAttribute<JsonIgnoreAttribute>()?.Condition is not JsonIgnoreCondition.Always)
.Select(m => (Name: GetFallbackName(m.Member), Type: m.Type))
.Select(m => (Name: GetFallbackName(m.Member), Type: m.Type, Converter: MemberConverter(m.Member)))
.OrderBy(m => m.Name, StringComparer.Ordinal);
}

// A member may pin its own converter via [JsonConverter], overriding the serialization its CLR type
// would otherwise get (e.g. a raw-number converter on a block number). Probing through that converter
// is what lets two fields of the same type document different wire forms.
private static Type? MemberConverter(ICustomAttributeProvider? member) =>
member?.GetCustomAttributes(typeof(JsonConverterAttribute), inherit: false) is [JsonConverterAttribute { ConverterType: { } converterType }]
? converterType
: null;

private static string GetFallbackName(MemberInfo member) =>
member.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
?? JsonNamingPolicy.CamelCase.ConvertName(member.Name);
Expand Down
Loading