Skip to content

Commit 0cbb969

Browse files
authored
Added base36 and hex for node id formatting. (#8706)
1 parent 13effe1 commit 0cbb969

22 files changed

+3575
-349
lines changed

src/All.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@
292292
<Project Path="HotChocolate/Utilities/src/Utilities.Introspection/HotChocolate.Utilities.Introspection.csproj" />
293293
<Project Path="HotChocolate/Utilities/src/Utilities.Tasks/HotChocolate.Utilities.Tasks.csproj" />
294294
<Project Path="HotChocolate/Utilities/src/Utilities/HotChocolate.Utilities.csproj" />
295+
<Project Path="HotChocolate/Utilities/src/Utilities.Base36/HotChocolate.Utilities.Base36.csproj" />
295296
</Folder>
296297
<Folder Name="/HotChocolate/Utilities/test/">
297298
<Project Path="HotChocolate/Utilities/test/Utilities.Introspection.Tests/HotChocolate.Utilities.Introspection.Tests.csproj" />

src/HotChocolate/Core/src/Types/Types/Relay/Serialization/NodeIdInvalidFormatException.cs renamed to src/HotChocolate/Core/src/Execution.Abstractions/Execution/Relay/NodeIdInvalidFormatException.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace HotChocolate.Types.Relay;
1+
namespace HotChocolate.Execution.Relay;
22

33
public sealed class NodeIdInvalidFormatException(object originalValue)
44
: GraphQLException(ErrorBuilder.New()
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
namespace HotChocolate.Execution.Relay;
2+
3+
/// <summary>
4+
/// Specifies the encoding format used for GraphQL Global ID serialization.
5+
/// </summary>
6+
public enum NodeIdSerializerFormat
7+
{
8+
/// <summary>
9+
/// Standard Base64 encoding using the characters A-Z, a-z, 0-9, +, /, and = for padding.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// Uses the standard Base64 alphabet as defined in RFC 4648. This format provides
14+
/// excellent size efficiency (33% overhead) and fast encoding/decoding performance.
15+
/// </para>
16+
/// </remarks>
17+
Base64,
18+
19+
/// <summary>
20+
/// URL-safe Base64 encoding using A-Z, a-z, 0-9, -, _, with padding removed or replaced.
21+
/// </summary>
22+
/// <remarks>
23+
/// <para>
24+
/// Uses URL-safe Base64 alphabet where + becomes -, / becomes _, and padding
25+
/// is handled appropriately for URL contexts. This is the recommended format
26+
/// for web applications and APIs.
27+
/// </para>
28+
/// </remarks>
29+
UrlSafeBase64,
30+
31+
/// <summary>
32+
/// Mathematical Base36 encoding using digits 0-9 and letters A-Z (case-insensitive).
33+
/// </summary>
34+
/// <remarks>
35+
/// <para>
36+
/// Uses Base36 encoding which treats the entire byte array as a single big-endian
37+
/// number and converts it to base 36. This format preserves trailing zeros and
38+
/// provides case-insensitive parsing.
39+
/// </para>
40+
/// </remarks>
41+
Base36,
42+
43+
/// <summary>
44+
/// Uppercase hexadecimal encoding using characters 0-9 and A-F.
45+
/// </summary>
46+
/// <remarks>
47+
/// <para>
48+
/// Standard hexadecimal representation where each byte becomes two uppercase
49+
/// hex characters. This format provides maximum human readability and is
50+
/// useful for debugging and development scenarios.
51+
/// </para>
52+
/// </remarks>
53+
UpperHex,
54+
55+
/// <summary>
56+
/// Lowercase hexadecimal encoding using characters 0-9 and a-f.
57+
/// </summary>
58+
/// <remarks>
59+
/// <para>
60+
/// Hexadecimal representation using lowercase letters. Functionally identical
61+
/// to <see cref="UpperHex"/> but uses lowercase a-f instead of A-F. Choice
62+
/// between upper and lower case is typically based on convention preferences.
63+
/// </para>
64+
/// </remarks>
65+
LowerHex
66+
}

src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.IdSerializer.cs

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using HotChocolate;
22
using HotChocolate.Execution.Configuration;
3+
using HotChocolate.Execution.Options;
4+
using HotChocolate.Execution.Relay;
35
using HotChocolate.Types.Relay;
46
using HotChocolate.Utilities;
57
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -35,9 +37,40 @@ public static IRequestExecutorBuilder AddDefaultNodeIdSerializer(
3537
int maxIdLength = 1024,
3638
bool outputNewIdFormat = true,
3739
bool useUrlSafeBase64 = false)
40+
=> AddDefaultNodeIdSerializer(
41+
builder,
42+
new NodeIdSerializerOptions
43+
{
44+
MaxIdLength = maxIdLength,
45+
OutputNewIdFormat = outputNewIdFormat,
46+
Format = useUrlSafeBase64
47+
? NodeIdSerializerFormat.UrlSafeBase64
48+
: NodeIdSerializerFormat.Base64
49+
});
50+
51+
/// <summary>
52+
/// Adds a default node id serializer to the schema.
53+
/// </summary>
54+
/// <param name="builder">
55+
/// The request executor builder.
56+
/// </param>
57+
/// <param name="options">
58+
/// The serializer options.
59+
/// </param>
60+
/// <returns>
61+
/// Returns the request executor builder.
62+
/// </returns>
63+
/// <exception cref="ArgumentNullException">
64+
/// <paramref name="builder"/> is <see langword="null"/>.
65+
/// </exception>
66+
public static IRequestExecutorBuilder AddDefaultNodeIdSerializer(
67+
this IRequestExecutorBuilder builder,
68+
NodeIdSerializerOptions options)
3869
{
3970
ArgumentNullException.ThrowIfNull(builder);
4071

72+
var outputNewIdFormat = options.OutputNewIdFormat;
73+
4174
if (!builder.Services.IsImplementationTypeRegistered<StringNodeIdValueSerializer>())
4275
{
4376
builder.Services.AddSingleton<INodeIdValueSerializer, StringNodeIdValueSerializer>();
@@ -47,7 +80,7 @@ public static IRequestExecutorBuilder AddDefaultNodeIdSerializer(
4780
builder.Services.AddSingleton<INodeIdValueSerializer, DecimalNodeIdValueSerializer>();
4881
builder.Services.AddSingleton<INodeIdValueSerializer, SingleNodeIdValueSerializer>();
4982
builder.Services.AddSingleton<INodeIdValueSerializer, DoubleNodeIdValueSerializer>();
50-
builder.Services.AddSingleton<INodeIdValueSerializer>(new GuidNodeIdValueSerializer(compress: outputNewIdFormat));
83+
builder.Services.AddSingleton<INodeIdValueSerializer>(new GuidNodeIdValueSerializer(outputNewIdFormat));
5184
}
5285
else
5386
{
@@ -60,8 +93,7 @@ public static IRequestExecutorBuilder AddDefaultNodeIdSerializer(
6093
if (serviceRegistration is not null)
6194
{
6295
builder.Services.Remove(serviceRegistration);
63-
builder.Services.AddSingleton<INodeIdValueSerializer>(
64-
new GuidNodeIdValueSerializer(compress: outputNewIdFormat));
96+
builder.Services.AddSingleton<INodeIdValueSerializer>(new GuidNodeIdValueSerializer(outputNewIdFormat));
6597
}
6698
}
6799

@@ -71,9 +103,10 @@ public static IRequestExecutorBuilder AddDefaultNodeIdSerializer(
71103
var allSerializers = sp.GetServices<INodeIdValueSerializer>().ToArray();
72104
return new DefaultNodeIdSerializer(
73105
allSerializers,
74-
maxIdLength,
106+
options.MaxIdLength,
75107
outputNewIdFormat,
76-
useUrlSafeBase64);
108+
options.Format,
109+
options.MaxCachedTypeNames);
77110
});
78111

79112
builder.ConfigureSchemaServices(
@@ -119,9 +152,9 @@ public static IRequestExecutorBuilder AddDefaultNodeIdSerializer(
119152
return new OptimizedNodeIdSerializer(
120153
boundSerializers,
121154
allSerializers,
122-
maxIdLength,
155+
options.MaxIdLength,
123156
outputNewIdFormat,
124-
useUrlSafeBase64);
157+
options.Format);
125158
});
126159
});
127160
return builder;
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using HotChocolate.Execution.Relay;
2+
using HotChocolate.Types.Relay;
3+
4+
namespace HotChocolate.Execution.Options;
5+
6+
/// <summary>
7+
/// Configuration options for GraphQL Global ID serialization behavior.
8+
/// </summary>
9+
public struct NodeIdSerializerOptions
10+
{
11+
private int _maxIdLength = 1024;
12+
private int _maxCachedTypeNames = 1024;
13+
14+
/// <summary>
15+
/// Initializes a new instance of the <see cref="NodeIdSerializerOptions"/> struct
16+
/// with default values.
17+
/// </summary>
18+
public NodeIdSerializerOptions()
19+
{
20+
}
21+
22+
/// <summary>
23+
/// Gets or sets the maximum allowed length in characters for a formatted Global ID string.
24+
/// </summary>
25+
/// <value>
26+
/// The maximum length of a Global ID string. Default is 1024 characters.
27+
/// </value>
28+
/// <remarks>
29+
/// <para>
30+
/// This limit prevents potential denial-of-service attacks through extremely large ID strings
31+
/// and guards against memory exhaustion during ID parsing operations.
32+
/// </para>
33+
/// <para>
34+
/// Consider increasing this value for applications with complex composite IDs, long type names,
35+
/// or when using less efficient encoding formats. The actual memory usage depends on the
36+
/// chosen <see cref="Format"/>:
37+
/// </para>
38+
/// <list type="bullet">
39+
/// <item><description>Base64: ~33% larger than original data</description></item>
40+
/// <item><description>Hex: 100% larger than original data</description></item>
41+
/// <item><description>Base36: Variable size, generally larger than Base64</description></item>
42+
/// </list>
43+
/// </remarks>
44+
/// <exception cref="ArgumentOutOfRangeException">
45+
/// Thrown when set to a value less than 128 characters.
46+
/// </exception>
47+
public int MaxIdLength
48+
{
49+
readonly get => _maxIdLength;
50+
set
51+
{
52+
if (value < 128)
53+
{
54+
throw new ArgumentOutOfRangeException(
55+
nameof(value),
56+
value,
57+
"MaxIdLength must be at least 128.");
58+
}
59+
60+
_maxIdLength = value;
61+
}
62+
}
63+
/// <summary>
64+
/// Gets or sets a value indicating whether to use the new Hot Chocolate Global ID format.
65+
/// </summary>
66+
/// <value>
67+
/// <c>true</c> to use the new format; <c>false</c> to use the legacy format.
68+
/// Default is <c>true</c>.
69+
/// </value>
70+
/// <remarks>
71+
/// <para>
72+
/// The new format uses a simple delimiter (":") between type name and internal ID:
73+
/// <c>TypeName:InternalId</c>
74+
/// </para>
75+
/// <para>
76+
/// The legacy format includes additional type indicator bytes for backward compatibility:
77+
/// <c>TypeName\n[TypeCode]InternalId</c>
78+
/// </para>
79+
/// <para>
80+
/// New applications should use the new simpler format (<c>true</c>) for better performance
81+
/// and smaller encoded identifier sizes. Set to <c>false</c> only when compatibility with
82+
/// older Hot Chocolate versions is required.
83+
/// </para>
84+
/// </remarks>
85+
public bool OutputNewIdFormat { get; set; } = true;
86+
87+
/// <summary>
88+
/// Gets or sets the encoding format used for Global ID serialization.
89+
/// </summary>
90+
/// <value>
91+
/// A <see cref="NodeIdSerializerFormat"/> value specifying the encoding format.
92+
/// Default is <see cref="NodeIdSerializerFormat.UrlSafeBase64"/>.
93+
/// </value>
94+
/// <remarks>
95+
/// <para>Available formats:</para>
96+
/// <list type="table">
97+
/// <listheader>
98+
/// <term>Format</term>
99+
/// <description>Characteristics</description>
100+
/// </listheader>
101+
/// <item>
102+
/// <term><see cref="NodeIdSerializerFormat.Base64"/></term>
103+
/// <description>Standard Base64 encoding. Compact but contains URL-unsafe characters (+, /, =)</description>
104+
/// </item>
105+
/// <item>
106+
/// <term><see cref="NodeIdSerializerFormat.UrlSafeBase64"/></term>
107+
/// <description>URL-safe Base64 (- and _ instead of + and /). Recommended for web applications</description>
108+
/// </item>
109+
/// <item>
110+
/// <term><see cref="NodeIdSerializerFormat.UpperHex"/></term>
111+
/// <description>Uppercase hexadecimal. Human-readable, larger than Base64</description>
112+
/// </item>
113+
/// <item>
114+
/// <term><see cref="NodeIdSerializerFormat.LowerHex"/></term>
115+
/// <description>Lowercase hexadecimal. Human-readable, larger than Base64</description>
116+
/// </item>
117+
/// <item>
118+
/// <term><see cref="NodeIdSerializerFormat.Base36"/></term>
119+
/// <description>Mathematical Base36 encoding (0-9, A-Z). Case-insensitive, preserves trailing zeros</description>
120+
/// </item>
121+
/// </list>
122+
/// <para>
123+
/// Performance considerations:
124+
/// Base64 formats offer the best size/performance ratio for most applications.
125+
/// Hex formats are more human-readable but produce larger output.
126+
/// Base36 provides mathematical properties useful for numeric-heavy IDs.
127+
/// </para>
128+
/// </remarks>
129+
public NodeIdSerializerFormat Format { get; set; } = NodeIdSerializerFormat.UrlSafeBase64;
130+
131+
/// <summary>
132+
/// Gets or sets the maximum number of type names to cache for improved performance.
133+
/// </summary>
134+
/// <value>
135+
/// The maximum number of type name byte arrays to cache. Default is 1024.
136+
/// Minimum value is 128.
137+
/// </value>
138+
/// <remarks>
139+
/// <para>
140+
/// The serializer caches UTF-8 encoded type names to avoid repeated string-to-byte
141+
/// conversions during Global ID formatting. This significantly improves performance
142+
/// for frequently used type names.
143+
/// </para>
144+
/// <para>
145+
/// Memory usage: Each cached entry stores the type name as a UTF-8 byte array.
146+
/// For typical GraphQL type names (5-20 characters), this uses roughly 10-40 bytes
147+
/// per entry plus caching overhead.
148+
/// </para>
149+
/// <para>
150+
/// Tuning guidelines:
151+
/// </para>
152+
/// <list type="bullet">
153+
/// <item><description>Small schemas (&lt;50 types): 128-256</description></item>
154+
/// <item><description>Medium schemas (50-200 types): 512-1024</description></item>
155+
/// <item><description>Large schemas (&gt;200 types): 1024-2048</description></item>
156+
/// <item><description>Dynamic schemas: Consider higher values</description></item>
157+
/// </list>
158+
/// </remarks>
159+
/// <exception cref="ArgumentOutOfRangeException">
160+
/// Thrown when set to a value less than 128 items.
161+
/// </exception>
162+
public int MaxCachedTypeNames
163+
{
164+
readonly get => _maxCachedTypeNames;
165+
set
166+
{
167+
if (value < 128)
168+
{
169+
throw new ArgumentOutOfRangeException(
170+
nameof(value),
171+
value,
172+
"MaxCachedTypeNames must be at least 128.");
173+
}
174+
175+
_maxCachedTypeNames = value;
176+
}
177+
}
178+
}

src/HotChocolate/Core/src/Types/HotChocolate.Types.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@
5858

5959
<ItemGroup>
6060
<ProjectReference Include="..\..\..\..\GreenDonut\src\GreenDonut\GreenDonut.csproj" />
61+
<ProjectReference Include="..\..\..\Caching\src\Caching.Memory\HotChocolate.Caching.Memory.csproj" />
6162
<ProjectReference Include="..\..\..\Fusion-vnext\src\Fusion.Language\HotChocolate.Fusion.Language.csproj" />
63+
<ProjectReference Include="..\..\..\Utilities\src\Utilities.Base36\HotChocolate.Utilities.Base36.csproj" />
6264
<ProjectReference Include="..\..\..\Utilities\src\Utilities\HotChocolate.Utilities.csproj" />
6365
<ProjectReference Include="..\Abstractions\HotChocolate.Abstractions.csproj" />
6466
<ProjectReference Include="..\Features\HotChocolate.Features.csproj" />

0 commit comments

Comments
 (0)