diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3de62d..baace2d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Weaviate Cloud Helpers Rejected the Cluster URL** — `Connect.Cloud`, `WeaviateClientBuilder.Cloud` and the endpoint overloads of `AddWeaviateCloud` used the endpoint verbatim as the host, so the cluster URL the Weaviate Cloud console shows (`https://my-cluster.weaviate.cloud`) failed with `UriFormatException`. They now accept that URL or a bare hostname and keep only the host, always connecting over TLS on port 443, and throw `ArgumentException` for any other scheme, embedded credentials, a port other than 443 or an invalid hostname (the DI overloads at registration). + --- ## [1.2.0] — 2026-08-21 diff --git a/src/Weaviate.Client.Tests/Unit/TestCloudEndpoint.cs b/src/Weaviate.Client.Tests/Unit/TestCloudEndpoint.cs new file mode 100644 index 00000000..dae4514a --- /dev/null +++ b/src/Weaviate.Client.Tests/Unit/TestCloudEndpoint.cs @@ -0,0 +1,311 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Weaviate.Client.DependencyInjection; + +namespace Weaviate.Client.Tests.Unit; + +/// +/// Unit tests verifying that the Weaviate Cloud connection helpers accept a full cluster URL or a +/// bare hostname, derive the REST and gRPC endpoints from its host, and reject malformed endpoints. +/// +[Collection("Unit Tests")] +public class TestCloudEndpoint +{ + /// + /// Accepted cluster endpoints and the host each one must resolve to + /// + public static TheoryData AcceptedEndpoints => + new() + { + { "https://xyz.weaviate.cloud", "xyz.weaviate.cloud" }, + { "xyz.weaviate.cloud", "xyz.weaviate.cloud" }, + { "https://xyz.weaviate.cloud/", "xyz.weaviate.cloud" }, + { "https://xyz.weaviate.cloud/v1", "xyz.weaviate.cloud" }, + { "http://xyz.weaviate.cloud", "xyz.weaviate.cloud" }, + { "https://xyz.weaviate.cloud:443", "xyz.weaviate.cloud" }, + { " https://xyz.weaviate.cloud/v1?x=1#top ", "xyz.weaviate.cloud" }, + { "HTTPS://XYZ.Weaviate.Cloud", "xyz.weaviate.cloud" }, + { "xyz.weaviate.cloud:443", "xyz.weaviate.cloud" }, + { "xyz.weaviate.cloud/a://b", "xyz.weaviate.cloud" }, + { "https://https.weaviate.cloud", "https.weaviate.cloud" }, + }; + + /// + /// Cluster endpoints that must be rejected, each with a fragment of it that the exception + /// message must not echo (null where the input has nothing distinctive) + /// + public static TheoryData RejectedEndpoints => + new() + { + { null, null }, + { "", null }, + { " ", null }, + { "https://", null }, + { "ftp://xyz.weaviate.cloud", "ftp" }, + { "sk-SECRET123://xyz.weaviate.cloud", "SECRET123" }, + { "https://user:pw@xyz.weaviate.cloud", "pw" }, + { "https://xyz.weaviate.cloud:8080", "8080" }, + { "xyz.weaviate.cloud:8080", "8080" }, + { "http://xyz.weaviate.cloud:80", ":80" }, + { "https://10.0.0.1", "10.0.0.1" }, + { "https://https://xyz.weaviate.cloud", "xyz" }, + { "https:/xyz.weaviate.cloud", "xyz" }, + { "https//xyz.weaviate.cloud", "xyz" }, + { "not a host!", "not a host" }, + }; + + /// + /// Tests that AddWeaviateCloud keeps only the host and always uses TLS on port 443 + /// + [Theory] + [MemberData(nameof(AcceptedEndpoints))] + public void AddWeaviateCloud_WithUrlOrHostname_UsesClusterHost( + string endpoint, + string expectedHost + ) + { + using var provider = new ServiceCollection() + .AddWeaviateCloud(endpoint, apiKey: "key", eagerInitialization: false) + .BuildServiceProvider(); + + AssertCloudOptions( + provider.GetRequiredService>().Value, + expectedHost + ); + AssertCloudUris(provider.GetRequiredService(), expectedHost); + } + + /// + /// Tests that AddWeaviateCloud rejects a malformed endpoint when it is registered, not when + /// the client is first resolved + /// + [Theory] + [MemberData(nameof(RejectedEndpoints))] + public void AddWeaviateCloud_WithInvalidEndpoint_ThrowsAtRegistration( + string? endpoint, + string? mustNotEcho + ) + { + var services = new ServiceCollection(); + + var ex = Assert.Throws(() => + services.AddWeaviateCloud(endpoint!, eagerInitialization: false) + ); + + AssertRejected(ex, "clusterEndpoint", mustNotEcho); + } + + /// + /// Tests that the scoped token service overload of AddWeaviateCloud keeps only the host + /// + [Fact] + public void AddWeaviateCloudWithTokenService_WithUrl_UsesClusterHost() + { + using var provider = new ServiceCollection() + .AddWeaviateCloud("https://xyz.weaviate.cloud/") + .BuildServiceProvider(); + + AssertCloudOptions( + provider.GetRequiredService>().Value, + "xyz.weaviate.cloud" + ); + AssertCloudUris(provider.GetRequiredService(), "xyz.weaviate.cloud"); + } + + /// + /// Tests that the scoped token service overload of AddWeaviateCloud rejects a malformed endpoint + /// at registration + /// + [Fact] + public void AddWeaviateCloudWithTokenService_WithInvalidEndpoint_ThrowsAtRegistration() + { + var services = new ServiceCollection(); + + var ex = Assert.Throws(() => + services.AddWeaviateCloud("https://xyz.weaviate.cloud:8080") + ); + + AssertRejected(ex, "clusterEndpoint", "8080"); + } + + /// + /// Tests that the named client overload of AddWeaviateCloud keeps only the host + /// + [Fact] + public void AddWeaviateCloudNamed_WithUrl_UsesClusterHost() + { + using var provider = new ServiceCollection() + .AddWeaviateCloud( + name: "prod", + clusterEndpoint: "https://xyz.weaviate.cloud/", + apiKey: "key" + ) + .BuildServiceProvider(); + + AssertCloudOptions( + provider.GetRequiredService>().Get("prod"), + "xyz.weaviate.cloud" + ); + } + + /// + /// Tests that the named client overload of AddWeaviateCloud rejects a malformed endpoint at + /// registration + /// + [Fact] + public void AddWeaviateCloudNamed_WithInvalidEndpoint_ThrowsAtRegistration() + { + var services = new ServiceCollection(); + + var ex = Assert.Throws(() => + services.AddWeaviateCloud(name: "prod", clusterEndpoint: "ftp://xyz.weaviate.cloud") + ); + + AssertRejected(ex, "clusterEndpoint", "ftp"); + } + + /// + /// Tests that a client built with WeaviateClientBuilder.Cloud sends its first REST request to + /// the cluster host over TLS on port 443 + /// + [Theory] + [MemberData(nameof(AcceptedEndpoints))] + public async Task Cloud_WithUrlOrHostname_SendsRequestsToClusterHost( + string endpoint, + string expectedHost + ) + { + var handler = new CaptureFirstRequestHandler(); + + await Assert.ThrowsAsync(() => + WeaviateClientBuilder.Cloud(endpoint, httpMessageHandler: handler).BuildAsync() + ); + + AssertCloudRequest(handler.RequestUri, expectedHost); + } + + /// + /// Tests that WeaviateClientBuilder.Cloud rejects a malformed endpoint before any request is made + /// + [Theory] + [MemberData(nameof(RejectedEndpoints))] + public void Cloud_WithInvalidEndpoint_Throws(string? endpoint, string? mustNotEcho) + { + var ex = Assert.Throws(() => + { + WeaviateClientBuilder.Cloud(endpoint!); + }); + + AssertRejected(ex, "restEndpoint", mustNotEcho); + } + + /// + /// Tests that Connect.Cloud sends its first REST request to the cluster host + /// + [Fact] + public async Task ConnectCloud_WithUrl_SendsRequestsToClusterHost() + { + var handler = new CaptureFirstRequestHandler(); + + await Assert.ThrowsAsync(() => + Connect.Cloud("https://xyz.weaviate.cloud/", httpMessageHandler: handler) + ); + + AssertCloudRequest(handler.RequestUri, "xyz.weaviate.cloud"); + } + + /// + /// Tests that Connect.Cloud rejects a malformed endpoint + /// + [Fact] + public async Task ConnectCloud_WithInvalidEndpoint_Throws() + { + var ex = await Assert.ThrowsAsync(() => + Connect.Cloud("https://user:pw@xyz.weaviate.cloud") + ); + + AssertRejected(ex, "restEndpoint", "pw"); + } + + /// + /// Asserts that a rejection names the parameter and the expected forms, and does not echo the input + /// + private static void AssertRejected(ArgumentException ex, string paramName, string? mustNotEcho) + { + Assert.Equal(paramName, ex.ParamName); + Assert.Contains("'https://my-cluster.weaviate.cloud'", ex.Message); + if (mustNotEcho is not null) + { + Assert.DoesNotContain(mustNotEcho, ex.Message); + } + } + + /// + /// Asserts the options registered for a Weaviate Cloud client + /// + private static void AssertCloudOptions(WeaviateOptions options, string expectedHost) + { + Assert.Equal(expectedHost, options.RestEndpoint); + Assert.Equal($"grpc-{expectedHost}", options.GrpcEndpoint); + Assert.Equal((ushort)443, options.RestPort); + Assert.Equal((ushort)443, options.GrpcPort); + Assert.True(options.UseSsl); + } + + /// + /// Asserts the REST and gRPC URIs of a client resolved for Weaviate Cloud + /// + private static void AssertCloudUris(WeaviateClient client, string expectedHost) + { + Assert.Equal($"https://{expectedHost}/v1/", client.Configuration.RestUri.AbsoluteUri); + Assert.Equal($"https://grpc-{expectedHost}/", client.Configuration.GrpcUri.AbsoluteUri); + } + + /// + /// Asserts the URI of the first REST request a Weaviate Cloud client sends + /// + private static void AssertCloudRequest(Uri? requestUri, string expectedHost) + { + Assert.NotNull(requestUri); + Assert.Equal("https", requestUri.Scheme); + Assert.Equal(expectedHost, requestUri.Host); + Assert.Equal(443, requestUri.Port); + Assert.Equal("/v1/meta", requestUri.AbsolutePath); + } + + /// + /// Records the first request and stops client initialization before any network call + /// + private sealed class CaptureFirstRequestHandler : HttpMessageHandler + { + public Uri? RequestUri { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + RequestUri ??= request.RequestUri; + throw new RequestCapturedException(); + } + } + + /// + /// Thrown by once the request is recorded + /// + private sealed class RequestCapturedException : Exception; + + /// + /// A token service that is never called by these tests + /// + private sealed class StubTokenService : ITokenService + { + public Task GetAccessTokenAsync(CancellationToken cancellationToken = default) => + Task.FromResult("token"); + + public Task RefreshTokenAsync(CancellationToken cancellationToken = default) => + Task.FromResult(true); + + public bool IsAuthenticated() => true; + } +} diff --git a/src/Weaviate.Client/ConnectionHelpers.cs b/src/Weaviate.Client/ConnectionHelpers.cs index fd939804..366c3685 100644 --- a/src/Weaviate.Client/ConnectionHelpers.cs +++ b/src/Weaviate.Client/ConnectionHelpers.cs @@ -51,6 +51,19 @@ public static Task Local( /// /// Creates a WeaviateClient connecting to Weaviate Cloud. /// + /// The cluster URL (e.g. https://my-cluster.weaviate.cloud) or its + /// bare hostname (e.g. my-cluster.weaviate.cloud). Only the host is used: the scheme, including + /// http://, and any path are ignored, because Weaviate Cloud always uses TLS on port 443. + /// API key for authentication. + /// Additional HTTP headers to include in requests. + /// Optional HTTP message handler for REST requests. + /// Default timeout for all operations. + /// Timeout for initialization operations. + /// Timeout for data operations. + /// Timeout for query operations. + /// is empty, uses a scheme other + /// than http or https, contains user credentials, specifies a port other than 443, or does not have a + /// valid DNS hostname. public static Task Cloud( string restEndpoint, string? apiKey = null, diff --git a/src/Weaviate.Client/DependencyInjection/WeaviateServiceCollectionExtensions.cs b/src/Weaviate.Client/DependencyInjection/WeaviateServiceCollectionExtensions.cs index 8ff9adc5..d58dc7ca 100644 --- a/src/Weaviate.Client/DependencyInjection/WeaviateServiceCollectionExtensions.cs +++ b/src/Weaviate.Client/DependencyInjection/WeaviateServiceCollectionExtensions.cs @@ -154,7 +154,9 @@ public static IServiceCollection AddWeaviateLocal( /// Similar to Connect.Cloud() but for dependency injection. /// /// The service collection. - /// The Weaviate Cloud cluster endpoint (e.g., "my-cluster.weaviate.cloud"). + /// The cluster URL (e.g. "https://my-cluster.weaviate.cloud") or its bare + /// hostname (e.g. "my-cluster.weaviate.cloud"). Only the host is used: the scheme, including http://, + /// and any path are ignored, because Weaviate Cloud always uses TLS on port 443. /// API key for authentication. /// Additional HTTP headers to include in requests. /// Default timeout for all operations. @@ -163,6 +165,9 @@ public static IServiceCollection AddWeaviateLocal( /// Timeout for query operations. /// Whether to initialize the client eagerly on application startup. Default is true. /// The service collection for method chaining. + /// is empty, uses a scheme other + /// than http or https, contains user credentials, specifies a port other than 443, or does not have a + /// valid DNS hostname. public static IServiceCollection AddWeaviateCloud( this IServiceCollection services, string clusterEndpoint, @@ -175,11 +180,13 @@ public static IServiceCollection AddWeaviateCloud( bool eagerInitialization = true ) { + var host = Internal.CloudEndpoint.NormalizeHost(clusterEndpoint, nameof(clusterEndpoint)); + services.AddWeaviate( options => { - options.RestEndpoint = clusterEndpoint; - options.GrpcEndpoint = $"grpc-{clusterEndpoint}"; + options.RestEndpoint = host; + options.GrpcEndpoint = $"grpc-{host}"; options.RestPort = 443; options.GrpcPort = 443; options.UseSsl = true; @@ -250,10 +257,15 @@ public static IServiceCollection AddWeaviate( /// /// A scoped implementation. /// The service collection. - /// The Weaviate Cloud cluster endpoint (e.g. "my-cluster.weaviate.cloud"). + /// The cluster URL (e.g. "https://my-cluster.weaviate.cloud") or its bare + /// hostname (e.g. "my-cluster.weaviate.cloud"). Only the host is used: the scheme, including http://, + /// and any path are ignored, because Weaviate Cloud always uses TLS on port 443. /// Whether to initialize eagerly on startup. Defaults to false /// because the token service may depend on request context unavailable at startup. /// The service collection for method chaining. + /// is empty, uses a scheme other + /// than http or https, contains user credentials, specifies a port other than 443, or does not have a + /// valid DNS hostname. public static IServiceCollection AddWeaviateCloud( this IServiceCollection services, string clusterEndpoint, @@ -261,11 +273,13 @@ public static IServiceCollection AddWeaviateCloud( ) where TTokenService : class, ITokenService { + var host = Internal.CloudEndpoint.NormalizeHost(clusterEndpoint, nameof(clusterEndpoint)); + return services.AddWeaviate( options => { - options.RestEndpoint = clusterEndpoint; - options.GrpcEndpoint = $"grpc-{clusterEndpoint}"; + options.RestEndpoint = host; + options.GrpcEndpoint = $"grpc-{host}"; options.RestPort = 443; options.GrpcPort = 443; options.UseSsl = true; @@ -385,7 +399,9 @@ Action configureOptions /// /// The service collection. /// The logical name of the client. - /// The Weaviate Cloud cluster endpoint (e.g., "my-cluster.weaviate.cloud"). + /// The cluster URL (e.g. "https://my-cluster.weaviate.cloud") or its bare + /// hostname (e.g. "my-cluster.weaviate.cloud"). Only the host is used: the scheme, including http://, + /// and any path are ignored, because Weaviate Cloud always uses TLS on port 443. /// API key for authentication. /// Additional HTTP headers to include in requests. /// Default timeout for all operations. @@ -393,6 +409,9 @@ Action configureOptions /// Timeout for data operations. /// Timeout for query operations. /// The service collection for method chaining. + /// is empty, uses a scheme other + /// than http or https, contains user credentials, specifies a port other than 443, or does not have a + /// valid DNS hostname. public static IServiceCollection AddWeaviateCloud( this IServiceCollection services, string name, @@ -405,12 +424,14 @@ public static IServiceCollection AddWeaviateCloud( TimeSpan? queryTimeout = null ) { + var host = Internal.CloudEndpoint.NormalizeHost(clusterEndpoint, nameof(clusterEndpoint)); + return services.AddWeaviateClient( name, options => { - options.RestEndpoint = clusterEndpoint; - options.GrpcEndpoint = $"grpc-{clusterEndpoint}"; + options.RestEndpoint = host; + options.GrpcEndpoint = $"grpc-{host}"; options.RestPort = 443; options.GrpcPort = 443; options.UseSsl = true; diff --git a/src/Weaviate.Client/Internal/CloudEndpoint.cs b/src/Weaviate.Client/Internal/CloudEndpoint.cs new file mode 100644 index 00000000..26982c37 --- /dev/null +++ b/src/Weaviate.Client/Internal/CloudEndpoint.cs @@ -0,0 +1,81 @@ +namespace Weaviate.Client.Internal; + +/// +/// Normalizes the cluster endpoint passed to the Weaviate Cloud connection helpers. +/// +internal static class CloudEndpoint +{ + private const string ExpectedForms = + "Expected a Weaviate Cloud cluster URL such as 'https://my-cluster.weaviate.cloud' or a bare hostname such as 'my-cluster.weaviate.cloud'."; + + /// + /// Returns the host of a Weaviate Cloud cluster URL or bare hostname. The scheme, path, query + /// and fragment are dropped, because Weaviate Cloud is always reached over TLS on port 443. + /// + /// A cluster URL such as https://my-cluster.weaviate.cloud, or a bare hostname. + /// The caller's parameter name, reported in the exception. + /// The bare, lowercased host, e.g. my-cluster.weaviate.cloud. + /// + /// The endpoint is empty, uses a scheme other than http or https, contains user credentials, + /// specifies a port other than 443, or does not have a valid DNS hostname. + /// + internal static string NormalizeHost(string? endpoint, string paramName) + { + var value = endpoint?.Trim(); + if (string.IsNullOrEmpty(value)) + throw Invalid("The Weaviate Cloud cluster endpoint is empty.", paramName); + + var hostAndRest = value; + var schemeEnd = value.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd > 0 && Uri.CheckSchemeName(value[..schemeEnd])) + { + var scheme = value[..schemeEnd]; + if ( + !scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && !scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + ) + { + throw Invalid("Only the http and https schemes are supported.", paramName); + } + hostAndRest = value[(schemeEnd + 3)..]; + } + + // Parsed as https whatever the given scheme, so an absent port means 443, never 80. + if (!Uri.TryCreate("https://" + hostAndRest, UriKind.Absolute, out var uri)) + throw Invalid( + "The Weaviate Cloud cluster endpoint is not a valid URL or hostname.", + paramName + ); + + if (uri.UserInfo.Length > 0) + throw Invalid( + "The Weaviate Cloud cluster endpoint must not contain user credentials; pass the API key separately.", + paramName + ); + + if (uri.Port != 443) + throw Invalid( + "Weaviate Cloud is served on port 443; other ports are not supported.", + paramName + ); + + if (Uri.CheckHostName(uri.Host) != UriHostNameType.Dns) + throw Invalid( + "The Weaviate Cloud cluster endpoint does not have a valid DNS hostname.", + paramName + ); + + // A mistyped scheme ("https:/x", "https://https://x") parses as the host "https". + if (uri.Host is "http" or "https") + throw Invalid( + "The Weaviate Cloud cluster endpoint is not a valid URL or hostname.", + paramName + ); + + return uri.Host; + } + + // Never echo the input: it may be a misplaced API key or carry a password. + private static ArgumentException Invalid(string reason, string paramName) => + new($"{reason} {ExpectedForms}", paramName); +} diff --git a/src/Weaviate.Client/WeaviateClientBuilder.cs b/src/Weaviate.Client/WeaviateClientBuilder.cs index 766d7c48..b546b5da 100644 --- a/src/Weaviate.Client/WeaviateClientBuilder.cs +++ b/src/Weaviate.Client/WeaviateClientBuilder.cs @@ -178,26 +178,35 @@ public static WeaviateClientBuilder Local( /// /// Clouds the rest endpoint /// - /// The rest endpoint + /// The cluster URL (e.g. https://my-cluster.weaviate.cloud) or its + /// bare hostname (e.g. my-cluster.weaviate.cloud). Only the host is used: the scheme, including + /// http://, and any path are ignored, because Weaviate Cloud always uses TLS on port 443. /// The api key /// The headers /// The http message handler /// The weaviate client builder + /// is empty, uses a scheme other + /// than http or https, contains user credentials, specifies a port other than 443, or does not have a + /// valid DNS hostname. public static WeaviateClientBuilder Cloud( string restEndpoint, string? apiKey = null, Dictionary? headers = null, HttpMessageHandler? httpMessageHandler = null - ) => - new WeaviateClientBuilder() - .WithRestEndpoint(restEndpoint) - .WithGrpcEndpoint($"grpc-{restEndpoint}") + ) + { + var host = Internal.CloudEndpoint.NormalizeHost(restEndpoint, nameof(restEndpoint)); + + return new WeaviateClientBuilder() + .WithRestEndpoint(host) + .WithGrpcEndpoint($"grpc-{host}") .WithRestPort(443) .WithGrpcPort(443) .UseSsl(true) .WithCredentials(string.IsNullOrEmpty(apiKey) ? null : Auth.ApiKey(apiKey)) .WithHeaders(headers) .WithHttpMessageHandler(httpMessageHandler); + } /// /// Adds the rest endpoint using the specified endpoint