Skip to content
Merged
Changes from 1 commit
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
195 changes: 115 additions & 80 deletions tools/DocGen/JsonRpcGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
// SPDX-FileCopyrightText: 2023 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using Nethermind.Blockchain.Find;
using Nethermind.Core;
using Nethermind.Core.Buffers;
using Nethermind.Core.Collections;
using Nethermind.Core.Crypto;
using Nethermind.Int256;
using Nethermind.JsonRpc.Modules;
using Nethermind.JsonRpc.Modules.Evm;
using Nethermind.JsonRpc.Modules.Rpc;
using Nethermind.JsonRpc.Modules.Subscribe;
using Nethermind.Serialization.Json;
using Spectre.Console;
using System.Net;
using System.Numerics;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace Nethermind.DocGen;

Expand All @@ -22,6 +33,38 @@ internal static class JsonRpcGenerator
"Nethermind.JsonRpc"
];
private const string _objectTypeName = "_object_";
private static readonly SortedSet<string> _guessedTypeNames = new(StringComparer.Ordinal);
private static readonly Dictionary<Type, string> _knownTypeNames = new()
{
[typeof(Address)] = "_string_ (address)",
[typeof(AddressAsKey)] = "_string_ (address)",
[typeof(BigInteger)] = "_string_ (hex integer)",
[typeof(BlockParameter)] = "_string_ (block number or hash or either of `earliest`, `finalized`, `latest`, `pending`, or `safe`)",
[typeof(Bloom)] = "_string_ (hex data)",
[typeof(bool)] = "_boolean_",
[typeof(byte)] = "_string_ (hex data)",
[typeof(byte[])] = "_string_ (hex data)",
[typeof(byte[][])] = "array of _string_ (hex data)",
[typeof(DateTime)] = "_string_ (date-time)",
[typeof(double)] = "_number_",
[typeof(double[])] = "array of _number_",
[typeof(DateTimeOffset)] = "_string_ (date-time)",
[typeof(Hash256)] = "_string_ (hash)",
[typeof(Hash256[])] = "array of _string_ (hash)",
[typeof(HexBytes)] = "_string_ (hex data)",
[typeof(int)] = "_string_ (hex integer)",
Comment thread
dipkakwani marked this conversation as resolved.
Outdated
[typeof(IPAddress)] = "_string_",
[typeof(long)] = "_string_ (hex integer)",
[typeof(PublicKey)] = "_string_ (hex data)",
[typeof(Signature)] = "_string_ (hex data)",
[typeof(string)] = "_string_",
[typeof(TimeSpan)] = "_string_ (duration)",
[typeof(TxType)] = "_string_ (transaction type)",
[typeof(uint)] = "_string_ (hex integer)",
[typeof(ulong)] = "_string_ (hex integer)",
[typeof(UInt256)] = "_string_ (hex integer)",
[typeof(ValueHash256)] = "_string_ (hash)",
};

internal static void Generate(string path)
{
Expand Down Expand Up @@ -83,6 +126,10 @@ internal static void Generate(string path)

WriteMarkdown(path, ns, methodMap[ns], i++);
}

if (_guessedTypeNames.Count != 0)
AnsiConsole.MarkupLine(
$"[yellow]Documented from CLR shape, no serializer contract:[/] {string.Join(", ", _guessedTypeNames)}");
}

private static void WriteMarkdown(string path, string ns, IEnumerable<MethodInfo> methods, int sidebarIndex)
Expand Down Expand Up @@ -242,6 +289,8 @@ private static void WriteResponse(StreamWriter file, MethodInfo method, JsonRpcM

private static void WriteExpandedType(StreamWriter file, Type type, int indentation = 0, bool omitTypeName = false, IEnumerable<string?>? parentTypes = null)
{
type = Nullable.GetUnderlyingType(type) ?? type;

parentTypes ??= new List<string>();

if (parentTypes.Any(a => type.FullName?.Equals(a, StringComparison.Ordinal) ?? false))
Expand Down Expand Up @@ -270,18 +319,19 @@ private static void WriteExpandedType(StreamWriter file, Type type, int indentat
if (!omitTypeName)
file.WriteLine(_objectTypeName);

IEnumerable<PropertyInfo> properties = GetSerializableProperties(type);
if (IsOpaqueJson(type))
return;

foreach (PropertyInfo prop in properties)
foreach ((string name, Type memberType) in GetSerializedMembers(type))
{
string propJsonType = GetJsonTypeName(prop.PropertyType);
string memberJsonType = GetJsonTypeName(memberType);

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

if (propJsonType.Equals(_objectTypeName, StringComparison.Ordinal))
WriteExpandedType(file, prop.PropertyType, indentation + 2, true, parentTypes.Append(type.FullName));
else if (propJsonType.Contains($" of {_objectTypeName}", StringComparison.Ordinal) &&
TryGetEnumerableItemType(prop.PropertyType, out Type? itemType, out bool _))
if (memberJsonType.Equals(_objectTypeName, StringComparison.Ordinal))
WriteExpandedType(file, memberType, indentation + 2, true, parentTypes.Append(type.FullName));
else if (memberJsonType.Contains($" of {_objectTypeName}", StringComparison.Ordinal) &&
TryGetEnumerableItemType(memberType, out Type? itemType, out bool _))
WriteExpandedType(file, itemType!, indentation + 2, true, parentTypes.Append(type.FullName));
}
}
Expand All @@ -304,6 +354,9 @@ private static void WriteFromFile(StreamWriter file, string fileName)

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

Type? underlyingType = Nullable.GetUnderlyingType(type);

if (underlyingType is not null)
Expand All @@ -312,28 +365,24 @@ private static string GetJsonTypeName(Type type)
if (type.IsEnum)
return "_integer_";

if (type.IsGenericType)
{
Type definition = type.GetGenericTypeDefinition();

// Buffer wrappers are written by a converter: as hex when they hold bytes, else as their items
if (definition == typeof(ArrayPoolList<>) || definition == typeof(CappedArray<>)
|| definition == typeof(Memory<>) || definition == typeof(ReadOnlyMemory<>))
{
Type bufferItemType = type.GetGenericArguments()[0];

return bufferItemType == typeof(byte) ? "_string_ (hex data)" : $"array of {GetJsonTypeName(bufferItemType)}";
}
}

if (TryGetEnumerableItemType(type, out Type? itemType, out bool isDictionary))
return $"{(isDictionary ? "map" : "array")} of {GetJsonTypeName(itemType!)}";

return type.Name switch
{
"Address" => "_string_ (address)",
"BigInteger"
or "Int32"
or "Int64"
or "Int64&"
or "UInt64"
or "UInt256" => "_string_ (hex integer)",
"BlockParameter" => "_string_ (block number or hash or either of `earliest`, `finalized`, `latest`, `pending`, or `safe`)",
"Bloom"
or "Byte"
or "Byte[]" => "_string_ (hex data)",
"Boolean" => "_boolean_",
"Hash256" => "_string_ (hash)",
"String" => "_string_",
"TxType" => "_string_ (transaction type)",
_ => _objectTypeName
};
return _knownTypeNames.GetValueOrDefault(type, _objectTypeName);
Comment thread
dipkakwani marked this conversation as resolved.
Outdated
}

private static Type GetReturnType(Type type)
Expand All @@ -347,70 +396,56 @@ private static Type GetReturnType(Type type)
return Nullable.GetUnderlyingType(returnType) ?? returnType;
}

private static IEnumerable<PropertyInfo> GetSerializableProperties(Type type) =>
type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(p => p.GetCustomAttribute<JsonIgnoreAttribute>()?.Condition is not JsonIgnoreCondition.Always)
.OrderBy(p => p.Name);

private static string GetSerializedName(PropertyInfo prop) =>
prop.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
?? JsonNamingPolicy.CamelCase.ConvertName(prop.Name);

private static string Indent(int depth) => string.Empty.PadLeft(depth, ' ');
private static bool IsOpaqueJson(Type type) =>
typeof(JsonNode).IsAssignableFrom(type) || type == typeof(JsonElement) || type == typeof(JsonDocument);

