diff --git a/README.md b/README.md index ba8dc923..5bc4b594 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ - [Get Task information](#get-task-information) - [Search](#search) - [🧰 Use a Custom HTTP Client](#-use-a-custom-http-client) +- [🗜️ Request Compression](#️-request-compression) - [⚙️ Contributing](#️-contributing) ## 📖 Documentation @@ -388,6 +389,73 @@ var client = new MeilisearchClient(_httpClient); Where `ClientFactory` is declared [like this](/tests/Meilisearch.Tests/ClientFactory.cs). +## 🗜️ Request Compression + +The SDK supports automatic HTTP request compression to reduce bandwidth usage and improve performance when sending large document payloads. This is especially beneficial for document upload operations. + +### Supported Algorithms + +- **Gzip** (recommended) - Widely supported, good compression ratio, works with .NET Standard 2.0+ +- **Deflate** - Alternative to Gzip (requires .NET 6.0+ for ZLibStream support) +- **Brotli** - Best compression ratio (requires .NET Standard 2.1+ / .NET Core 2.1+) + +> **Note:** This SDK targets .NET Standard 2.0, so only **Gzip** is available by default. If your project targets .NET 6.0+ or .NET Standard 2.1+, Deflate and Brotli will also be available. + +### Basic Usage + +Enable compression by passing `CompressionOptions` when creating the client: + +```c# +using Meilisearch; + +// Enable Gzip compression with default settings (1400 byte threshold) +var client = new MeilisearchClient( + "http://localhost:7700", + "masterKey", + CompressionOptions.Gzip()); + +// All document operations automatically use compression +var index = client.Index("movies"); +await index.AddDocumentsAsync(largeDocumentCollection); +``` + +### Custom Configuration + +You can customize compression behavior: + +```c# +var compressionOptions = new CompressionOptions +{ + Algorithm = CompressionAlgorithm.Gzip, + MinimumSizeBytes = 1024, // Only compress payloads >= 1KB + EnableResponseDecompression = true // Request compressed responses from server +}; + +var client = new MeilisearchClient( + "http://localhost:7700", + "masterKey", + compressionOptions); +``` + +> **Note:** The `EnableResponseDecompression` option only works when using the default `MeilisearchClient` constructor. If you provide a custom `HttpClient`, you must configure `HttpClientHandler.AutomaticDecompression` yourself. + +### When to Use Compression + +Compression is beneficial when: +- Sending large document collections (hundreds to thousands of documents) +- Working with documents containing large text fields +- Operating on limited bandwidth connections +- Performing batch operations with significant payload sizes + +The default threshold of 1400 bytes ensures compression only applies when beneficial, avoiding unnecessary overhead for small payloads. + +### Performance Notes + +- Compression adds minimal CPU overhead (typically <5ms for MB-sized payloads) +- Network transfer time reduction often exceeds compression overhead +- Most effective with text-heavy documents (JSON, CSV, NDJSON) +- The 1400-byte default threshold aligns with TCP packet sizes for optimal performance + ## ⚙️ Contributing Any new contribution is more than welcome in this project! diff --git a/src/Meilisearch/Compression/CompressionHelper.cs b/src/Meilisearch/Compression/CompressionHelper.cs new file mode 100644 index 00000000..fc6efa8e --- /dev/null +++ b/src/Meilisearch/Compression/CompressionHelper.cs @@ -0,0 +1,221 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Meilisearch.Compression +{ + /// + /// Helper class for compressing HTTP request content. + /// + internal static class CompressionHelper + { + /// + /// Wraps existing HttpContent with compression if applicable. + /// + /// Original HTTP content. + /// Compression options. + /// Compressed content or original if compression not applicable. + internal static async Task CompressAsync(HttpContent content, CompressionOptions options) + { + if (!ShouldCompress(content, options)) + { + return content; + } + + var originalBytes = await content.ReadAsByteArrayAsync().ConfigureAwait(false); + + if (!MeetsSizeThreshold(originalBytes, options)) + { + // Content stream was already consumed; reconstruct it with original headers + var reconstructedContent = new ByteArrayContent(originalBytes); + foreach (var header in content.Headers) + { + reconstructedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + return reconstructedContent; + } + + var compressedBytes = CompressData(originalBytes, options.Algorithm); + return CreateCompressedContent(compressedBytes, content, options.Algorithm); + } + + /// + /// Determines whether content should be compressed. + /// + private static bool ShouldCompress(HttpContent content, CompressionOptions options) + { + return content != null && options?.Algorithm != CompressionAlgorithm.None; + } + + /// + /// Checks if the data size meets the minimum threshold for compression. + /// + private static bool MeetsSizeThreshold(byte[] data, CompressionOptions options) + { + return data.Length >= options.MinimumSizeBytes; + } + + /// + /// Compresses data using the specified algorithm. + /// + private static byte[] CompressData(byte[] data, CompressionAlgorithm algorithm) + { + switch (algorithm) + { + case CompressionAlgorithm.Gzip: + return CompressWithGzip(data); + + case CompressionAlgorithm.Deflate: + return CompressWithDeflate(data); + + case CompressionAlgorithm.Brotli: + return CompressWithBrotli(data); + + default: + throw new ArgumentException($"Unsupported compression algorithm: {algorithm}", nameof(algorithm)); + } + } + + /// + /// Creates new HttpContent with compressed data and appropriate headers. + /// + private static HttpContent CreateCompressedContent(byte[] compressedBytes, HttpContent originalContent, CompressionAlgorithm algorithm) + { + var compressedContent = new ByteArrayContent(compressedBytes); + + // Copy headers from original content, excluding Content-Encoding and Content-Length + // as these will be set explicitly for the compressed content + foreach (var header in originalContent.Headers) + { + if (header.Key != "Content-Encoding" && header.Key != "Content-Length") + { + compressedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + // Set Content-Encoding header + var contentEncoding = GetContentEncoding(algorithm); + compressedContent.Headers.ContentEncoding.Add(contentEncoding); + + // Set Content-Length + compressedContent.Headers.ContentLength = compressedBytes.Length; + + return compressedContent; + } + + /// + /// Compresses data using Gzip algorithm. + /// + private static byte[] CompressWithGzip(byte[] data) + { + using (var outputStream = new MemoryStream()) + { + using (var gzipStream = new GZipStream(outputStream, CompressionLevel.Fastest, leaveOpen: true)) + { + gzipStream.Write(data, 0, data.Length); + gzipStream.Flush(); + } + return outputStream.ToArray(); + } + } + + /// + /// Compresses data using Deflate algorithm with zlib wrapper. + /// Meilisearch expects zlib format (RFC 1950), not raw deflate (RFC 1951). + /// ZLibStream is only available in .NET 6.0+ + /// + private static byte[] CompressWithDeflate(byte[] data) + { +#if NET6_0_OR_GREATER + using (var outputStream = new MemoryStream()) + { + using (var zlibStream = new ZLibStream(outputStream, CompressionLevel.Fastest, leaveOpen: true)) + { + zlibStream.Write(data, 0, data.Length); + zlibStream.Flush(); + } + return outputStream.ToArray(); + } +#else + throw new NotSupportedException( + "Deflate compression requires .NET 6.0+ for ZLibStream support. " + + "Current target framework is .NET Standard 2.0. " + + "DeflateStream produces raw deflate (RFC 1951), but Meilisearch expects zlib format (RFC 1950). " + + "Please use Gzip compression instead, or target .NET 6.0+ in your project."); +#endif + } + + /// + /// Compresses data using Brotli algorithm. + /// Only available in .NET Standard 2.1+ / .NET Core 2.1+ + /// + private static byte[] CompressWithBrotli(byte[] data) + { +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_1_OR_GREATER + using (var outputStream = new MemoryStream()) + { + using (var brotliStream = new BrotliStream(outputStream, CompressionLevel.Fastest, leaveOpen: true)) + { + brotliStream.Write(data, 0, data.Length); + brotliStream.Flush(); + } + return outputStream.ToArray(); + } +#else + throw new NotSupportedException( + "Brotli compression requires .NET Standard 2.1+ or .NET Core 2.1+. " + + "Current target framework is .NET Standard 2.0. " + + "Please use Gzip or Deflate compression instead."); +#endif + } + + /// + /// Gets the Content-Encoding header value for a given algorithm. + /// + internal static string GetContentEncoding(CompressionAlgorithm algorithm) + { + switch (algorithm) + { + case CompressionAlgorithm.Gzip: + return "gzip"; + case CompressionAlgorithm.Deflate: + return "deflate"; + case CompressionAlgorithm.Brotli: + return "br"; + default: + return null; + } + } + + /// + /// Checks if the compression algorithm is supported in the current runtime. + /// + internal static bool IsAlgorithmSupported(CompressionAlgorithm algorithm) + { + switch (algorithm) + { + case CompressionAlgorithm.Gzip: + return true; + + case CompressionAlgorithm.Deflate: +#if NET6_0_OR_GREATER + return true; +#else + return false; +#endif + + case CompressionAlgorithm.Brotli: +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_1_OR_GREATER + return true; +#else + return false; +#endif + + default: + return false; + } + } + } +} diff --git a/src/Meilisearch/CompressionOptions.cs b/src/Meilisearch/CompressionOptions.cs new file mode 100644 index 00000000..8ccf7781 --- /dev/null +++ b/src/Meilisearch/CompressionOptions.cs @@ -0,0 +1,124 @@ +namespace Meilisearch +{ + /// + /// Compression algorithms supported by the SDK. + /// + public enum CompressionAlgorithm + { + /// No compression. + None = 0, + + /// Gzip compression (RFC 1952). + Gzip = 1, + + /// Deflate compression with zlib wrapper (RFC 1950). Requires .NET 6.0+. + Deflate = 2, + + /// Brotli compression (.NET Standard 2.1+ / .NET Core 2.1+). + Brotli = 3 + } + + /// + /// Configuration options for HTTP request compression. + /// + public class CompressionOptions + { + private int _minimumSizeBytes = 1400; + + /// + /// Gets or sets the compression algorithm to use. + /// Default is None (no compression). + /// + public CompressionAlgorithm Algorithm { get; set; } = CompressionAlgorithm.None; + + /// + /// Gets or sets the minimum payload size in bytes before compression is applied. + /// Default is 1400 bytes (1.4 KB). + /// Set to 0 to compress all payloads. + /// + /// Thrown when value is negative. + public int MinimumSizeBytes + { + get => _minimumSizeBytes; + set + { + if (value < 0) + { + throw new System.ArgumentOutOfRangeException(nameof(value), value, "MinimumSizeBytes cannot be negative."); + } + _minimumSizeBytes = value; + } + } + + /// + /// Gets or sets whether to enable automatic decompression of compressed responses from the server. + /// When enabled, the client will request compressed responses and automatically decompress them. + /// Default is false. + /// + /// + /// This option only takes effect when using the default constructor + /// that creates its own HttpClient internally. If you provide a custom HttpClient, you must configure + /// on your HttpClientHandler yourself. + /// + public bool EnableResponseDecompression { get; set; } = false; + + /// + /// Creates default compression options with no compression enabled. + /// + public static CompressionOptions None => new CompressionOptions(); + + /// + /// Creates compression options with Gzip compression enabled. + /// + /// Minimum payload size to compress. Default is 1400 bytes. + /// Compression options configured for Gzip. + public static CompressionOptions Gzip(int minimumSizeBytes = 1400) + { + if (minimumSizeBytes < 0) + { + throw new System.ArgumentOutOfRangeException(nameof(minimumSizeBytes), minimumSizeBytes, "MinimumSizeBytes cannot be negative."); + } + return new CompressionOptions + { + Algorithm = CompressionAlgorithm.Gzip, + MinimumSizeBytes = minimumSizeBytes + }; + } + + /// + /// Creates compression options with Deflate compression enabled. + /// + /// Minimum payload size to compress. Default is 1400 bytes. + /// Compression options configured for Deflate. + public static CompressionOptions Deflate(int minimumSizeBytes = 1400) + { + if (minimumSizeBytes < 0) + { + throw new System.ArgumentOutOfRangeException(nameof(minimumSizeBytes), minimumSizeBytes, "MinimumSizeBytes cannot be negative."); + } + return new CompressionOptions + { + Algorithm = CompressionAlgorithm.Deflate, + MinimumSizeBytes = minimumSizeBytes + }; + } + + /// + /// Creates compression options with Brotli compression enabled. + /// + /// Minimum payload size to compress. Default is 1400 bytes. + /// Compression options configured for Brotli. + public static CompressionOptions Brotli(int minimumSizeBytes = 1400) + { + if (minimumSizeBytes < 0) + { + throw new System.ArgumentOutOfRangeException(nameof(minimumSizeBytes), minimumSizeBytes, "MinimumSizeBytes cannot be negative."); + } + return new CompressionOptions + { + Algorithm = CompressionAlgorithm.Brotli, + MinimumSizeBytes = minimumSizeBytes + }; + } + } +} diff --git a/src/Meilisearch/MeilisearchClient.cs b/src/Meilisearch/MeilisearchClient.cs index c4be414b..3bbc6dc3 100644 --- a/src/Meilisearch/MeilisearchClient.cs +++ b/src/Meilisearch/MeilisearchClient.cs @@ -22,16 +22,43 @@ public class MeilisearchClient private TaskEndpoint _taskEndpoint; public string ApiKey { get; } + /// + /// Gets the compression options for this client. + /// + public CompressionOptions CompressionOptions { get; } + /// /// Initializes a new instance of the class. /// Default client for Meilisearch API. /// /// URL corresponding to Meilisearch server. /// API Key to connect to the Meilisearch server. - public MeilisearchClient(string url, string apiKey = default) : this( - new HttpClient(new MeilisearchMessageHandler(new HttpClientHandler())) { BaseAddress = url.ToSafeUri() }, - apiKey) + /// Compression configuration options. + public MeilisearchClient(string url, string apiKey = default, CompressionOptions compressionOptions = default) : this( + new HttpClient(new MeilisearchMessageHandler(CreateHttpClientHandler(compressionOptions), compressionOptions)) { BaseAddress = url.ToSafeUri() }, + apiKey, + compressionOptions) + { + } + + /// + /// Creates an HttpClientHandler with automatic decompression configured based on compression options. + /// + /// Compression configuration options. + /// Configured HttpClientHandler. + private static HttpClientHandler CreateHttpClientHandler(CompressionOptions compressionOptions) { + var handler = new HttpClientHandler(); + + if (compressionOptions?.EnableResponseDecompression == true) + { + handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_1_OR_GREATER + handler.AutomaticDecompression |= DecompressionMethods.Brotli; +#endif + } + + return handler; } /// @@ -40,13 +67,15 @@ public MeilisearchClient(string url, string apiKey = default) : this( /// /// Injects the reusable HttpClient. /// API Key to connect to the Meilisearch server. Best practice is to use HttpClient default header rather than this parameter. - public MeilisearchClient(HttpClient client, string apiKey = default) + /// Compression configuration options. + public MeilisearchClient(HttpClient client, string apiKey = default, CompressionOptions compressionOptions = default) { client.BaseAddress = client.BaseAddress.OriginalString.ToSafeUri(); _http = client; _http.AddApiKeyToHeader(apiKey); _http.AddDefaultUserAgent(); ApiKey = apiKey; + CompressionOptions = compressionOptions; } /// diff --git a/src/Meilisearch/MeilisearchMessageHandler.cs b/src/Meilisearch/MeilisearchMessageHandler.cs index 0ba19158..02b510d5 100644 --- a/src/Meilisearch/MeilisearchMessageHandler.cs +++ b/src/Meilisearch/MeilisearchMessageHandler.cs @@ -1,8 +1,11 @@ +using System; using System.Net.Http; using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; +using Meilisearch.Compression; + namespace Meilisearch { /// @@ -10,6 +13,7 @@ namespace Meilisearch /// public class MeilisearchMessageHandler : DelegatingHandler { + private readonly CompressionOptions _compressionOptions; /// /// Initializes a new instance of the class. @@ -17,6 +21,7 @@ public class MeilisearchMessageHandler : DelegatingHandler /// public MeilisearchMessageHandler() { + _compressionOptions = null; } /// @@ -27,10 +32,23 @@ public MeilisearchMessageHandler() public MeilisearchMessageHandler(HttpMessageHandler innerHandler) : base(innerHandler) { + _compressionOptions = null; } /// - /// Override SendAsync to handle errors. + /// Initializes a new instance of the class + /// with compression options. + /// + /// InnerHandler. + /// Compression configuration options. + public MeilisearchMessageHandler(HttpMessageHandler innerHandler, CompressionOptions compressionOptions) + : base(innerHandler) + { + _compressionOptions = compressionOptions; + } + + /// + /// Override SendAsync to handle errors and compression. /// /// Request. /// Cancellation Token. @@ -39,6 +57,35 @@ protected async override Task SendAsync(HttpRequestMessage { try { + // Apply compression if enabled and request has content + if (_compressionOptions != null && + _compressionOptions.Algorithm != CompressionAlgorithm.None && + request.Content != null) + { + // Validate algorithm support + if (!CompressionHelper.IsAlgorithmSupported(_compressionOptions.Algorithm)) + { + var message = $"Compression algorithm '{_compressionOptions.Algorithm}' is not supported in the current runtime."; + + if (_compressionOptions.Algorithm == CompressionAlgorithm.Deflate) + { + message += " Deflate requires .NET 6.0+ for ZLibStream support. Please use Gzip instead."; + } + else if (_compressionOptions.Algorithm == CompressionAlgorithm.Brotli) + { + message += " Brotli requires .NET Standard 2.1+ or .NET Core 2.1+."; + } + + throw new NotSupportedException(message); + } + + request.Content = await CompressionHelper.CompressAsync(request.Content, _compressionOptions) + .ConfigureAwait(false); + } + + // Note: Accept-Encoding headers for response decompression are automatically + // added by HttpClientHandler.AutomaticDecompression when enabled + var response = await base.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { diff --git a/tests/Meilisearch.Tests/CompressionTests.cs b/tests/Meilisearch.Tests/CompressionTests.cs new file mode 100644 index 00000000..d8f86ba7 --- /dev/null +++ b/tests/Meilisearch.Tests/CompressionTests.cs @@ -0,0 +1,253 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +using FluentAssertions; + +using Meilisearch.QueryParameters; +using Meilisearch.Tests.ServerConfigs; + +using Xunit; + +namespace Meilisearch.Tests +{ + [Collection(nameof(BaseUriServer))] + public class CompressionTests : IAsyncLifetime + { + private readonly IndexFixture _fixture; + + public CompressionTests(BaseUriServer.ConfigFixture fixture) + { + _fixture = fixture; + } + + public async Task InitializeAsync() => + await _fixture.DeleteAllIndexes(); + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task GzipCompression_ShouldSuccessfullyAddDocuments() + { + // Arrange + var compressionOptions = CompressionOptions.Gzip(minimumSizeBytes: 100); + var client = new MeilisearchClient( + _fixture.MeilisearchAddress(), + "masterKey", + compressionOptions); + + var index = client.Index("movies-gzip-test"); + var movies = Enumerable.Range(1, 100) + .Select(i => new Movie { Id = i.ToString(), Name = $"Movie {i}" }) + .ToList(); + + // Act + var task = await index.AddDocumentsAsync(movies); + await index.WaitForTaskAsync(task.TaskUid); + + // Assert + var docs = await index.GetDocumentsAsync(new DocumentsQuery { Limit = 100 }); + docs.Results.Should().HaveCount(100); + } + + [Fact] + public async Task DeflateCompression_ShouldThrowNotSupportedExceptionOnNetStandard20() + { + // Arrange + var compressionOptions = CompressionOptions.Deflate(minimumSizeBytes: 100); + var client = new MeilisearchClient( + _fixture.MeilisearchAddress(), + "masterKey", + compressionOptions); + + var index = client.Index("movies-deflate-test"); + var movies = new[] { new Movie { Id = "1", Name = "Test Movie" } }; + + // Act & Assert + // Since Meilisearch library targets netstandard2.0, Deflate will always throw + // NotSupportedException regardless of the test target framework. + // Deflate requires .NET 6.0+ for ZLibStream support. + var exception = await Assert.ThrowsAsync(async () => + { + await index.AddDocumentsAsync(movies); + }); + + exception.Message.Should().Contain("Deflate requires .NET 6.0+"); + } + + [Fact] + public async Task UpdateDocuments_ShouldWorkWithCompression() + { + // Arrange + var compressionOptions = CompressionOptions.Gzip(minimumSizeBytes: 100); + var client = new MeilisearchClient( + _fixture.MeilisearchAddress(), + "masterKey", + compressionOptions); + + var index = client.Index("movies-update-test"); + + // Add initial documents + var movies = new[] { new Movie { Id = "1", Name = "Original" } }; + var addTask = await index.AddDocumentsAsync(movies); + await index.WaitForTaskAsync(addTask.TaskUid); + + // Act - Update with compression + var updatedMovies = new[] { new Movie { Id = "1", Name = "Updated" } }; + var updateTask = await index.UpdateDocumentsAsync(updatedMovies); + await index.WaitForTaskAsync(updateTask.TaskUid); + + // Assert + var doc = await index.GetDocumentAsync("1"); + doc.Name.Should().Be("Updated"); + } + + [Fact] + public async Task NoCompression_ShouldWorkAsDefault() + { + // Arrange - No compression options specified + var client = new MeilisearchClient(_fixture.MeilisearchAddress(), "masterKey"); + + var index = client.Index("movies-no-compression"); + var movies = new[] { new Movie { Id = "1", Name = "Test Movie" } }; + + // Act + var task = await index.AddDocumentsAsync(movies); + await index.WaitForTaskAsync(task.TaskUid); + + // Assert + var docs = await index.GetDocumentsAsync(); + docs.Results.Should().HaveCount(1); + } + + [Theory] + [InlineData(0)] // Compress all + [InlineData(100)] // 100 bytes + [InlineData(1024)] // 1 KB + [InlineData(1400)] // 1.4 KB (recommended) + public async Task DifferentThresholds_ShouldWorkCorrectly(int threshold) + { + // Arrange + var compressionOptions = new CompressionOptions + { + Algorithm = CompressionAlgorithm.Gzip, + MinimumSizeBytes = threshold + }; + + var client = new MeilisearchClient( + _fixture.MeilisearchAddress(), + "masterKey", + compressionOptions); + + var index = client.Index($"movies-threshold-{threshold}"); + var movies = Enumerable.Range(1, 10) + .Select(i => new Movie { Id = i.ToString(), Name = $"Movie {i}" }) + .ToList(); + + // Act + var task = await index.AddDocumentsAsync(movies); + await index.WaitForTaskAsync(task.TaskUid); + + // Assert + var docs = await index.GetDocumentsAsync(); + docs.Results.Should().HaveCount(10); + } + + [Fact] + public void CompressionOptions_ShouldExposeCorrectDefaults() + { + // Arrange & Act + var options = new CompressionOptions(); + + // Assert + options.Algorithm.Should().Be(CompressionAlgorithm.None); + options.MinimumSizeBytes.Should().Be(1400); + options.EnableResponseDecompression.Should().BeFalse(); + } + + [Fact] + public void CompressionOptions_GzipFactoryMethod_ShouldCreateCorrectOptions() + { + // Act + var options = CompressionOptions.Gzip(); + + // Assert + options.Algorithm.Should().Be(CompressionAlgorithm.Gzip); + options.MinimumSizeBytes.Should().Be(1400); + } + + [Fact] + public void CompressionOptions_DeflateFactoryMethod_ShouldCreateCorrectOptions() + { + // Act + var options = CompressionOptions.Deflate(1024); + + // Assert + options.Algorithm.Should().Be(CompressionAlgorithm.Deflate); + options.MinimumSizeBytes.Should().Be(1024); + } + + [Fact] + public async Task BrotliCompression_ShouldThrowNotSupportedExceptionOnNetStandard20() + { + // Arrange + var compressionOptions = new CompressionOptions + { + Algorithm = CompressionAlgorithm.Brotli, + MinimumSizeBytes = 100 + }; + var client = new MeilisearchClient( + _fixture.MeilisearchAddress(), + "masterKey", + compressionOptions); + + var index = client.Index("movies-brotli-test"); + var movies = new[] { new Movie { Id = "1", Name = "Test Movie" } }; + + // Act & Assert + // Since Meilisearch library targets netstandard2.0, Brotli will always throw + // NotSupportedException regardless of the test target framework. + // Brotli requires .NET Standard 2.1+ or .NET Core 2.1+. + var exception = await Assert.ThrowsAsync(async () => + { + await index.AddDocumentsAsync(movies); + }); + + exception.Message.Should().Contain("Brotli requires .NET Standard 2.1+"); + } + + [Fact] + public async Task GzipCompression_ShouldReducePayloadSize() + { + // Arrange - Create a large dataset to ensure compression has an effect + var compressionOptions = CompressionOptions.Gzip(minimumSizeBytes: 100); + var client = new MeilisearchClient( + _fixture.MeilisearchAddress(), + "masterKey", + compressionOptions); + + var index = client.Index("movies-compression-size-test"); + + // Create 50 movies with long names to generate a sizable payload + var movies = Enumerable.Range(1, 50) + .Select(i => new Movie + { + Id = i.ToString(), + Name = $"Movie {i} with a very long title that will compress well due to repetitive patterns" + }) + .ToList(); + + // Act + var task = await index.AddDocumentsAsync(movies); + await index.WaitForTaskAsync(task.TaskUid); + + // Assert - Verify documents were successfully added + // This confirms compression worked without errors + var docs = await index.GetDocumentsAsync(new DocumentsQuery { Limit = 50 }); + docs.Results.Should().HaveCount(50); + + // Additional verification: Check that all movies have the expected long names + docs.Results.Should().OnlyContain(m => m.Name.Contains("very long title")); + } + } +}