From facb70f34e99672a8e3a4180343811f38ef58e5b Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Wed, 16 Oct 2024 09:44:37 -0700 Subject: [PATCH] Add more detail when errors occur (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- phpunit.xml | 2 +- src/ApiError.php | 4 +- src/Client.php | 69 ++++++++++++++--------- src/ErrorCode.php | 30 ++++++++++ src/Exception/CodedError.php | 41 ++++++++++++++ src/Exception/InvalidSecretKey.php | 18 ++++++ src/Exception/MalformedResponse.php | 31 ++++++++++ src/Exception/MissingSecretKey.php | 19 +++++++ src/Exception/Network.php | 28 +++++++++ tests/ClientTest.php | 8 +-- tests/Exception/CodedErrorTest.php | 28 +++++++++ tests/Exception/InvalidSecretKeyTest.php | 21 +++++++ tests/Exception/MalformedResponseTest.php | 22 ++++++++ tests/Exception/MissingSecretKeyTest.php | 21 +++++++ tests/Exception/NetworkTest.php | 23 ++++++++ 15 files changed, 329 insertions(+), 36 deletions(-) create mode 100644 src/ErrorCode.php create mode 100644 src/Exception/CodedError.php create mode 100644 src/Exception/InvalidSecretKey.php create mode 100644 src/Exception/MalformedResponse.php create mode 100644 src/Exception/MissingSecretKey.php create mode 100644 src/Exception/Network.php create mode 100644 tests/Exception/CodedErrorTest.php create mode 100644 tests/Exception/InvalidSecretKeyTest.php create mode 100644 tests/Exception/MalformedResponseTest.php create mode 100644 tests/Exception/MissingSecretKeyTest.php create mode 100644 tests/Exception/NetworkTest.php diff --git a/phpunit.xml b/phpunit.xml index 41d88c5..0ba7278 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,7 +8,7 @@ failOnWarning="true" cacheDirectory=".phpunit.cache" requireCoverageMetadata="true" - beStrictAboutCoverageMetadata="true"> + beStrictAboutCoverageMetadata="false"> tests diff --git a/src/ApiError.php b/src/ApiError.php index 6ddd4ba..bfc21ac 100644 --- a/src/ApiError.php +++ b/src/ApiError.php @@ -4,8 +4,6 @@ namespace SnapAuth; -use Exception; - -class ApiError extends Exception +interface ApiError { } diff --git a/src/Client.php b/src/Client.php index 6664889..d538c9c 100644 --- a/src/Client.php +++ b/src/Client.php @@ -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; @@ -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 diff --git a/src/ErrorCode.php b/src/ErrorCode.php new file mode 100644 index 0000000..84fc85d --- /dev/null +++ b/src/ErrorCode.php @@ -0,0 +1,30 @@ +errorCode = ErrorCode::tryFrom($errorCode) ?? ErrorCode::Unknown; + } +} diff --git a/src/Exception/InvalidSecretKey.php b/src/Exception/InvalidSecretKey.php new file mode 100644 index 0000000..a90b02a --- /dev/null +++ b/src/Exception/InvalidSecretKey.php @@ -0,0 +1,18 @@ +errorCode); + } + + public function testFormattingFromUnknownErrorCode(): void + { + $e = new CodedError('Something bad happened', 'SevereError', 400); + self::assertSame(ErrorCode::Unknown, $e->errorCode); + } +} diff --git a/tests/Exception/InvalidSecretKeyTest.php b/tests/Exception/InvalidSecretKeyTest.php new file mode 100644 index 0000000..449511b --- /dev/null +++ b/tests/Exception/InvalidSecretKeyTest.php @@ -0,0 +1,21 @@ +getMessage()); + } +} diff --git a/tests/Exception/MalformedResponseTest.php b/tests/Exception/MalformedResponseTest.php new file mode 100644 index 0000000..a8cc765 --- /dev/null +++ b/tests/Exception/MalformedResponseTest.php @@ -0,0 +1,22 @@ +getMessage()); + self::assertSame(503, $e->getCode()); + } +} diff --git a/tests/Exception/MissingSecretKeyTest.php b/tests/Exception/MissingSecretKeyTest.php new file mode 100644 index 0000000..f968876 --- /dev/null +++ b/tests/Exception/MissingSecretKeyTest.php @@ -0,0 +1,21 @@ +getMessage()); + } +} diff --git a/tests/Exception/NetworkTest.php b/tests/Exception/NetworkTest.php new file mode 100644 index 0000000..c80e04d --- /dev/null +++ b/tests/Exception/NetworkTest.php @@ -0,0 +1,23 @@ +getMessage()); + } +}