private static bool TryGetEnumerableItemType(Type type, out Type? itemType, out bool isDictionary)
private static JsonTypeInfo? GetContract(Type type)
{
if (type.IsArray && type.HasElementType)
try
{
Type? elementType = type.GetElementType();

// Ignore a byte array as it is treated as a hex string
if (elementType == typeof(byte))
{
itemType = null;
isDictionary = false;

return false;
}

itemType = type.GetElementType();
isDictionary = false;

return true;
return EthereumJsonSerializer.JsonOptions.TryGetTypeInfo(type, out JsonTypeInfo? typeInfo) ? typeInfo : null;
}

if (type.IsInterface && type.IsGenericType)
catch (ArgumentException)
{
if (type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
{
itemType = type.GetGenericArguments().Last();
isDictionary = false;
// Never-serializable types (by-ref, pointer, open generic) throw instead of reporting false
return null;
}
}
Comment thread
dipkakwani marked this conversation as resolved.

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

if (type.GetGenericTypeDefinition() == typeof(IDictionary<,>))
{
itemType = type.GetGenericArguments().Last();
isDictionary = true;
if (contract is not null && contract.Properties.Count != 0)
return contract.Properties
.Where(p => p.Get is not null)
.Select(p => (Name: p.Name, Type: p.PropertyType))
Comment thread
dipkakwani marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contract path maps each member by its CLR PropertyType, so a long or ulong field written by a raw-number converter such as LongRawJsonConverter would still be documented as a hex string. Is that per-property converter case meant to be covered here, or left to the type-level mapping?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not coverable by the type-level mapping e.g. struct-log gas and transaction gas are both ulong but need different encodings. Will fix it in a follow-up PR, since this is an existing issue in master.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed here #12869

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One same-family gap to note: the enum-with-converter branch (GetJsonTypeName) hardcodes string, so an enum whose converter emits a number would be mislabeled, another member-converter case. Could later share the same probe as in #12869 (safe for enums, though not for value-dependent unions like eth_syncing).

.OrderBy(m => m.Name, StringComparer.Ordinal);

return true;
}
}
// A hand-rolled converter exposes no contract members, leaving the CLR shape as the only guess
_guessedTypeNames.Add(type.Name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — type.Name makes the new warning ambiguous exactly where it matters.

The diagnostic is the only signal that a type fell back to guessing, so it should identify the type unambiguously. type.Name collapses same-named types in different namespaces (there are two BlockHeaderForRpc in the tree — Nethermind.Facade.Eth and Nethermind.Taiko.Tdx) and prints generics mangled as ArrayPoolList\1, which is the common case here since the buffer wrappers are precisely the converter-backed types. type.FullName ?? type.Name` costs nothing and keeps the set genuinely deduplicated.

Also worth noting for anyone reading the output: TransactionForRpc will always appear in this list — it carries [JsonConverter(typeof(TransactionJsonConverter))] and is abstract, so the CLR-shape guess only yields the seven base fields (type, hash, transactionIndex, blockHash, blockNumber, blockTimestamp, gas) and none of the per-type fields (nonce, to, from, value, input, gasPrice, …). That matches master, so it's not a regression, but it does mean the headline transaction shapes are still the guessed ones.


if (type.IsGenericType && type.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)))
{
itemType = type.GetGenericArguments().Last();
isDictionary = type.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>));
return type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.GetCustomAttribute<JsonIgnoreAttribute>()?.Condition is not JsonIgnoreCondition.Always)
.Select(p => (Name: GetFallbackName(p), Type: p.PropertyType))
.OrderBy(m => m.Name, StringComparer.Ordinal);
Comment thread
dipkakwani marked this conversation as resolved.
Comment on lines +431 to +440

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — three notes on the fallback path, none blocking.

  1. $"{type.Namespace}.{type.Name}" still mangles generics. It does disambiguate the two BlockHeaderForRpc (Nethermind.Facade.Eth vs Nethermind.Taiko.Tdx), which was the point — but a constructed generic still prints as Nethermind.Core.Collections.ArrayPoolList`1. type.FullName ?? $"{type.Namespace}.{type.Name}" gives the full construction for free.

  2. GetProperties includes indexers, which the serializer never writes. A converter-backed type with public T this[int i] would gain a phantom item member here. Nothing in the documented surface hits it today (ArrayPoolList<T>/CappedArray<T> are intercepted by the buffer-wrapper branch before they reach expansion), so this is prophylactic: .Where(p => p.GetIndexParameters().Length == 0).

  3. Worth capturing what the new warning line prints. The value of the diagnostic is that it names pages that are guesses, and at least two of them are guesses in a load-bearing way:

    • AccessListForRpc has no public properties or fields at all (its IEnumerable<Item> _items is private, Nethermind.Facade/Eth/RpcTransaction/AccessListForRpc.cs:20), so accessList documents as a bare _object_ while the wire is an array of {address, storageKeys}.
    • SyncingResult documents isSyncing and syncMode, neither of which SyncingResultJsonConverter ever writes — it emits either false or {startingBlock, currentBlock, highestBlock} (Nethermind.Facade/Eth/SyncingResultJsonConverter.cs:22-33).

    Both predate this PR. Since the run now lists them, pasting that list into the PR (or a follow-up issue) would turn it into a work queue instead of console output nobody reads.

}

return true;
}
private static string GetFallbackName(PropertyInfo prop) =>
prop.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
?? JsonNamingPolicy.CamelCase.ConvertName(prop.Name);

private static string Indent(int depth) => string.Empty.PadLeft(depth, ' ');

private static bool TryGetEnumerableItemType(Type type, out Type? itemType, out bool isDictionary)
{
JsonTypeInfo? contract = GetContract(type);

itemType = null;
isDictionary = false;
isDictionary = contract?.Kind is JsonTypeInfoKind.Dictionary;
itemType = contract?.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary
? contract.ElementType
: null;

return false;
return itemType is not null;
}
}
Loading