Skip to content

Commit 304abc7

Browse files
committed
chore: sync PHP client with Apify OpenAPI spec v2-2026-07-08T143931Z
- Bump API_SPEC_VERSION to v2-2026-07-08T143931Z and client version to 0.2.0 - Align User-Agent OS token with reference clients via Platform::osToken (WIN* -> win32, CYGWIN* -> cygwin, else lowercase PHP_OS) - Add request-body compression for bodies >= 1024 bytes: brotli (Content-Encoding: br) when the PECL brotli extension is present, gzip (Content-Encoding: gzip) fallback - Add zlib to CI setup-php extensions
1 parent a485740 commit 304abc7

14 files changed

Lines changed: 376 additions & 9 deletions

.github/workflows/php-integration-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
uses: shivammathur/setup-php@v2
3636
with:
3737
php-version: '8.1'
38-
extensions: mbstring, json, curl
38+
extensions: mbstring, json, curl, zlib
3939
coverage: none
4040
tools: composer:v2
4141

.github/workflows/php-publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
uses: shivammathur/setup-php@v2
4646
with:
4747
php-version: '8.1'
48-
extensions: mbstring, json, curl
48+
extensions: mbstring, json, curl, zlib
4949
coverage: none
5050
tools: composer:v2
5151

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.2.0
4+
5+
- Synced to Apify OpenAPI spec `v2-2026-07-08T143931Z`. No public interface changes.
6+
- Request bodies larger than 1024 bytes are now compressed before being sent, using brotli
7+
(`Content-Encoding: br`) when the PECL `brotli` extension is available and gzip
8+
(`Content-Encoding: gzip`) as a fallback. Matches the reference client's request compression.
9+
- The `User-Agent` OS token now reports the short lowercase platform identifier (e.g. `linux`,
10+
`darwin`, `win32`), matching the reference JS client's `os.platform()` token, instead of the
11+
upper-cased `PHP_OS_FAMILY` value.
12+
313
## 0.1.1
414

515
- Synced to Apify OpenAPI spec `v2-2026-07-07T132551Z`. No public interface changes.

