Skip to content

Commit 8fb87c4

Browse files
feat(webhooks): align cross-SDK contract — InvalidWebhookError + gunzipPayload
- Rename InvalidWebhookException → InvalidWebhookError (extends StreamException) - Actually throw InvalidWebhookError on every failure path (was raw StreamException) - Align messages to canonical strings via class constants: SIGNATURE_MISMATCH / INVALID_BASE64 / GZIP_FAILED / INVALID_JSON - parseEvent JsonException + non-array now wrapped as InvalidWebhookError(INVALID_JSON) - Rename ungzipPayload → gunzipPayload everywhere - declare(strict_types=1) in new files
1 parent 7399515 commit 8fb87c4

5 files changed

Lines changed: 68 additions & 60 deletions

File tree

lib/GetStream/StreamChat/Client.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,14 +1250,14 @@ public static function verifySignature(string $body, string $signature, string $
12501250
/** Returns `$body` unchanged unless it starts with the gzip magic, in which
12511251
* case the gzip stream is inflated and the decompressed bytes are returned.
12521252
*
1253-
* Backward-compatible alias for {@see Webhook::ungzipPayload()}; new code
1253+
* Backward-compatible alias for {@see Webhook::gunzipPayload()}; new code
12541254
* should call the canonical helper directly.
12551255
*
1256-
* @throws StreamException
1256+
* @throws InvalidWebhookError
12571257
*/
1258-
public static function ungzipPayload(string $body): string
1258+
public static function gunzipPayload(string $body): string
12591259
{
1260-
return Webhook::ungzipPayload($body);
1260+
return Webhook::gunzipPayload($body);
12611261
}
12621262

12631263
/** Reverses the SQS firehose envelope (base64 + optional gzip).
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace GetStream\StreamChat;
6+
7+
/**
8+
* Raised by webhook verify/parse helpers when the HMAC does not match, or a
9+
* gzip/base64/JSON envelope cannot be decoded.
10+
*
11+
* The message text identifies which failure mode fired; the class constants
12+
* below are the canonical strings for callers that prefer exact-match
13+
* filtering over substring matching.
14+
*/
15+
class InvalidWebhookError extends StreamException
16+
{
17+
public const SIGNATURE_MISMATCH = 'signature mismatch';
18+
public const INVALID_BASE64 = 'invalid base64 encoding';
19+
public const GZIP_FAILED = 'gzip decompression failed';
20+
public const INVALID_JSON = 'invalid JSON payload';
21+
}

lib/GetStream/StreamChat/InvalidWebhookException.php

Lines changed: 0 additions & 13 deletions
This file was deleted.

lib/GetStream/StreamChat/Webhook.php

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
declare(strict_types=0);
3+
declare(strict_types=1);
44

55
namespace GetStream\StreamChat;
66

@@ -10,7 +10,7 @@
1010
*
1111
* The composite functions (`verifyAndParseWebhook`, `parseSqs`, `parseSns`).
1212
* The primitives they
13-
* compose (`ungzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`,
13+
* compose (`gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`,
1414
* `verifySignature`, `parseEvent`) are exposed so callers can build custom
1515
* flows or run individual steps in isolation.
1616
*
@@ -39,17 +39,17 @@ public static function verifySignature(string $body, string $signature, string $
3939
* handler correct when middleware auto-decompresses the request before your
4040
* code sees it.
4141
*
42-
* @throws StreamException when the body has the gzip magic but cannot be
42+
* @throws InvalidWebhookError when the body has the gzip magic but cannot be
4343
* inflated.
4444
*/
45-
public static function ungzipPayload(string $body): string
45+
public static function gunzipPayload(string $body): string
4646
{
4747
if (substr($body, 0, 2) !== "\x1f\x8b") {
4848
return $body;
4949
}
5050
$decoded = @gzdecode($body);
5151
if ($decoded === false) {
52-
throw new StreamException('failed to decompress gzip payload');
52+
throw new InvalidWebhookError(InvalidWebhookError::GZIP_FAILED);
5353
}
5454
return $decoded;
5555
}
@@ -58,16 +58,16 @@ public static function ungzipPayload(string $body): string
5858
* and, when the result begins with the gzip magic, gzip-decompressed. The
5959
* same call works whether or not Stream is currently compressing payloads.
6060
*
61-
* @throws StreamException when the input is not valid base64 or the inner
62-
* gzip stream cannot be inflated.
61+
* @throws InvalidWebhookError when the input is not valid base64 or the
62+
* inner gzip stream cannot be inflated.
6363
*/
6464
public static function decodeSqsPayload(string $body): string
6565
{
6666
$decoded = base64_decode($body, true);
6767
if ($decoded === false) {
68-
throw new StreamException('failed to base64-decode payload');
68+
throw new InvalidWebhookError(InvalidWebhookError::INVALID_BASE64);
6969
}
70-
return self::ungzipPayload($decoded);
70+
return self::gunzipPayload($decoded);
7171
}
7272

7373
/** Reverses an SNS HTTP notification envelope. When `$notificationBody` is
@@ -77,7 +77,7 @@ public static function decodeSqsPayload(string $body): string
7777
* envelope it is treated as the already-extracted `Message` string, so
7878
* call sites that pre-unwrap continue to work.
7979
*
80-
* @throws StreamException
80+
* @throws InvalidWebhookError
8181
*/
8282
public static function decodeSnsPayload(string $notificationBody): string
8383
{
@@ -107,17 +107,17 @@ private static function extractSnsMessage(string $notificationBody): ?string
107107
* changing call sites.
108108
*
109109
* @return array<string, mixed>
110-
* @throws StreamException when the bytes are not valid JSON.
110+
* @throws InvalidWebhookError when the bytes are not valid JSON.
111111
*/
112112
public static function parseEvent(string $payload): array
113113
{
114114
try {
115115
$event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
116116
} catch (\JsonException $e) {
117-
throw new StreamException('failed to parse webhook event: ' . $e->getMessage());
117+
throw new InvalidWebhookError(InvalidWebhookError::INVALID_JSON);
118118
}
119119
if (!is_array($event)) {
120-
throw new StreamException('failed to parse webhook event: top-level value is not an object');
120+
throw new InvalidWebhookError(InvalidWebhookError::INVALID_JSON);
121121
}
122122
return $event;
123123
}
@@ -126,14 +126,14 @@ public static function parseEvent(string $payload): array
126126
* the parsed event.
127127
*
128128
* @return array<string, mixed>
129-
* @throws StreamException when the signature does not match or the gzip
129+
* @throws InvalidWebhookError when the signature does not match or the gzip
130130
* envelope is malformed.
131131
*/
132132
public static function verifyAndParseWebhook(string $body, string $signature, string $secret): array
133133
{
134-
$inflated = self::ungzipPayload($body);
134+
$inflated = self::gunzipPayload($body);
135135
if (!self::verifySignature($inflated, $signature, $secret)) {
136-
throw new StreamException('invalid webhook signature');
136+
throw new InvalidWebhookError(InvalidWebhookError::SIGNATURE_MISMATCH);
137137
}
138138
return self::parseEvent($inflated);
139139
}
@@ -142,7 +142,7 @@ public static function verifyAndParseWebhook(string $body, string $signature, st
142142
* Stream does not HMAC-sign SQS message bodies.
143143
*
144144
* @return array<string, mixed>
145-
* @throws StreamException
145+
* @throws InvalidWebhookError
146146
*/
147147
public static function parseSqs(string $messageBody): array
148148
{
@@ -154,7 +154,7 @@ public static function parseSqs(string $messageBody): array
154154
/** Decode an SNS payload (unwrap envelope when present). No HMAC verification.
155155
*
156156
* @return array<string, mixed>
157-
* @throws StreamException
157+
* @throws InvalidWebhookError
158158
*/
159159
public static function parseSns(string $message): array
160160
{

tests/unit/WebhookCompressionTest.php

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<?php
22

3-
declare(strict_types=0);
3+
declare(strict_types=1);
44

55
namespace GetStream\Unit;
66

77
use GetStream\StreamChat\Client;
8-
use GetStream\StreamChat\StreamException;
8+
use GetStream\StreamChat\InvalidWebhookError;
99
use GetStream\StreamChat\Webhook;
1010
use PHPUnit\Framework\TestCase;
1111

@@ -27,34 +27,34 @@ private function sign(string $body): string
2727
return hash_hmac('sha256', $body, self::API_SECRET);
2828
}
2929

30-
public function testUngzipPayloadPassthroughPlainBytes(): void
30+
public function testGunzipPayloadPassthroughPlainBytes(): void
3131
{
32-
$this->assertSame(self::JSON_BODY, Client::ungzipPayload(self::JSON_BODY));
32+
$this->assertSame(self::JSON_BODY, Client::gunzipPayload(self::JSON_BODY));
3333
}
3434

35-
public function testUngzipPayloadInflatesGzipBytes(): void
35+
public function testGunzipPayloadInflatesGzipBytes(): void
3636
{
3737
$compressed = gzencode(self::JSON_BODY);
3838
$this->assertNotFalse($compressed);
39-
$this->assertSame(self::JSON_BODY, Client::ungzipPayload($compressed));
39+
$this->assertSame(self::JSON_BODY, Client::gunzipPayload($compressed));
4040
}
4141

42-
public function testUngzipPayloadEmptyInput(): void
42+
public function testGunzipPayloadEmptyInput(): void
4343
{
44-
$this->assertSame('', Client::ungzipPayload(''));
44+
$this->assertSame('', Client::gunzipPayload(''));
4545
}
4646

47-
public function testUngzipPayloadShortInputBelowMagicLength(): void
47+
public function testGunzipPayloadShortInputBelowMagicLength(): void
4848
{
49-
$this->assertSame('ab', Client::ungzipPayload('ab'));
49+
$this->assertSame('ab', Client::gunzipPayload('ab'));
5050
}
5151

52-
public function testUngzipPayloadThrowsOnTruncatedGzipMagic(): void
52+
public function testGunzipPayloadThrowsOnTruncatedGzipMagic(): void
5353
{
5454
$bad = "\x1f\x8b\x08\x00\x00\x00";
55-
$this->expectException(StreamException::class);
56-
$this->expectExceptionMessageMatches('/decompress gzip/');
57-
Client::ungzipPayload($bad);
55+
$this->expectException(InvalidWebhookError::class);
56+
$this->expectExceptionMessage(InvalidWebhookError::GZIP_FAILED);
57+
Client::gunzipPayload($bad);
5858
}
5959

6060
public function testDecodeSqsPayloadBase64Only(): void
@@ -76,8 +76,8 @@ public function testDecodeSqsPayloadBase64Plusgzip(): void
7676

7777
public function testDecodeSqsPayloadThrowsOnMalformedBase64(): void
7878
{
79-
$this->expectException(StreamException::class);
80-
$this->expectExceptionMessageMatches('/base64-decode/');
79+
$this->expectException(InvalidWebhookError::class);
80+
$this->expectExceptionMessage(InvalidWebhookError::INVALID_BASE64);
8181
Client::decodeSqsPayload('!!!not-base64!!!');
8282
}
8383

@@ -160,8 +160,8 @@ public function testParseEventUnknownTypeStillParses(): void
160160

161161
public function testParseEventMalformedJsonThrows(): void
162162
{
163-
$this->expectException(StreamException::class);
164-
$this->expectExceptionMessageMatches('/parse webhook event/');
163+
$this->expectException(InvalidWebhookError::class);
164+
$this->expectExceptionMessage(InvalidWebhookError::INVALID_JSON);
165165
Client::parseEvent('not json');
166166
}
167167

@@ -182,17 +182,17 @@ public function testVerifyAndParseWebhookGzip(): void
182182

183183
public function testVerifyAndParseWebhookSignatureMismatch(): void
184184
{
185-
$this->expectException(StreamException::class);
186-
$this->expectExceptionMessageMatches('/invalid webhook signature/');
185+
$this->expectException(InvalidWebhookError::class);
186+
$this->expectExceptionMessage(InvalidWebhookError::SIGNATURE_MISMATCH);
187187
$this->client->verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64));
188188
}
189189

190190
public function testVerifyAndParseWebhookRejectsSignatureOverCompressedBytes(): void
191191
{
192192
$compressed = gzencode(self::JSON_BODY);
193193
$sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET);
194-
$this->expectException(StreamException::class);
195-
$this->expectExceptionMessageMatches('/invalid webhook signature/');
194+
$this->expectException(InvalidWebhookError::class);
195+
$this->expectExceptionMessage(InvalidWebhookError::SIGNATURE_MISMATCH);
196196
$this->client->verifyAndParseWebhook($compressed, $sigOverCompressed);
197197
}
198198

@@ -249,7 +249,7 @@ public function testWebhookStaticPrimitivesMatchClient(): void
249249
{
250250
$compressed = gzencode(self::JSON_BODY);
251251
$wrapped = base64_encode($compressed);
252-
$this->assertSame(Client::ungzipPayload($compressed), Webhook::ungzipPayload($compressed));
252+
$this->assertSame(Client::gunzipPayload($compressed), Webhook::gunzipPayload($compressed));
253253
$this->assertSame(Client::decodeSqsPayload($wrapped), Webhook::decodeSqsPayload($wrapped));
254254
$this->assertSame(Client::decodeSnsPayload($wrapped), Webhook::decodeSnsPayload($wrapped));
255255
$sig = $this->sign(self::JSON_BODY);
@@ -274,8 +274,8 @@ public function testWebhookVerifyAndParseWebhookStaticGzip(): void
274274

275275
public function testWebhookVerifyAndParseWebhookStaticSignatureMismatch(): void
276276
{
277-
$this->expectException(StreamException::class);
278-
$this->expectExceptionMessageMatches('/invalid webhook signature/');
277+
$this->expectException(InvalidWebhookError::class);
278+
$this->expectExceptionMessage(InvalidWebhookError::SIGNATURE_MISMATCH);
279279
Webhook::verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64), self::API_SECRET);
280280
}
281281

0 commit comments

Comments
 (0)