Skip to content

Commit 322e1b7

Browse files
committed
chore: sync .NET client with Apify OpenAPI spec v2-2026-07-08T143931Z
- Bump ApiSpecVersion to v2-2026-07-08T143931Z and client version to 0.1.2 - Align User-Agent OS token with reference clients (win32/darwin/linux/android/freebsd) - Add brotli request-body compression for bodies >= 1024 bytes (Content-Encoding: br) - Document the Actor versions / environment-variables sub-API and make doc token preamble null-safe
1 parent 7d6b076 commit 322e1b7

11 files changed

Lines changed: 247 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 0.1.2
4+
5+
- Bumped `ApifyClientVersion.ApiSpecVersion` to the Apify OpenAPI spec `v2-2026-07-08T143931Z` and the
6+
project version to `0.1.2`.
7+
- Aligned the `User-Agent` OS token with the other Apify clients: it now uses the short, lowercase
8+
platform identifier (`win32`, `darwin`, `linux`, `android`, `freebsd`) instead of `windows`.
9+
- Request bodies of at least 1024 bytes are now compressed with brotli (`Content-Encoding: br`) before
10+
sending. Brotli is always used because .NET's `BrotliStream` is always available, so the reference
11+
client's gzip fallback (for runtimes lacking brotli) is not needed.
12+
313
## 0.1.1
414

515
- Bumped `ApifyClientVersion.ApiSpecVersion` to the Apify OpenAPI spec `v2-2026-07-07T132551Z` and the

docs/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ Console.WriteLine("Item count: " + items.Count);
5757

5858
`new ApifyClient("my-api-token")` takes the token as an explicit argument — it does **not** read
5959
`APIFY_TOKEN` (or any other environment variable) automatically. Read it yourself if you want that,
60-
e.g. `new ApifyClient(Environment.GetEnvironmentVariable("APIFY_TOKEN"))`.
60+
e.g. `new ApifyClient(Environment.GetEnvironmentVariable("APIFY_TOKEN") ?? throw new InvalidOperationException("Set APIFY_TOKEN"))`
61+
(the null-coalescing throw keeps the call null-safe when the variable is unset).
6162

6263
Get your API token from the
6364
[Apify Console → Settings → API & Integrations](https://console.apify.com/settings/integrations).
@@ -70,7 +71,7 @@ the `using` directives for whichever ones a file references:
7071
| Namespace | What lives here |
7172
|---|---|
7273
| `Apify.Client` | The entry point (`ApifyClient`), `ApifyClientOptions`, `ApifyClientVersion`, and the log-redirection helper `StreamedLog`. |
73-
| `Apify.Client.Resources` | Every resource client the entry point returns — `ActorClient`, `RunClient`, `BuildClient`, `DatasetClient`, `KeyValueStoreClient`, `RequestQueueClient`, `TaskClient`, `ScheduleClient`, `LogClient`, `UserClient`, the `…CollectionClient` types (including `NestedWebhookCollectionClient` and `WebhookDispatchCollectionClient`), etc. |
74+
| `Apify.Client.Resources` | Every resource client the entry point returns — `ActorClient`, `RunClient`, `BuildClient`, `DatasetClient`, `KeyValueStoreClient`, `RequestQueueClient`, `TaskClient`, `ScheduleClient`, `LogClient`, `UserClient`, `ActorVersionClient`, `ActorEnvVarClient`, the `…CollectionClient` types (including `ActorVersionCollectionClient`, `ActorEnvVarCollectionClient`, `NestedWebhookCollectionClient`, and `WebhookDispatchCollectionClient`), etc. |
7475
| `Apify.Client.Models` | Data models returned by the clients — `Actor`, `ActorRun`, `Build`, `Dataset`, `RequestQueueRequest`, `ActorEnvVar`, `PaginationList<T>`, and so on. |
7576
| `Apify.Client.Options` | The option/request objects passed into methods — `ActorStartOptions`, `DatasetListItemsOptions`, `DownloadItemsFormat`, `ListOptions`, `SetRecordOptions`, `StorageListOptions`, etc. |
7677
| `Apify.Client.Exceptions` | `ApifyApiException` and `ApifyTransportException`. |

docs/actors.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ foreach (var actor in page.Items)
4848
- `DefaultBuildAsync(int? waitForFinish = null)``BuildClient`.
4949
- `LastRun(LastRunOptions? options = null)``RunClient` (filter by `Status`/`Origin`).
5050
- `Builds()``BuildCollectionClient`; `Runs()``RunCollectionClient`.
51-
- `Version(string versionNumber)` / `Versions()` Actor versions.
51+
- `Version(string versionNumber)` `ActorVersionClient`; `Versions()` `ActorVersionCollectionClient` — a single Actor version and the version collection.
5252
- `Webhooks()` → read-only `NestedWebhookCollectionClient`.
5353

5454
`ActorStartOptions` fields:
@@ -87,6 +87,51 @@ against) and `ContentType` (`string?`, content type of the input; defaults to `a
8787

8888
## Versions and environment variables
8989

90+
Manage an Actor's versions with `client.Actor(id).Versions()` (the whole collection) and
91+
`client.Actor(id).Version(versionNumber)` (one version). Each version in turn owns a collection of
92+
environment variables, reached with `.EnvVars()` / `.EnvVar(name)`.
93+
94+
### Version collection — `client.Actor(id).Versions()``ActorVersionCollectionClient`
95+
96+
- `ListAsync(ListOptions? options = null)` — list the Actor's versions (one page). Returns
97+
`PaginationList<ActorVersion>`.
98+
- `IterateAsync(ListOptions? options = null)``IAsyncEnumerable<ActorVersion>` — lazily iterate every
99+
version across pages, fetching each page on demand.
100+
- `CreateAsync(object version)` — create a version from any JSON-serializable definition. Returns
101+
`ActorVersion`.
102+
103+
`ListOptions` fields: `Offset` (`int?`, items to skip), `Limit` (`int?`, page size), `Desc` (`bool?`,
104+
newest-first when `true`).
105+
106+
### Single version — `client.Actor(id).Version(versionNumber)``ActorVersionClient`
107+
108+
`versionNumber` is the version identifier (e.g. `0.1`).
109+
110+
- `GetAsync()``ActorVersion?` (null if not found).
111+
- `UpdateAsync(object newFields)``ActorVersion` — update with any JSON-serializable set of fields.
112+
- `DeleteAsync()`.
113+
- `EnvVars()``ActorEnvVarCollectionClient` — this version's environment-variable collection.
114+
- `EnvVar(string name)``ActorEnvVarClient` — a single environment variable of this version.
115+
116+
### Env-var collection — `Version(versionNumber).EnvVars()``ActorEnvVarCollectionClient`
117+
118+
- `ListAsync()` — list the version's environment variables (the endpoint returns them in a single page).
119+
Returns `PaginationList<ActorEnvVar>`.
120+
- `IterateAsync()``IAsyncEnumerable<ActorEnvVar>` — iterate the variables; provided for parity with
121+
the other collection iterators (yields the single page's items).
122+
- `CreateAsync(ActorEnvVar envVar)``ActorEnvVar` — create an environment variable.
123+
124+
### Single env-var — `Version(versionNumber).EnvVar(name)``ActorEnvVarClient`
125+
126+
`name` is the environment variable's name.
127+
128+
- `GetAsync()``ActorEnvVar?` (null if not found).
129+
- `UpdateAsync(ActorEnvVar envVar)``ActorEnvVar`.
130+
- `DeleteAsync()`.
131+
132+
See [`ActorVersion`](models.md#actorversion) and [`ActorEnvVar`](models.md#actorenvvar) for the returned
133+
models and the `ActorEnvVar` constructor used below.
134+
90135
```csharp
91136
using Apify.Client;
92137
using Apify.Client.Models;

docs/examples.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# Examples
22

3-
Each example below is a complete, runnable scenario. The canonical, compiled versions live in
4-
[`tests/Apify.Client.Tests/Examples`](../tests/Apify.Client.Tests/Examples) and are executed
5-
end-to-end against the live API by the **Test examples** CI step (they require an `APIFY_TOKEN`), so
6-
the snippets here are guaranteed to stay valid and working.
3+
Each example below is a complete, runnable scenario. The canonical, compiled versions live in the
4+
client's source repository under
5+
[`tests/Apify.Client.Tests/Examples`](../tests/Apify.Client.Tests/Examples) — a repo-internal path that
6+
is not part of the published NuGet package — and are executed end-to-end against the live API by the
7+
**Test examples** CI step (they require an `APIFY_TOKEN`), so the snippets here are guaranteed to stay
8+
valid and working.
79

810
Every snippet runs inside an `async` context and assumes the following `using` directives appear at the
911
top of the file, **before** any top-level statements (a `using` after the first statement is a `CS1529`
@@ -16,7 +18,9 @@ using Apify.Client;
1618
using Apify.Client.Models;
1719
using Apify.Client.Options;
1820

19-
var client = new ApifyClient(Environment.GetEnvironmentVariable("APIFY_TOKEN"));
21+
var client = new ApifyClient(
22+
Environment.GetEnvironmentVariable("APIFY_TOKEN")
23+
?? throw new InvalidOperationException("Set the APIFY_TOKEN environment variable."));
2024
```
2125

2226
## Run a store Actor and read its dataset

docs/misc.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ var client = new ApifyClient("my-api-token");
5454
var me = await client.Me().GetAsync();
5555
Console.WriteLine(me?.Username);
5656
var usage = await client.Me().MonthlyUsageAsync();
57+
58+
// Any user's public profile by id (no private fields, and no account-only methods).
59+
var otherUser = await client.User("some-user-id").GetAsync();
60+
Console.WriteLine(otherUser?.Username);
5761
```
5862

5963
## Logs — `client.Log(buildOrRunId)`
@@ -66,10 +70,16 @@ var usage = await client.Me().MonthlyUsageAsync();
6670

6771
```csharp
6872
using System;
73+
using System.IO;
6974
using Apify.Client;
7075
using Apify.Client.Options;
7176

7277
var client = new ApifyClient("my-api-token");
7378
var log = await client.Log("some-run-id").GetAsync(new LogOptions { Raw = true });
7479
Console.WriteLine(log);
80+
81+
// Read a run's raw live log as a stream (the low-level alternative to GetStreamedLog's sink redirection).
82+
await using var logStream = await client.Run("some-run-id").GetStreamedLogAsync();
83+
using var reader = new StreamReader(logStream);
84+
Console.WriteLine(await reader.ReadToEndAsync());
7585
```

src/Apify.Client/ApifyClient.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,24 +266,40 @@ private static string BuildUserAgent(string? suffix, Func<bool> isAtHomeFn)
266266
return ua;
267267
}
268268

269-
/// <summary>The lowercase operating-system family name, matching the reference clients' convention.</summary>
269+
/// <summary>
270+
/// The short, lowercase platform identifier used in the <c>User-Agent</c> OS token. These match the
271+
/// reference client's Node <c>os.platform()</c> values (<c>win32</c>, <c>darwin</c>, <c>linux</c>,
272+
/// <c>android</c>, <c>freebsd</c>) rather than uname-style names, keeping the token aligned across
273+
/// Apify clients. Android is checked before Linux so an Android runtime is reported as
274+
/// <c>android</c> rather than <c>linux</c>.
275+
/// </summary>
270276
private static string CurrentOs()
271277
{
272278
if (OperatingSystem.IsWindows())
273279
{
274-
return "windows";
280+
return "win32";
275281
}
276282

277283
if (OperatingSystem.IsMacOS())
278284
{
279285
return "darwin";
280286
}
281287

288+
if (OperatingSystem.IsAndroid())
289+
{
290+
return "android";
291+
}
292+
282293
if (OperatingSystem.IsLinux())
283294
{
284295
return "linux";
285296
}
286297

298+
if (OperatingSystem.IsFreeBSD())
299+
{
300+
return "freebsd";
301+
}
302+
287303
return "unknown";
288304
}
289305
}