src/ApifyClient.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Apify\Client\Http\GuzzleHttpClient;
88
use Apify\Client\Http\HttpClientInterface;
99
use Apify\Client\Internal\HttpClientCore;
10+
use Apify\Client\Internal\Platform;
1011
use Apify\Client\Internal\RetryConfig;
1112
use Apify\Client\Model\ActorRun;
1213
use Apify\Client\Options\RequestQueueClientOptions;
@@ -347,7 +348,7 @@ private static function defaultIsAtHome(): bool
347348
*/
348349
private static function buildUserAgent(?string $suffix, callable $isAtHomeFn): string
349350
{
350-
$os = strtolower(PHP_OS_FAMILY);
351+
$os = Platform::osToken(PHP_OS);
351352
$atHome = $isAtHomeFn() ? 'true' : 'false';
352353
$ua = sprintf('ApifyClient/%s (%s; PHP/%s); isAtHome/%s', Version::CLIENT_VERSION, $os, PHP_VERSION, $atHome);
353354
if ($suffix !== null && $suffix !== '') {

src/Internal/Compression.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Apify\Client\Internal;
6+
7+
/**
8+
* Optional request-body compression, matching the reference JS client's behaviour.
9+
*
10+
* Large request bodies are compressed before being sent, saving bandwidth on uploads (Actor inputs,
11+
* key-value-store records, dataset item batches, ...). Brotli ({@code Content-Encoding: br}) is
12+
* preferred when available and gzip ({@code Content-Encoding: gzip}) is used as a fallback.
13+
*
14+
* In PHP, brotli lives in the optional PECL {@code brotli} extension, which is frequently absent,
15+
* while gzip ({@code gzencode}) ships with the standard {@code zlib} extension. We therefore prefer
16+
* brotli only when the extension is loaded and fall back to gzip otherwise. Compression is
17+
* best-effort: if neither codec is available (or a payload is too small), the body is sent
18+
* unchanged rather than raising an error.
19+
*
20+
* @internal
21+
*/
22+
final class Compression
23+
{
24+
/**
25+
* Minimum body size (in bytes) worth compressing. Below this the CPU cost and the few bytes of
26+
* codec framing outweigh the savings, so the body is left uncompressed. Matches the reference
27+
* client's {@code MIN_COMPRESS_BYTES}.
28+
*/
29+
public const MIN_COMPRESS_BYTES = 1024;
30+
31+
/**
32+
* Brotli quality level. Level 6 mirrors the reference client and trades a little ratio for much
33+
* faster compression than the brotli default (11).
34+
*/
35+
private const BROTLI_QUALITY = 6;
36+
37+
/**
38+
* Returns {@code [encoding, compressedBody]} when {@code $body} should be sent compressed, or
39+
* {@code null} to send it unchanged. {@code $encoding} is the value for the
40+
* {@code Content-Encoding} header ({@code 'br'} or {@code 'gzip'}).
41+
*
42+
* Only byte payloads at least {@see MIN_COMPRESS_BYTES} long are compressed. PHP strings are
43+
* byte strings, so {@code strlen()} already measures the encoded size.
44+
*
45+
* @return array{0: string, 1: string}|null
46+
*/
47+
public static function maybeCompress(string $body): ?array
48+
{
49+
if (strlen($body) < self::MIN_COMPRESS_BYTES) {
50+
return null;
51+
}
52+
53+
if (function_exists('brotli_compress')) {
54+
// Called indirectly: brotli_compress only exists when the PECL brotli extension is
55+
// loaded, so a direct call would be an unresolved reference for static analysis on the
56+
// (common) PHP builds without the extension.
57+
$brotliCompress = 'brotli_compress';
58+
$compressed = $brotliCompress($body, self::BROTLI_QUALITY);
59+
if (is_string($compressed)) {
60+
return ['br', $compressed];
61+
}
62+
}
63+
64+
if (function_exists('gzencode')) {
65+
$compressed = gzencode($body);
66+
if ($compressed !== false) {
67+
return ['gzip', $compressed];
68+
}
69+
}
70+
71+
return null;
72+
}
73+
74+
private function __construct()
75+
{
76+
}
77+
}

src/Internal/HttpClientCore.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ public function call(
7070
bool $doNotRetryTimeouts = false,
7171
array $extraHeaders = []
7272
): ResponseInterface {
73+
// Compress the body once, up front, so every retry reuses the already-compressed payload.
74+
[$body, $extraHeaders] = self::maybeCompressBody($body, $extraHeaders);
75+
7376
$delayMillis = $this->retry->minDelayMillis;
7477
$maxAttempts = $this->retry->maxRetries + 1;
7578
$path = self::extractPath($url);
@@ -110,6 +113,46 @@ public function call(
110113
throw $lastError ?? new TransportException('request failed with no attempts');
111114
}
112115

116+
/**
117+
* Compresses the request body when it is large enough to be worth it, returning the possibly
118+
* replaced body together with the (possibly extended) header map. A caller that already set a
119+
* {@code Content-Encoding} header is left untouched, so an explicitly-encoded body is never
120+
* double-compressed.
121+
*
122+
* @param array<string,string> $extraHeaders
123+
* @return array{0: string|null, 1: array<string,string>}
124+
*/
125+
private static function maybeCompressBody(?string $body, array $extraHeaders): array
126+
{
127+
if ($body === null || self::hasHeader($extraHeaders, 'Content-Encoding')) {
128+
return [$body, $extraHeaders];
129+
}
130+
131+
$compressed = Compression::maybeCompress($body);
132+
if ($compressed === null) {
133+
return [$body, $extraHeaders];
134+
}
135+
136+
[$encoding, $compressedBody] = $compressed;
137+
$extraHeaders['Content-Encoding'] = $encoding;
138+
return [$compressedBody, $extraHeaders];
139+
}
140+
141+
/**
142+
* Case-insensitive check for a header key, since HTTP header names are case-insensitive.
143+
*
144+
* @param array<string,string> $headers
145+
*/
146+
private static function hasHeader(array $headers, string $name): bool
147+
{
148+
foreach (array_keys($headers) as $key) {
149+
if (strcasecmp($key, $name) === 0) {
150+
return true;
151+
}
152+
}
153+
return false;
154+
}
155+
113156
/** Opens a live streaming response (single attempt, no retry). Used by log streaming. */
114157
public function stream(string $url): ResponseInterface
115158
{

src/Internal/Platform.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Apify\Client\Internal;
6+
7+
/**
8+
* Derives the short, lowercase OS platform token used in the {@code User-Agent} header.
9+
*
10+
* The Apify clients agreed to report the operating system using the same short identifiers that
11+
* Node's {@code os.platform()} yields (e.g. {@code linux}, {@code darwin}, {@code win32}), so a
12+
* server-side parser sees one consistent value across every client. PHP's {@code PHP_OS} instead
13+
* reports uname-style names ({@code Linux}, {@code Darwin}, {@code WINNT}, ...), so we translate
14+
* the ones that differ and lowercase the rest.
15+
*
16+
* @internal
17+
*/
18+
final class Platform
19+
{
20+
/**
21+
* Translates a {@code PHP_OS}-style value into the short lowercase platform token used in the
22+
* User-Agent header. Pass {@code PHP_OS} in production; the argument exists so the mapping can be
23+
* unit-tested for every platform without depending on the host it runs on.
24+
*
25+
* Only two families need translating; everything else (Linux, Darwin, FreeBSD, OpenBSD, NetBSD,
26+
* SunOS, ...) already matches Node's token once lowercased:
27+
* - Windows: PHP reports {@code WINNT} (or {@code Windows}), Node reports {@code win32}.
28+
* - Cygwin: PHP reports {@code CYGWIN_NT-10.0-...}, Node reports {@code cygwin}.
29+
*/
30+
public static function osToken(string $phpOs): string
31+
{
32+
$upper = strtoupper($phpOs);
33+
if (str_starts_with($upper, 'WIN')) {
34+
return 'win32';
35+
}
36+
if (str_starts_with($upper, 'CYGWIN')) {
37+
return 'cygwin';
38+
}
39+
return strtolower($phpOs);
40+
}
41+
42+
private function __construct()
43+
{
44+
}
45+
}

src/Version.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ final class Version
1717
* The semantic version of this client library (see https://semver.org/).
1818
* Changes to the public interface other than additive ones are considered breaking changes.
1919
*/
20-
public const CLIENT_VERSION = '0.1.1';
20+
public const CLIENT_VERSION = '0.2.0';
2121

2222
/**
2323
* The version of the Apify OpenAPI specification this client was generated and verified
2424
* against. Corresponds to the {@code info.version} field of the Apify OpenAPI document.
2525
*/
26-
public const API_SPEC_VERSION = 'v2-2026-07-07T132551Z';
26+
public const API_SPEC_VERSION = 'v2-2026-07-08T143931Z';
2727

2828
private function __construct()
2929
{

tests/Unit/BatchAddRequestsTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ public function testRetriesOnlyUnprocessedFromSuccessfulResponse(): void
114114
self::assertSame([], $result->getUnprocessedRequests());
115115

116116
// The retry must send only the still-unprocessed request (r1), not the whole batch again.
117-
$retryBody = Json::decode((string) $transport->received[1]->getBody());
117+
$retryBody = Json::decode(MockTransport::readBody($transport->received[1]));
118118
self::assertIsArray($retryBody);
119119
self::assertCount(1, $retryBody);
120120
self::assertSame('r1', $retryBody[0]['uniqueKey']);
@@ -150,7 +150,7 @@ public function testChunksByCountLimit(): void
150150
self::assertSame(2, $transport->callCount());
151151
self::assertCount(30, $result->getProcessedRequests());
152152
// First batch must respect the 25-request count limit.
153-
$firstBody = Json::decode((string) $transport->received[0]->getBody());
153+
$firstBody = Json::decode(MockTransport::readBody($transport->received[0]));
154154
self::assertIsArray($firstBody);
155155
self::assertCount(25, $firstBody);
156156
}
@@ -172,7 +172,7 @@ public function testChunksByPayloadByteSize(): void
172172

173173
self::assertSame(2, $transport->callCount());
174174
self::assertCount(3, $result->getProcessedRequests());
175-
$firstBody = Json::decode((string) $transport->received[0]->getBody());
175+
$firstBody = Json::decode(MockTransport::readBody($transport->received[0]));
176176
self::assertIsArray($firstBody);
177177
self::assertCount(2, $firstBody); // byte limit, not the count limit, governed here
178178
}

tests/Unit/CompressionTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Apify\Client\Tests\Unit;
6+
7+
use Apify\Client\Internal\Compression;
8+
use PHPUnit\Framework\TestCase;
9+
10+
final class CompressionTest extends TestCase
11+
{
12+
/** True when at least one compression codec is available in this PHP build. */
13+
private static function hasCodec(): bool
14+
{
15+
return function_exists('brotli_compress') || function_exists('gzencode');
16+
}
17+
18+
public function testSmallBodyIsNotCompressed(): void
19+
{
20+
$body = str_repeat('a', Compression::MIN_COMPRESS_BYTES - 1);
21+
self::assertNull(Compression::maybeCompress($body));
22+
}
23+
24+
public function testBodyAtThresholdIsCompressed(): void
25+
{
26+
if (!self::hasCodec()) {
27+
self::markTestSkipped('no compression codec available in this PHP build');
28+
}
29+
// A body of exactly MIN_COMPRESS_BYTES is compressed (the below-threshold case is covered by
30+
// testSmallBodyIsNotCompressed).
31+
$result = Compression::maybeCompress(str_repeat('a', Compression::MIN_COMPRESS_BYTES));
32+
self::assertNotNull($result);
33+
[$encoding, $data] = $result;
34+
self::assertContains($encoding, ['br', 'gzip']);
35+
self::assertNotSame('', $data);
36+
}
37+
38+
public function testCompressedBodyRoundTrips(): void
39+
{
40+
if (!self::hasCodec()) {
41+
self::markTestSkipped('no compression codec available in this PHP build');
42+
}
43+
$original = json_encode(['items' => array_fill(0, 500, ['field' => 'value-with-some-length'])]);
44+
self::assertIsString($original);
45+
46+
$result = Compression::maybeCompress($original);
47+
self::assertNotNull($result);
48+
[$encoding, $data] = $result;
49+
50+
// Highly repetitive JSON must shrink.
51+
self::assertLessThan(strlen($original), strlen($data));
52+
53+
$decoded = self::decode($encoding, $data);
54+
self::assertSame($original, $decoded);
55+
}
56+
57+
public function testPrefersBrotliWhenAvailableElseGzip(): void
58+
{
59+
if (!self::hasCodec()) {
60+
self::markTestSkipped('no compression codec available in this PHP build');
61+
}
62+
$result = Compression::maybeCompress(str_repeat('payload-', 500));
63+
self::assertNotNull($result);
64+
[$encoding] = $result;
65+
66+
$expected = function_exists('brotli_compress') ? 'br' : 'gzip';
67+
self::assertSame($expected, $encoding);
68+
}
69+
70+
private static function decode(string $encoding, string $data): string
71+
{
72+
if ($encoding === 'br') {
73+
self::assertTrue(function_exists('brotli_uncompress'), 'brotli extension needed to decode');
74+
// Called indirectly: the symbol only exists when the PECL brotli extension is loaded,
75+
// so a direct call would be an unresolved reference for static analysis.
76+
$brotliUncompress = 'brotli_uncompress';
77+
$decoded = $brotliUncompress($data);
78+
} else {
79+
$decoded = gzdecode($data);
80+
}
81+
self::assertIsString($decoded);
82+
return $decoded;
83+
}
84+
}

0 commit comments

Comments
 (0)