Skip to content

Commit facb70f

Browse files
authored
Add more detail when errors occur (#24)
Adds several specific exceptions, including a CodedError with additional codes, to handle various configuration and runtime errors. This converts `ApiError` from a concrete class to an interface, which ensures that catch blocks following the documented best practices will continue to work as before. Fixes #23 In a real application, you'll now (typically) get back some actionable details. ![Screenshot 2024-09-25 at 12 23 03 PM](https://github.com/user-attachments/assets/9c357712-945e-4cb4-8365-3cf28f7b01ee)
1 parent 1f69757 commit facb70f

15 files changed

+329
-36
lines changed

phpunit.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
failOnWarning="true"
99
cacheDirectory=".phpunit.cache"
1010
requireCoverageMetadata="true"
11-
beStrictAboutCoverageMetadata="true">
11+
beStrictAboutCoverageMetadata="false">
1212
<testsuites>
1313
<testsuite name="default">
1414
<directory suffix="Test.php">tests</directory>

src/ApiError.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44

55
namespace SnapAuth;
66

7-
use Exception;
8-
9-
class ApiError extends Exception
7+
interface ApiError
108
{
119
}

src/Client.php

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,12 @@ public function __construct(
5353
if ($secretKey === null) {
5454
$env = getenv('SNAPAUTH_SECRET_KEY');
5555
if ($env === false) {
56-
throw new ApiError(
57-
'Secret key missing. It can be explictly provided, or it ' .
58-
'can be auto-detected from the SNAPAUTH_SECRET_KEY ' .
59-
'environment variable.',
60-
);
56+
throw new Exception\MissingSecretKey();
6157
}
6258
$secretKey = $env;
6359
}
6460
if (!str_starts_with($secretKey, 'secret_')) {
65-
throw new ApiError(
66-
'Invalid secret key. Please verify you copied the full value from the SnapAuth dashboard.',
67-
);
61+
throw new Exception\InvalidSecretKey();
6862
}
6963

7064
$this->secretKey = $secretKey;
@@ -136,31 +130,52 @@ public function makeApiCall(string $route, array $params): array
136130
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
137131

138132
if ($response === false || $errno !== CURLE_OK) {
139-
$this->error();
133+
throw new Exception\Network($errno);
140134
}
135+
} finally {
136+
curl_close($ch);
137+
}
141138

142-
if ($code >= 300) {
143-
$this->error();
144-
}
145-
// Handle non-200s, non-JSON (severe upstream error)
146-
assert(is_string($response));
139+
assert(is_string($response), 'No response body despite CURLOPT_RETURNTRANSFER');
140+
try {
147141
$decoded = json_decode($response, true, flags: JSON_THROW_ON_ERROR);
148-
assert(is_array($decoded));
149-
return $decoded['result'];
150142
} catch (JsonException) {
151-
$this->error();
152-
} finally {
153-
curl_close($ch);
143+
// Received non-JSON response - wrap and rethrow
144+
throw new Exception\MalformedResponse('Received non-JSON response', $code);
154145
}
155-
}
156146

157-
/**
158-
* TODO: specific error info!
159-
*/
160-
private function error(): never
161-
{
162-
throw new ApiError();
163-
// TODO: also make this more specific
147+
if (!is_array($decoded) || !array_key_exists('result', $decoded)) {
148+
// Received JSON response in an unexpected format
149+
throw new Exception\MalformedResponse('Received JSON in an unexpected format', $code);
150+
}
151+
152+
// Success!
153+
if ($decoded['result'] !== null) {
154+
assert($code >= 200 && $code < 300, 'Got a result with a non-2xx response');
155+
return $decoded['result'];
156+
}
157+
158+
// The `null` result indicated an error. Parse out the response shape
159+
// more and throw an appropriate ApiError.
160+
if (!array_key_exists('errors', $decoded)) {
161+
throw new Exception\MalformedResponse('Error details missing', $code);
162+
}
163+
$errors = $decoded['errors'];
164+
if (!is_array($errors) || !array_is_list($errors) || count($errors) === 0) {
165+
throw new Exception\MalformedResponse('Error details are invalid or empty', $code);
166+
}
167+
168+
$primaryError = $errors[0];
169+
if (
170+
!is_array($primaryError)
171+
|| !array_key_exists('code', $primaryError)
172+
|| !array_key_exists('message', $primaryError)
173+
) {
174+
throw new Exception\MalformedResponse('Error details are invalid or empty', $code);
175+
}
176+
177+
// Finally, the error details are known to be in the correct shape.
178+
throw new Exception\CodedError($primaryError['message'], $primaryError['code'], $code);
164179
}
165180

166181
public function __debugInfo(): array

