You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tinkerpop.apache.org by xi...@apache.org on 2026/03/17 00:54:21 UTC
(tinkerpop) 01/01: Add interceptor to .net with new reference auth class
This is an automated email from the ASF dual-hosted git repository.
xiazcy pushed a commit to branch dotnet-http-interceptors
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit 783772d49a19aedce3dcff956bd9b682ff7bac7c
Author: Yang Xia <xi...@gmail.com>
AuthorDate: Mon Mar 16 17:54:13 2026 -0700
Add interceptor to .net with new reference auth class
---
gremlin-dotnet/Examples/Connections/Connections.cs | 2 +-
gremlin-dotnet/docker-compose.yml | 1 +
gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs | 187 +++++++++++++++++
.../src/Gremlin.Net/Driver/Connection.cs | 59 ++++--
.../src/Gremlin.Net/Driver/ConnectionSettings.cs | 6 +
.../src/Gremlin.Net/Driver/GremlinClient.cs | 13 +-
.../src/Gremlin.Net/Driver/GremlinServer.cs | 16 +-
.../src/Gremlin.Net/Driver/HttpRequestContext.cs | 80 +++++++
.../Driver/Remote/DriverRemoteConnection.cs | 10 +-
gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj | 1 +
.../Docs/Reference/GremlinVariantsTests.cs | 4 +-
.../Driver/AuthIntegrationTests.cs | 104 ++++++++++
.../test/Gremlin.Net.UnitTest/Driver/AuthTests.cs | 230 +++++++++++++++++++++
.../Gremlin.Net.UnitTest/Driver/ConnectionTests.cs | 104 ++++++++++
.../Driver/HttpRequestContextTests.cs | 103 +++++++++
15 files changed, 883 insertions(+), 37 deletions(-)
diff --git a/gremlin-dotnet/Examples/Connections/Connections.cs b/gremlin-dotnet/Examples/Connections/Connections.cs
index c09eea3f7b..519eb70363 100644
--- a/gremlin-dotnet/Examples/Connections/Connections.cs
+++ b/gremlin-dotnet/Examples/Connections/Connections.cs
@@ -52,7 +52,7 @@ public class ConnectionExample
static void WithConf()
{
using var remoteConnection = new DriverRemoteConnection(new GremlinClient(
- new GremlinServer(hostname: ServerHost, port: ServerPort, enableSsl: false, username: "", password: "")), "g");
+ new GremlinServer(hostname: ServerHost, port: ServerPort, enableSsl: false)), "g");
var g = Traversal().With(remoteConnection);
var v = g.AddV(VertexLabel).Iterate();
diff --git a/gremlin-dotnet/docker-compose.yml b/gremlin-dotnet/docker-compose.yml
index 697eab2184..b29e9036f6 100644
--- a/gremlin-dotnet/docker-compose.yml
+++ b/gremlin-dotnet/docker-compose.yml
@@ -56,6 +56,7 @@ services:
- GREMLIN_SERVER_PORT=45940
- VERTEX_LABEL=dotnet-example
working_dir: /gremlin-dotnet
+ # TODO: fix examples issue after feature-completion
command: >
bash -c "find . -path '*/TestResults/*.trx' -delete 2>/dev/null || true;
dotnet tool update -g dotnet-trx; dotnet test ./Gremlin.Net.sln -c Release --logger trx;
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
new file mode 100644
index 0000000000..d7d0770c1b
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
@@ -0,0 +1,187 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Text;
+using System.Threading.Tasks;
+using Amazon.Runtime;
+using Amazon.Runtime.Internal;
+using Amazon.Runtime.Internal.Auth;
+using Amazon.Runtime.Internal.Util;
+
+namespace Gremlin.Net.Driver
+{
+ /// <summary>
+ /// Provides factory methods for built-in request interceptors.
+ /// </summary>
+ public static class Auth
+ {
+ /// <summary>
+ /// Returns a request interceptor that adds an HTTP Basic Authentication header.
+ /// The credentials are pre-computed once and set on every request.
+ /// </summary>
+ /// <param name="username">The username.</param>
+ /// <param name="password">The password.</param>
+ /// <returns>A request interceptor delegate.</returns>
+ public static Func<HttpRequestContext, Task> BasicAuth(string username, string password)
+ {
+ var encoded = Convert.ToBase64String(
+ Encoding.UTF8.GetBytes(username + ":" + password));
+ var headerValue = "Basic " + encoded;
+
+ return context =>
+ {
+ context.Headers["Authorization"] = headerValue;
+ return Task.CompletedTask;
+ };
+ }
+
+ /// <summary>
+ /// Returns a request interceptor that signs requests using AWS Signature Version 4.
+ /// If <paramref name="credentials"/> is null, the default AWS credential chain is
+ /// used and the resolved provider is cached on first use (the provider itself handles
+ /// credential refresh for expiring credentials like STS).
+ /// </summary>
+ /// <param name="region">The AWS region (e.g. "us-east-1").</param>
+ /// <param name="service">The AWS service name (e.g. "neptune-db").</param>
+ /// <param name="credentials">
+ /// Optional AWS credentials. When null, the default credential chain is used.
+ /// </param>
+ /// <returns>A request interceptor delegate.</returns>
+ public static Func<HttpRequestContext, Task> SigV4Auth(
+ string region, string service, AWSCredentials? credentials = null)
+ {
+ // Cache the credential provider once when using the default chain.
+ AWSCredentials? cachedProvider = credentials;
+ var cacheLock = new object();
+
+ // Create signer and config once — both are stateless and thread-safe
+ var signer = new AWS4Signer();
+ var clientConfig = new SigningClientConfig
+ {
+ AuthenticationRegion = region,
+ AuthenticationServiceName = service,
+ };
+
+ return context =>
+ {
+ if (cachedProvider == null)
+ {
+ lock (cacheLock)
+ {
+ cachedProvider ??= FallbackCredentialsFactory.GetCredentials();
+ }
+ }
+ var immutableCreds = cachedProvider.GetCredentials();
+ SignRequest(context, region, service, immutableCreds, signer, clientConfig);
+ return Task.CompletedTask;
+ };
+ }
+
+ private static void SignRequest(HttpRequestContext context, string region, string service,
+ ImmutableCredentials credentials, AWS4Signer signer, SigningClientConfig clientConfig)
+ {
+ // Build a DefaultRequest from the HttpRequestContext for the AWS SDK signer.
+ var endpointUri = new Uri(context.Uri.GetLeftPart(UriPartial.Authority));
+ var awsRequest = new DefaultRequest(new NullRequest(), service)
+ {
+ HttpMethod = context.Method,
+ Endpoint = endpointUri,
+ ResourcePath = context.Uri.AbsolutePath,
+ Content = context.Body ?? Array.Empty<byte>(),
+ AuthenticationRegion = region,
+ OverrideSigningServiceName = service,
+ };
+
+ // Copy headers (skip Host — signer adds it)
+ foreach (var header in context.Headers)
+ {
+ if (!string.Equals(header.Key, "Host", StringComparison.OrdinalIgnoreCase))
+ {
+ awsRequest.Headers[header.Key] = header.Value;
+ }
+ }
+
+ // Copy query parameters
+ var query = context.Uri.Query;
+ if (!string.IsNullOrEmpty(query))
+ {
+ // Remove leading '?'
+ var queryString = query.StartsWith("?") ? query.Substring(1) : query;
+ foreach (var param in queryString.Split('&'))
+ {
+ if (string.IsNullOrEmpty(param)) continue;
+ var parts = param.Split(new[] { '=' }, 2);
+ var key = Uri.UnescapeDataString(parts[0]);
+ var value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : "";
+ awsRequest.Parameters[key] = value;
+ }
+ }
+
+ // Set content hash header before signing
+ var payloadHash = context.GetPayloadHash();
+ awsRequest.Headers["x-amz-content-sha256"] = payloadHash;
+
+ // Sign the request
+ signer.Sign(awsRequest, clientConfig, new RequestMetrics(),
+ credentials.AccessKey, credentials.SecretKey);
+
+ // Copy signed headers back to context. Cherry-pick the known SigV4 headers
+ // because the .NET Dictionary is case-sensitive and the AWS SDK may use
+ // different casing than what interceptors expect.
+ context.Headers["Host"] = endpointUri.Host;
+ if (awsRequest.Headers.ContainsKey("Authorization"))
+ {
+ context.Headers["Authorization"] = awsRequest.Headers["Authorization"];
+ }
+ if (awsRequest.Headers.ContainsKey("X-Amz-Date"))
+ {
+ context.Headers["X-Amz-Date"] = awsRequest.Headers["X-Amz-Date"];
+ }
+ context.Headers["x-amz-content-sha256"] = payloadHash;
+
+ // Add session token if temporary credentials
+ if (!string.IsNullOrEmpty(credentials.Token))
+ {
+ context.Headers["X-Amz-Security-Token"] = credentials.Token;
+ }
+ }
+
+ /// <summary>
+ /// A minimal AmazonWebServiceRequest implementation required by DefaultRequest.
+ /// </summary>
+ private class NullRequest : AmazonWebServiceRequest
+ {
+ }
+
+ /// <summary>
+ /// A minimal ClientConfig implementation for SigV4 signing.
+ /// </summary>
+ private class SigningClientConfig : ClientConfig
+ {
+ public override string RegionEndpointServiceName => "execute-api";
+ public override string ServiceVersion => "2024-01-01";
+ public override string UserAgent => "gremlin-dotnet";
+ }
+ }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
index 54d53d7815..9f8a644e12 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
@@ -49,17 +49,18 @@ namespace Gremlin.Net.Driver
private readonly bool _enableCompression;
private readonly bool _enableUserAgentOnConnect;
private readonly bool _bulkResults;
- // Interceptor slot reserved for future spec
- // private readonly IReadOnlyList<Func<HttpRequestMessage, Task>> _interceptors;
+ private readonly IReadOnlyList<Func<HttpRequestContext, Task>> _interceptors;
public Connection(Uri uri, IMessageSerializer serializer,
- ConnectionSettings settings)
+ ConnectionSettings settings,
+ IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
{
_uri = uri;
_serializer = serializer;
_enableCompression = settings.EnableCompression;
_enableUserAgentOnConnect = settings.EnableUserAgentOnConnect;
_bulkResults = settings.BulkResults;
+ _interceptors = interceptors ?? Array.Empty<Func<HttpRequestContext, Task>>();
#if NET6_0_OR_GREATER
var handler = new SocketsHttpHandler
@@ -69,6 +70,13 @@ namespace Gremlin.Net.Driver
ConnectTimeout = settings.ConnectionTimeout,
KeepAlivePingTimeout = settings.KeepAliveInterval,
};
+ if (settings.SkipCertificateValidation)
+ {
+ handler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions
+ {
+ RemoteCertificateValidationCallback = (_, _, _, _) => true,
+ };
+ }
_httpClient = new HttpClient(handler);
#else
_httpClient = new HttpClient();
@@ -80,7 +88,8 @@ namespace Gremlin.Net.Driver
/// Constructor that accepts a pre-configured HttpClient (for testing).
/// </summary>
internal Connection(Uri uri, IMessageSerializer serializer,
- ConnectionSettings settings, HttpClient httpClient)
+ ConnectionSettings settings, HttpClient httpClient,
+ IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
{
_uri = uri;
_serializer = serializer;
@@ -88,6 +97,7 @@ namespace Gremlin.Net.Driver
_enableUserAgentOnConnect = settings.EnableUserAgentOnConnect;
_bulkResults = settings.BulkResults;
_httpClient = httpClient;
+ _interceptors = interceptors ?? Array.Empty<Func<HttpRequestContext, Task>>();
}
public async Task<ResultSet<T>> SubmitAsync<T>(RequestMessage requestMessage,
@@ -96,29 +106,50 @@ namespace Gremlin.Net.Driver
var requestBytes = await _serializer.SerializeMessageAsync(requestMessage, cancellationToken)
.ConfigureAwait(false);
- using var content = new ByteArrayContent(requestBytes);
- content.Headers.ContentType = new MediaTypeHeaderValue(GraphBinaryMimeType);
-
- using var httpRequest = new HttpRequestMessage(HttpMethod.Post, _uri);
- httpRequest.Content = content;
- httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(GraphBinaryMimeType));
+ // Build HttpRequestContext with default headers
+ var headers = new Dictionary<string, string>();
+ headers["Content-Type"] = GraphBinaryMimeType;
+ headers["Accept"] = GraphBinaryMimeType;
if (_enableCompression)
{
- httpRequest.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate"));
+ headers["Accept-Encoding"] = "deflate";
}
if (_enableUserAgentOnConnect)
{
- httpRequest.Headers.TryAddWithoutValidation("User-Agent", Utils.UserAgent);
+ headers["User-Agent"] = Utils.UserAgent;
}
if (_bulkResults)
{
- httpRequest.Headers.Add("bulkResults", "true");
+ headers["bulkResults"] = "true";
+ }
+
+ var context = new HttpRequestContext("POST", _uri, headers, requestBytes);
+
+ // Apply interceptors in order
+ foreach (var interceptor in _interceptors)
+ {
+ await interceptor(context).ConfigureAwait(false);
}
- // Future: apply interceptors here
+ // Convert HttpRequestContext to HttpRequestMessage
+ using var httpRequest = new HttpRequestMessage(new HttpMethod(context.Method), context.Uri);
+ httpRequest.Content = new ByteArrayContent(context.Body);
+
+ foreach (var header in context.Headers)
+ {
+ // Content-Type must be set on the content headers, not the request headers
+ if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
+ {
+ httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(header.Value);
+ }
+ else
+ {
+ httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+ }
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken)
.ConfigureAwait(false);
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
index 0d8df4a6a7..83faebf7ed 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
@@ -84,5 +84,11 @@ namespace Gremlin.Net.Driver
/// Gets or sets whether to send the bulkResults: true header on all requests.
/// </summary>
public bool BulkResults { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets whether to skip SSL certificate validation.
+ /// Only use for testing with self-signed certificates.
+ /// </summary>
+ public bool SkipCertificateValidation { get; set; } = false;
}
}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
index 6445ac6621..62ad5b5dca 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
@@ -22,6 +22,7 @@
#endregion
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Gremlin.Net.Driver.Messages;
@@ -50,11 +51,14 @@ namespace Gremlin.Net.Driver
/// </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>
- // Interceptor slot reserved for future spec:
- // IReadOnlyList<Func<HttpRequestMessage, Task>>? interceptors = null,
+ /// <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.
+ /// </param>
public GremlinClient(GremlinServer gremlinServer, IMessageSerializer? messageSerializer = null,
ConnectionSettings? connectionSettings = null,
- ILoggerFactory? loggerFactory = null)
+ ILoggerFactory? loggerFactory = null,
+ IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
{
messageSerializer ??= new GraphBinaryMessageSerializer();
connectionSettings ??= new ConnectionSettings();
@@ -63,7 +67,8 @@ namespace Gremlin.Net.Driver
_connection = new Connection(
gremlinServer.Uri,
messageSerializer,
- connectionSettings);
+ connectionSettings,
+ interceptors);
var logger = LoggerFactory.CreateLogger<GremlinClient>();
logger.InitializedHttpConnection(gremlinServer.Uri);
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
index 94c46936ad..19ed2fbb18 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
@@ -36,15 +36,11 @@ namespace Gremlin.Net.Driver
/// <param name="hostname">The hostname of the server.</param>
/// <param name="port">The port on which Gremlin Server can be reached.</param>
/// <param name="enableSsl">Specifies whether SSL should be enabled.</param>
- /// <param name="username">The username to submit on requests that require authentication.</param>
- /// <param name="password">The password to submit on requests that require authentication.</param>
/// <param name="path">The path to the Gremlin endpoint on the server.</param>
public GremlinServer(string hostname = "localhost", int port = 8182, bool enableSsl = false,
- string? username = null, string? password = null, string path = "/gremlin")
+ string path = "/gremlin")
{
Uri = CreateUri(hostname, port, enableSsl, path);
- Username = username;
- Password = password;
}
/// <summary>
@@ -52,16 +48,6 @@ namespace Gremlin.Net.Driver
/// </summary>
public Uri Uri { get; }
- /// <summary>
- /// Gets the username to submit on requests that require authentication.
- /// </summary>
- public string? Username { get; }
-
- /// <summary>
- /// Gets the password to submit on requests that require authentication.
- /// </summary>
- public string? Password { get; }
-
private static Uri CreateUri(string hostname, int port, bool enableSsl, string path)
{
var scheme = enableSsl ? "https" : "http";
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/HttpRequestContext.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/HttpRequestContext.cs
new file mode 100644
index 0000000000..f2ce916885
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/HttpRequestContext.cs
@@ -0,0 +1,80 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+
+namespace Gremlin.Net.Driver
+{
+ /// <summary>
+ /// Mutable HTTP request context passed to request interceptors.
+ /// </summary>
+ public class HttpRequestContext
+ {
+ /// <summary>
+ /// Gets or sets the HTTP method (e.g. "POST").
+ /// </summary>
+ public string Method { get; set; }
+
+ /// <summary>
+ /// Gets or sets the request URI.
+ /// </summary>
+ public Uri Uri { get; set; }
+
+ /// <summary>
+ /// Gets the HTTP headers. Interceptors may add, modify, or remove entries.
+ /// </summary>
+ public Dictionary<string, string> Headers { get; }
+
+ /// <summary>
+ /// Gets or sets the serialized request body.
+ /// </summary>
+ public byte[] Body { get; set; }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="HttpRequestContext" /> class.
+ /// </summary>
+ /// <param name="method">The HTTP method.</param>
+ /// <param name="uri">The request URI.</param>
+ /// <param name="headers">The HTTP headers.</param>
+ /// <param name="body">The serialized request body.</param>
+ public HttpRequestContext(string method, Uri uri, Dictionary<string, string> headers, byte[] body)
+ {
+ Method = method ?? throw new ArgumentNullException(nameof(method));
+ Uri = uri ?? throw new ArgumentNullException(nameof(uri));
+ Headers = headers ?? throw new ArgumentNullException(nameof(headers));
+ Body = body;
+ }
+
+ /// <summary>
+ /// Returns the lowercase hex-encoded SHA-256 digest of the body.
+ /// </summary>
+ public string GetPayloadHash()
+ {
+ using var sha256 = SHA256.Create();
+ var hash = sha256.ComputeHash(Body ?? Array.Empty<byte>());
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+ }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs
index c25cffe725..83aeb9ceb4 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs
@@ -65,10 +65,16 @@ namespace Gremlin.Net.Driver.Remote
/// <param name="port">The port to connect to.</param>
/// <param name="traversalSource">The name of the traversal source on the server to bind to.</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 forwarded to the underlying
+ /// <see cref="GremlinClient" />.
+ /// </param>
/// <exception cref="ArgumentNullException">Thrown when client is null.</exception>
public DriverRemoteConnection(string host, int port, string traversalSource = "g",
- ILoggerFactory? loggerFactory = null) : this(
- new GremlinClient(new GremlinServer(host, port), loggerFactory: loggerFactory), traversalSource,
+ ILoggerFactory? loggerFactory = null,
+ IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null) : this(
+ new GremlinClient(new GremlinServer(host, port), loggerFactory: loggerFactory, interceptors: interceptors),
+ traversalSource,
logger: loggerFactory?.CreateLogger<DriverRemoteConnection>() ?? NullLogger<DriverRemoteConnection>.Instance)
{
}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj b/gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj
index df9cac5be7..c1c53ae69d 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj
+++ b/gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj
@@ -72,6 +72,7 @@ NOTE that versions suffixed with "-rc" are considered release candidates (i.e. p
</PropertyGroup>
<ItemGroup>
+ <PackageReference Include="AWSSDK.Core" Version="3.7.400.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
<PackageReference Include="Polly" Version="8.5.1" />
diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
index 7f5c833bf6..c7b82d2583 100644
--- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
@@ -158,7 +158,9 @@ var response =
// tag::submittingScriptsWithAuthentication[]
var username = "username";
var password = "password";
-var gremlinServer = new GremlinServer("localhost", 8182, true, username, password);
+var gremlinServer = new GremlinServer("localhost", 8182, enableSsl: true);
+using var gremlinClient = new GremlinClient(gremlinServer,
+ interceptors: new[] { Auth.BasicAuth(username, password) });
// end::submittingScriptsWithAuthentication[]
}
diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs
new file mode 100644
index 0000000000..802a9fb066
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs
@@ -0,0 +1,104 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Gremlin.Net.Driver;
+using Gremlin.Net.Driver.Remote;
+using Gremlin.Net.Process.Traversal;
+using Gremlin.Net.Structure.IO.GraphBinary4;
+using Xunit;
+
+namespace Gremlin.Net.IntegrationTest.Driver
+{
+ /// <summary>
+ /// Integration tests for authentication interceptors against the secure Gremlin Server
+ /// (port 45941, SSL + SimpleAuthenticator).
+ /// </summary>
+ public class AuthIntegrationTests
+ {
+ private static readonly string TestHost = ConfigProvider.Configuration["TestServerIpAddress"]!;
+ private static readonly int TestSecurePort = Convert.ToInt32(ConfigProvider.Configuration["TestSecureServerPort"]);
+
+ private GremlinClient CreateSecureClient(Func<HttpRequestContext, Task>[]? interceptors = null)
+ {
+ var gremlinServer = new GremlinServer(TestHost, TestSecurePort, enableSsl: true);
+ return new GremlinClient(gremlinServer,
+ new GraphBinaryMessageSerializer(),
+ new ConnectionSettings { SkipCertificateValidation = true },
+ interceptors: interceptors);
+ }
+
+ [Fact]
+ public async Task ShouldAuthenticateWithBasicAuth()
+ {
+ // The secure server uses SimpleAuthenticator with credentials: stephen/password
+ using var gremlinClient = CreateSecureClient(
+ new[] { Auth.BasicAuth("stephen", "password") });
+
+ var response = await gremlinClient.SubmitAsync<long>("g.inject(1).count()");
+
+ Assert.Single(response);
+ }
+
+ [Fact]
+ public async Task ShouldAuthenticateWithBasicAuthViaDriverRemoteConnection()
+ {
+ // Test through DriverRemoteConnection + traversal
+ using var client = CreateSecureClient(
+ new[] { Auth.BasicAuth("stephen", "password") });
+ using var remote = new DriverRemoteConnection(client, "gmodern");
+ var g = AnonymousTraversalSource.Traversal().With(remote);
+
+ var count = await g.V().Count().Promise(t => t.Next());
+
+ Assert.True(count > 0);
+ }
+
+ [Fact]
+ public async Task ShouldFailWithWrongCredentials()
+ {
+ using var gremlinClient = CreateSecureClient(
+ new[] { Auth.BasicAuth("stephen", "wrongpassword") });
+
+ var ex = await Assert.ThrowsAsync<HttpRequestException>(
+ () => gremlinClient.SubmitAsync<long>("g.inject(1).count()"));
+
+ // Verify this is an HTTP 401 error, not a connection refused error
+ Assert.Contains("401", ex.Message);
+ }
+
+ [Fact]
+ public async Task ShouldFailWithNoCredentials()
+ {
+ using var gremlinClient = CreateSecureClient();
+
+ var ex = await Assert.ThrowsAsync<HttpRequestException>(
+ () => gremlinClient.SubmitAsync<long>("g.inject(1).count()"));
+
+ // Verify this is an HTTP 401 error, not a connection refused error
+ Assert.Contains("401", ex.Message);
+ }
+ }
+}
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
new file mode 100644
index 0000000000..6090521187
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
@@ -0,0 +1,230 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Amazon.Runtime;
+using Gremlin.Net.Driver;
+using Xunit;
+
+namespace Gremlin.Net.UnitTest.Driver
+{
+ public class AuthTests
+ {
+ private static HttpRequestContext CreateTestContext()
+ {
+ return new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+ new Dictionary<string, string>(), new byte[] { 0x01 });
+ }
+
+ [Fact]
+ public async Task BasicAuthShouldSetCorrectAuthorizationHeader()
+ {
+ var interceptor = Auth.BasicAuth("user", "pass");
+ var context = CreateTestContext();
+
+ await interceptor(context);
+
+ var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass"));
+ Assert.Equal(expected, context.Headers["Authorization"]);
+ }
+
+ [Fact]
+ public async Task BasicAuthShouldSetHeaderOnEveryInvocation()
+ {
+ var interceptor = Auth.BasicAuth("user", "pass");
+ var context1 = CreateTestContext();
+ var context2 = CreateTestContext();
+
+ await interceptor(context1);
+ await interceptor(context2);
+
+ var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass"));
+ Assert.Equal(expected, context1.Headers["Authorization"]);
+ Assert.Equal(expected, context2.Headers["Authorization"]);
+ }
+
+ [Fact]
+ public async Task BasicAuthShouldHandleColonsInPassword()
+ {
+ var interceptor = Auth.BasicAuth("user", "pass:with:colons");
+ var context = CreateTestContext();
+
+ await interceptor(context);
+
+ var expected = "Basic " + Convert.ToBase64String(
+ Encoding.UTF8.GetBytes("user:pass:with:colons"));
+ Assert.Equal(expected, context.Headers["Authorization"]);
+ }
+
+ [Fact]
+ public async Task BasicAuthShouldHandleUnicodeCharacters()
+ {
+ var interceptor = Auth.BasicAuth("用户", "密码");
+ var context = CreateTestContext();
+
+ await interceptor(context);
+
+ var expected = "Basic " + Convert.ToBase64String(
+ Encoding.UTF8.GetBytes("用户:密码"));
+ Assert.Equal(expected, context.Headers["Authorization"]);
+ }
+
+ [Fact]
+ public async Task BasicAuthShouldOverwriteExistingAuthorizationHeader()
+ {
+ var interceptor = Auth.BasicAuth("user", "pass");
+ var context = CreateTestContext();
+ context.Headers["Authorization"] = "Bearer old-token";
+
+ await interceptor(context);
+
+ var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass"));
+ Assert.Equal(expected, context.Headers["Authorization"]);
+ }
+
+ [Fact]
+ public async Task BasicAuthShouldHandleEmptyCredentials()
+ {
+ var interceptor = Auth.BasicAuth("", "");
+ var context = CreateTestContext();
+
+ await interceptor(context);
+
+ var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(":"));
+ Assert.Equal(expected, context.Headers["Authorization"]);
+ }
+
+ // --- SigV4 Tests ---
+
+ private static readonly BasicAWSCredentials TestBasicCredentials =
+ new BasicAWSCredentials("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
+
+ private static readonly SessionAWSCredentials TestSessionCredentials =
+ new SessionAWSCredentials("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ "FwoGZXIvYXdzEBYaDHqa0AP");
+
+ private static HttpRequestContext CreateSigv4TestContext(byte[]? body = null)
+ {
+ return new HttpRequestContext("POST", new Uri("https://example.com:8182/gremlin"),
+ new Dictionary<string, string>
+ {
+ { "Content-Type", "application/vnd.graphbinary-v4.0" },
+ { "Accept", "application/vnd.graphbinary-v4.0" },
+ },
+ body ?? new byte[] { 0x84, 0x00 });
+ }
+
+ [Fact]
+ public async Task SigV4AuthShouldAddRequiredHeaders()
+ {
+ var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+ var context = CreateSigv4TestContext();
+
+ await interceptor(context);
+
+ Assert.True(context.Headers.ContainsKey("Authorization"));
+ Assert.True(context.Headers.ContainsKey("X-Amz-Date"));
+ Assert.True(context.Headers.ContainsKey("x-amz-content-sha256"));
+ Assert.True(context.Headers.ContainsKey("Host"));
+ }
+
+ [Fact]
+ public async Task SigV4AuthShouldHaveCorrectAuthorizationPrefix()
+ {
+ var interceptor = Auth.SigV4Auth("us-west-2", "neptune-db", TestBasicCredentials);
+ var context = CreateSigv4TestContext();
+
+ await interceptor(context);
+
+ Assert.StartsWith("AWS4-HMAC-SHA256 Credential=", context.Headers["Authorization"]);
+ Assert.Contains("us-west-2", context.Headers["Authorization"]);
+ Assert.Contains("neptune-db", context.Headers["Authorization"]);
+ }
+
+ [Fact]
+ public async Task SigV4AuthShouldAddSessionTokenForTemporaryCredentials()
+ {
+ var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestSessionCredentials);
+ var context = CreateSigv4TestContext();
+
+ await interceptor(context);
+
+ Assert.True(context.Headers.ContainsKey("X-Amz-Security-Token"));
+ Assert.Equal("FwoGZXIvYXdzEBYaDHqa0AP", context.Headers["X-Amz-Security-Token"]);
+ }
+
+ [Fact]
+ public async Task SigV4AuthShouldNotAddSessionTokenForPermanentCredentials()
+ {
+ var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+ var context = CreateSigv4TestContext();
+
+ await interceptor(context);
+
+ Assert.False(context.Headers.ContainsKey("X-Amz-Security-Token"));
+ }
+
+ [Fact]
+ public async Task SigV4AuthContentHashShouldMatchBodySha256()
+ {
+ var body = new byte[] { 0x84, 0x00, 0xFD, 0x01 };
+ var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+ var context = CreateSigv4TestContext(body);
+
+ await interceptor(context);
+
+ using var sha256 = SHA256.Create();
+ var expectedHash = BitConverter.ToString(sha256.ComputeHash(body))
+ .Replace("-", "").ToLowerInvariant();
+ Assert.Equal(expectedHash, context.Headers["x-amz-content-sha256"]);
+ }
+
+ [Fact]
+ public async Task SigV4AuthShouldHandleEmptyBody()
+ {
+ var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+ var context = CreateSigv4TestContext(Array.Empty<byte>());
+
+ await interceptor(context);
+
+ Assert.True(context.Headers.ContainsKey("Authorization"));
+ Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ context.Headers["x-amz-content-sha256"]);
+ }
+
+ [Fact]
+ public async Task SigV4AuthShouldSetCorrectHost()
+ {
+ var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+ var context = CreateSigv4TestContext();
+
+ await interceptor(context);
+
+ Assert.Equal("example.com", context.Headers["Host"]);
+ }
+ }
+}
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
index a73b5acc8a..bcb4b35d8b 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
@@ -278,6 +278,110 @@ namespace Gremlin.Net.UnitTest.Driver
return serializer;
}
+ [Fact]
+ public async Task ShouldCallInterceptorsInOrder()
+ {
+ var (httpClient, _) = CreateMockHttpClient();
+ var serializer = CreateMockSerializer();
+ var settings = new ConnectionSettings();
+ var callOrder = new List<int>();
+
+ var interceptors = new List<Func<HttpRequestContext, Task>>
+ {
+ ctx => { callOrder.Add(1); return Task.CompletedTask; },
+ ctx => { callOrder.Add(2); return Task.CompletedTask; },
+ ctx => { callOrder.Add(3); return Task.CompletedTask; },
+ };
+
+ using var connection = new Connection(TestUri, serializer, settings, httpClient, interceptors);
+
+ await connection.SubmitAsync<object>(CreateTestRequest());
+
+ Assert.Equal(new List<int> { 1, 2, 3 }, callOrder);
+ }
+
+ [Fact]
+ public async Task ShouldPropagateInterceptorException()
+ {
+ var (httpClient, handler) = CreateMockHttpClient();
+ var serializer = CreateMockSerializer();
+ var settings = new ConnectionSettings();
+ var expectedException = new InvalidOperationException("interceptor failed");
+
+ var interceptors = new List<Func<HttpRequestContext, Task>>
+ {
+ _ => throw expectedException,
+ };
+
+ using var connection = new Connection(TestUri, serializer, settings, httpClient, interceptors);
+
+ var ex = await Assert.ThrowsAsync<InvalidOperationException>(
+ () => connection.SubmitAsync<object>(CreateTestRequest()));
+
+ Assert.Same(expectedException, ex);
+ // HTTP request should not have been sent
+ Assert.Null(handler.CapturedRequest);
+ }
+
+ [Fact]
+ public async Task ShouldAllowInterceptorToModifyHeaders()
+ {
+ var (httpClient, handler) = CreateMockHttpClient();
+ var serializer = CreateMockSerializer();
+ var settings = new ConnectionSettings();
+
+ var interceptors = new List<Func<HttpRequestContext, Task>>
+ {
+ ctx =>
+ {
+ ctx.Headers["Authorization"] = "Basic dGVzdDp0ZXN0";
+ return Task.CompletedTask;
+ },
+ };
+
+ using var connection = new Connection(TestUri, serializer, settings, httpClient, interceptors);
+
+ await connection.SubmitAsync<object>(CreateTestRequest());
+
+ Assert.NotNull(handler.CapturedRequest);
+ Assert.True(handler.CapturedRequest!.Headers.Contains("Authorization"));
+ Assert.Equal("Basic dGVzdDp0ZXN0",
+ handler.CapturedRequest.Headers.GetValues("Authorization").First());
+ }
+
+ [Fact]
+ public async Task ShouldSeeEarlierInterceptorModifications()
+ {
+ var (httpClient, handler) = CreateMockHttpClient();
+ var serializer = CreateMockSerializer();
+ var settings = new ConnectionSettings();
+ string? observedHeader = null;
+
+ var interceptors = new List<Func<HttpRequestContext, Task>>
+ {
+ ctx =>
+ {
+ ctx.Headers["X-Custom"] = "first";
+ return Task.CompletedTask;
+ },
+ ctx =>
+ {
+ observedHeader = ctx.Headers.ContainsKey("X-Custom") ? ctx.Headers["X-Custom"] : null;
+ ctx.Headers["X-Custom"] = "second";
+ return Task.CompletedTask;
+ },
+ };
+
+ using var connection = new Connection(TestUri, serializer, settings, httpClient, interceptors);
+
+ await connection.SubmitAsync<object>(CreateTestRequest());
+
+ Assert.Equal("first", observedHeader);
+ Assert.NotNull(handler.CapturedRequest);
+ Assert.Equal("second",
+ handler.CapturedRequest!.Headers.GetValues("X-Custom").First());
+ }
+
/// <summary>
/// A test HttpMessageHandler that captures the request and returns a canned response.
/// </summary>
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/HttpRequestContextTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/HttpRequestContextTests.cs
new file mode 100644
index 0000000000..d5fbb2bc90
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/HttpRequestContextTests.cs
@@ -0,0 +1,103 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Gremlin.Net.Driver;
+using Xunit;
+
+namespace Gremlin.Net.UnitTest.Driver
+{
+ public class HttpRequestContextTests
+ {
+ [Fact]
+ public void ShouldConstructWithAllProperties()
+ {
+ var method = "POST";
+ var uri = new Uri("http://localhost:8182/gremlin");
+ var headers = new Dictionary<string, string> { { "Content-Type", "application/vnd.graphbinary-v4.0" } };
+ var body = new byte[] { 0x01, 0x02, 0x03 };
+
+ var context = new HttpRequestContext(method, uri, headers, body);
+
+ Assert.Equal(method, context.Method);
+ Assert.Equal(uri, context.Uri);
+ Assert.Same(headers, context.Headers);
+ Assert.Same(body, context.Body);
+ }
+
+ [Fact]
+ public void ShouldAllowMutatingProperties()
+ {
+ var context = new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+ new Dictionary<string, string>(), new byte[] { 0x01 });
+
+ var newUri = new Uri("https://example.com/gremlin");
+ context.Method = "PUT";
+ context.Uri = newUri;
+ context.Body = new byte[] { 0x02, 0x03 };
+ context.Headers["Authorization"] = "Basic dGVzdA==";
+
+ Assert.Equal("PUT", context.Method);
+ Assert.Equal(newUri, context.Uri);
+ Assert.Equal(new byte[] { 0x02, 0x03 }, context.Body);
+ Assert.Equal("Basic dGVzdA==", context.Headers["Authorization"]);
+ }
+
+ [Fact]
+ public void ShouldComputePayloadHashForKnownBody()
+ {
+ // SHA-256 of "hello" = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
+ var body = Encoding.UTF8.GetBytes("hello");
+ var context = new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+ new Dictionary<string, string>(), body);
+
+ var hash = context.GetPayloadHash();
+
+ Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash);
+ }
+
+ [Fact]
+ public void ShouldComputePayloadHashForEmptyBody()
+ {
+ var context = new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+ new Dictionary<string, string>(), Array.Empty<byte>());
+
+ var hash = context.GetPayloadHash();
+
+ Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
+ }
+
+ [Fact]
+ public void ShouldComputePayloadHashForNullBody()
+ {
+ var context = new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+ new Dictionary<string, string>(), null!);
+
+ var hash = context.GetPayloadHash();
+
+ Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
+ }
+ }
+}