diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index a1e797ffe37..294d9d8596e 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -25,6 +25,19 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima [[release-4-0-0]] === TinkerPop 4.0.0 (Release Date: NOT OFFICIALLY RELEASED YET) +* Standardized `gremlin-dotnet` connection options per the TinkerPop 4.x GLV proposal: +** Renamed the `Auth.BasicAuth`/`Auth.SigV4Auth` factory methods to `Auth.Basic`/`Auth.Sigv4` (breaking; the old methods have been removed). *(breaking)* +** Renamed `MaxConnectionsPerServer` to `MaxConnections`, `IdleConnectionTimeout` to `IdleTimeout`, and `KeepAliveInterval` to `KeepAliveTime` (now wired to a real TCP keep-alive socket option rather than the inert HTTP/2 ping timeout, enabling `SO_KEEPALIVE` and setting the per-socket idle time on Windows, Linux, and macOS, with a no-op fallback to the OS default idle time on other platforms); the old property names have been removed. *(breaking)* +** Renamed `ConnectionTimeout` to `ConnectTimeout` (default lowered from 15s to 5s; old name removed). *(breaking)* +** Renamed `EnableCompression` to `Compression`, now a `{None, Deflate}` enum defaulting to `Deflate` (compression on by default; set `None` to disable). The old `EnableCompression` `bool` has been removed. *(breaking)* +** Added `Ssl` (an `SslClientAuthenticationOptions`; `SkipCertificateValidation` is applied to an internal copy rather than mutating the caller's options). +** Added `BatchSize` (default 64), a connection-level default that fills the per-request `batchSize` when unset. +** Added `MaxResponseHeaderBytes`, exposing the handler's maximum response header size. +** Added `ReadTimeout`, a per-read idle timeout applied to each read of the response stream. +** Each timeout option is also settable in milliseconds via an `int` companion property (`ConnectTimeoutMillis`, `IdleTimeoutMillis`, `ReadTimeoutMillis`, `KeepAliveTimeMillis`); the unsuffixed `TimeSpan` property remains the idiomatic form and both reflect the same value. +** Added `Proxy`, routing connections through an `IWebProxy`. +* Fixed `gremlin-dotnet` deflate response decompression, which threw on the server's zlib-framed output because it used `DeflateStream` (raw DEFLATE, RFC 1951) instead of `ZLibStream` (zlib, RFC 1950); the bug was previously masked because compression was off by default. +* Fixed `gremlin-dotnet` SSL options cloning (used on the skip-certificate-validation path) to copy `ClientCertificateContext` and `AllowTlsResume`, which were previously dropped, breaking mTLS client certificates and silently re-enabling TLS resumption. * Standardized `gremlin-python` connection options per the TinkerPop 4.x GLV proposal: ** Renamed `pool_size` to `max_connections` (breaking; the old name has been removed) and changed the default from 8 to 128; `max_connections` is now also applied to the aiohttp `TCPConnector` `limit` so the transport layer reflects the option in addition to sizing the Connection pool. ** Renamed `ssl_options` to `ssl` accepting an `ssl.SSLContext` (breaking; the old name has been removed). diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc index eb3e324a98f..941e2e83ddc 100644 --- a/docs/src/reference/gremlin-variants.asciidoc +++ b/docs/src/reference/gremlin-variants.asciidoc @@ -2589,7 +2589,7 @@ include::../../../gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference Authentication is handled through request interceptors. Interceptors are functions that modify the outgoing HTTP request before it is sent — they are used for authentication, custom headers, or request signing. The `Auth` class -provides `BasicAuth()` and `SigV4Auth()` as built-in interceptors: +provides `Basic()` and `SigV4()` as built-in interceptors: [source,csharp] ---- @@ -2597,12 +2597,12 @@ provides `BasicAuth()` and `SigV4Auth()` as built-in interceptors: var server = new GremlinServer("localhost", 8182, enableSsl: true); using var client = new GremlinClient(server, connectionSettings: new ConnectionSettings { SkipCertificateValidation = true }, - interceptors: new[] { Auth.BasicAuth("username", "password") }); + interceptors: new[] { Auth.Basic("username", "password") }); // SigV4 authentication var server = new GremlinServer("localhost", 8182, enableSsl: true); using var client = new GremlinClient(server, - interceptors: new[] { Auth.SigV4Auth("service-region", "service-name") }); + interceptors: new[] { Auth.Sigv4("service-region", "service-name") }); ---- If you authenticate to a remote <> or @@ -2658,18 +2658,36 @@ constructor: [width="100%",cols="3,10,^2",options="header"] |========================================================= |Key |Description |Default -|ConnectionTimeout |The TCP connection timeout. |15 s -|IdleConnectionTimeout |How long idle connections stay in the pool before being closed. |180 s -|MaxConnectionsPerServer |The maximum concurrent connections to a single server. |128 -|KeepAliveInterval |The TCP keep-alive probe interval. |30 s -|EnableCompression |Whether to request deflate compression. |false +|ConnectTimeoutMillis |The TCP connect timeout in milliseconds (transport establishment, i.e. TCP connect plus TLS handshake where applicable, not an HTTP request timeout). Also settable as `ConnectTimeout` (a `TimeSpan`). |5000 +|IdleTimeoutMillis |How long in milliseconds idle connections stay in the pool before being closed. Also settable as `IdleTimeout` (a `TimeSpan`). |180000 +|MaxConnections |The maximum concurrent connections to a single server. |128 +|KeepAliveTimeMillis |Idle time in milliseconds before TCP keep-alive probes begin on an otherwise idle connection. Enables `SO_KEEPALIVE` on the socket and sets the per-socket idle time on Windows, Linux, and macOS; on other platforms keep-alive stays enabled at the OS default idle time. Also settable as `KeepAliveTime` (a `TimeSpan`). |30000 +|Compression |The response compression algorithm (`Compression.None` or `Compression.Deflate`). |`Compression.Deflate` +|BatchSize |The connection-level default batch size used to fill the per-request batch size when it is unset. |64 +|Ssl |The `SslClientAuthenticationOptions` used for HTTPS connections (client certificates, custom CA, protocols, etc.). `SkipCertificateValidation` is applied to an internal copy of these options rather than mutating the object you provide. |`null` +|MaxResponseHeaderBytes |The maximum allowed size, in bytes, of the response headers. `0` leaves the handler default unchanged (converted internally to the handler's native kilobyte unit). |0 +|ReadTimeoutMillis |The idle-read timeout in milliseconds applied to each individual read of the response stream. It resets per chunk. `0` (the default) disables it. Also settable as `ReadTimeout` (a `TimeSpan`; `Timeout.InfiniteTimeSpan` disables). |0 +|Proxy |The `IWebProxy` used for connections. |`null` |EnableUserAgentOnConnect |Enables sending a user agent to the server on requests. More details can be found in provider docs link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_graph_driver_provider_requirements[here].|true |BulkResults |Whether to send the bulkResults header on all requests. |false -|SkipCertificateValidation |Whether to skip SSL certificate validation. Only use for testing with self-signed certificates. |false +|SkipCertificateValidation |Whether to skip SSL certificate validation. Only use for testing with self-signed certificates. When `Ssl` is also provided, the accept-all callback is set on an internal copy so the supplied options object is never mutated. |false |========================================================= +Note that no driver timeout bounds the *total* duration of a request once it is under way. `ReadTimeout` only bounds +the gap between response chunks, so a response that keeps producing chunks will not time out no matter how long it +runs overall, and there is no client-side "overall" request timeout. If you need an absolute deadline, impose it in +your application by passing a cancellation token that cancels after the deadline: + +[source,csharp] +---- +// bound the entire request (submit plus full result iteration) to 30 seconds +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); +var results = await client.SubmitAsync( + "g.V().out().out()", cancellationToken: cts.Token); +---- + ==== GremlinClient Settings The following options can be passed to the `GremlinClient` constructor: @@ -2699,7 +2717,7 @@ A request interceptor is a `Func` that mutates the `Ht `Task` for async support but does not produce a value). A list of these is maintained and will be run sequentially for each request. When creating a `GremlinClient`, the `interceptors` parameter accepts an ordered collection of interceptors. Order matters, so if one interceptor depends on another's output, ensure they are added in the correct -order. Note that authentication (e.g. `Auth.BasicAuth()`, `Auth.SigV4Auth()`) is also implemented using interceptors. +order. Note that authentication (e.g. `Auth.Basic()`, `Auth.Sigv4()`) is also implemented using interceptors. These factory methods return interceptor delegates that can be included in the `interceptors` list. Alternatively, the `auth` parameter on `GremlinClient` appends the auth interceptor to the end of the list so it runs last. @@ -2709,7 +2727,7 @@ var server = new GremlinServer("localhost", 8182); using var client = new GremlinClient(server, interceptors: new Func[] { - Auth.BasicAuth("username", "password"), + Auth.Basic("username", "password"), context => { context.Headers["X-Custom-Header"] = "value"; @@ -2881,7 +2899,7 @@ order and are useful for authentication, custom headers, or request signing. [source,csharp] ---- var client = new GremlinClient(new GremlinServer("localhost", 8182), - interceptors: new[] { Auth.BasicAuth("username", "password") }); + interceptors: new[] { Auth.Basic("username", "password") }); ---- When `requestSerializer` is set to `null`, the request body is passed as a `RequestMessage` to interceptors, and an diff --git a/docs/src/upgrade/release-4.x.x.asciidoc b/docs/src/upgrade/release-4.x.x.asciidoc index 15c63c84000..1d2d2e96295 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -32,6 +32,46 @@ complete list of all the modifications that are part of this release. === Upgrading for Users +==== Standardizing .NET Connection Options + +TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-dotnet`, several +`ConnectionSettings` properties and the `Auth` factory methods have been renamed for consistency. Because this is a +major version, the old names have been removed rather than retained as aliases, and a number of new options have been +added. The notes below describe the .NET changes. See <> for the +equivalent changes in the other drivers. + +Renames (breaking). The following members have been renamed and the old names removed. Migrate to the new names: + +- `ConnectionTimeout` is now `ConnectTimeout`. +- `MaxConnectionsPerServer` is now `MaxConnections`. +- `IdleConnectionTimeout` is now `IdleTimeout`. +- `KeepAliveInterval` is now `KeepAliveTime`. +- `EnableCompression` is now `Compression`. +- The `Auth.BasicAuth` and `Auth.SigV4Auth` factory methods are now `Auth.Basic` and `Auth.Sigv4`. + +Behavior changes. These change runtime behavior on upgrade, even if you do not change your configuration: + +- `ConnectTimeout` now defaults to 5s (lowered from 15s). +- `KeepAliveTime` is now wired to a real TCP keep-alive socket option rather than the inert HTTP/2 ping timeout. It +enables `SO_KEEPALIVE` and sets the per-socket idle time on Windows, Linux, and macOS; on other platforms keep-alive +stays enabled at the OS default idle time. +- `Compression` is now a `{None, Deflate}` enum defaulting to `Deflate` (compression on by default), so the driver +sends `Accept-Encoding: deflate` by default. Set `Compression.None` to disable. The old `EnableCompression` `bool` +has been removed. + +New options: + +- `Ssl` (an `SslClientAuthenticationOptions` for client certificates and custom CAs; `SkipCertificateValidation` is +applied to an internal copy rather than mutating the supplied options). +- `BatchSize` (default 64): a connection-level default that fills the per-request batch size when unset. +- `MaxResponseHeaderBytes`: the maximum allowed size, in bytes, of the response headers. +- `ReadTimeout`: a per-read idle timeout applied to each individual read of the response stream. +- Each timeout is also settable in milliseconds via an `int` companion property (`ConnectTimeoutMillis`, `IdleTimeoutMillis`, + `ReadTimeoutMillis`, `KeepAliveTimeMillis`); the unsuffixed `TimeSpan` property is the idiomatic form. +- `Proxy`: routes connections through an `IWebProxy`. + +See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[[DISCUSS] Standardizing GLV connection options in TinkerPop 4]. + ==== Standardizing Go Connection Options TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-go`, several diff --git a/gremlin-dotnet/Examples/Connections/Connections.cs b/gremlin-dotnet/Examples/Connections/Connections.cs index bcfda7e4f8f..8e7354c2012 100644 --- a/gremlin-dotnet/Examples/Connections/Connections.cs +++ b/gremlin-dotnet/Examples/Connections/Connections.cs @@ -54,7 +54,7 @@ static void WithConf() var server = new GremlinServer(ServerHost, ServerPort); var settings = new ConnectionSettings { - ConnectionTimeout = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(30), }; using var remoteConnection = new DriverRemoteConnection( new GremlinClient(server, connectionSettings: settings), "g"); @@ -71,7 +71,7 @@ static void WithBasicAuth() var server = new GremlinServer(ServerHost, SecureServerPort, enableSsl: true); var client = new GremlinClient(server, connectionSettings: new ConnectionSettings { SkipCertificateValidation = true }, - interceptors: new[] { Auth.BasicAuth("stephen", "password") }); + interceptors: new[] { Auth.Basic("stephen", "password") }); using var remoteConnection = new DriverRemoteConnection(client, "g"); var g = Traversal().With(remoteConnection); diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs index eaf706f6dda..d713dc94734 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs @@ -43,7 +43,7 @@ public static class Auth /// The username. /// The password. /// A request interceptor delegate. - public static Func BasicAuth(string username, string password) + public static Func Basic(string username, string password) { var encoded = Convert.ToBase64String( Encoding.UTF8.GetBytes(username + ":" + password)); @@ -68,7 +68,7 @@ public static Func BasicAuth(string username, string p /// Optional AWS credentials. When null, the default credential chain is used. /// /// A request interceptor delegate. - public static Func SigV4Auth( + public static Func Sigv4( string region, string service, AWSCredentials? credentials = null) { // Cache the credential provider once when using the default chain. @@ -105,6 +105,7 @@ public static Func SigV4Auth( }; } + private static void SignRequest(HttpRequestContext context, ImmutableCredentials credentials, AWS4Signer signer, SigningClientConfig clientConfig) { diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs new file mode 100644 index 00000000000..15bc0e58533 --- /dev/null +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs @@ -0,0 +1,107 @@ +#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; + +namespace Gremlin.Net.Driver +{ + /// + /// The compression algorithm requested for server responses. The server currently + /// supports only; additional members are reserved for when the + /// server adds support for them (server-side first). + /// + public enum CompressionType + { + /// + /// No compression. + /// + None, + + /// + /// Deflate compression. + /// + Deflate + } + + /// + /// Configures response compression. A is implicitly + /// convertible ( = , + /// = ). + /// + public readonly struct Compression : IEquatable + { + /// + /// Gets the configured compression algorithm. + /// + public CompressionType Type { get; } + + /// + /// Initializes a new instance of the struct. + /// + /// The compression algorithm. + public Compression(CompressionType type) + { + Type = type; + } + + /// + /// No compression. + /// + public static Compression None => new Compression(CompressionType.None); + + /// + /// Deflate compression. + /// + public static Compression Deflate => new Compression(CompressionType.Deflate); + + /// + /// Gets whether compression is enabled (i.e. the algorithm is not ). + /// + public bool Enabled => Type != CompressionType.None; + + /// + /// Implicitly converts a to a . + /// + /// The compression algorithm. + public static implicit operator Compression(CompressionType type) => + new Compression(type); + + /// + public bool Equals(Compression other) => Type == other.Type; + + /// + public override bool Equals(object? obj) => obj is Compression other && Equals(other); + + /// + public override int GetHashCode() => (int)Type; + + /// Determines whether two values are equal. + public static bool operator ==(Compression left, Compression right) => left.Equals(right); + + /// Determines whether two values are not equal. + public static bool operator !=(Compression left, Compression right) => !left.Equals(right); + + /// + public override string ToString() => Type.ToString(); + } +} diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs index 82a0d17724a..7e16bffea9a 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs @@ -67,18 +67,125 @@ public Connection(Uri uri, var handler = new SocketsHttpHandler { - PooledConnectionIdleTimeout = settings.IdleConnectionTimeout, - MaxConnectionsPerServer = settings.MaxConnectionsPerServer, - ConnectTimeout = settings.ConnectionTimeout, - KeepAlivePingTimeout = settings.KeepAliveInterval, + PooledConnectionIdleTimeout = settings.IdleTimeout, + MaxConnectionsPerServer = settings.MaxConnections, + ConnectTimeout = settings.ConnectTimeout, }; - if (settings.SkipCertificateValidation) + + // Rewire keep-alive to a real TCP socket option (HTTP/1.1). The handler's + // KeepAlivePingTimeout only applies to HTTP/2; instead open the socket ourselves + // in a ConnectCallback and set the TCP keep-alive idle time. Probe interval and + // count stay at OS defaults (not standardized). + var keepAliveTime = settings.KeepAliveTime; + handler.ConnectCallback = async (context, cancellationToken) => + { + // Resolve the endpoint to concrete IP addresses and attempt each with its own + // socket. A single Socket cannot be reused across connection attempts (handing a + // multi-address DnsEndPoint to one socket throws "Sockets on this platform are + // invalid for use after a failed connection attempt"), so a fresh socket per + // address is required to support round-robin/fallback DNS. + var endpoint = context.DnsEndPoint; + System.Net.IPAddress[] addresses; + if (System.Net.IPAddress.TryParse(endpoint.Host, out var literal)) + { + addresses = new[] { literal }; + } + else + { + addresses = await System.Net.Dns.GetHostAddressesAsync( + endpoint.Host, cancellationToken).ConfigureAwait(false); + } + + if (addresses.Length == 0) + { + throw new System.Net.Sockets.SocketException( + (int)System.Net.Sockets.SocketError.HostNotFound); + } + + System.Exception? lastError = null; + foreach (var address in addresses) + { + var socket = new System.Net.Sockets.Socket( + address.AddressFamily, + System.Net.Sockets.SocketType.Stream, + System.Net.Sockets.ProtocolType.Tcp) + { + NoDelay = true + }; + try + { + socket.SetSocketOption(System.Net.Sockets.SocketOptionLevel.Socket, + System.Net.Sockets.SocketOptionName.KeepAlive, true); + var keepAliveSeconds = (int)keepAliveTime.TotalSeconds; + if (keepAliveSeconds > 0) + { + // Set the idle time before the first keep-alive probe. Windows/Linux use + // the TcpKeepAliveTime enum; macOS uses the equivalent raw TCP_KEEPALIVE + // option. Other platforms keep the OS default idle time. + if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) + { + socket.SetSocketOption(System.Net.Sockets.SocketOptionLevel.Tcp, + System.Net.Sockets.SocketOptionName.TcpKeepAliveTime, keepAliveSeconds); + } + else if (OperatingSystem.IsMacOS()) + { + // TCP_KEEPALIVE on macOS (: 0x10) is the idle-time knob, + // the analog of Linux TCP_KEEPIDLE. + const int tcpKeepAliveMacOs = 0x10; + socket.SetSocketOption(System.Net.Sockets.SocketOptionLevel.Tcp, + (System.Net.Sockets.SocketOptionName)tcpKeepAliveMacOs, keepAliveSeconds); + } + } + await socket.ConnectAsync( + new System.Net.IPEndPoint(address, endpoint.Port), cancellationToken) + .ConfigureAwait(false); + return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true); + } + catch (System.Exception ex) + { + socket.Dispose(); + lastError = ex; + } + } + + throw lastError ?? new System.Net.Sockets.SocketException( + (int)System.Net.Sockets.SocketError.HostUnreachable); + }; + + // Configure SSL/TLS. Start from the user-supplied options (if any) so client + // certificates, custom CAs, and protocol settings are preserved. When + // SkipCertificateValidation is set we must NOT mutate the caller's options object + // (it is a reference type that may be shared across clients); instead we clone it + // and install the accept-all callback on the copy. + if (settings.Ssl != null || settings.SkipCertificateValidation) { - handler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions + System.Net.Security.SslClientAuthenticationOptions sslOptions; + if (settings.SkipCertificateValidation) { - RemoteCertificateValidationCallback = (_, _, _, _) => true, - }; + sslOptions = CloneSslOptions(settings.Ssl); + sslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + } + else + { + sslOptions = settings.Ssl!; + } + handler.SslOptions = sslOptions; } + + // Expose the max response header size. The native handler unit is kilobytes while + // the user provides bytes, so convert (rounding up to avoid silently lowering the cap). + if (settings.MaxResponseHeaderBytes > 0) + { + handler.MaxResponseHeadersLength = + MaxResponseHeaderBytesToKilobytes(settings.MaxResponseHeaderBytes); + } + + if (settings.Proxy != null) + { + handler.Proxy = settings.Proxy; + handler.UseProxy = true; + } + _httpClient = new HttpClient(handler); } @@ -111,7 +218,18 @@ public async Task> SubmitAsync(RequestMessage requestMessage, var headers = new Dictionary(); headers["Accept"] = _responseSerializer.MimeType; - if (_settings.EnableCompression) + // Fill the per-request batch size from the connection-level default when the + // request did not set one. Build a copy for the outgoing request so the caller's + // RequestMessage is never mutated (resubmitting the same message must not pick up + // a previously injected default). A per-request explicit batchSize always wins. + var outgoingMessage = requestMessage; + if (!outgoingMessage.Fields.ContainsKey(Tokens.ArgsBatchSize)) + { + outgoingMessage = outgoingMessage.CloneWithField( + Tokens.ArgsBatchSize, _settings.BatchSize); + } + + if (_settings.Compression.Type == CompressionType.Deflate) { headers["Accept-Encoding"] = "deflate"; } @@ -129,13 +247,13 @@ public async Task> SubmitAsync(RequestMessage requestMessage, // 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) && + if (outgoingMessage.Fields.TryGetValue(Tokens.ArgsTransactionId, out var txIdObj) && txIdObj is string txId && !string.IsNullOrEmpty(txId)) { headers["X-Transaction-Id"] = txId; } - var context = new HttpRequestContext("POST", _uri, headers, requestMessage); + var context = new HttpRequestContext("POST", _uri, headers, outgoingMessage); foreach (var interceptor in _interceptors) { @@ -212,13 +330,26 @@ public async Task> SubmitAsync(RequestMessage requestMessage, { var contentStream = await response.Content.ReadAsStreamAsync() .ConfigureAwait(false); - DeflateStream? deflateStream = null; + + // Apply the per-read idle timeout (if configured) to the raw content stream so it + // covers both the compressed and decompressed read paths. + if (_settings.ReadTimeout > TimeSpan.Zero) + { + contentStream = new ReadTimeoutStream(contentStream, _settings.ReadTimeout); + } + + // The server (gremlin-server HttpContentCompressionHandler) compresses with + // java.util.zip.Deflater's default constructor, which emits a zlib-wrapped + // stream (RFC 1950: 2-byte header + Adler-32 checksum), not raw DEFLATE + // (RFC 1951). ZLibStream understands that wrapper; DeflateStream would throw + // on the zlib header. + Stream? decompressionStream = null; if (response.Content.Headers.ContentEncoding.Contains("deflate")) { - deflateStream = new DeflateStream(contentStream, CompressionMode.Decompress); + decompressionStream = new ZLibStream(contentStream, CompressionMode.Decompress); } streamingContext = new StreamingResponseContext( - response, contentStream, deflateStream); + response, contentStream, decompressionStream); var resultStream = _responseSerializer.DeserializeMessageAsync( streamingContext.Stream, cancellationToken); @@ -283,6 +414,58 @@ await channel.Writer.WriteAsync(item, capturedLinkedCts.Token) } } + /// + /// Converts a maximum response header size expressed in bytes to the kilobyte unit + /// used by , rounding up so + /// the configured byte cap is never silently lowered. For example 1024 bytes maps to + /// 1 KB, 1025 bytes maps to 2 KB, and 8192 bytes maps to 8 KB. Callers only invoke + /// this when is positive. + /// + /// The header cap in bytes (expected to be positive). + /// The equivalent cap in kilobytes, rounded up. + internal static int MaxResponseHeaderBytesToKilobytes(int maxResponseHeaderBytes) + { + return (maxResponseHeaderBytes + 1023) / 1024; + } + + /// + /// Creates a shallow copy of the supplied + /// so the caller's + /// object is never mutated when the skip-cert convenience is applied. Copies the + /// commonly used properties; the accept-all + /// + /// is set on the returned copy by the caller. + /// + /// The caller-owned options to clone, or null. + /// A new options instance carrying the copied settings. + private static System.Net.Security.SslClientAuthenticationOptions CloneSslOptions( + System.Net.Security.SslClientAuthenticationOptions? source) + { + var clone = new System.Net.Security.SslClientAuthenticationOptions(); + if (source == null) + { + return clone; + } + + clone.ClientCertificates = source.ClientCertificates; + clone.EnabledSslProtocols = source.EnabledSslProtocols; + clone.TargetHost = source.TargetHost; + // RemoteCertificateValidationCallback is intentionally NOT copied here: the caller + // overwrites it with the accept-all callback (skip-cert is the only path that clones). + clone.LocalCertificateSelectionCallback = source.LocalCertificateSelectionCallback; + clone.CipherSuitesPolicy = source.CipherSuitesPolicy; + clone.EncryptionPolicy = source.EncryptionPolicy; + clone.ApplicationProtocols = source.ApplicationProtocols; + clone.CertificateRevocationCheckMode = source.CertificateRevocationCheckMode; + clone.AllowRenegotiation = source.AllowRenegotiation; + // ClientCertificateContext carries the mTLS client certificate chain; omitting it + // would break client-certificate auth when combined with skip-cert. + clone.ClientCertificateContext = source.ClientCertificateContext; + // AllowTlsResume defaults to true, so it must be copied to honor a caller's false. + clone.AllowTlsResume = source.AllowTlsResume; + return clone; + } + /// /// Attempts to extract an error message from a JSON response body. /// The server sometimes responds with a JSON object containing a "message" field diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs index 83faebf7edf..5381acc0557 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs @@ -22,6 +22,8 @@ #endregion using System; +using System.Net; +using System.Net.Security; namespace Gremlin.Net.Driver { @@ -31,49 +33,91 @@ namespace Gremlin.Net.Driver public class ConnectionSettings { /// - /// The default TCP connection timeout. + /// The default TCP connect timeout (transport establishment, i.e. TCP connect plus + /// TLS handshake where applicable - not an HTTP request timeout). /// - public static readonly TimeSpan DefaultConnectionTimeout = TimeSpan.FromSeconds(15); + public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(5); /// /// The default idle connection timeout. /// - public static readonly TimeSpan DefaultIdleConnectionTimeout = TimeSpan.FromSeconds(180); + public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromSeconds(180); /// - /// The default maximum connections per server. + /// The default maximum concurrent connections to a single server. /// - public const int DefaultMaxConnectionsPerServer = 128; + public const int DefaultMaxConnections = 128; /// - /// The default TCP keep-alive probe interval. + /// The default TCP keep-alive idle time (how long a connection is idle before the + /// first keep-alive probe is sent). /// - public static readonly TimeSpan DefaultKeepAliveInterval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan DefaultKeepAliveTime = TimeSpan.FromSeconds(30); /// - /// Gets or sets the TCP connection timeout. + /// The default batch size used to fill the per-request batch size when it is unset. /// - public TimeSpan ConnectionTimeout { get; set; } = DefaultConnectionTimeout; + public const int DefaultBatchSizeValue = 64; + + /// + /// Gets or sets the TCP connect timeout. This is a transport-establishment timeout + /// (TCP connect plus TLS handshake where applicable), not an HTTP request timeout. + /// + public TimeSpan ConnectTimeout { get; set; } = DefaultConnectTimeout; + + /// + /// Gets or sets in whole milliseconds. This is the millisecond + /// view of the same setting; is the idiomatic form. + /// + public int ConnectTimeoutMillis + { + get => (int) ConnectTimeout.TotalMilliseconds; + set => ConnectTimeout = TimeSpan.FromMilliseconds(value); + } /// /// Gets or sets how long idle connections stay in the pool before being closed. /// - public TimeSpan IdleConnectionTimeout { get; set; } = DefaultIdleConnectionTimeout; + public TimeSpan IdleTimeout { get; set; } = DefaultIdleTimeout; + + /// + /// Gets or sets in whole milliseconds. This is the millisecond + /// view of the same setting; is the idiomatic form. + /// + public int IdleTimeoutMillis + { + get => (int) IdleTimeout.TotalMilliseconds; + set => IdleTimeout = TimeSpan.FromMilliseconds(value); + } /// /// Gets or sets the maximum concurrent connections to a single server. /// - public int MaxConnectionsPerServer { get; set; } = DefaultMaxConnectionsPerServer; + public int MaxConnections { get; set; } = DefaultMaxConnections; + + /// + /// Gets or sets the TCP keep-alive idle time: how long a connection may be idle + /// before the first keep-alive probe is sent. Probe interval and count stay at OS + /// defaults. + /// + public TimeSpan KeepAliveTime { get; set; } = DefaultKeepAliveTime; /// - /// Gets or sets the TCP keep-alive probe interval. + /// Gets or sets in whole milliseconds. This is the millisecond + /// view of the same setting; is the idiomatic form. /// - public TimeSpan KeepAliveInterval { get; set; } = DefaultKeepAliveInterval; + public int KeepAliveTimeMillis + { + get => (int) KeepAliveTime.TotalMilliseconds; + set => KeepAliveTime = TimeSpan.FromMilliseconds(value); + } /// - /// Gets or sets whether to request deflate compression. + /// Gets or sets the response compression algorithm. Defaults to + /// (on); set + /// to disable. /// - public bool EnableCompression { get; set; } = false; + public Compression Compression { get; set; } = Compression.Deflate; /// /// Gets or sets whether to send the User-Agent header. @@ -85,10 +129,60 @@ public class ConnectionSettings /// public bool BulkResults { get; set; } = false; + /// + /// Gets or sets the connection-level default batch size that fills the per-request batch size + /// when it is not set on the request (client-side default-filling; no wire change). + /// + public int BatchSize { get; set; } = DefaultBatchSizeValue; + + /// + /// Gets or sets the SSL/TLS options used for HTTPS connections (client certificates, + /// custom CA, protocols, etc.). When set, these options are used to configure the + /// underlying handler. is applied to an + /// internal copy of these options rather than mutating the object provided here. + /// + public SslClientAuthenticationOptions? Ssl { get; set; } + /// /// Gets or sets whether to skip SSL certificate validation. - /// Only use for testing with self-signed certificates. + /// Only use for testing with self-signed certificates. When is also + /// provided, the accept-all callback is set on an internal copy of those options so the + /// caller's instance is never mutated + /// (which is important when one instance is shared across multiple clients). /// public bool SkipCertificateValidation { get; set; } = false; + + /// + /// Gets or sets the maximum allowed size, in bytes, of the response headers. + /// A value of 0 (the default) leaves the handler default unchanged. The native + /// handler unit is kilobytes; the byte value provided here is converted internally. + /// + public int MaxResponseHeaderBytes { get; set; } = 0; + + /// + /// Gets or sets the idle-read timeout applied to each individual read of the response + /// stream. It resets per chunk, so it is an idle-read timeout rather than a + /// whole-request deadline. + /// (the default) disables it. + /// + public TimeSpan ReadTimeout { get; set; } = System.Threading.Timeout.InfiniteTimeSpan; + + /// + /// Gets or sets in whole milliseconds, where 0 disables it + /// (mapping to ). This is the millisecond + /// view of the same setting; is the idiomatic form. + /// + public int ReadTimeoutMillis + { + get => ReadTimeout <= TimeSpan.Zero ? 0 : (int) ReadTimeout.TotalMilliseconds; + set => ReadTimeout = value <= 0 ? System.Threading.Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(value); + } + + /// + /// Gets or sets the HTTP proxy used for connections. When set, it is applied to the + /// underlying handler explicitly. + /// + public IWebProxy? Proxy { get; set; } + } } diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs index 0d8a41dfd18..0e023cfa5c0 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs @@ -43,6 +43,62 @@ public GremlinServer(string hostname = "localhost", int port = 8182, bool enable Uri = CreateUri(hostname, port, enableSsl, path); } + /// + /// Creates a new instance of the class from a single URL. + /// + /// + /// The URL of the Gremlin endpoint, e.g. https://localhost:8182/gremlin. The scheme determines + /// whether SSL is enabled (https enables it, http disables it) and the host, port and path + /// are taken from the URL. When the URL omits the port the default 8182 is used, and when it omits + /// the path the default /gremlin is used. + /// + /// A new configured from the given URL. + /// Thrown when is null. + /// + /// Thrown when is not a valid absolute URL or does not use the + /// http or https scheme. + /// + public static GremlinServer FromUrl(string url) + { + if (url == null) throw new ArgumentNullException(nameof(url)); + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + throw new ArgumentException($"'{url}' is not a valid absolute URL.", nameof(url)); + return new GremlinServer(uri); + } + + /// + /// Initializes a new instance of the class from a . + /// + /// + /// The URI of the Gremlin endpoint. The scheme determines whether SSL is enabled (https enables it, + /// http disables it). When the URI omits the port the default 8182 is used, and when it omits + /// the path the default /gremlin is used. + /// + /// Thrown when is null. + /// + /// Thrown when does not use the http or https scheme. + /// + public GremlinServer(Uri uri) + { + if (uri == null) throw new ArgumentNullException(nameof(uri)); + ValidateScheme(uri.Scheme); + + var enableSsl = string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase); + + // Only override the port when the URL specifies one; otherwise keep the default 8182. + // System.Uri auto-fills the scheme default port (80/443) and flags it via IsDefaultPort, + // so treat that case as "not specified". + var port = uri.IsDefaultPort ? 8182 : uri.Port; + + // Likewise, only override the path when the URL has a non-empty path, otherwise keep the default + // /gremlin. System.Uri turns a path-less URL into AbsolutePath "/", so treat "/" or empty as default. + var path = string.IsNullOrEmpty(uri.AbsolutePath) || uri.AbsolutePath == "/" + ? "/gremlin" + : uri.AbsolutePath; + + Uri = CreateUri(uri.Host, port, enableSsl, path); + } + /// /// Gets the URI of the Gremlin Server. /// @@ -53,5 +109,13 @@ private static Uri CreateUri(string hostname, int port, bool enableSsl, string p var scheme = enableSsl ? "https" : "http"; return new Uri($"{scheme}://{hostname}:{port}{path}"); } + + private static void ValidateScheme(string scheme) + { + if (!string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase) && + !string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException( + $"Unsupported scheme '{scheme}'. Only 'http' and 'https' are supported."); + } } } diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs index 2ebd02cc29d..e843f43d9e4 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs @@ -52,6 +52,21 @@ private RequestMessage(string gremlin, Dictionary fields) /// public Dictionary Fields { get; } + /// + /// Returns a copy of this message with set to + /// , without mutating this instance (and therefore without + /// mutating the caller-owned message). Used to fill connection-level defaults onto + /// the outgoing request only. + /// + /// The field key to set on the copy. + /// The field value to set on the copy. + /// A new carrying the added field. + internal RequestMessage CloneWithField(string key, object value) + { + var copiedFields = new Dictionary(Fields) { [key] = value }; + return new RequestMessage(Gremlin, copiedFields); + } + /// /// Initializes a to build a . /// diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs new file mode 100644 index 00000000000..aa006ed6a02 --- /dev/null +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs @@ -0,0 +1,111 @@ +#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.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Gremlin.Net.Driver +{ + /// + /// Wraps a stream and applies an idle-read timeout to each individual read by linking a + /// with the caller's token. + /// The timeout resets per chunk, so it is an idle-read timeout rather than a whole-request + /// deadline. A non-positive timeout disables the behavior. + /// + internal sealed class ReadTimeoutStream : Stream + { + private readonly Stream _inner; + private readonly TimeSpan _readTimeout; + + public ReadTimeoutStream(Stream inner, TimeSpan readTimeout) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _readTimeout = readTimeout; + } + + private bool TimeoutEnabled => _readTimeout > TimeSpan.Zero; + + public override async ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken = default) + { + if (!TimeoutEnabled) + { + return await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + using var timeoutCts = new CancellationTokenSource(); + using var linkedCts = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + timeoutCts.CancelAfter(_readTimeout); + try + { + return await _inner.ReadAsync(buffer, linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && + !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Read timed out after {_readTimeout.TotalSeconds:0.###}s waiting for response data."); + } + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + // The driver always reads asynchronously; provide a correct sync fallback. + return _inner.Read(buffer, offset, count); + } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs index 9d40e1f39fe..a61ed2c647e 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs @@ -36,26 +36,26 @@ internal sealed class StreamingResponseContext : IDisposable { private readonly HttpResponseMessage _response; private readonly Stream _contentStream; - private readonly DeflateStream? _deflateStream; + private readonly Stream? _decompressionStream; /// /// Gets the stream to read from — the decompression stream if present, /// otherwise the raw content stream. /// - public Stream Stream => (Stream?)_deflateStream ?? _contentStream; + public Stream Stream => _decompressionStream ?? _contentStream; /// /// Initializes a new instance of the class. /// /// The HTTP response message. /// The raw content stream from the response. - /// An optional deflate decompression stream wrapping the content stream. + /// An optional decompression stream wrapping the content stream. public StreamingResponseContext(HttpResponseMessage response, Stream contentStream, - DeflateStream? deflateStream = null) + Stream? decompressionStream = null) { _response = response; _contentStream = contentStream; - _deflateStream = deflateStream; + _decompressionStream = decompressionStream; } /// @@ -63,7 +63,7 @@ public StreamingResponseContext(HttpResponseMessage response, Stream contentStre /// public void Dispose() { - _deflateStream?.Dispose(); + _decompressionStream?.Dispose(); _contentStream.Dispose(); _response.Dispose(); } diff --git a/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs b/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs index ac51e17f0a4..8e8abf462b0 100644 --- a/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs +++ b/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs @@ -34,14 +34,14 @@ public class CompressionBenchmarks public static async Task GraphBinaryWithoutCompression() { var client = new GremlinClient(new GremlinServer("localhost", 45940), - connectionSettings: new ConnectionSettings { EnableCompression = false }); + connectionSettings: new ConnectionSettings { Compression = Compression.None }); await PerformBenchmarkWithClient(client); } public static async Task GraphBinaryWithCompression() { var client = new GremlinClient(new GremlinServer("localhost", 45940), - connectionSettings: new ConnectionSettings { EnableCompression = true }); + connectionSettings: new ConnectionSettings { Compression = Compression.Deflate }); await PerformBenchmarkWithClient(client); } 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 be2506151e4..b67d7a7ecbd 100644 --- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs @@ -143,7 +143,7 @@ public void SubmittingScriptsWithAuthenticationTest() var password = "password"; var gremlinServer = new GremlinServer("localhost", 8182, enableSsl: true); using var gremlinClient = new GremlinClient(gremlinServer, - interceptors: new[] { Auth.BasicAuth(username, password) }); + interceptors: new[] { Auth.Basic(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 index 3b5c9eed072..ca564f987a8 100644 --- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs @@ -53,7 +53,7 @@ public async Task ShouldAuthenticateWithBasicAuth() { // The secure server uses SimpleAuthenticator with credentials: stephen/password using var gremlinClient = CreateSecureClient( - new[] { Auth.BasicAuth("stephen", "password") }); + new[] { Auth.Basic("stephen", "password") }); var response = await gremlinClient.SubmitAsync("g.inject(1).count()"); @@ -65,7 +65,7 @@ public async Task ShouldAuthenticateWithBasicAuthViaDriverRemoteConnection() { // Test through DriverRemoteConnection + traversal using var client = CreateSecureClient( - new[] { Auth.BasicAuth("stephen", "password") }); + new[] { Auth.Basic("stephen", "password") }); using var remote = new DriverRemoteConnection(client, "gmodern"); var g = AnonymousTraversalSource.Traversal().With(remote); @@ -78,7 +78,7 @@ public async Task ShouldAuthenticateWithBasicAuthViaDriverRemoteConnection() public async Task ShouldFailWithWrongCredentials() { using var gremlinClient = CreateSecureClient( - new[] { Auth.BasicAuth("stephen", "wrongpassword") }); + new[] { Auth.Basic("stephen", "wrongpassword") }); // The server returns auth errors as JSON (not GraphBinary), so Connection // extracts the message and throws HttpRequestException. diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs index 40f5239e446..76c9910d7f5 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs @@ -43,7 +43,7 @@ private static HttpRequestContext CreateTestContext() [Fact] public async Task BasicAuthShouldSetCorrectAuthorizationHeader() { - var interceptor = Auth.BasicAuth("user", "pass"); + var interceptor = Auth.Basic("user", "pass"); var context = CreateTestContext(); await interceptor(context); @@ -55,7 +55,7 @@ public async Task BasicAuthShouldSetCorrectAuthorizationHeader() [Fact] public async Task BasicAuthShouldSetHeaderOnEveryInvocation() { - var interceptor = Auth.BasicAuth("user", "pass"); + var interceptor = Auth.Basic("user", "pass"); var context1 = CreateTestContext(); var context2 = CreateTestContext(); @@ -70,7 +70,7 @@ public async Task BasicAuthShouldSetHeaderOnEveryInvocation() [Fact] public async Task BasicAuthShouldHandleColonsInPassword() { - var interceptor = Auth.BasicAuth("user", "pass:with:colons"); + var interceptor = Auth.Basic("user", "pass:with:colons"); var context = CreateTestContext(); await interceptor(context); @@ -83,7 +83,7 @@ public async Task BasicAuthShouldHandleColonsInPassword() [Fact] public async Task BasicAuthShouldHandleUnicodeCharacters() { - var interceptor = Auth.BasicAuth("用户", "密码"); + var interceptor = Auth.Basic("用户", "密码"); var context = CreateTestContext(); await interceptor(context); @@ -96,7 +96,7 @@ public async Task BasicAuthShouldHandleUnicodeCharacters() [Fact] public async Task BasicAuthShouldOverwriteExistingAuthorizationHeader() { - var interceptor = Auth.BasicAuth("user", "pass"); + var interceptor = Auth.Basic("user", "pass"); var context = CreateTestContext(); context.Headers["Authorization"] = "Bearer old-token"; @@ -109,7 +109,7 @@ public async Task BasicAuthShouldOverwriteExistingAuthorizationHeader() [Fact] public async Task BasicAuthShouldHandleEmptyCredentials() { - var interceptor = Auth.BasicAuth("", ""); + var interceptor = Auth.Basic("", ""); var context = CreateTestContext(); await interceptor(context); @@ -140,7 +140,7 @@ private static HttpRequestContext CreateSigv4TestContext(byte[]? body = null) [Fact] public async Task SigV4AuthShouldAddRequiredHeaders() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -154,7 +154,7 @@ public async Task SigV4AuthShouldAddRequiredHeaders() [Fact] public async Task SigV4AuthShouldHaveCorrectAuthorizationPrefix() { - var interceptor = Auth.SigV4Auth("gremlin-west-2", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-west-2", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -166,7 +166,7 @@ public async Task SigV4AuthShouldHaveCorrectAuthorizationPrefix() [Fact] public async Task SigV4AuthShouldAddSessionTokenForTemporaryCredentials() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestSessionCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestSessionCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -178,7 +178,7 @@ public async Task SigV4AuthShouldAddSessionTokenForTemporaryCredentials() [Fact] public async Task SigV4AuthShouldNotAddSessionTokenForPermanentCredentials() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -190,7 +190,7 @@ public async Task SigV4AuthShouldNotAddSessionTokenForPermanentCredentials() public async Task SigV4AuthContentHashShouldMatchBodySha256() { var body = new byte[] { 0x84, 0x00, 0xFD, 0x01 }; - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(body); await interceptor(context); @@ -204,7 +204,7 @@ public async Task SigV4AuthContentHashShouldMatchBodySha256() [Fact] public async Task SigV4AuthShouldHandleEmptyBody() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(Array.Empty()); await interceptor(context); @@ -217,7 +217,7 @@ public async Task SigV4AuthShouldHandleEmptyBody() [Fact] public async Task SigV4AuthShouldSetCorrectHost() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -228,7 +228,7 @@ public async Task SigV4AuthShouldSetCorrectHost() [Fact] public async Task SigV4AuthShouldThrowWhenBodyIsNotByteArray() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = new HttpRequestContext("POST", new Uri("https://example.com:8182/gremlin"), new Dictionary { diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs new file mode 100644 index 00000000000..19d37e93cca --- /dev/null +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs @@ -0,0 +1,125 @@ +#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.Security; +using System.Threading; +using Gremlin.Net.Driver; +using Xunit; + +namespace Gremlin.Net.UnitTest.Driver +{ + public class ConnectionSettingsTests + { + [Fact] + public void ShouldUseStandardizedDefaults() + { + var settings = new ConnectionSettings(); + + Assert.Equal(TimeSpan.FromSeconds(5), settings.ConnectTimeout); + Assert.Equal(TimeSpan.FromSeconds(180), settings.IdleTimeout); + Assert.Equal(128, settings.MaxConnections); + Assert.Equal(TimeSpan.FromSeconds(30), settings.KeepAliveTime); + Assert.Equal(64, settings.BatchSize); + Assert.Equal(Compression.Deflate, settings.Compression); + Assert.False(settings.SkipCertificateValidation); + Assert.Null(settings.Ssl); + Assert.Null(settings.Proxy); + Assert.Equal(0, settings.MaxResponseHeaderBytes); + Assert.Equal(Timeout.InfiniteTimeSpan, settings.ReadTimeout); + } + + [Fact] + public void CompressionShouldDefaultToDeflate() + { + Assert.Equal(CompressionType.Deflate, new ConnectionSettings().Compression.Type); + Assert.True(new ConnectionSettings().Compression.Enabled); + } + + [Fact] + public void CompressionShouldAcceptEnumValue() + { + var settings = new ConnectionSettings { Compression = CompressionType.Deflate }; + + Assert.Equal(Compression.Deflate, settings.Compression); + } + + [Fact] + public void CompressionEqualityShouldWork() + { + Assert.Equal(Compression.Deflate, Compression.Deflate); + Assert.NotEqual(Compression.Deflate, Compression.None); + Assert.True(Compression.None == CompressionType.None); + Assert.True(Compression.Deflate != Compression.None); + } + + [Fact] + public void ShouldAllowSettingSslOptions() + { + var ssl = new SslClientAuthenticationOptions { TargetHost = "example.com" }; + var settings = new ConnectionSettings { Ssl = ssl }; + + Assert.Same(ssl, settings.Ssl); + } + + [Fact] + public void MillisOptionsShouldSetTheTimeSpanProperties() + { + var settings = new ConnectionSettings + { + ConnectTimeoutMillis = 2000, + IdleTimeoutMillis = 60000, + KeepAliveTimeMillis = 15000, + ReadTimeoutMillis = 30000 + }; + + Assert.Equal(TimeSpan.FromMilliseconds(2000), settings.ConnectTimeout); + Assert.Equal(TimeSpan.FromMilliseconds(60000), settings.IdleTimeout); + Assert.Equal(TimeSpan.FromMilliseconds(15000), settings.KeepAliveTime); + Assert.Equal(TimeSpan.FromMilliseconds(30000), settings.ReadTimeout); + } + + [Fact] + public void MillisOptionsShouldReflectTheTimeSpanProperties() + { + var settings = new ConnectionSettings + { + ConnectTimeout = TimeSpan.FromSeconds(2), + ReadTimeout = TimeSpan.FromSeconds(30) + }; + + Assert.Equal(2000, settings.ConnectTimeoutMillis); + Assert.Equal(30000, settings.ReadTimeoutMillis); + } + + [Fact] + public void ReadTimeoutMillisZeroShouldDisableTheReadTimeout() + { + var settings = new ConnectionSettings { ReadTimeoutMillis = 0 }; + + Assert.Equal(Timeout.InfiniteTimeSpan, settings.ReadTimeout); + Assert.Equal(0, settings.ReadTimeoutMillis); + } + + } +} diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs index 56010293637..7a3bee9e629 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs @@ -141,7 +141,7 @@ public async Task ShouldSetAcceptEncodingWhenCompressionEnabled() { var (httpClient, handler) = CreateMockHttpClient(); var serializer = CreateMockSerializer(); - var settings = new ConnectionSettings { EnableCompression = true }; + var settings = new ConnectionSettings { Compression = Compression.Deflate }; using var connection = new Connection(TestUri, serializer, settings, httpClient); await connection.SubmitAsync(CreateTestRequest()); @@ -156,7 +156,7 @@ public async Task ShouldNotSetAcceptEncodingWhenCompressionDisabled() { var (httpClient, handler) = CreateMockHttpClient(); var serializer = CreateMockSerializer(); - var settings = new ConnectionSettings { EnableCompression = false }; + var settings = new ConnectionSettings { Compression = Compression.None }; using var connection = new Connection(TestUri, serializer, settings, httpClient); await connection.SubmitAsync(CreateTestRequest()); @@ -166,6 +166,21 @@ public async Task ShouldNotSetAcceptEncodingWhenCompressionDisabled() e => e.Value == "deflate"); } + [Fact] + public async Task ShouldSetAcceptEncodingByDefault() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings(); + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + await connection.SubmitAsync(CreateTestRequest()); + + Assert.NotNull(handler.CapturedRequest); + Assert.Contains(handler.CapturedRequest!.Headers.AcceptEncoding, + e => e.Value == "deflate"); + } + [Fact] public async Task ShouldSetUserAgentWhenEnabled() { @@ -226,21 +241,24 @@ public async Task ShouldNotSetBulkResultsHeaderWhenDisabled() [Fact] public async Task ShouldDecompressDeflateResponse() { - // Compress the minimal response bytes with deflate + // Compress the minimal response bytes the way the server does: java.util.zip.Deflater's + // default constructor emits a zlib-wrapped stream (RFC 1950: 2-byte header + Adler-32), + // which corresponds to .NET's ZLibStream (NOT the raw RFC 1951 DeflateStream). Using + // ZLibStream here exercises the real wire format and would catch a raw/zlib mismatch. var originalBytes = BuildMinimalResponseBytes(); byte[] compressedBytes; using (var compressedStream = new MemoryStream()) { - using (var deflateStream = new DeflateStream(compressedStream, CompressionMode.Compress, true)) + using (var zlibStream = new ZLibStream(compressedStream, CompressionMode.Compress, true)) { - deflateStream.Write(originalBytes, 0, originalBytes.Length); + zlibStream.Write(originalBytes, 0, originalBytes.Length); } compressedBytes = compressedStream.ToArray(); } var (httpClient, handler) = CreateMockHttpClient(compressedBytes, "deflate"); var serializer = CreateMockSerializer(); - var settings = new ConnectionSettings { EnableCompression = true }; + var settings = new ConnectionSettings { Compression = Compression.Deflate }; using var connection = new Connection(TestUri, serializer, settings, httpClient); // Should not throw — decompression should work @@ -262,6 +280,110 @@ public void ShouldDisposeWithoutError() connection.Dispose(); } + [Fact] + public void ShouldNotMutateUserSslOptionsWhenSkippingCertValidation() + { + // The public constructor must NOT mutate the caller's SslClientAuthenticationOptions + // when SkipCertificateValidation is set. The options object is a reference type that + // may be shared across clients, so mutating it in place could silently disable + // validation on another client. Instead, the skip-cert callback must be installed on + // an internal clone, leaving the caller's object untouched. + var userSsl = new System.Net.Security.SslClientAuthenticationOptions + { + TargetHost = "example.com" + }; + var settings = new ConnectionSettings + { + Ssl = userSsl, + SkipCertificateValidation = true + }; + + using var connection = new Connection( + TestUri, CreateMockSerializer(), settings); + + // The caller's own options object must be left exactly as supplied: its + // RemoteCertificateValidationCallback must remain null and its other fields intact. + Assert.Equal("example.com", userSsl.TargetHost); + Assert.Null(userSsl.RemoteCertificateValidationCallback); + // settings.Ssl must still reference the very same object the caller provided. + Assert.Same(userSsl, settings.Ssl); + } + + [Fact] + public void ShouldNotShareSkipCertCallbackAcrossClientsSharingSslOptions() + { + // Reusing one Ssl options object across two clients, only one of which skips cert + // validation, must not leak the accept-all callback onto the shared object (and thus + // onto the other client). + var sharedSsl = new System.Net.Security.SslClientAuthenticationOptions + { + TargetHost = "example.com" + }; + + var skipSettings = new ConnectionSettings + { + Ssl = sharedSsl, + SkipCertificateValidation = true + }; + var strictSettings = new ConnectionSettings + { + Ssl = sharedSsl, + SkipCertificateValidation = false + }; + + using var skipConnection = new Connection(TestUri, CreateMockSerializer(), skipSettings); + using var strictConnection = new Connection(TestUri, CreateMockSerializer(), strictSettings); + + // The shared object must never have had the accept-all callback written onto it. + Assert.Null(sharedSsl.RemoteCertificateValidationCallback); + } + + [Fact] + public void ShouldConstructWithProxyAndMaxHeaderBytes() + { + var settings = new ConnectionSettings + { + Proxy = new System.Net.WebProxy("http://localhost:3128"), + MaxResponseHeaderBytes = 16384 + }; + + // Should construct the handler without throwing. + using var connection = new Connection( + TestUri, CreateMockSerializer(), settings); + } + + [Theory] + [InlineData(1, 1)] // a single byte still needs one whole kilobyte + [InlineData(1023, 1)] // just under 1 KB rounds up to 1 + [InlineData(1024, 1)] // exactly 1 KB stays 1 (no spurious round-up) + [InlineData(1025, 2)] // one byte over a KB boundary rounds up to 2 + [InlineData(8191, 8)] // just under 8 KB rounds up to 8 + [InlineData(8192, 8)] // exactly 8 KB (the default) stays 8 + [InlineData(8193, 9)] // one byte over rounds up to 9 + [InlineData(16384, 16)] // exactly 16 KB stays 16 + public void ShouldRoundMaxResponseHeaderBytesUpToKilobytes(int bytes, int expectedKilobytes) + { + // SocketsHttpHandler.MaxResponseHeadersLength is expressed in kilobytes while the + // public option is in bytes. The conversion must round UP so the configured byte cap + // is never silently lowered (ceil(bytes / 1024)). This asserts the rounding math the + // public Connection constructor applies to the handler. + Assert.Equal(expectedKilobytes, Connection.MaxResponseHeaderBytesToKilobytes(bytes)); + } + + [Fact] + public void ShouldLeaveMaxResponseHeadersAtDefaultWhenBytesUnset() + { + // When MaxResponseHeaderBytes is 0 (the default / unset), the constructor must NOT + // touch the handler's MaxResponseHeadersLength, leaving the .NET default in place. + // The conversion is only applied for a positive byte cap, so constructing with the + // default must succeed without invoking the rounding path. + var settings = new ConnectionSettings(); + Assert.Equal(0, settings.MaxResponseHeaderBytes); + + // Should construct without throwing and without configuring the header cap. + using var connection = new Connection(TestUri, CreateMockSerializer(), settings); + } + private static RequestMessage CreateTestRequest() { return RequestMessage.Build("g.V()").AddG("g").Create(); @@ -940,6 +1062,162 @@ public async Task ShouldStreamResponseWithoutFullBuffering() Assert.True(handler.WasCalled, "SendAsync should have been called"); } + [Fact] + public async Task ShouldFillBatchSizeFromDefaultWhenUnset() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings { BatchSize = 42 }; + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + var request = CreateTestRequest(); + await connection.SubmitAsync(request); + + // The caller-owned request must NOT be mutated by default-filling. + Assert.False(request.Fields.ContainsKey(Tokens.ArgsBatchSize)); + // The outgoing wire payload must carry the connection-level default. + Assert.Equal(42, await ReadBatchSizeFromBodyAsync(handler)); + } + + [Fact] + public async Task ShouldNotOverrideExplicitBatchSize() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings { BatchSize = 42 }; + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + var request = RequestMessage.Build("g.V()").AddG("g").AddBatchSize(100).Create(); + await connection.SubmitAsync(request); + + // A per-request explicit batchSize always wins, on the caller object and the wire. + Assert.Equal(100, request.Fields[Tokens.ArgsBatchSize]); + Assert.Equal(100, await ReadBatchSizeFromBodyAsync(handler)); + } + + [Fact] + public async Task ShouldUseDefaultBatchSizeOf64ByDefault() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings(); + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + var request = CreateTestRequest(); + await connection.SubmitAsync(request); + + Assert.False(request.Fields.ContainsKey(Tokens.ArgsBatchSize)); + Assert.Equal(64, await ReadBatchSizeFromBodyAsync(handler)); + } + + [Fact] + public async Task ShouldNotPersistDefaultBatchSizeAcrossResubmissions() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings { BatchSize = 42 }; + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + // Resubmitting the same message must not carry over a previously injected default. + var request = CreateTestRequest(); + await connection.SubmitAsync(request); + await connection.SubmitAsync(request); + + Assert.False(request.Fields.ContainsKey(Tokens.ArgsBatchSize)); + Assert.Equal(42, await ReadBatchSizeFromBodyAsync(handler)); + } + + /// + /// Reads the serialized JSON request body captured by the mock handler and returns + /// the batchSize field value, or null when it is absent. + /// + private static async Task ReadBatchSizeFromBodyAsync(MockHandler handler) + { + Assert.NotNull(handler.CapturedRequest); + var bodyBytes = await handler.CapturedRequest!.Content!.ReadAsByteArrayAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(bodyBytes); + if (doc.RootElement.TryGetProperty(Tokens.ArgsBatchSize, out var batchSizeProp)) + { + return batchSizeProp.GetInt32(); + } + return null; + } + + [Fact] + public async Task ShouldTimeOutSlowReadWhenReadTimeoutSet() + { + // A response stream that blocks indefinitely on read should trigger the + // per-read idle timeout once it is consumed during deserialization. + var blockingStream = new BlockingStream(); + var handler = new StreamMockHandler(blockingStream); + var httpClient = new HttpClient(handler); + // The serializer actually reads from the stream so the read timeout can fire. + var serializer = CreateReadingSerializer(); + var settings = new ConnectionSettings + { + ReadTimeout = TimeSpan.FromMilliseconds(100) + }; + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + var result = await connection.SubmitAsync(CreateTestRequest()); + + // The background streaming task surfaces the timeout as a faulted enumeration. + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var _ in result) { } + }); + } + + private static IMessageSerializer CreateReadingSerializer( + string mimeType = SerializationTokens.GraphBinary4MimeType) + { + var serializer = Substitute.For(); + serializer.MimeType.Returns(mimeType); + serializer.DeserializeMessageAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => ReadAllAsync((Stream)callInfo[0], (CancellationToken)callInfo[1])); + return serializer; + } + + private static async IAsyncEnumerable ReadAllAsync(Stream stream, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var buffer = new byte[16]; + while (await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false) > 0) + { + yield return new object(); + } + } + + /// + /// A stream whose ReadAsync never completes until cancelled, used to exercise the + /// per-read timeout. + /// + private sealed class BlockingStream : Stream + { + public override async ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + return 0; + } + + public override int Read(byte[] buffer, int offset, int count) + { + Thread.Sleep(Timeout.Infinite); + return 0; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => 0; set { } } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + /// /// A test HttpMessageHandler that captures the request and returns a canned response. /// The response uses ByteArrayContent but does NOT dispose the content stream diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs index 58442eb7d12..83518bc3b49 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs @@ -51,7 +51,7 @@ public void ShouldCreateClientWithCustomSettings() { var settings = new ConnectionSettings { - EnableCompression = true, + Compression = Compression.Deflate, BulkResults = true }; using var client = new GremlinClient(new GremlinServer(), connectionSettings: settings); diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs index add13d169f6..35e6113b80d 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs @@ -21,6 +21,7 @@ #endregion +using System; using Gremlin.Net.Driver; using Xunit; @@ -80,5 +81,140 @@ public void ShouldUseDefaultGremlinPath() Assert.Equal("/gremlin", gremlinServer.Uri.AbsolutePath); } + + [Fact] + public void ShouldBuildFromHttpUrl() + { + var gremlinServer = GremlinServer.FromUrl("http://example.com:8182/gremlin"); + + var uri = gremlinServer.Uri; + + Assert.Equal("http", uri.Scheme); + Assert.Equal("example.com", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldBuildFromHttpsUrl() + { + var gremlinServer = GremlinServer.FromUrl("https://example.com:8182/gremlin"); + + var uri = gremlinServer.Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("example.com", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldBuildFromUrlWithCustomPath() + { + var gremlinServer = GremlinServer.FromUrl("https://example.com:8182/custom/path"); + + var uri = gremlinServer.Uri; + + Assert.Equal("/custom/path", uri.AbsolutePath); + Assert.Equal("https://example.com:8182/custom/path", uri.AbsoluteUri); + } + + [Theory] + [InlineData("http://example.com:8182/gremlin", "example.com", 8182)] + [InlineData("https://1.2.3.4:5678/gremlin", "1.2.3.4", 5678)] + public void ShouldExtractHostAndPortFromUrl(string url, string expectedHost, int expectedPort) + { + var gremlinServer = GremlinServer.FromUrl(url); + + Assert.Equal(expectedHost, gremlinServer.Uri.Host); + Assert.Equal(expectedPort, gremlinServer.Uri.Port); + } + + [Fact] + public void ShouldParseSecureUrlWithExplicitPortAndPath() + { + var uri = GremlinServer.FromUrl("https://h:1234/p").Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("h", uri.Host); + Assert.Equal(1234, uri.Port); + Assert.Equal("/p", uri.AbsolutePath); + } + + [Fact] + public void ShouldParseInsecureUrlWithExplicitPortAndPath() + { + var uri = GremlinServer.FromUrl("http://h:1234/p").Uri; + + Assert.Equal("http", uri.Scheme); + Assert.Equal("h", uri.Host); + Assert.Equal(1234, uri.Port); + Assert.Equal("/p", uri.AbsolutePath); + } + + [Fact] + public void ShouldDefaultPortWhenUrlOmitsPort() + { + var uri = GremlinServer.FromUrl("https://host/gremlin").Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("host", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldDefaultPathWhenUrlOmitsPath() + { + var uri = GremlinServer.FromUrl("https://host:8182").Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("host", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldDefaultPortAndPathWhenUrlOmitsBoth() + { + var uri = GremlinServer.FromUrl("https://host").Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("host", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldRejectUnsupportedSchemeForUrl() + { + Assert.Throws(() => GremlinServer.FromUrl("ws://example.com:8182/gremlin")); + } + + [Fact] + public void ShouldRejectNonAbsoluteUrl() + { + Assert.Throws(() => GremlinServer.FromUrl("example.com:8182/gremlin")); + } + + [Fact] + public void ShouldRejectNullUrl() + { + Assert.Throws(() => GremlinServer.FromUrl(null!)); + } + + [Fact] + public void ShouldBuildFromUri() + { + var gremlinServer = new GremlinServer(new Uri("https://example.com:8182/gremlin")); + + Assert.Equal("https://example.com:8182/gremlin", gremlinServer.Uri.AbsoluteUri); + } + + [Fact] + public void ShouldRejectUriWithUnsupportedScheme() + { + Assert.Throws(() => new GremlinServer(new Uri("ftp://example.com:8182/gremlin"))); + } } }