src/Apify.Client/ApifyClientVersion.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ public static class ApifyClientVersion
1414
/// The semantic version of this client library (see https://semver.org/). Changes to the public
1515
/// interface other than additive ones are considered breaking changes.
1616
/// </summary>
17-
public const string ClientVersion = "0.1.1";
17+
public const string ClientVersion = "0.1.2";
1818

1919
/// <summary>
2020
/// The version of the Apify OpenAPI specification this client was generated and verified against.
2121
/// Corresponds to the <c>info.version</c> field of the Apify OpenAPI document.
2222
/// </summary>
23-
public const string ApiSpecVersion = "v2-2026-07-07T132551Z";
23+
public const string ApiSpecVersion = "v2-2026-07-08T143931Z";
2424
}

src/Apify.Client/Internal/HttpClientCore.cs

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Globalization;
4+
using System.IO;
5+
using System.IO.Compression;
46
using System.Net.Http;
57
using System.Net.Http.Headers;
68
using System.Text;
@@ -31,6 +33,15 @@ internal sealed class HttpClientCore
3133

3234
private const int NotFound = 404;
3335

36+
/// <summary>
37+
/// Request bodies whose size in bytes is at or above this threshold are compressed before sending,
38+
/// matching the reference client's minimum-compression size.
39+
/// </summary>
40+
private const int MinCompressBytes = 1024;
41+
42+
/// <summary>The <c>Content-Encoding</c> token used for brotli-compressed request bodies.</summary>
43+
private const string BrotliEncoding = "br";
44+
3445
private readonly IHttpTransport _transport;
3546
private readonly string? _token;
3647
private readonly RetryConfig _retry;
@@ -68,6 +79,9 @@ public async Task<HttpResponseMessage> CallAsync(
6879
var maxAttempts = _retry.MaxRetries + 1;
6980
var path = ExtractPath(url);
7081
var baseTimeout = timeout ?? TimeSpan.FromSeconds(_retry.TimeoutSecs);
82+
// Normalize (and, when large enough, compress) the body once up front so retries reuse the same
83+
// prepared payload instead of re-encoding and re-compressing on every attempt.
84+
var prepared = PrepareBody(body, bodyBytes, contentType);
7185
Exception? lastError = null;
7286

7387
for (var attempt = 1; attempt <= maxAttempts; attempt++)
@@ -76,7 +90,7 @@ public async Task<HttpResponseMessage> CallAsync(
7690
try
7791
{
7892
var response = await SendOnceAsync(
79-
method, url, body, bodyBytes, contentType, extraHeaders,
93+
method, url, prepared, extraHeaders,
8094
AttemptTimeout(baseTimeout, attempt), cancellationToken).ConfigureAwait(false);
8195

8296
var status = (int)response.StatusCode;
@@ -114,31 +128,27 @@ await Task.Delay(TimeSpan.FromMilliseconds(RandomizedDelayMillis(delayMillis)),
114128
/// <summary>Opens a live streaming response (single attempt, no retry). Used by log streaming.</summary>
115129
public Task<HttpResponseMessage> StreamAsync(string url, CancellationToken cancellationToken)
116130
{
117-
var request = BuildRequest(HttpMethod.Get, url, null, null, string.Empty, null);
131+
var request = BuildRequest(HttpMethod.Get, url, default, null);
118132
return _transport.SendAsync(request, TimeSpan.FromSeconds(_retry.TimeoutSecs), streaming: true, cancellationToken);
119133
}
120134

121135
private async Task<HttpResponseMessage> SendOnceAsync(
122136
HttpMethod method,
123137
string url,
124-
string? body,
125-
byte[]? bodyBytes,
126-
string contentType,
138+
PreparedBody prepared,
127139
IReadOnlyDictionary<string, string>? extraHeaders,
128140
TimeSpan timeout,
129141
CancellationToken cancellationToken)
130142
{
131-
using var request = BuildRequest(method, url, body, bodyBytes, contentType, extraHeaders);
143+
using var request = BuildRequest(method, url, prepared, extraHeaders);
132144
return await _transport.SendAsync(request, timeout, streaming: false, cancellationToken).ConfigureAwait(false);
133145
}
134146

135147
/// <summary>Builds a fully-prepared request with auth, User-Agent, content type and extra headers.</summary>
136148
private HttpRequestMessage BuildRequest(
137149
HttpMethod method,
138150
string url,
139-
string? body,
140-
byte[]? bodyBytes,
141-
string contentType,
151+
PreparedBody prepared,
142152
IReadOnlyDictionary<string, string>? extraHeaders)
143153
{
144154
var request = new HttpRequestMessage(method, url);
@@ -156,22 +166,67 @@ private HttpRequestMessage BuildRequest(
156166
}
157167
}
158168

159-
// Raw bytes take precedence so binary records (e.g. images, gzip) are sent verbatim; a string body
160-
// is UTF-8 encoded. Setting the content type verbatim (no charset appended unless the caller added one).
161-
HttpContent? content = bodyBytes is not null
162-
? new ByteArrayContent(bodyBytes)
163-
: body is not null ? new StringContent(body, Encoding.UTF8) : null;
164-
if (content is not null)
169+
if (prepared.Bytes is not null)
165170
{
166-
content.Headers.ContentType = string.IsNullOrEmpty(contentType)
171+
var content = new ByteArrayContent(prepared.Bytes);
172+
// Set the content type verbatim (no charset appended unless the caller added one).
173+
content.Headers.ContentType = string.IsNullOrEmpty(prepared.ContentType)
167174
? null
168-
: MediaTypeHeaderValue.Parse(contentType);
175+
: MediaTypeHeaderValue.Parse(prepared.ContentType);
176+
if (prepared.ContentEncoding is not null)
177+
{
178+
content.Headers.ContentEncoding.Add(prepared.ContentEncoding);
179+
}
180+
169181
request.Content = content;
170182
}
171183

172184
return request;
173185
}
174186

187+
/// <summary>
188+
/// Normalizes a request body to bytes and, when it is large enough, compresses it. Raw bytes take
189+
/// precedence so binary records (e.g. images) are used as-is rather than re-encoded through a UTF-8
190+
/// string; a string body is UTF-8 encoded. Either kind of payload is then compressed once it reaches
191+
/// the size threshold (see the remarks).
192+
/// </summary>
193+
/// <remarks>
194+
/// Bodies at or above <see cref="MinCompressBytes"/> are brotli-compressed (<c>Content-Encoding: br</c>),
195+
/// matching the reference client, which prefers brotli and only falls back to gzip on runtimes where
196+
/// brotli is unavailable. .NET's <see cref="BrotliStream"/> is always available, so brotli is always
197+
/// used here and a gzip fallback would be unreachable.
198+
/// </remarks>
199+
private static PreparedBody PrepareBody(string? body, byte[]? bodyBytes, string contentType)
200+
{
201+
var raw = bodyBytes ?? (body is not null ? Encoding.UTF8.GetBytes(body) : null);
202+
if (raw is null)
203+
{
204+
return default;
205+
}
206+
207+
return raw.Length >= MinCompressBytes
208+
? new PreparedBody(BrotliCompress(raw), contentType, BrotliEncoding)
209+
: new PreparedBody(raw, contentType, null);
210+
}
211+
212+
/// <summary>Brotli-compresses a payload into a self-contained byte array.</summary>
213+
private static byte[] BrotliCompress(byte[] data)
214+
{
215+
using var output = new MemoryStream();
216+
using (var brotli = new BrotliStream(output, CompressionMode.Compress))
217+
{
218+
brotli.Write(data, 0, data.Length);
219+
}
220+
221+
return output.ToArray();
222+
}
223+
224+
/// <summary>
225+
/// A request body normalized to bytes, together with the content type and optional
226+
/// <c>Content-Encoding</c> to send. A <see langword="default"/> value carries no body.
227+
/// </summary>
228+
private readonly record struct PreparedBody(byte[]? Bytes, string ContentType, string? ContentEncoding);
229+
175230
/// <summary>
176231
/// Returns <c>min(overall, base * 2^(attempt-1))</c>: the first attempt uses the base timeout; each
177232
/// retry doubles it (a slow-but-progressing connection gets more time) while never exceeding the

tests/Apify.Client.Tests/Unit/ConfigTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ public void UserAgentIsAtHomeTrueAndSuffix()
3737
Assert.EndsWith("; my-suffix", client.UserAgent, System.StringComparison.Ordinal);
3838
}
3939

40+
[Fact]
41+
public void UserAgentOsTokenUsesShortLowercasePlatformIdentifier()
42+
{
43+
// The OS token must be a short, lowercase platform identifier aligned with the other Apify
44+
// clients' Node `os.platform()` values, not a uname-style name (e.g. "win32", never "windows").
45+
var client = new ApifyClient(new ApifyClientOptions
46+
{
47+
Token = "t",
48+
HttpTransport = new MockTransport(),
49+
});
50+
51+
var osToken = Regex.Match(client.UserAgent, @"\(([^;]+);").Groups[1].Value;
52+
Assert.Contains(osToken, new[] { "win32", "darwin", "linux", "android", "freebsd", "unknown" });
53+
Assert.DoesNotContain("windows", client.UserAgent, System.StringComparison.Ordinal);
54+
}
55+
4056
[Fact]
4157
public void ApiBaseUrlAppendsV2()
4258
{

0 commit comments

Comments
 (0)