From 4f5ac821e2b61f8a388c1b8d12ade5a50ef2ed7a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 17:13:01 +0000 Subject: [PATCH 1/3] chore: sync .NET client with Apify OpenAPI spec v2-2026-07-10T105921Z Bump ApiSpecVersion to v2-2026-07-10T105921Z and client version to 0.1.3. Enable automatic br/gzip/deflate response decompression in the default transport to match the API's documented response compression and the reference JS client. Nullable-field and 401/402 spec changes need no code change (null-safe JsonObject models; generic non-2xx error handling). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019rkZYoebybdnv7MFH9Ua6E --- CHANGELOG.md | 7 ++ src/Apify.Client/Apify.Client.csproj | 2 +- src/Apify.Client/ApifyClientVersion.cs | 4 +- src/Apify.Client/Http/HttpClientTransport.cs | 6 + .../Unit/ResponseDecompressionTests.cs | 109 ++++++++++++++++++ 5 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 tests/Apify.Client.Tests/Unit/ResponseDecompressionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index acee918..cb9328a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.3 + +- Bumped `ApifyClientVersion.ApiSpecVersion` to the Apify OpenAPI spec `v2-2026-07-10T105921Z` and the + project version to `0.1.3`. +- The default HTTP transport now negotiates and transparently decompresses brotli, gzip and deflate + responses, matching the API's newly documented response compression and the reference client. + ## 0.1.2 - Bumped `ApifyClientVersion.ApiSpecVersion` to the Apify OpenAPI spec `v2-2026-07-08T143931Z` and the diff --git a/src/Apify.Client/Apify.Client.csproj b/src/Apify.Client/Apify.Client.csproj index a88b3af..0708960 100644 --- a/src/Apify.Client/Apify.Client.csproj +++ b/src/Apify.Client/Apify.Client.csproj @@ -6,7 +6,7 @@ Apify.Client - 0.1.2 + 0.1.3 Apify Apify Apify API client for .NET diff --git a/src/Apify.Client/ApifyClientVersion.cs b/src/Apify.Client/ApifyClientVersion.cs index f433471..03807e8 100644 --- a/src/Apify.Client/ApifyClientVersion.cs +++ b/src/Apify.Client/ApifyClientVersion.cs @@ -14,11 +14,11 @@ public static class ApifyClientVersion /// The semantic version of this client library (see https://semver.org/). Changes to the public /// interface other than additive ones are considered breaking changes. /// - public const string ClientVersion = "0.1.2"; + public const string ClientVersion = "0.1.3"; /// /// The version of the Apify OpenAPI specification this client was generated and verified against. /// Corresponds to the info.version field of the Apify OpenAPI document. /// - public const string ApiSpecVersion = "v2-2026-07-08T143931Z"; + public const string ApiSpecVersion = "v2-2026-07-10T105921Z"; } diff --git a/src/Apify.Client/Http/HttpClientTransport.cs b/src/Apify.Client/Http/HttpClientTransport.cs index 89ec4be..6641945 100644 --- a/src/Apify.Client/Http/HttpClientTransport.cs +++ b/src/Apify.Client/Http/HttpClientTransport.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -42,6 +43,11 @@ public HttpClientTransport(HttpClient? httpClient = null) { ConnectTimeout = ConnectTimeout, AllowAutoRedirect = true, + // The API advertises brotli/gzip/deflate response compression on the dataset-items and + // key-value-store record endpoints. Enabling automatic decompression makes the handler send + // the matching `Accept-Encoding` request header and transparently inflate the response, so + // compressed payloads are handled the same way as in the reference JS client. + AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip | DecompressionMethods.Deflate, }; // The client-side retry orchestrator owns the per-request timeout, so disable HttpClient's own. _httpClient = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; diff --git a/tests/Apify.Client.Tests/Unit/ResponseDecompressionTests.cs b/tests/Apify.Client.Tests/Unit/ResponseDecompressionTests.cs new file mode 100644 index 0000000..30533c7 --- /dev/null +++ b/tests/Apify.Client.Tests/Unit/ResponseDecompressionTests.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Apify.Client; +using Xunit; + +namespace Apify.Client.Tests.Unit; + +/// +/// Verifies that the default transparently decompresses +/// brotli/gzip/deflate responses, matching the API's documented response compression. A real loopback HTTP +/// server is used because automatic decompression is a property of the default handler, not of the scripted +/// . +/// +[Trait("Category", "Unit")] +public sealed class ResponseDecompressionTests +{ + [Theory] + [InlineData("gzip")] + [InlineData("br")] + [InlineData("deflate")] + public async Task DefaultTransportDecompressesResponse(string encoding) + { + const string json = "{\"data\":{\"id\":\"act1\",\"name\":\"compressed-actor\"}}"; + var payload = Compress(Encoding.UTF8.GetBytes(json), encoding); + + using var server = new LoopbackServer(encoding, payload); + server.Start(); + + var client = new ApifyClient(new ApifyClientOptions + { + Token = "test-token", + BaseUrl = server.BaseUrl, + }); + + var actor = await client.Actor("act1").GetAsync(); + + Assert.NotNull(actor); + Assert.Equal("act1", actor!.Id); + Assert.Equal("compressed-actor", actor.Name); + } + + private static byte[] Compress(byte[] data, string encoding) + { + using var output = new MemoryStream(); + using (Stream compressor = encoding switch + { + "gzip" => new GZipStream(output, CompressionMode.Compress), + "br" => new BrotliStream(output, CompressionMode.Compress), + "deflate" => new DeflateStream(output, CompressionMode.Compress), + _ => throw new ArgumentOutOfRangeException(nameof(encoding), encoding, "unsupported encoding"), + }) + { + compressor.Write(data, 0, data.Length); + } + + return output.ToArray(); + } + + /// A minimal single-response loopback HTTP server returning a compressed body. + private sealed class LoopbackServer : IDisposable + { + private readonly HttpListener _listener = new(); + private readonly string _encoding; + private readonly byte[] _payload; + + public LoopbackServer(string encoding, byte[] payload) + { + _encoding = encoding; + _payload = payload; + var port = FreePort(); + BaseUrl = $"http://127.0.0.1:{port}"; + _listener.Prefixes.Add(BaseUrl + "/"); + } + + public string BaseUrl { get; } + + public void Start() + { + _listener.Start(); + _ = Task.Run(async () => + { + var context = await _listener.GetContextAsync().ConfigureAwait(false); + var response = context.Response; + response.StatusCode = 200; + response.ContentType = "application/json"; + response.AddHeader("Content-Encoding", _encoding); + response.OutputStream.Write(_payload, 0, _payload.Length); + response.OutputStream.Close(); + }); + } + + public void Dispose() => _listener.Close(); + + /// Reserves a free TCP port by briefly binding to port 0, then releasing it. + private static int FreePort() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + } +} From a8a0e1c68982f953acfd37d40372bb91538fd2bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 17:25:43 +0000 Subject: [PATCH 2/3] test: harden loopback decompression server (observe write task, guard port race) Addresses staff-review test-robustness nits on the new ResponseDecompressionTests: surface the background server task's exceptions via WaitForResponseWrittenAsync (previously fire-and-forget), and retry HttpListener binding to guard the reserve-then-bind port race. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019rkZYoebybdnv7MFH9Ua6E --- .../Unit/ResponseDecompressionTests.cs | 75 ++++++++++++++++--- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/tests/Apify.Client.Tests/Unit/ResponseDecompressionTests.cs b/tests/Apify.Client.Tests/Unit/ResponseDecompressionTests.cs index 30533c7..d3696d9 100644 --- a/tests/Apify.Client.Tests/Unit/ResponseDecompressionTests.cs +++ b/tests/Apify.Client.Tests/Unit/ResponseDecompressionTests.cs @@ -42,6 +42,10 @@ public async Task DefaultTransportDecompressesResponse(string encoding) Assert.NotNull(actor); Assert.Equal("act1", actor!.Id); Assert.Equal("compressed-actor", actor.Name); + + // Surface any exception thrown while the server wrote the response, so a server-side failure fails + // the test explicitly instead of hiding behind a client-side timeout. + await server.WaitForResponseWrittenAsync(); } private static byte[] Compress(byte[] data, string encoding) @@ -64,40 +68,89 @@ private static byte[] Compress(byte[] data, string encoding) /// A minimal single-response loopback HTTP server returning a compressed body. private sealed class LoopbackServer : IDisposable { + /// How long the test waits for the server to finish writing before giving up. + private static readonly TimeSpan WriteTimeout = TimeSpan.FromSeconds(30); + + /// Attempts to claim a free port before failing (guards the reserve-then-bind race). + private const int BindAttempts = 20; + private readonly HttpListener _listener = new(); private readonly string _encoding; private readonly byte[] _payload; + private Task? _serveTask; public LoopbackServer(string encoding, byte[] payload) { _encoding = encoding; _payload = payload; - var port = FreePort(); - BaseUrl = $"http://127.0.0.1:{port}"; - _listener.Prefixes.Add(BaseUrl + "/"); + BaseUrl = ClaimListenerPort(_listener); } public string BaseUrl { get; } public void Start() { - _listener.Start(); - _ = Task.Run(async () => + _serveTask = Task.Run(async () => { var context = await _listener.GetContextAsync().ConfigureAwait(false); var response = context.Response; - response.StatusCode = 200; - response.ContentType = "application/json"; - response.AddHeader("Content-Encoding", _encoding); - response.OutputStream.Write(_payload, 0, _payload.Length); - response.OutputStream.Close(); + try + { + response.StatusCode = 200; + response.ContentType = "application/json"; + response.AddHeader("Content-Encoding", _encoding); + await response.OutputStream.WriteAsync(_payload).ConfigureAwait(false); + } + finally + { + response.OutputStream.Close(); + } }); } + /// Awaits the single response having been written, propagating any server-side exception. + public async Task WaitForResponseWrittenAsync() + { + if (_serveTask is null) + { + return; + } + + var finished = await Task.WhenAny(_serveTask, Task.Delay(WriteTimeout)).ConfigureAwait(false); + Assert.True(finished == _serveTask, "loopback server did not finish writing the response in time"); + await _serveTask.ConfigureAwait(false); // rethrows any server-side failure + } + public void Dispose() => _listener.Close(); + /// + /// Binds and starts the listener on a free loopback port, returning its base URL. A port is reserved + /// by briefly binding a to port 0 and reusing the number; because that + /// leaves a race where the port could be re-taken before claims it, + /// binding is retried on conflict rather than assumed to succeed the first time. The listener is left + /// running so can begin accepting immediately. + /// + private static string ClaimListenerPort(HttpListener listener) + { + for (var attempt = 1; ; attempt++) + { + var baseUrl = $"http://127.0.0.1:{ReserveFreePort()}"; + listener.Prefixes.Clear(); + listener.Prefixes.Add(baseUrl + "/"); + try + { + listener.Start(); + return baseUrl; + } + catch (HttpListenerException) when (attempt < BindAttempts) + { + // The reserved port was taken between reservation and binding; try another. + } + } + } + /// Reserves a free TCP port by briefly binding to port 0, then releasing it. - private static int FreePort() + private static int ReserveFreePort() { var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); From d57708b03b4d8c187c334beb1c3b9e757ad3b91b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 17:32:23 +0000 Subject: [PATCH 3/3] docs: clarify CancellationToken is the omitted trailing parameter Addresses a review doc-polish item: docs/README.md claimed every method accepts an optional CancellationToken while the per-page reference signatures omit it. Clarify that it is the final parameter, omitted from those signatures for brevity. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019rkZYoebybdnv7MFH9Ua6E --- docs/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index fc0784f..9d5c6c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,9 +8,10 @@ A resource-oriented .NET client for the [Apify API](https://docs.apify.com/api/v official [JavaScript](https://github.com/apify/apify-client-js) reference client: start from an `ApifyClient`, then drill down into resources. -All API calls are asynchronous and return `Task`/`Task`; every method accepts an optional -`CancellationToken`. Method names mirror the reference client with the .NET `Async` suffix -(`GetAsync`, `ListAsync`, `CallAsync`, …). +All API calls are asynchronous and return `Task`/`Task`; every method takes an optional +`CancellationToken cancellationToken = default` as its final parameter (omitted from the reference +signatures on the pages below for brevity). Method names mirror the reference client with the .NET +`Async` suffix (`GetAsync`, `ListAsync`, `CallAsync`, …). ## Contents