brandon14/fossabot-commander

View on GitHub
src/FossabotCommander.php

Summary

Maintainability
D
2 days
Test Coverage
A
100%
<?php

/**
 * This file is part of the brandon14/fossabot-commander package.
 *
 * MIT License
 *
 * Copyright (c) 2023-2024 Brandon Clothier
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 *
 */

declare(strict_types=1);

namespace Brandon14\FossabotCommander;

use Throwable;

use function compact;

use DateTimeImmutable;
use DateTimeInterface;

use function get_class;
use function urlencode;

use Psr\Log\LoggerTrait;

use function array_merge;
use function is_callable;
use function json_decode;

use Psr\Log\LoggerInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerAwareInterface;

use function date_create_immutable;

use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Brandon14\FossabotCommander\Context\FossabotContext;
use Brandon14\FossabotCommander\Contracts\Exceptions\RateLimitException;
use Brandon14\FossabotCommander\Contracts\Exceptions\FossabotApiException;
use Brandon14\FossabotCommander\Contracts\Exceptions\JsonParsingException;
use Brandon14\FossabotCommander\Contracts\Exceptions\InvalidTokenException;
use Brandon14\FossabotCommander\Contracts\Exceptions\InvalidStatusException;
use Brandon14\FossabotCommander\Contracts\Exceptions\CannotGetContextException;
use Brandon14\FossabotCommander\Contracts\Exceptions\CannotCreateContextException;
use Brandon14\FossabotCommander\Contracts\Exceptions\CannotExecuteCommandException;
use Brandon14\FossabotCommander\Contracts\Exceptions\CannotValidateRequestException;
use Brandon14\FossabotCommander\Contracts\Exceptions\NoValidLoggerProvidedException;
use Brandon14\FossabotCommander\Contracts\FossabotCommander as FossabotCommanderInterface;
use Brandon14\FossabotCommander\Contracts\Context\FossabotContext as FossabotContextInterface;

/**
 * Main class to invoke a given {@link \Brandon14\FossabotCommander\Contracts\FossabotCommand} instance.
 *
 * @author Brandon Clothier <brandon14125@gmail.com>
 */
class FossabotCommander implements FossabotCommanderInterface, LoggerAwareInterface
{
    use LoggerAwareTrait;
    use LoggerTrait;

    /**
     * Fossabot API version.
     */
    private const FOSSABOT_API_VERSION = 'v2';

    /**
     * Fossabot API route.
     */
    private const FOSSABOT_API_ROUTE = 'customapi';

    /**
     * Fossabot context route.
     */
    private const FOSSABOT_CONTEXT_ROUTE = 'context';

    /**
     * Fossabot validate route.
     */
    private const FOSSABOT_VALIDATE_ROUTE = 'validate';

    /**
     * Fossabot API base URL.
     */
    private const FOSSABOT_API_BASE_URL = 'https://api.fossabot.com/'.self::FOSSABOT_API_VERSION.'/'.self::FOSSABOT_API_ROUTE;

    /**
     * PSR HTTP client instance.
     */
    private ClientInterface $httpClient;

    /**
     * PSR HTTP request factory instance.
     */
    private RequestFactoryInterface $requestFactory;

    /**
     * Whether to enable logging or not.
     */
    private bool $logging = false;

    /**
     * Whether to include additional context to log messages.
     *
     * @noinspection PhpPropertyNamingConventionInspection
     */
    private bool $includeLogContext = false;

    /**
     * Constructs a new FossabotCommander class.
     *
     * @SuppressWarnings(PHPMD.BooleanArgumentFlag)
     *
     * @param \Psr\Http\Client\ClientInterface          $httpClient        PSR HTTP client instance
     * @param \Psr\Http\Message\RequestFactoryInterface $requestFactory    PSR HTTP request factory instance
     * @param \Psr\Log\LoggerInterface|null             $logger            PSR logger instance
     * @param bool                                      $logging           Whether to enable logging or not
     * @param bool                                      $includeLogContext Whether to include additional context to log
     *                                                                     messages
     *
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\NoValidLoggerProvidedException
     */
    public function __construct(
        ClientInterface $httpClient,
        RequestFactoryInterface $requestFactory,
        ?LoggerInterface $logger = null,
        bool $logging = false,
        bool $includeLogContext = true
    ) {
        $this->setHttpClient($httpClient)
            ->setRequestFactory($requestFactory)
            ->setLog($logger)
            ->setLogging($logging)
            ->setIncludeLogContext($includeLogContext);
    }

    /**
     * {@inheritDoc}
     */
    public function getHttpClient(): ClientInterface
    {
        return $this->httpClient;
    }

    /**
     * {@inheritDoc}
     */
    public function setHttpClient(ClientInterface $httpClient): FossabotCommanderInterface
    {
        $this->httpClient = $httpClient;

        return $this;
    }

