Skip to content

Commit

Permalink
Add more detail when errors occur (#24)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
Firehed authored Oct 16, 2024
1 parent 1f69757 commit facb70f
Show file tree
Hide file tree
Showing 15 changed files with 329 additions and 36 deletions.
2 changes: 1 addition & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
failOnWarning="true"
cacheDirectory=".phpunit.cache"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true">
beStrictAboutCoverageMetadata="false">
<testsuites>
<testsuite name="default">
<directory suffix="Test.php">tests</directory>
Expand Down
4 changes: 1 addition & 3 deletions src/ApiError.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

namespace SnapAuth;

use Exception;

class ApiError extends Exception
interface ApiError
{
}
69 changes: 42 additions & 27 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,12 @@ public function __construct(
if ($secretKey === null) {
$env = getenv('SNAPAUTH_SECRET_KEY');
if ($env === false) {
throw new ApiError(
'Secret key missing. It can be explictly provided, or it ' .
'can be auto-detected from the SNAPAUTH_SECRET_KEY ' .
'environment variable.',
);
throw new Exception\MissingSecretKey();
}
$secretKey = $env;
}
if (!str_starts_with($secretKey, 'secret_')) {
throw new ApiError(
'Invalid secret key. Please verify you copied the full value from the SnapAuth dashboard.',
);
throw new Exception\InvalidSecretKey();
}

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

if ($response === false || $errno !== CURLE_OK) {
$this->error();
throw new Exception\Network($errno);
}
} finally {
curl_close($ch);
}

if ($code >= 300) {
$this->error();
}
// Handle non-200s, non-JSON (severe upstream error)
assert(is_string($response));
assert(is_string($response), 'No response body despite CURLOPT_RETURNTRANSFER');
try {
$decoded = json_decode($response, true, flags: JSON_THROW_ON_ERROR);
assert(is_array($decoded));
return $decoded['result'];
} catch (JsonException) {
$this->error();
} finally {
curl_close($ch);
// Received non-JSON response - wrap and rethrow
throw new Exception\MalformedResponse('Received non-JSON response', $code);
}
}

/**
* TODO: specific error info!
*/
private function error(): never
{
throw new ApiError();
// TODO: also make this more specific
if (!is_array($decoded) || !array_key_exists('result', $decoded)) {
// Received JSON response in an unexpected format
throw new Exception\MalformedResponse('Received JSON in an unexpected format', $code);
}

// Success!
if ($decoded['result'] !== null) {
assert($code >= 200 && $code < 300, 'Got a result with a non-2xx response');
return $decoded['result'];
}

// The `null` result indicated an error. Parse out the response shape
// more and throw an appropriate ApiError.
if (!array_key_exists('errors', $decoded)) {
throw new Exception\MalformedResponse('Error details missing', $code);
}
$errors = $decoded['errors'];
if (!is_array($errors) || !array_is_list($errors) || count($errors) === 0) {
throw new Exception\MalformedResponse('Error details are invalid or empty', $code);
}

$primaryError = $errors[0];
if (
!is_array($primaryError)
|| !array_key_exists('code', $primaryError)
|| !array_key_exists('message', $primaryError)
) {
throw new Exception\MalformedResponse('Error details are invalid or empty', $code);
}

// Finally, the error details are known to be in the correct shape.
throw new Exception\CodedError($primaryError['message'], $primaryError['code'], $code);
}

public function __debugInfo(): array
Expand Down
30 changes: 30 additions & 0 deletions src/ErrorCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace SnapAuth;

enum ErrorCode: string
{
case AuthenticatingUserAccountNotFound = 'AuthenticatingUserAccountNotFound';
case EntityNotFound = 'EntityNotFound';
case HandleCannotChange = 'HandleCannotChange';
case HandleInUseByDifferentAccount = 'HandleInUseByDifferentAccount';
case InvalidAuthorizationHeader = 'InvalidAuthorizationHeader';
case InvalidInput = 'InvalidInput';
case PermissionViolation = 'PermissionViolation';
case PublishableKeyNotFound = 'PublishableKeyNotFound';
case RegisteredUserLimitReached = 'RegisteredUserLimitReached';
case SecretKeyExpired = 'SecretKeyExpired';
case SecretKeyNotFound = 'SecretKeyNotFound';
case TokenExpired = 'TokenExpired';
case TokenNotFound = 'TokenNotFound';
case UsingDeactivatedCredential = 'UsingDeactivatedCredential';

/**
* This is a catch-all code if the API has returned an error code that's
* unknown to this SDK. Often this means that a new SDK version will handle
* the new code.
*/
case Unknown = '(unknown)';
}
41 changes: 41 additions & 0 deletions src/Exception/CodedError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace SnapAuth\Exception;

use RuntimeException;
use SnapAuth\{
ApiError,
ErrorCode,
};

use function sprintf;