src/ErrorCode.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SnapAuth;
6+
7+
enum ErrorCode: string
8+
{
9+
case AuthenticatingUserAccountNotFound = 'AuthenticatingUserAccountNotFound';
10+
case EntityNotFound = 'EntityNotFound';
11+
case HandleCannotChange = 'HandleCannotChange';
12+
case HandleInUseByDifferentAccount = 'HandleInUseByDifferentAccount';
13+
case InvalidAuthorizationHeader = 'InvalidAuthorizationHeader';
14+
case InvalidInput = 'InvalidInput';
15+
case PermissionViolation = 'PermissionViolation';
16+
case PublishableKeyNotFound = 'PublishableKeyNotFound';
17+
case RegisteredUserLimitReached = 'RegisteredUserLimitReached';
18+
case SecretKeyExpired = 'SecretKeyExpired';
19+
case SecretKeyNotFound = 'SecretKeyNotFound';
20+
case TokenExpired = 'TokenExpired';
21+
case TokenNotFound = 'TokenNotFound';
22+
case UsingDeactivatedCredential = 'UsingDeactivatedCredential';
23+
24+
/**
25+
* This is a catch-all code if the API has returned an error code that's
26+
* unknown to this SDK. Often this means that a new SDK version will handle
27+
* the new code.
28+
*/
29+
case Unknown = '(unknown)';
30+
}

src/Exception/CodedError.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SnapAuth\Exception;
6+
7+
use RuntimeException;
8+
use SnapAuth\{
9+
ApiError,
10+
ErrorCode,
11+
};
12+
13+
use function sprintf;
14+
15+
/**
16+
* The API returned a well-formed coded error message. Examine the $errorCode
17+
* property for additional information.
18+
*/
19+
class CodedError extends RuntimeException implements ApiError
20+
{
21+
public readonly ErrorCode $errorCode;
22+
23+
/**
24+
* @param int $httpCode The HTTP status code of the error response
25+
*
26+
* @internal Constructing errors is not covered by BC
27+
*/
28+
public function __construct(string $message, string $errorCode, int $httpCode)
29+
{
30+
parent::__construct(
31+
message: sprintf(
32+
'[HTTP %d] %s: %s',
33+
$httpCode,
34+
$errorCode,
35+
$message,
36+
),
37+
code: $httpCode,
38+
);
39+
$this->errorCode = ErrorCode::tryFrom($errorCode) ?? ErrorCode::Unknown;
40+
}
41+
}

src/Exception/InvalidSecretKey.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SnapAuth\Exception;
6+
7+
use InvalidArgumentException;
8+
use SnapAuth\ApiError;
9+
10+
class InvalidSecretKey extends InvalidArgumentException implements ApiError
11+
{
12+
public function __construct()
13+
{
14+
parent::__construct(
15+
message: 'Invalid secret key. Please verify you copied the full value from the SnapAuth dashboard.',
16+
);
17+
}
18+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SnapAuth\Exception;
6+
7+
use RuntimeException;
8+
use SnapAuth\ApiError;
9+
10+
use function sprintf;
11+
12+
/**
13+
* A response arrived, but was not in an expected format
14+
*/
15+
class MalformedResponse extends RuntimeException implements ApiError
16+
{
17+
/**
18+
* @internal Constructing errors is not covered by BC
19+
*/
20+
public function __construct(string $details, int $statusCode)
21+
{
22+
parent::__construct(
23+
message: sprintf(
24+
'[HTTP %d] SnapAuth API returned data in an unexpected format: %s',
25+
$statusCode,
26+
$details,
27+
),
28+
code: $statusCode,
29+
);
30+
}
31+
}

src/Exception/MissingSecretKey.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SnapAuth\Exception;
6+
7+
use InvalidArgumentException;
8+
use SnapAuth\ApiError;
9+
10+
class MissingSecretKey extends InvalidArgumentException implements ApiError
11+
{
12+
public function __construct()
13+
{
14+
parent::__construct(
15+
'Secret key missing. It can be explictly provided, or it can be ' .
16+
'auto-detected from the SNAPAUTH_SECRET_KEY environment variable.'
17+
);
18+
}
19+
}

src/Exception/Network.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SnapAuth\Exception;
6+
7+
use RuntimeException;
8+
use SnapAuth\ApiError;
9+
10+
/**
11+
* A network interruption occurred.
12+
*/
13+
class Network extends RuntimeException implements ApiError
14+
{
15+
/**
16+
* @param $code a cURL error code
17+
*
18+
* @internal Constructing errors is not covered by BC
19+
*/
20+
public function __construct(int $code)
21+
{
22+
$message = curl_strerror($code);
23+
parent::__construct(
24+
message: 'SnapAuth network error: ' . $message,
25+
code: $code,
26+
);
27+
}
28+
}

tests/ClientTest.php

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,20 @@ public function testConstructSecretKeyAutodetectInvalid(): void
2323
{
2424
assert(getenv('SNAPAUTH_SECRET_KEY') === false);
2525
putenv('SNAPAUTH_SECRET_KEY=invalid');
26-
self::expectException(ApiError::class);
27-
self::expectExceptionMessage('Invalid secret key.');
26+
self::expectException(Exception\InvalidSecretKey::class);
2827
new Client();
2928
}
3029

3130
public function testConstructSecretKeyAutodetectMissing(): void
3231
{
3332
assert(getenv('SNAPAUTH_SECRET_KEY') === false);
34-
self::expectException(ApiError::class);
35-
self::expectExceptionMessage('Secret key missing.');
33+
self::expectException(Exception\MissingSecretKey::class);
3634
new Client();
3735
}
3836

3937
public function testSecretKeyValidation(): void
4038
{
41-
self::expectException(ApiError::class);
39+
self::expectException(Exception\InvalidSecretKey::class);
4240
new Client(secretKey: 'not_a_secret');
4341
}
4442

0 commit comments

Comments
 (0)