    /**
     * {@inheritDoc}
     *
     * @noinspection PhpMethodNamingConventionInspection
     */
    public function getRequestFactory(): RequestFactoryInterface
    {
        return $this->requestFactory;
    }

    /**
     * {@inheritDoc}
     *
     * @noinspection PhpMethodNamingConventionInspection
     */
    public function setRequestFactory(RequestFactoryInterface $requestFactory): FossabotCommanderInterface
    {
        $this->requestFactory = $requestFactory;

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function getLogger(): ?LoggerInterface
    {
        return $this->logger;
    }

    /**
     * {@inheritDoc}
     */
    public function setLog(?LoggerInterface $logger): FossabotCommanderInterface
    {
        if ($this->logging && $logger === null) {
            throw new NoValidLoggerProvidedException('No PSR compliant logger provided.');
        }

        $this->logger = $logger;

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function enableLogging(): FossabotCommanderInterface
    {
        return $this->setLogging(true);
    }

    /**
     * {@inheritDoc}
     */
    public function disableLogging(): FossabotCommanderInterface
    {
        return $this->setLogging(false);
    }

    /**
     * {@inheritDoc}
     *
     * @SuppressWarnings(PHPMD.BooleanArgumentFlag)
     */
    public function setLogging(bool $logging): FossabotCommanderInterface
    {
        if ($logging === true && $this->logger === null) {
            throw new NoValidLoggerProvidedException('No PSR compliant logger provided.');
        }

        $this->logging = $logging;

        return $this;
    }

    /**
     * {@inheritDoc}
     *
     * @SuppressWarnings(PHPMD.BooleanGetMethodName)
     */
    public function getLogging(): bool
    {
        return $this->logging;
    }

    /**
     * {@inheritDoc}
     *
     * @SuppressWarnings(PHPMD.BooleanArgumentFlag)
     *
     * @noinspection PhpMethodNamingConventionInspection
     */
    public function setIncludeLogContext(bool $includeLogContext): FossabotCommanderInterface
    {
        $this->includeLogContext = $includeLogContext;

        return $this;
    }

    /**
     * {@inheritDoc}
     *
     * @SuppressWarnings(PHPMD.BooleanGetMethodName)
     *
     * @noinspection PhpMethodNamingConventionInspection
     */
    public function getIncludeLogContext(): bool
    {
        return $this->includeLogContext;
    }

    /**
     * {@inheritDoc}
     *
     * @SuppressWarnings(PHPMD.BooleanArgumentFlag)
     */
    public function runCommand(
        $command,
        string $customApiToken,
        bool $getContext = true
    ): string {
        $context = null;
        $body = null;

        $this->info('Sending request to validate incoming Fossabot request.');

        try {
            // Send validate request. Will throw exception if unable to validate.
            $this->sendValidateRequest($customApiToken);

            $this->info('Validated Fossabot request.');
        } catch (Throwable $exception) {
            $this->error(
                "Caught exception during validation with message [{$exception->getMessage()}].",
                compact('exception'),
            );

            // Allow rate limit exceptions to be rethrown here.
            if ($exception instanceof RateLimitException) {
                $this->debug('Rethrowing ['.RateLimitException::class.'] exception.');

                throw $exception;
            }

            $this->debug('Transforming exception of class ['.get_class($exception).'] to ['.CannotValidateRequestException::class.'].');

            // Rethrow API exception as a CannotValidateRequestException.
            if ($exception instanceof FossabotApiException) {
                throw new CannotValidateRequestException($exception->fossabotCode(), $exception->errorClass(), $exception->errorMessage(), $exception->statusCode(), $exception->body(), $exception);
            }

            // Transform all other exceptions into a CannotValidateRequestException.
            throw new CannotValidateRequestException('unknown', 'unknown_error', $exception->getMessage(), 400, $body ?? null, $exception);
        }

        // Get context if needed.
        if ($getContext) {
            $context = $this->getContext($customApiToken);
        }

        $this->info('Invoking FossabotCommand.', [
            'command' => get_class($command),
            'context' => $context !== null ? $context->toArray() : null,
        ]);

        // Invoke command.
        try {
            // Check if command is a callable, and execute if so.
            if (is_callable($command)) {
                return $command($context);
            }

            return $command->getResponse($context);
        } catch (Throwable $exception) {
            $message = $exception->getMessage();
            $this->error(
                "Caught exception during command execution with message [{$message}].",
                compact('exception'),
            );

            $this->debug(
                'Transforming exception of class ['.get_class($exception).'] to ['.CannotExecuteCommandException::class.'].'
            );

            throw new CannotExecuteCommandException($message, $exception->getCode(), $exception);
        }
    }

    /**
     * Makes and sends a request to validate the Fossabot custom API token provided in the request.
     *
     * @noinspection PhpMethodNamingConventionInspection
     *
     * @param string $customApiToken Fossabot custom API token
     *
     * @throws Throwable
     * @throws \Psr\Http\Client\ClientExceptionInterface
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\RateLimitException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\JsonParsingException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\FossabotApiException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\InvalidTokenException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\InvalidStatusException
     */
    private function sendValidateRequest(string $customApiToken): void
    {
        // Validate Fossabot request using the validate API call.
        ['body' => $body, 'statusCode' => $statusCode, 'headers' => $headers] = $this->sendRequest(
            self::FOSSABOT_API_BASE_URL.'/'.self::FOSSABOT_VALIDATE_ROUTE.'/'.urlencode($customApiToken)
        );

        if ($statusCode !== 200) {
            $this->info("Received non-200 HTTP response code [{$statusCode}] back from validation.");

            throw $this->getExceptionFromBody($body, $statusCode, $headers);
        }
    }

    /**
     * Sends a Fossabot API request.
     *
     * @param string $url Fossabot API url
     *
     * @throws Throwable
     * @throws \Psr\Http\Client\ClientExceptionInterface
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\JsonParsingException
     *
     * @return array{
     *     body: array,
     *     statusCode: int,
     *     headers: array,
     * } Response data
     */
    private function sendRequest(string $url): array
    {
        $request = $this->requestFactory->createRequest(
            'GET',
            $url,
        );

        $this->debug("Sending Fossabot API request to [{$request->getUri()}].", compact('request'));

        // Make request to validate
        $response = $this->httpClient->sendRequest($request);
        $body = $this->getResponseBody($response->getBody()->getContents());
        $statusCode = $response->getStatusCode();
        $headers = $response->getHeaders();

        return compact('body', 'statusCode', 'headers');
    }

    /**
     * Get additional message context from a Fossabot request.
     *
     * @param string $customApiToken Fossabot custom API token
     *
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\RateLimitException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\CannotGetContextException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\CannotCreateContextException
     *
     * @return \Brandon14\FossabotCommander\Contracts\Context\FossabotContext Fossabot context
     */
    private function getContext(string $customApiToken): FossabotContextInterface
    {
        $this->info('Sending request to get additional context.');

        try {
            $body = $this->sendContextRequest($customApiToken);

            $this->info('Successfully received context response.');
        } catch (Throwable $exception) {
            $this->error(
                "Caught exception getting context with message [{$exception->getMessage()}].",
                compact('exception'),
            );

            // Allow rate limit exceptions to be rethrown here.
            if ($exception instanceof RateLimitException) {
                $this->debug('Rethrowing ['.RateLimitException::class.'] exception.');

                throw $exception;
            }

            $this->debug('Transforming exception of class ['.get_class($exception).'] to ['.CannotGetContextException::class.'].');

            // Rethrow API exception as a CannotGetContextException.
            if ($exception instanceof FossabotApiException) {
                throw new CannotGetContextException($exception->fossabotCode(), $exception->errorClass(), $exception->errorMessage(), $exception->statusCode(), $exception->body(), $exception);
            }

            // Transform all other exceptions into a CannotGetContextException.
            throw new CannotGetContextException('unknown', 'unknown_error', $exception->getMessage(), 400, $body ?? null, $exception);
        }

        $this->info('Creating context data model from context response.');

        try {
            return FossabotContext::createFromBody($body);
        } catch (Throwable $exception) {
            $this->error(
                "Caught exception creating context data model with message [{$exception->getMessage()}].",
                compact('exception'),
            );

            throw new CannotCreateContextException($exception->getMessage(), $exception->getCode(), $exception);
        }
    }

    /**
     * Makes and sends a request to get the additional Fossabot context and returns the parsed JSON body as an array.
     *
     * @noinspection PhpMethodNamingConventionInspection
     *
     * @param string $customApiToken Fossabot custom API token
     *
     * @throws Throwable
     * @throws \Psr\Http\Client\ClientExceptionInterface
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\RateLimitException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\FossabotApiException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\JsonParsingException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\InvalidTokenException
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\InvalidStatusException
     *
     * @return array{
     *     channel: array{
     *         id: string,
     *         login: string,
     *         display_name: string,
     *         avatar: string,
     *         slug: string,
     *         broadcaster_type: string,
     *         provider: string,
     *         provider_id: string,
     *         created_at: string,
     *         stream_timestamp: string,
     *         is_live: bool,
     *     },
     *     message: array{
     *         id: string,
     *         content: string,
     *         provider: string,
     *         user: array{
     *             provider_id: string,
     *             login: string,
     *             display_name: string,
     *             roles: array{
     *                 array{
     *                     id: string,
     *                     name: string,
     *                     type: string,
     *                 },
     *             },
     *         },
     *     }|null,
     * } Fossabot context API response
     */
    private function sendContextRequest(string $customApiToken): array
    {
        ['body' => $body, 'statusCode' => $statusCode, 'headers' => $headers] = $this->sendRequest(
            self::FOSSABOT_API_BASE_URL.'/'.self::FOSSABOT_CONTEXT_ROUTE.'/'.urlencode($customApiToken)
        );

        if ($statusCode !== 200) {
            $this->info("Received non-200 HTTP response code [{$statusCode}] back from context.");

            throw $this->getExceptionFromBody($body, $statusCode, $headers);
        }

        return $body;
    }

    /**
     * Parses JSON body from Fossabot API.
     *
     * @param string $body JSON body content
     *
     * @throws \Brandon14\FossabotCommander\Contracts\Exceptions\JsonParsingException
     *
     * @return array Parsed body content
     */
    private function getResponseBody(string $body): array
    {
        try {
            return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (Throwable $exception) {
            $this->error(
                "Caught exception decoding JSON response body with message [{$exception->getMessage()}].",
                compact('exception')
            );

            throw new JsonParsingException($exception->getMessage(), $exception->getCode(), $exception);
        }
    }

    /**
     * Gets the exception context data from a given Fossabot API error response and creates the appropriate exception
     * class.
     *
     * @noinspection MultipleReturnStatementsInspection
     * @noinspection PhpMethodNamingConventionInspection
     *
     * @param array{
     *     code: string,
     *     error: string,
     *     message: string,
     *     status: int,
     * } $body       Parsed JSON body
     * @param int   $statusCode HTTP status code
     * @param array $headers    HTTP response headers
     *
     * @returns \Brandon14\FossabotCommander\Contracts\Exceptions\FossabotApiException Exception
     */
    private function getExceptionFromBody(array $body, int $statusCode, array $headers): FossabotApiException
    {
        // Get exception details from response body.
        $fossabotCode = $body['code'] ?? 'unknown';
        $errorClass = $body['error'] ?? 'Unknown Error';
        $errorMessage = $body['message'] ?? 'An unknown error occurred. Could not get message from Fossabot response.';

        // Return the proper exception for the given status code.
        switch ($statusCode) {
            case 400:
                return new InvalidTokenException(
                    $fossabotCode,
                    $errorClass,
                    $errorMessage,
                    $body,
                );
            case 429:
                // Guard for rate limit headers.
                if (! isset($headers['x-ratelimit-total'][0], $headers['x-ratelimit-remaining'][0], $headers['x-ratelimit-reset'][0])) {
                    return new InvalidStatusException(
                        $fossabotCode,
                        $errorClass,
                        $errorMessage,
                        $statusCode,
                        $body,
                    );
                }

                return new RateLimitException(
                    $fossabotCode,
                    $errorClass,
                    $errorMessage,
                    (int) $headers['x-ratelimit-total'][0],
                    (int) $headers['x-ratelimit-remaining'][0],
                    (int) $headers['x-ratelimit-reset'][0],
                    $body,
                );
            default:
                return new InvalidStatusException(
                    $fossabotCode,
                    $errorClass,
                    $errorMessage,
                    $statusCode,
                    $body,
                );
        }
    }

    /**
     * {@inheritDoc}
     *
     * @SuppressWarnings(PHPMD.ElseExpression)
     *
     * @noinspection PhpMethodNamingConventionInspection
     */
    public function log($level, $message, array $context = []): void // @pest-ignore-type
    {
        // Only log if logging is enabled and we have a logger instance.
        if (! $this->logging || $this->logger === null) {
            return;
        }

        $class = static::class;

        // Get additional context together if it needs to be included, otherwise set to empty array.
        if ($this->includeLogContext) {
            // This should never happen.
            // @codeCoverageIgnoreStart
            try {
                $timestamp = (new DateTimeImmutable())->format(DateTimeInterface::ATOM);
            } catch (Throwable $exception) {
                $timestamp = date_create_immutable()->format(DateTimeInterface::ATOM);
            }
            // @codeCoverageIgnoreEnd

            $context = array_merge($this->getLoggingContext(), compact('timestamp'), $context);
        } else {
            $context = [];
        }

        $message = "[{$class}] {$message}";

        $this->logger->log($level, (string) $message, $context);
    }

    /**
     * {@inheritDoc}
     *
     * @noinspection PhpMethodNamingConventionInspection
     */
    public function getLoggingContext(): array
    {
        return [
            'class' => static::class,
            'http_client' => get_class($this->httpClient),
            'request_factory' => get_class($this->requestFactory),
            'logger' => $this->logger === null ? null : get_class($this->logger),
            'logging' => $this->logging,
            'base_api_url' => self::FOSSABOT_API_BASE_URL,
        ];
    }
}