/**
* The API returned a well-formed coded error message. Examine the $errorCode
* property for additional information.
*/
class CodedError extends RuntimeException implements ApiError
{
public readonly ErrorCode $errorCode;

/**
* @param int $httpCode The HTTP status code of the error response
*
* @internal Constructing errors is not covered by BC
*/
public function __construct(string $message, string $errorCode, int $httpCode)
{
parent::__construct(
message: sprintf(
'[HTTP %d] %s: %s',
$httpCode,
$errorCode,
$message,
),
code: $httpCode,
);
$this->errorCode = ErrorCode::tryFrom($errorCode) ?? ErrorCode::Unknown;
}
}
18 changes: 18 additions & 0 deletions src/Exception/InvalidSecretKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace SnapAuth\Exception;

use InvalidArgumentException;
use SnapAuth\ApiError;

class InvalidSecretKey extends InvalidArgumentException implements ApiError
{
public function __construct()
{
parent::__construct(
message: 'Invalid secret key. Please verify you copied the full value from the SnapAuth dashboard.',
);
}
}
31 changes: 31 additions & 0 deletions src/Exception/MalformedResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace SnapAuth\Exception;

use RuntimeException;
use SnapAuth\ApiError;

use function sprintf;

/**
* A response arrived, but was not in an expected format
*/
class MalformedResponse extends RuntimeException implements ApiError
{
/**
* @internal Constructing errors is not covered by BC
*/
public function __construct(string $details, int $statusCode)
{
parent::__construct(
message: sprintf(
'[HTTP %d] SnapAuth API returned data in an unexpected format: %s',
$statusCode,
$details,
),
code: $statusCode,
);
}
}
19 changes: 19 additions & 0 deletions src/Exception/MissingSecretKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace SnapAuth\Exception;

use InvalidArgumentException;
use SnapAuth\ApiError;

class MissingSecretKey extends InvalidArgumentException implements ApiError
{
public function __construct()
{
parent::__construct(
'Secret key missing. It can be explictly provided, or it can be ' .
'auto-detected from the SNAPAUTH_SECRET_KEY environment variable.'
);
}
}
28 changes: 28 additions & 0 deletions src/Exception/Network.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace SnapAuth\Exception;

use RuntimeException;
use SnapAuth\ApiError;

/**
* A network interruption occurred.
*/
class Network extends RuntimeException implements ApiError
{
/**
* @param $code a cURL error code
*
* @internal Constructing errors is not covered by BC
*/
public function __construct(int $code)
{
$message = curl_strerror($code);
parent::__construct(
message: 'SnapAuth network error: ' . $message,
code: $code,
);
}
}
8 changes: 3 additions & 5 deletions tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,20 @@ public function testConstructSecretKeyAutodetectInvalid(): void
{
assert(getenv('SNAPAUTH_SECRET_KEY') === false);
putenv('SNAPAUTH_SECRET_KEY=invalid');
self::expectException(ApiError::class);
self::expectExceptionMessage('Invalid secret key.');
self::expectException(Exception\InvalidSecretKey::class);
new Client();
}

public function testConstructSecretKeyAutodetectMissing(): void
{
assert(getenv('SNAPAUTH_SECRET_KEY') === false);
self::expectException(ApiError::class);
self::expectExceptionMessage('Secret key missing.');
self::expectException(Exception\MissingSecretKey::class);
new Client();
}

public function testSecretKeyValidation(): void
{
self::expectException(ApiError::class);
self::expectException(Exception\InvalidSecretKey::class);
new Client(secretKey: 'not_a_secret');
}

Expand Down
28 changes: 28 additions & 0 deletions tests/Exception/CodedErrorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace SnapAuth\Exception;

use SnapAuth\ErrorCode;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

#[CoversClass(CodedError::class)]
#[Small]
class CodedErrorTest extends TestCase
{
public function testFormattingFromKnownErrorCode(): void
{
$e = new CodedError('Missing parameter foo', 'InvalidInput', 400);
self::assertSame(ErrorCode::InvalidInput, $e->errorCode);
}

public function testFormattingFromUnknownErrorCode(): void
{
$e = new CodedError('Something bad happened', 'SevereError', 400);
self::assertSame(ErrorCode::Unknown, $e->errorCode);
}
}
21 changes: 21 additions & 0 deletions tests/Exception/InvalidSecretKeyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace SnapAuth\Exception;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

#[CoversClass(InvalidSecretKey::class)]
#[Small]
class InvalidSecretKeyTest extends TestCase
{
public function testConstruct(): void
{
$e = new InvalidSecretKey();
self::assertStringContainsString('Invalid secret key', $e->getMessage());
}
}
22 changes: 22 additions & 0 deletions tests/Exception/MalformedResponseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace SnapAuth\Exception;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

#[CoversClass(MalformedResponse::class)]
#[Small]
class MalformedResponseTest extends TestCase
{
public function testConstruct(): void
{
$e = new MalformedResponse('Invalid data', 503);
self::assertStringContainsString('Invalid data', $e->getMessage());
self::assertSame(503, $e->getCode());
}
}
Loading

0 comments on commit facb70f

Please sign in to comment.