Skip to content

Instantly share code, notes, and snippets.

@Pnoexz
Last active March 25, 2021 15:49
Show Gist options
  • Save Pnoexz/a435d1a0fb92a6806684142c5e0cdfc0 to your computer and use it in GitHub Desktop.
Save Pnoexz/a435d1a0fb92a6806684142c5e0cdfc0 to your computer and use it in GitHub Desktop.

PHP API Exception

PHP API Exception relies on the following principles:

Why should I use this package?

If you've never used Exceptions to handle and display errors, here's a great quick introductory video by freekmurze. This implementation, however, fails short on the first two principles.

Each exception must be unique

Let's say one of our processes sending the emails dies after marking the campaign as being sent and we want to handle this specific case from elsewhere in the code. There's no way for us to reliably determine why ensureSendable() threw an exception. By using unique exceptions, we can specifically catch those we need to handle differently and let the rest bubble up. Our calling code would look like this:

try {
    $this->ensureSendable($campaign);
} catch (\Acme\Exceptions\CouldNotSendCampaign\BeingSent $exception) {
    // Check campaign hasn't been in sending state for too long
    // If so, do something about it, like creating an alert on a different system
    throw $exception;
}

Consumers MUST NOT rely on the human-readable error message to identify it.

What happens when we want to add the sent date if the campaign was already sent? Changing the exception message would introduce a breaking change to all consumers that rely on this message to handle this error or modify it (for example, to apply internationalization or convert the date to local time). In order to address this, ApiException introduces two fields: class and data.

{
  "class": "Acme\\Exceptions\\CouldNotSendCampaign\\AlreadySent",
  "message": "The campaign with id 8354 was already sent on 2020-03-03T01:35:31+00:00.",
  "statusCode": 400,
  "data": {
    "campaignId": 8354,
    "sentAt": "2020-03-03T01:35:31+00:00"
  }
}

Do note the message is still relevant for consumers that don't have to transform it.

Signature

/**
 * @param array<mixed>|null $data   Any JSON serializable data we should
 *                                  send to the consumer along with the
 *                                  built-in information
 * @param \Throwable|null $previous The previous exception thrown to
 *                                  maintain the exception chain
 */
ApiException::__construct(?array $data = [], ?\Throwable $previous = null)

Examples

Simple usage

<?php

namespace Pnoexz\Examples\Exceptions;

use Pnoexz\ApiException\Http\ClientError\NotFoundException;

class ArticleNotFoundException extends NotFoundException
{
    public string $typedMessage = "Article not found";
}

Will output the following JSON:

{
  "class": "Pnoexz\\Examples\\Exceptions\\ArticleNotFoundException",
  "message": "Article not found",
  "statusCode": 404
}

Creating a custom exception

If you need to support an HTTP Status Code that's not on the standard or is missing, it's possible to extend from \Pnoexz\ApiException\Http\ClientError\ClientErrorException (default level WARNING), \Pnoexz\ApiException\Http\ServerError\ServerErrorException (default level ERROR) or Pnoexz\ApiException (no default level).

<?php

namespace Pnoexz\Examples\Exceptions;

use Pnoexz\ApiException;

class ArticleNotFoundException extends ApiException
{
    protected int $httpStatus = 701;
    protected string $level = \Psr\Log\LogLevel::WARNING;
    public string $typedMessage = "Article not found";
}

Sending extra data to the client along with the exception

Exceptions have an array of JSON serializable data if you need to provide context to consumers. This can be either internal, such as providing the context for your logger, or external, so clients can handle errors gracefully.

Suppose we are building an ecommerce system in which users can't have more than one pending cart at the same time. If the user tries to create a new cart, the frontend can use the data field in our response to redirect to their existing cart using its id. This example assumes the returned value of $this->getPendingCartByUser($user) can be serialized in JSON.

Exception

<?php

namespace Pnoexz\Examples\Exceptions;

use Pnoexz\ApiException\Http\ClientError\ConflictException;

class PendingCartExistsException extends ConflictException
{
    public string $typedMessage = "A pending cart already exists";
}

From the service

$potentiallyPendingCart = $this->getPendingCartByUser($user);
if (!empty($potentiallyPendingCart)) {
    throw new PendingCartExistsException(['cart' => $potentiallyPendingCart]);
}

Output to the client

{
  "class": "Pnoexz\\Examples\\Exceptions\\PendingCartExistsException",
  "message": "A pending cart already exists",
  "statusCode": 409,
  "data": {
    "cart": {
      "id": 8,
      "items": [{ "id": 8931 }]
    }
  }
}

Maintaining the previously thrown exception

In this case, we will be catching an exception that contains sensitive data (a raw SQL query). We need to separate what the client will see and what we should log. This might require some extra code in our handler, but when throwing the exception, all we need to do is the pass what we caught to the second parameter.

class FruitRepository
{
    public function get(int $id)
    {
        try {
            $sth = $this->dbh->prepare("
                SELECT name, colour, calories
                FROM fruit
                WHERE id = :id
            ");

            $sth->execute([':id' => $id]);
            return $sth->fetch(PDO::FETCH_ASSOC);
        } catch (\Exception $e) {
            throw new DatabaseException([], $e);
        }
    }
}

We can then catch this exception from the calling method and do similarly; maintaining the exception trace and sending back a nice output to the client.

Handler

These examples use the Slim3 error handler but should work similarly with other frameworks.

Handling only ApiExceptions

    /**
     * @param Request    $request
     * @param Response   $response
     * @param \Throwable $throwable
     * @return Response
     */
    public function __invoke(
        Request $request,
        Response $response,
        ApiException $exception
    ) {
        $encodedBody = \json_encode($exception);

        $this->log(
            $exception->getLevel(),
            $exception->getMessage(),
            $encodedBody
        );

        return $response->withJson($encodedBody, $exception->getStatusCode());
    }

Handling all exceptions

    /**
     * @param Request    $request
     * @param Response   $response
     * @param \Throwable $throwable
     * @return Response
     */
    public function __invoke(
        Request $request,
        Response $response,
        \Throwable $throwable
    ) {
        $statusCode = 500;
        $level = LogLevel::ERROR;
        $message = $throwable->getMessage();

        if ($throwable instanceof ApiException) {
            $statusCode = $throwable->getStatusCode();
            $level = $throwable->getLevel();
        }

        $body = [
            'statusCode' => $statusCode,
            'level' => $level,
            'message' => $message,
        ];

        $this->log($level, $message, $body);

        return $response->withJson($body, $statusCode);
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment