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
208 changes: 192 additions & 16 deletions docs/src/reference/gremlin-variants.asciidoc

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions docs/src/upgrade/release-4.x.x.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,37 @@ complete list of all the modifications that are part of this release.

=== Upgrading for Users

==== Request Interceptors
Comment thread
kenhuuu marked this conversation as resolved.

When TinkerPop supported WebSockets prior to 4.0.0, the Java driver offered a `RequestInterceptor` interface (and
its predecessor, `HandshakeInterceptor`) that allowed modification of the raw Netty `FullHttpRequest`. For WebSocket
connections, the interceptor only ran on the initial HTTP upgrade handshake. With the move to HTTP, the notion of
the "interceptor" has shifted to a per-request concern that is now standardized across all GLVs.

All GLVs now support request interceptors, which allow modification of the HTTP request before it is sent to the
server. An interceptor is a function that receives the mutable HTTP request object and can modify headers, the
request body, the URI, or the HTTP method. Interceptors are run in the order they are registered.

The most common use case for interceptors is authentication (e.g., SigV4 signing), but they can also be used to add
provider-specific fields, inject custom headers, or transform the request body.

Here is a simple Java example that adds a custom header:

[source,java]
----
Cluster cluster = Cluster.build("localhost")
.interceptors(request -> request.headers().put("X-Custom-Header", "value"))
.create();
----

Authentication is also an interceptor. Each GLV provides convenience methods (e.g., `Auth.basic()`, `Auth.sigv4()`)
that return interceptors and can be registered alongside custom ones.

For full details on the interceptor API for each language variant, refer to the RequestInterceptor section in
each GLV's documentation in the
link:https://tinkerpop.apache.org/docs/x.y.z/reference/#gremlin-drivers-variants[Gremlin Drivers and Variants]
reference.

==== Gremlator

link:https://gremlator.com[Gremlator] has been rebuilt entirely in JavaScript as a browser-based single-page
Expand Down Expand Up @@ -567,6 +598,20 @@ registration.

==== Graph Driver Providers

===== Request Interceptors

Graph driver providers should implement request interceptor support in their drivers. Interceptors allow users to
modify the HTTP request before it is sent, which is essential for authentication schemes (like SigV4), adding
provider-specific request fields, and other server-specific capabilities.

All TinkerPop reference drivers now include interceptor support. The interceptor contract is standardized across all
GLVs: interceptors receive a mutable HTTP request object, can modify headers/body/URI, and the driver auto-serializes
the request body to JSON after all interceptors have run.

For the full specification of how interceptors should behave, see the
link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_http_request_interceptor[HTTP Request Interceptor]
section in the provider documentation.

== TinkerPop 4.0.0-beta.2

*Release Date: April 1, 2026*
Expand Down
5 changes: 4 additions & 1 deletion gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ public static Func<HttpRequestContext, Task> SigV4Auth(
}
}

// Ensure the body is serialized before signing so we have bytes to hash.
context.SerializeBody();

// Use the async path — important for credential providers that perform
// network I/O (e.g. IMDS on EC2, ECS task role endpoint).
var immutableCreds = await cachedProvider.GetCredentialsAsync()
Expand All @@ -116,7 +119,7 @@ private static void SignRequest(HttpRequestContext context,
? bytes
: throw new InvalidOperationException(
"SigV4 signing requires Body to be byte[]. " +
"Ensure serialization occurs before the SigV4 interceptor."),
"Ensure SerializeBody() was called before signing."),
AuthenticationRegion = clientConfig.AuthenticationRegion,
OverrideSigningServiceName = clientConfig.AuthenticationServiceName,
};
Expand Down
53 changes: 19 additions & 34 deletions gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@
using System.Threading.Tasks;
using Gremlin.Net.Driver.Messages;
using Gremlin.Net.Process;
using Gremlin.Net.Process.Traversal;
using Gremlin.Net.Structure.IO;

namespace Gremlin.Net.Driver
{
Expand All @@ -44,7 +42,6 @@ internal class Connection : IDisposable
{
private readonly HttpClient _httpClient;
private readonly Uri _uri;
private readonly IMessageSerializer? _requestSerializer;
private readonly IMessageSerializer _responseSerializer;
private readonly ConnectionSettings _settings;
private readonly IReadOnlyList<Func<HttpRequestContext, Task>> _interceptors;
Expand All @@ -55,24 +52,15 @@ internal class Connection : IDisposable
/// so a single <see cref="Connection"/> instance handles concurrent requests efficiently.
/// </summary>
/// <param name="uri">The Gremlin Server URI.</param>
/// <param name="requestSerializer">
/// The serializer for outgoing requests. When non-null, the request body is serialized
/// to <c>byte[]</c> before interceptors run and the <c>Content-Type</c> header is set
/// automatically. When <c>null</c>, the body is passed as a <see cref="RequestMessage"/>
/// and an interceptor is responsible for serializing it to <c>byte[]</c> and setting
/// <c>Content-Type</c>. This follows the Python driver's <c>request_serializer=None</c>
/// pattern.
/// </param>
/// <param name="responseSerializer">The serializer for incoming responses (always required).</param>
/// <param name="settings">Connection settings.</param>
/// <param name="interceptors">Optional request interceptors.</param>
public Connection(Uri uri, IMessageSerializer? requestSerializer,
public Connection(Uri uri,
IMessageSerializer responseSerializer,
ConnectionSettings settings,
IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
{
_uri = uri;
_requestSerializer = requestSerializer;
_responseSerializer = responseSerializer;
_settings = settings;
_interceptors = interceptors ?? Array.Empty<Func<HttpRequestContext, Task>>();
Expand All @@ -97,13 +85,12 @@ public Connection(Uri uri, IMessageSerializer? requestSerializer,
/// <summary>
/// Constructor that accepts a pre-configured HttpClient (for testing).
/// </summary>
internal Connection(Uri uri, IMessageSerializer? requestSerializer,
internal Connection(Uri uri,
IMessageSerializer responseSerializer,
ConnectionSettings settings, HttpClient httpClient,
IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
{
_uri = uri;
_requestSerializer = requestSerializer;
_responseSerializer = responseSerializer;
_settings = settings;
_httpClient = httpClient;
Expand Down Expand Up @@ -139,7 +126,7 @@ public async Task<ResultSet<T>> SubmitAsync<T>(RequestMessage requestMessage,
headers["bulkResults"] = "true";
}

// Promote transactionId to HTTP header before serialization.
// Promote transactionId to HTTP header before interceptors run.
// The field remains in the serialized body as well (dual transmission
// per the HTTP transaction protocol specification).
if (requestMessage.Fields.TryGetValue(Tokens.ArgsTransactionId, out var txIdObj) &&
Expand All @@ -148,26 +135,20 @@ public async Task<ResultSet<T>> SubmitAsync<T>(RequestMessage requestMessage,
headers["X-Transaction-Id"] = txId;
}

object body;
if (_requestSerializer != null)
{
var requestBytes = await _requestSerializer.SerializeMessageAsync(requestMessage, cancellationToken)
.ConfigureAwait(false);
body = requestBytes;
headers["Content-Type"] = _requestSerializer.MimeType;
}
else
{
body = requestMessage;
}

var context = new HttpRequestContext("POST", _uri, headers, body);
var context = new HttpRequestContext("POST", _uri, headers, requestMessage);

foreach (var interceptor in _interceptors)
{
await interceptor(context).ConfigureAwait(false);
}

// Auto-serialize after interceptors: idempotent if already serialized by an interceptor.
// Skip if body is HttpContent (an escape hatch for full wire-format control).
if (context.Body is not System.Net.Http.HttpContent)
{
context.SerializeBody();
}

// The HttpResponseMessage is NOT disposed here — ownership transfers to
// StreamingResponseContext via the background task.
HttpResponseMessage response;
Expand All @@ -184,10 +165,8 @@ public async Task<ResultSet<T>> SubmitAsync<T>(RequestMessage requestMessage,
else
{
throw new InvalidOperationException(
"Request body must be byte[] or HttpContent after all interceptors complete, " +
"but found " + (context.Body?.GetType().Name ?? "null") +
". Either provide a requestSerializer or add an interceptor " +
"that serializes the RequestMessage.");
"Request body must be byte[] or HttpContent after serialization, " +
"but found " + (context.Body?.GetType().Name ?? "null") + ".");
}

foreach (var header in context.Headers)
Expand All @@ -196,6 +175,10 @@ public async Task<ResultSet<T>> SubmitAsync<T>(RequestMessage requestMessage,
{
httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(header.Value);
}
else if (string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase))
{
// Content-Length is set automatically by ByteArrayContent; skip to avoid conflict.
}
else
{
httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
Expand Down Expand Up @@ -345,5 +328,7 @@ protected virtual void Dispose(bool disposing)
}

#endregion

internal IReadOnlyList<Func<HttpRequestContext, Task>> Interceptors => _interceptors;
}
}
75 changes: 28 additions & 47 deletions gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Gremlin.Net.Driver.Messages;
Expand All @@ -48,86 +49,64 @@ public class GremlinClient : IGremlinClient
/// Initializes a new instance of the <see cref="GremlinClient" /> class for the specified Gremlin Server.
/// </summary>
/// <param name="gremlinServer">The <see cref="GremlinServer" /> the requests should be sent to.</param>
/// <param name="requestSerializer">
/// A <see cref="IMessageSerializer" /> instance to serialize outgoing request messages.
/// When <c>null</c>, the request body is passed as a <see cref="RequestMessage"/> to
/// interceptors, and an interceptor must serialize it to <c>byte[]</c> and set the
/// <c>Content-Type</c> header. This follows the Python driver's
/// <c>request_serializer=None</c> pattern.
/// </param>
/// <param name="responseSerializer">
/// A <see cref="IMessageSerializer" /> instance to deserialize incoming response messages.
/// Always required.
/// Defaults to <see cref="GraphBinary4MessageSerializer"/>.
/// </param>
/// <param name="connectionSettings">The <see cref="ConnectionSettings" /> for the HTTP connection.</param>
/// <param name="loggerFactory">A factory to create loggers. If not provided, then nothing will be logged.</param>
/// <param name="interceptors">
/// An optional list of request interceptors. Each interceptor receives a mutable
/// <see cref="HttpRequestContext" /> and can modify headers, body, URI, and method
/// before the request is sent.
/// before the request is sent. Interceptors that need the serialized bytes (e.g.
/// for payload signing) should call <see cref="HttpRequestContext.SerializeBody"/>.
/// </param>
/// <param name="auth">
/// An optional auth interceptor. As a convenience, this is appended to the end of the
/// interceptor list so it runs last (after any user interceptors have modified the request).
/// This is equivalent to including the auth interceptor as the last element of <paramref name="interceptors"/>.
/// </param>
/// <param name="pdtRegistry">
/// An optional <see cref="ProviderDefinedTypeRegistry"/> for automatic hydration of
/// provider-defined types.
/// </param>
public GremlinClient(GremlinServer gremlinServer, IMessageSerializer? requestSerializer,
IMessageSerializer responseSerializer,
public GremlinClient(GremlinServer gremlinServer,
IMessageSerializer? responseSerializer = null,
ConnectionSettings? connectionSettings = null,
ILoggerFactory? loggerFactory = null,
IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null,
Func<HttpRequestContext, Task>? auth = null,
ProviderDefinedTypeRegistry? pdtRegistry = null)
{
connectionSettings ??= new ConnectionSettings();
LoggerFactory = loggerFactory ?? NullLoggerFactory.Instance;

var actualResponseSerializer = responseSerializer ?? new GraphBinary4MessageSerializer();

if (pdtRegistry != null)
{
requestSerializer?.SetPdtRegistry(pdtRegistry);
responseSerializer.SetPdtRegistry(pdtRegistry);
actualResponseSerializer.SetPdtRegistry(pdtRegistry);
}

// Append auth interceptor to the end of the list so it runs last.
IReadOnlyList<Func<HttpRequestContext, Task>>? allInterceptors = interceptors;
if (auth != null)
{
var list = interceptors?.ToList() ?? new List<Func<HttpRequestContext, Task>>();
list.Add(auth);
allInterceptors = list;
}

_connection = new Connection(
gremlinServer.Uri,
requestSerializer,
responseSerializer,
actualResponseSerializer,
connectionSettings,
interceptors);
allInterceptors);

var logger = LoggerFactory.CreateLogger<GremlinClient>();
logger.InitializedHttpConnection(gremlinServer.Uri);
}

/// <summary>
/// Initializes a new instance of the <see cref="GremlinClient" /> class with a single
/// serializer used for both request serialization and response deserialization.
/// This is the backward-compatible convenience constructor.
/// </summary>
/// <param name="gremlinServer">The <see cref="GremlinServer" /> the requests should be sent to.</param>
/// <param name="messageSerializer">
/// A <see cref="IMessageSerializer" /> instance used for both request serialization and
/// response deserialization. Defaults to <see cref="GraphBinary4MessageSerializer"/>.
/// </param>
/// <param name="connectionSettings">The <see cref="ConnectionSettings" /> for the HTTP connection.</param>
/// <param name="loggerFactory">A factory to create loggers. If not provided, then nothing will be logged.</param>
/// <param name="interceptors">
/// An optional list of request interceptors.
/// </param>
/// <param name="pdtRegistry">
/// An optional <see cref="ProviderDefinedTypeRegistry"/> for automatic hydration of
/// provider-defined types.
/// </param>
public GremlinClient(GremlinServer gremlinServer, IMessageSerializer? messageSerializer = null,
ConnectionSettings? connectionSettings = null,
ILoggerFactory? loggerFactory = null,
IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null,
ProviderDefinedTypeRegistry? pdtRegistry = null)
: this(gremlinServer,
messageSerializer ?? new GraphBinary4MessageSerializer(),
messageSerializer ?? new GraphBinary4MessageSerializer(),
connectionSettings, loggerFactory, interceptors, pdtRegistry)
{
}

/// <inheritdoc />
public async Task<ResultSet<T>> SubmitAsync<T>(RequestMessage requestMessage,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -199,5 +178,7 @@ internal void UntrackTransaction(RemoteTransaction tx)
}

#endregion

internal Connection Connection => _connection;
}
}
Loading
Loading