diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6b9135c..0affd28 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -127,7 +127,7 @@ jobs: composer test-unit-coverage - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@v5 + uses: SonarSource/sonarqube-scan-action@v6.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.gitignore b/.gitignore index 958d87e..596f531 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ +.bash_history .cache .config -.local -.composer +.composer/ composer.lock -vendor/ -.bash_history -tests/_output .env +.local +storage/logs/ +tests/_output +vendor/ diff --git a/composer.json b/composer.json index e1380a5..6e97c9d 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "pds/skeleton": "^1.0", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^11.5", - "squizlabs/php_codesniffer": "^3.13" + "squizlabs/php_codesniffer": "^4.0" }, "autoload": { "psr-4": { diff --git a/public/index.php b/public/index.php index d20791f..5906089 100644 --- a/public/index.php +++ b/public/index.php @@ -2,65 +2,41 @@ declare(strict_types=1); -use Phalcon\Api\Domain\Interfaces\DomainInterface; -use Phalcon\Api\Domain\Interfaces\ResponderInterface; -use Phalcon\Api\Domain\Middleware\ResponseSender; -use Phalcon\Api\Domain\Services\ActionHandler; use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Providers\ErrorHandlerProvider; +use Phalcon\Api\Domain\Services\Providers\RouterProvider; +use Phalcon\Di\ServiceProviderInterface; use Phalcon\Mvc\Micro; require_once dirname(__DIR__) . '/vendor/autoload.php'; $container = new Container(); $application = new Micro($container); +$container->set(Container::APPLICATION, $application, true); +$now = hrtime(true); +$container->set( + Container::TIME, + function () use ($now) { + return $now; + }, + true +); /** - * Routes + * Providers */ -$routes = [ - [ - 'method' => 'get', - 'pattern' => '/', - 'service' => Container::HELLO_SERVICE, - 'responder' => Container::HELLO_RESPONDER_JSON, - ], +$providers = [ + ErrorHandlerProvider::class, + RouterProvider::class, ]; -foreach ($routes as $route) { - $method = $route['method']; - $pattern = $route['pattern']; - $serviceName = $route['service']; - $responderName = $route['responder']; - - $application->$method( - $pattern, - function () use ($container, $serviceName, $responderName) { - /** @var DomainInterface $service */ - $service = $container->get($serviceName); - /** @var ResponderInterface $responder */ - $responder = $container->get($responderName); - - $action = new ActionHandler($service, $responder); - $action->__invoke(); - } - ); +/** @var class-string $provider */ +foreach ($providers as $provider) { + /** @var ServiceProviderInterface $service */ + $service = new $provider(); + $container->register($service); } -$application->finish( - function () use ($container) { - $response = $container->getShared(Container::RESPONSE); - $sender = new ResponseSender(); - - $sender->__invoke($response); - } -); - -$application->notFound( - function () { - echo "404 - Not Found - " . date("Y-m-d H:i:s"); - } -); - /** @var string $uri */ $uri = $_SERVER['REQUEST_URI'] ?? ''; diff --git a/src/Domain/Services/ActionHandler.php b/src/Domain/ADR/Action/ActionHandler.php similarity index 75% rename from src/Domain/Services/ActionHandler.php rename to src/Domain/ADR/Action/ActionHandler.php index 6577ec4..2855f7d 100644 --- a/src/Domain/Services/ActionHandler.php +++ b/src/Domain/ADR/Action/ActionHandler.php @@ -11,11 +11,10 @@ declare(strict_types=1); -namespace Phalcon\Api\Domain\Services; +namespace Phalcon\Api\Domain\ADR\Action; -use Phalcon\Api\Domain\Interfaces\ActionInterface; -use Phalcon\Api\Domain\Interfaces\DomainInterface; -use Phalcon\Api\Domain\Interfaces\ResponderInterface; +use Phalcon\Api\Domain\ADR\Domain\DomainInterface; +use Phalcon\Api\Domain\ADR\Responder\ResponderInterface; final readonly class ActionHandler implements ActionInterface { diff --git a/src/Domain/Interfaces/ActionInterface.php b/src/Domain/ADR/Action/ActionInterface.php similarity index 88% rename from src/Domain/Interfaces/ActionInterface.php rename to src/Domain/ADR/Action/ActionInterface.php index bb74052..1d6586b 100644 --- a/src/Domain/Interfaces/ActionInterface.php +++ b/src/Domain/ADR/Action/ActionInterface.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace Phalcon\Api\Domain\Interfaces; +namespace Phalcon\Api\Domain\ADR\Action; interface ActionInterface { diff --git a/src/Domain/Interfaces/DomainInterface.php b/src/Domain/ADR/Domain/DomainInterface.php similarity index 89% rename from src/Domain/Interfaces/DomainInterface.php rename to src/Domain/ADR/Domain/DomainInterface.php index ef9d164..b8747ff 100644 --- a/src/Domain/Interfaces/DomainInterface.php +++ b/src/Domain/ADR/Domain/DomainInterface.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace Phalcon\Api\Domain\Interfaces; +namespace Phalcon\Api\Domain\ADR\Domain; use Phalcon\Domain\Payload; diff --git a/src/Domain/ADR/Responder/JsonResponder.php b/src/Domain/ADR/Responder/JsonResponder.php new file mode 100644 index 0000000..0e9cec1 --- /dev/null +++ b/src/Domain/ADR/Responder/JsonResponder.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\ADR\Responder; + +use Phalcon\Api\Domain\Services\Http\Response; +use Phalcon\Domain\Payload; + +final class JsonResponder implements ResponderInterface +{ + public function __construct( + private Response $response + ) { + } + + public function __invoke(Payload $payload): Response + { + $result = $payload->getResult(); + /** @var string $content */ + $content = $result['results']; + + $this + ->response + ->withPayloadData([$content]) + ->render() + ; + + return $this->response; + } +} diff --git a/src/Domain/Interfaces/ResponderInterface.php b/src/Domain/ADR/Responder/ResponderInterface.php similarity index 90% rename from src/Domain/Interfaces/ResponderInterface.php rename to src/Domain/ADR/Responder/ResponderInterface.php index d15b29f..c96e0db 100644 --- a/src/Domain/Interfaces/ResponderInterface.php +++ b/src/Domain/ADR/Responder/ResponderInterface.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace Phalcon\Api\Domain\Interfaces; +namespace Phalcon\Api\Domain\ADR\Responder; use Phalcon\Domain\Payload; use Phalcon\Http\ResponseInterface; diff --git a/src/Domain/Constants/Dates.php b/src/Domain/Constants/Dates.php new file mode 100644 index 0000000..34a181d --- /dev/null +++ b/src/Domain/Constants/Dates.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Constants; + +use DateTimeImmutable; +use DateTimeZone; +use Exception; + +final class Dates +{ + public const DATE_FORMAT = 'Y-m-d'; + public const DATE_NOW = 'NOW()'; + public const DATE_TIME_FORMAT = 'Y-m-d H:i:s'; + public const DATE_TIME_UTC_FORMAT = 'Y-m-d\\TH:i:sp'; + public const DATE_TIME_ZONE = 'UTC'; + + /** + * @param string $date + * @param string $format + * + * @return string + * @throws Exception + */ + public static function toUTC( + string $date = 'now', + string $format = self::DATE_TIME_UTC_FORMAT + ): string { + return (new DateTimeImmutable( + $date, + new DateTimeZone(self::DATE_TIME_ZONE) + ))->format($format); + } +} diff --git a/src/Domain/Exceptions/InvalidConfigurationArgumentException.php b/src/Domain/Exceptions/InvalidConfigurationArgumentException.php index 8e9b0cd..cf9c6de 100644 --- a/src/Domain/Exceptions/InvalidConfigurationArgumentException.php +++ b/src/Domain/Exceptions/InvalidConfigurationArgumentException.php @@ -13,6 +13,8 @@ namespace Phalcon\Api\Domain\Exceptions; -class InvalidConfigurationArgumentException extends \InvalidArgumentException +use InvalidArgumentException; + +class InvalidConfigurationArgumentException extends InvalidArgumentException { } diff --git a/src/Domain/Hello/HelloService.php b/src/Domain/Hello/HelloService.php index 289ddc4..505dae1 100644 --- a/src/Domain/Hello/HelloService.php +++ b/src/Domain/Hello/HelloService.php @@ -14,7 +14,8 @@ namespace Phalcon\Api\Domain\Hello; use PayloadInterop\DomainStatus; -use Phalcon\Api\Domain\Interfaces\DomainInterface; +use Phalcon\Api\Domain\ADR\Domain\DomainInterface; +use Phalcon\Api\Domain\Constants\Dates; use Phalcon\Domain\Payload; use function date; @@ -26,7 +27,7 @@ public function __invoke(): Payload return new Payload( DomainStatus::SUCCESS, [ - 'results' => "Hello World!!! - " . date("Y-m-d H:i:s") + 'results' => "Hello World!!! - " . date(Dates::DATE_TIME_FORMAT), ] ); } diff --git a/src/Domain/Interfaces/RoutesInterface.php b/src/Domain/Interfaces/RoutesInterface.php new file mode 100644 index 0000000..eb6d7c0 --- /dev/null +++ b/src/Domain/Interfaces/RoutesInterface.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Interfaces; + +use BackedEnum; + +/** + * Interface for route enumerations + * + * @phpstan-type TMiddleware array + */ +interface RoutesInterface extends BackedEnum +{ + public const DELETE = 'delete'; + public const EVENT_BEFORE = 'before'; + public const EVENT_FINISH = 'finish'; + public const GET = 'get'; + public const POST = 'post'; + public const PUT = 'put'; + + + /** + * @return string + */ + public function endpoint(): string; + + /** + * @return string + */ + public function method(): string; + + /** + * @return TMiddleware + */ + public static function middleware(): array; + + /** + * @return string + */ + public function prefix(): string; + + /** + * @return string + */ + public function service(): string; + + /** + * @return string + */ + public function suffix(): string; +} diff --git a/src/Domain/Middleware/AbstractMiddleware.php b/src/Domain/Middleware/AbstractMiddleware.php new file mode 100644 index 0000000..06c2513 --- /dev/null +++ b/src/Domain/Middleware/AbstractMiddleware.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Middleware; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Http\Response; +use Phalcon\Api\Domain\Services\Http\ResponseTypes; +use Phalcon\Events\Exception as EventsException; +use Phalcon\Http\Response\Exception as ResponseException; +use Phalcon\Mvc\Micro; +use Phalcon\Mvc\Micro\MiddlewareInterface; + +/** + * @phpstan-import-type TData from ResponseTypes + * @phpstan-import-type TErrors from ResponseTypes + */ +abstract class AbstractMiddleware implements MiddlewareInterface +{ + /** + * @param Micro $application + * @param int $code + * @param string $message + * @param TData $data + * @param TErrors $errors + * + * @return void + * @throws EventsException + * @throws ResponseException + */ + protected function halt( + Micro $application, + int $code, + string $message = '', + array $data = [], + array $errors = [] + ): void { + /** @var Response $response */ + $response = $application->getSharedService(Container::RESPONSE); + + $application->stop(); + + $response->withCode($code, $message); + + if (true === empty($errors)) { + $response->withPayloadData($data); + } else { + $response->withPayloadErrors($errors); + } + + $response->render()->send(); + } +} diff --git a/src/Domain/Middleware/HealthMiddleware.php b/src/Domain/Middleware/HealthMiddleware.php new file mode 100644 index 0000000..898a51f --- /dev/null +++ b/src/Domain/Middleware/HealthMiddleware.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Middleware; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Http\HttpCodesEnum; +use Phalcon\Events\Exception as EventsException; +use Phalcon\Http\Request; +use Phalcon\Http\Response\Exception; +use Phalcon\Mvc\Micro; + +final class HealthMiddleware extends AbstractMiddleware +{ + /** + * @param Micro $application + * + * @return true + * @throws EventsException + * @throws Exception + */ + public function call(Micro $application): bool + { + /** @var Request $request */ + $request = $application->getSharedService(Container::REQUEST); + + if ( + '/health' === $request->getURI() && + true === $request->isGet() + ) { + $payload = [ + 'status' => 'ok', + 'message' => 'service operational', + ]; + + $this->halt( + $application, + HttpCodesEnum::OK->value, + HttpCodesEnum::OK->text(), + $payload + ); + } + + return true; + } +} diff --git a/src/Domain/Middleware/NotFoundMiddleware.php b/src/Domain/Middleware/NotFoundMiddleware.php new file mode 100644 index 0000000..a5926e9 --- /dev/null +++ b/src/Domain/Middleware/NotFoundMiddleware.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Middleware; + +use Phalcon\Api\Domain\Services\Http\HttpCodesEnum; +use Phalcon\Events\Event; +use Phalcon\Events\Exception as EventsException; +use Phalcon\Http\Response\Exception; +use Phalcon\Mvc\Micro; + +final class NotFoundMiddleware extends AbstractMiddleware +{ + /** + * @param Event $event + * @param Micro $application + * + * @return bool + * @throws EventsException + * @throws Exception + */ + public function beforeNotFound(Event $event, Micro $application): bool + { + $this->halt( + $application, + HttpCodesEnum::NotFound->value, + 'error', + [], + [HttpCodesEnum::AppResourceNotFound->error()] + ); + + return false; + } + + /** + * @param Micro $application + * + * @return true + */ + public function call(Micro $application): bool + { + return true; + } +} diff --git a/src/Domain/Middleware/ResponseSender.php b/src/Domain/Middleware/ResponseSender.php deleted file mode 100644 index 616d1c0..0000000 --- a/src/Domain/Middleware/ResponseSender.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace Phalcon\Api\Domain\Middleware; - -use Phalcon\Api\Domain\Interfaces\ActionInterface; -use Phalcon\Api\Domain\Interfaces\DomainInterface; -use Phalcon\Api\Domain\Interfaces\ResponderInterface; -use Phalcon\Http\ResponseInterface; - -final readonly class ResponseSender -{ - public function __invoke(ResponseInterface $response): ResponseInterface - { - return $response->send(); - } -} diff --git a/src/Domain/Middleware/ResponseSenderMiddleware.php b/src/Domain/Middleware/ResponseSenderMiddleware.php new file mode 100644 index 0000000..22e7bdd --- /dev/null +++ b/src/Domain/Middleware/ResponseSenderMiddleware.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Middleware; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Http\Response; +use Phalcon\Events\Exception as EventsException; +use Phalcon\Http\Response\Exception; +use Phalcon\Mvc\Micro; + +final class ResponseSenderMiddleware extends AbstractMiddleware +{ + /** + * @param Micro $application + * + * @return true + * @throws EventsException + * @throws Exception + */ + public function call(Micro $application): bool + { + /** @var Response $response */ + $response = $application->getSharedService(Container::RESPONSE); + + $response->send(); + + return true; + } +} diff --git a/src/Domain/Services/Container.php b/src/Domain/Services/Container.php index 82d6870..91c1a29 100644 --- a/src/Domain/Services/Container.php +++ b/src/Domain/Services/Container.php @@ -13,56 +13,98 @@ namespace Phalcon\Api\Domain\Services; -use Phalcon\Api\Action\Hello\GetAction; +use Phalcon\Api\Domain\ADR\Responder\JsonResponder; +use Phalcon\Api\Domain\Health\HealthService; use Phalcon\Api\Domain\Hello\HelloService; -use Phalcon\Api\Responder\Hello\HelloTextResponder; -use Phalcon\Api\Responder\HelloJsonResponder; -use Phalcon\Api\Responder\JsonResponder; +use Phalcon\Api\Domain\Middleware\HealthMiddleware; +use Phalcon\Api\Domain\Middleware\NotFoundMiddleware; +use Phalcon\Api\Domain\Middleware\ResponseSenderMiddleware; +use Phalcon\Api\Domain\Services\Env\EnvManager; +use Phalcon\Api\Domain\Services\Http\Response; use Phalcon\Di\Di; use Phalcon\Di\Service; +use Phalcon\Events\Manager as EventsManager; use Phalcon\Filter\FilterFactory; use Phalcon\Http\Request; -use Phalcon\Http\Response; +use Phalcon\Logger\Adapter\Stream; +use Phalcon\Logger\Logger; use Phalcon\Mvc\Router; class Container extends Di { + /** @var string */ + public const APPLICATION = 'application'; /** @var string */ public const CACHE = 'cache'; /** @var string */ public const CONNECTION = 'connection'; /** @var string */ + public const EVENTS_MANAGER = 'eventsManager'; + /** @var string */ public const FILTER = 'filter'; + /** + * Services + */ + public const HELLO_SERVICE = 'hello.service'; /** @var string */ public const LOGGER = 'logger'; + /** + * Middleware + */ + public const MIDDLEWARE_HEALTH = 'middleware.health'; + public const MIDDLEWARE_NOT_FOUND = 'middleware.not.found'; + public const MIDDLEWARE_RESPONSE_SENDER = 'middleware.response.sender'; /** @var string */ public const REQUEST = 'request'; + /** + * Responders + */ + public const RESPONDER_JSON = 'hello.responder.json'; /** @var string */ public const RESPONSE = 'response'; /** @var string */ public const ROUTER = 'router'; - - /** - * Hello - */ - public const HELLO_SERVICE = 'hello.service'; - public const HELLO_RESPONDER_JSON = 'hello.responder.json'; + /** @var string */ + public const TIME = 'time'; public function __construct() { $this->services = [ - self::FILTER => $this->getServiceFilter(), - self::REQUEST => $this->getServiceSimple(Request::class, true), - self::RESPONSE => $this->getServiceSimple(Response::class, true), - self::ROUTER => $this->getServiceRouter(), + self::EVENTS_MANAGER => $this->getServiceEventsManger(), + self::FILTER => $this->getServiceFilter(), + self::LOGGER => $this->getServiceLogger(), + self::REQUEST => $this->getServiceSimple(Request::class, true), + self::RESPONSE => $this->getServiceSimple(Response::class, true), + self::ROUTER => $this->getServiceRouter(), + + self::HELLO_SERVICE => $this->getServiceSimple(HelloService::class), - self::HELLO_SERVICE => $this->getServiceSimple(HelloService::class), - self::HELLO_RESPONDER_JSON => $this->getServiceResponderJson(), + self::MIDDLEWARE_HEALTH => $this->getServiceSimple(HealthMiddleware::class), + self::MIDDLEWARE_NOT_FOUND => $this->getServiceSimple(NotFoundMiddleware::class), + self::MIDDLEWARE_RESPONSE_SENDER => $this->getServiceSimple(ResponseSenderMiddleware::class), + + self::RESPONDER_JSON => $this->getServiceResponderJson(), ]; parent::__construct(); } + /** + * @return Service + */ + private function getServiceEventsManger(): Service + { + return new Service( + function () { + $evm = new EventsManager(); + $evm->enablePriorities(true); + + return $evm; + }, + true + ); + } + /** * @return Service */ @@ -79,18 +121,23 @@ function () { /** * @return Service */ - private function getServiceRouter(): Service + private function getServiceLogger(): Service { + /** @var string $logName */ + $logName = EnvManager::get('LOG_FILENAME', 'rest-api'); + /** @var string $logPath */ + $logPath = EnvManager::get('LOG_PATH', 'storage/logs/'); + $logFile = EnvManager::appPath($logPath) . '/' . $logName . '.log'; + return new Service( - [ - 'className' => Router::class, - 'arguments' => [ + function () use ($logName, $logFile) { + return new Logger( + $logName, [ - 'type' => 'parameter', - 'value' => false, + 'main' => new Stream($logFile), ] - ] - ] + ); + } ); } @@ -103,8 +150,26 @@ private function getServiceResponderJson(): Service [ 'type' => 'service', 'name' => self::RESPONSE, - ] - ] + ], + ], + ] + ); + } + + /** + * @return Service + */ + private function getServiceRouter(): Service + { + return new Service( + [ + 'className' => Router::class, + 'arguments' => [ + [ + 'type' => 'parameter', + 'value' => false, + ], + ], ] ); } diff --git a/src/Domain/Services/Env/Adapters/AdapterInterface.php b/src/Domain/Services/Env/Adapters/AdapterInterface.php index ae377ad..b15db06 100644 --- a/src/Domain/Services/Env/Adapters/AdapterInterface.php +++ b/src/Domain/Services/Env/Adapters/AdapterInterface.php @@ -13,7 +13,18 @@ namespace Phalcon\Api\Domain\Services\Env\Adapters; +use Phalcon\Api\Domain\Services\Env\EnvManagerTypes; + +/** + * @phpstan-import-type TDotEnvOptions from EnvManagerTypes + * @phpstan-import-type TSettings from EnvManagerTypes + */ interface AdapterInterface { + /** + * @param TDotEnvOptions $options + * + * @return TSettings + */ public function load(array $options): array; } diff --git a/src/Domain/Services/Env/Adapters/DotEnv.php b/src/Domain/Services/Env/Adapters/DotEnv.php index 1d0b64b..bd234fe 100644 --- a/src/Domain/Services/Env/Adapters/DotEnv.php +++ b/src/Domain/Services/Env/Adapters/DotEnv.php @@ -16,13 +16,18 @@ use Dotenv\Dotenv as ParentDotEnv; use Exception; use Phalcon\Api\Domain\Exceptions\InvalidConfigurationArgumentException; +use Phalcon\Api\Domain\Services\Env\EnvManagerTypes; +/** + * @phpstan-import-type TDotEnvOptions from EnvManagerTypes + * @phpstan-import-type TSettings from EnvManagerTypes + */ class DotEnv implements AdapterInterface { /** - * @param array $options + * @param TDotEnvOptions $options * - * @return array + * @return TSettings * @throws Exception */ public function load(array $options): array @@ -39,6 +44,9 @@ public function load(array $options): array $dotenv = ParentDotEnv::createImmutable($filePath); $dotenv->load(); - return $_ENV; + /** @var TSettings $env */ + $env = $_ENV; + + return $env; } } diff --git a/src/Domain/Services/Env/EnvFactory.php b/src/Domain/Services/Env/EnvFactory.php index 5125ce2..84910e2 100644 --- a/src/Domain/Services/Env/EnvFactory.php +++ b/src/Domain/Services/Env/EnvFactory.php @@ -19,6 +19,9 @@ class EnvFactory { + /** + * @var array + */ protected array $instances = []; public function newInstance(string $name, mixed ...$parameters): AdapterInterface @@ -33,13 +36,16 @@ public function newInstance(string $name, mixed ...$parameters): AdapterInterfac $definition = $adapters[$name]; /** @var AdapterInterface $instance */ - $instance = new $definition(...$parameters); + $instance = new $definition(...$parameters); $this->instances[$name] = $instance; } return $this->instances[$name]; } + /** + * @return array + */ protected function getAdapters(): array { return [ diff --git a/src/Domain/Services/Env/EnvManager.php b/src/Domain/Services/Env/EnvManager.php index 1e76fac..6dde7a2 100644 --- a/src/Domain/Services/Env/EnvManager.php +++ b/src/Domain/Services/Env/EnvManager.php @@ -13,21 +13,79 @@ namespace Phalcon\Api\Domain\Services\Env; +use Phalcon\Api\Domain\Constants\Dates; + use function array_merge; use function getenv; +/** + * @phpstan-import-type TSettings from EnvManagerTypes + */ class EnvManager { private static bool $isLoaded = false; + + /** + * @var TSettings + */ private static array $settings = []; + /** + * @return string + */ + public static function appEnv(): string + { + self::load(); + + /** @var string $appEnv */ + $appEnv = self::get('APP_ENV', 'development'); + + return (string)$appEnv; + } + + /** + * @return int + */ + public static function appLogLevel(): int + { + self::load(); + + /** @var int|string $logLevel */ + $logLevel = self::get('APP_LOG_LEVEL', 1); + + return (int)$logLevel; + } + + /** + * @param string $path + * + * @return string + */ public static function appPath(string $path = ''): string { return dirname(__DIR__, 4) - . ($path ? DIRECTORY_SEPARATOR . $path : $path) - ; + . ($path ? DIRECTORY_SEPARATOR . $path : $path); + } + + /** + * @return string + */ + public static function appTimezone(): string + { + self::load(); + + /** @var string $timezone */ + $timezone = self::get('APP_TIMEZONE', Dates::DATE_TIME_ZONE); + + return (string)$timezone; } + /** + * @param string $key + * @param bool|int|string|null $defaultValue + * + * @return bool|int|string|null + */ public static function get( string $key, bool | int | string | null $defaultValue = null @@ -37,6 +95,26 @@ public static function get( return self::$settings[$key] ?? $defaultValue; } + /** + * @return array + */ + private static function getOptions(): array + { + $envs = array_merge(getenv(), $_ENV); + /** @var string $adapter */ + $adapter = $envs['APP_ENV_ADAPTER'] ?? 'dotenv'; + /** @var string $filePath */ + $filePath = $envs['APP_ENV_FILE_PATH'] ?? self::appPath(); + + return [ + 'adapter' => $adapter, + 'filePath' => $filePath, + ]; + } + + /** + * @return void + */ private static function load(): void { if (true !== self::$isLoaded) { @@ -46,14 +124,16 @@ private static function load(): void $options = self::getOptions(); $adapter = $options['adapter']; - $envs = array_merge(getenv(), $_ENV); + $envs = array_merge(getenv(), $_ENV); + /** @var TSettings $options */ $options = $envFactory->newInstance($adapter)->load($options); - $envs = array_merge($envs, $options); + /** @var TSettings $envs */ + $envs = array_merge($envs, $options); self::$settings = array_map( function ($value) { return match ($value) { - 'true' => true, + 'true' => true, 'false' => false, default => $value, }; @@ -62,16 +142,4 @@ function ($value) { ); } } - - private static function getOptions(): array - { - $envs = array_merge(getenv(), $_ENV); - $adapter = $envs['APP_ENV_ADAPTER'] ?? 'dotenv'; - $filePath = $envs['APP_ENV_FILE_PATH'] ?? ''; - - return [ - 'adapter' => $adapter, - 'filePath' => $filePath, - ]; - } } diff --git a/src/Domain/Services/Env/EnvManagerTypes.php b/src/Domain/Services/Env/EnvManagerTypes.php new file mode 100644 index 0000000..7819113 --- /dev/null +++ b/src/Domain/Services/Env/EnvManagerTypes.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Env; + +/** + * @phpstan-type TDotEnvOptions array{ + * filePath?: string + * } + * @phpstan-type TOptions array + * @phpstan-type TSettings array + */ +final class EnvManagerTypes +{ +} diff --git a/src/Domain/Services/Http/HttpCodesEnum.php b/src/Domain/Services/Http/HttpCodesEnum.php new file mode 100644 index 0000000..2b9216f --- /dev/null +++ b/src/Domain/Services/Http/HttpCodesEnum.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Http; + +/** + * Enumeration for Http Codes. These are not only the regular HTTP codes + * but also the ones generated by the application + */ +enum HttpCodesEnum: int +{ + /** + * Regular HTTP codes + */ + case OK = 200; + case BadRequest = 400; + case NotFound = 404; + case Unauthorized = 401; + + /** + * Application specific codes + */ + case AppMalformedPayload = 3400; + case AppRecordsNotFound = 3401; + case AppResourceNotFound = 3402; + case AppUnauthorized = 3403; + case AppInvalidArguments = 3409; + + /** + * Returns an array with the value as key and text as value + * + * @return array + */ + public function error(): array + { + return [$this->value => $this->text()]; + } + + /** + * Return the text associated with the option + * + * @return string + */ + public function text(): string + { + return match ($this) { + self::OK => 'OK', + self::BadRequest => 'Bad Request', + self::NotFound => 'Not Found', + self::AppMalformedPayload => 'Malformed payload', + self::AppRecordsNotFound => 'Record(s) not found', + self::AppResourceNotFound => 'Resource not found', + self::Unauthorized, + self::AppUnauthorized => 'Unauthorized', + self::AppInvalidArguments => 'Invalid arguments provided', + }; + } +} diff --git a/src/Domain/Services/Http/Response.php b/src/Domain/Services/Http/Response.php new file mode 100644 index 0000000..45495cb --- /dev/null +++ b/src/Domain/Services/Http/Response.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Http; + +use Exception; +use Phalcon\Api\Domain\Constants\Dates; +use Phalcon\Http\Response as PhalconResponse; +use Phalcon\Http\Response\Exception as ResponseException; + +use function json_encode; +use function sha1; + +/** + * @phpstan-import-type TData from ResponseTypes + * @phpstan-import-type TErrors from ResponseTypes + * @phpstan-import-type TResponsePayload from ResponseTypes + */ +class Response extends PhalconResponse +{ + /** @var TResponsePayload */ + private array $payload; + + /** + * @return $this + * @throws Exception + */ + public function render(): self + { + $this + ->calculateMeta() + ->setJsonContent($this->payload) + ; + + return $this; + } + + /** + * @param int $code + * @param string $message + * + * @return $this + * @throws ResponseException + */ + public function withCode(int $code, string $message = ''): self + { + $this->setStatusCode($code, $message); + + return $this; + } + + /** + * @param TData $data + * + * @return $this + * @throws Exception + */ + public function withPayloadData(array $data): self + { + if (empty($this->payload)) { + $this->initPayload(); + } + + $this->payload['data'] = $data; + + return $this; + } + + /** + * @param TErrors $errors + * + * @return $this + */ + public function withPayloadErrors(array $errors): self + { + if (empty($this->payload)) { + $this->initPayload(); + } + + $this->payload['errors'] = $errors; + $this->payload['meta']['code'] = 3000; + $this->payload['meta']['message'] = 'error'; + + return $this; + } + + /** + * @return self + * @throws Exception + */ + private function calculateMeta(): self + { + $payload = [ + 'data' => $this->payload['data'], + 'errors' => $this->payload['errors'], + ]; + $encoded = json_encode($payload); + $encoded = (false === $encoded) ? '' : $encoded; + $timestamp = Dates::toUTC(); + $hash = sha1($timestamp . $encoded); + $eTag = sha1($encoded); + + $this->payload['meta']['timestamp'] = $timestamp; + $this->payload['meta']['hash'] = $hash; + + $this + ->setHeader('ETag', $eTag) + ; + + return $this; + } + + /** + * @return void + */ + private function initPayload(): void + { + $this->payload = [ + 'data' => [], + 'errors' => [], + 'meta' => [ + 'code' => HttpCodesEnum::OK->value, + 'hash' => '', + 'message' => 'success', + 'timestamp' => '', + ], + ]; + } +} diff --git a/src/Domain/Services/Http/ResponseTypes.php b/src/Domain/Services/Http/ResponseTypes.php new file mode 100644 index 0000000..377200f --- /dev/null +++ b/src/Domain/Services/Http/ResponseTypes.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Http; + +/** + * This class is used to define phpstan types so that they are all in one + * place. + * + * @phpstan-type TData array|array{} + * @phpstan-type TErrors array>|array{} + * @phpstan-type TResponsePayload array{ + * data: TData, + * errors: TErrors, + * meta: array{ + * code: int, + * hash: string, + * message: string, + * timestamp: string + * } + * } + */ +final class ResponseTypes +{ +} diff --git a/src/Domain/Services/Providers/ErrorHandlerProvider.php b/src/Domain/Services/Providers/ErrorHandlerProvider.php new file mode 100644 index 0000000..d455014 --- /dev/null +++ b/src/Domain/Services/Providers/ErrorHandlerProvider.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Providers; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Env\EnvManager; +use Phalcon\Di\DiInterface; +use Phalcon\Di\ServiceProviderInterface; +use Phalcon\Logger\Logger; + +use function date_default_timezone_set; +use function error_reporting; +use function hrtime; +use function memory_get_usage; +use function number_format; +use function register_shutdown_function; +use function sprintf; + +class ErrorHandlerProvider implements ServiceProviderInterface +{ + public function register(DiInterface $container): void + { + /** @var Logger $logger */ + $logger = $container->getShared(Container::LOGGER); + + date_default_timezone_set(EnvManager::appTimezone()); + $errors = 'development' === EnvManager::appEnv() ? 'On' : 'Off'; + ini_set('display_errors', $errors); + error_reporting(E_ALL); + + set_error_handler( + function (int $number, string $message, string $file, int $line) use ($logger) { + $logger + ->error( + sprintf( + '[#:%s]-[L: %s] : %s (%s)', + $number, + $line, + $message, + $file + ) + ) + ; + + return true; + } + ); + + register_shutdown_function([$this, 'onShutdown'], $container); + } + + protected function onShutdown(DiInterface $container): bool + { + /** @var Logger $logger */ + $logger = $container->getShared(Container::LOGGER); + /** @var int $time */ + $time = $container->getShared(Container::TIME); + $memory = memory_get_usage() / 1000000; + $execution = hrtime(true) - $time; + $execution = $execution / 1000000000; + + if (EnvManager::appLogLevel() >= 1) { + $logger + ->info( + sprintf( + 'Shutdown completed [%s]s - [%s]MB', + number_format($execution, 4), + number_format($memory, 2), + ) + ) + ; + } + + return true; + } +} diff --git a/src/Domain/Services/Providers/RouterProvider.php b/src/Domain/Services/Providers/RouterProvider.php new file mode 100644 index 0000000..ee17143 --- /dev/null +++ b/src/Domain/Services/Providers/RouterProvider.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Providers; + +use Phalcon\Api\Domain\ADR\Action\ActionHandler; +use Phalcon\Api\Domain\ADR\Domain\DomainInterface; +use Phalcon\Api\Domain\ADR\Responder\ResponderInterface; +use Phalcon\Api\Domain\Interfaces\RoutesInterface; +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Di\DiInterface; +use Phalcon\Di\ServiceProviderInterface; +use Phalcon\Events\Manager as EventsManager; +use Phalcon\Mvc\Micro; +use Phalcon\Mvc\Micro\Collection; + +/** + * @phpstan-import-type TMiddleware from RoutesInterface + */ +class RouterProvider implements ServiceProviderInterface +{ + public function register(DiInterface $container): void + { + /** @var Micro $application */ + $application = $container->getShared(Container::APPLICATION); + /** @var EventsManager $eventsManager */ + $eventsManager = $container->getShared(Container::EVENTS_MANAGER); + + $this->attachRoutes($application); + $this->attachMiddleware($application, $eventsManager); + + $application->get('/health', function () { + /** empty */ + }); + + $application->setEventsManager($eventsManager); + } + + /** + * @param Micro $application + * @param EventsManager $eventsManager + * + * @return void + */ + private function attachMiddleware( + Micro $application, + EventsManager $eventsManager + ): void { + /** @var TMiddleware $middleware */ + $middleware = RoutesEnum::middleware(); + foreach ($middleware as $service => $method) { + /** @var Micro\MiddlewareInterface $instance */ + $instance = $application->getService($service); + $eventsManager->attach('micro', $instance); + $application->$method($instance); + } + } + + /** + * Attaches routes to the application, lazy loaded + * + * @param Micro $application + * + * @return void + */ + private function attachRoutes(Micro $application): void + { + /** @var ResponderInterface $responder */ + $responder = $application->getService(Container::RESPONDER_JSON); + + $routes = RoutesEnum::cases(); + foreach ($routes as $route) { + $serviceName = $route->service(); + $collection = new Collection(); + /** @var DomainInterface $service */ + $service = $application->getService($serviceName); + $action = new ActionHandler($service, $responder); + + $collection + ->setHandler($action) + ->setPrefix($route->prefix()) + ->{$route->method()}( + $route->suffix(), + '__invoke' + ) + ; + + $application->mount($collection); + } + } +} diff --git a/src/Domain/Services/Providers/RoutesEnum.php b/src/Domain/Services/Providers/RoutesEnum.php new file mode 100644 index 0000000..adad412 --- /dev/null +++ b/src/Domain/Services/Providers/RoutesEnum.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Providers; + +use Phalcon\Api\Domain\Interfaces\RoutesInterface; +use Phalcon\Api\Domain\Services\Container; + +use function str_replace; + +/** + * @phpstan-import-type TMiddleware from RoutesInterface + */ +enum RoutesEnum: string implements RoutesInterface +{ + case helloGet = ''; + + /** + * @return string + */ + public function endpoint(): string + { + return $this->prefix() . $this->suffix(); + } + + /** + * @return string + */ + public function method(): string + { + return match ($this) { + self::helloGet => self::GET, + }; + } + + /** + * @return TMiddleware + */ + public static function middleware(): array + { + return [ + Container::MIDDLEWARE_NOT_FOUND => self::EVENT_BEFORE, + Container::MIDDLEWARE_HEALTH => self::EVENT_BEFORE, + Container::MIDDLEWARE_RESPONSE_SENDER => self::EVENT_FINISH, + ]; + } + + /** + * @return string + */ + public function prefix(): string + { + return '/' . str_replace('-', '/', $this->value); + } + + public function service(): string + { + return match ($this) { + self::helloGet => Container::HELLO_SERVICE, + }; + } + + /** + * @return string + */ + public function suffix(): string + { + return ''; + } +} diff --git a/src/Responder/JsonResponder.php b/src/Responder/JsonResponder.php deleted file mode 100644 index 9783832..0000000 --- a/src/Responder/JsonResponder.php +++ /dev/null @@ -1,72 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace Phalcon\Api\Responder; - -use DateTimeImmutable; -use DateTimeZone; -use Phalcon\Api\Domain\Interfaces\ResponderInterface; -use Phalcon\Domain\Payload; -use Phalcon\Http\ResponseInterface; - -use function json_encode; - -final class JsonResponder implements ResponderInterface -{ - public function __construct( - private ResponseInterface $response - ) { - } - - public function __invoke(Payload $payload): ResponseInterface - { - $result = $payload->getResult(); - /** @var string $content */ - $content = $result['results']; - - $timestamp = new DateTimeImmutable('now', new DateTimeZone('UTC')); - $dateTime = $timestamp->format('Y-m-d H:i:s'); - $output = [ - 'data' => [ - $content - ], - 'errors' => [], - 'meta' => [ - 'code' => 200, - 'hash' => '', - 'message' => 'success', - 'timestamp' => $dateTime, - ] - ]; - - $dataErrors = [ - 'data' => $output['data'], - 'errors' => $output['errors'], - ]; - $encoded = json_encode($dataErrors); - $encoded = (false === $encoded) ? '' : $encoded; - $hash = sha1($dateTime . $encoded); - $eTag = sha1($encoded); - - $output['meta']['hash'] = $hash; - - $this - ->response - ->setContentType('application/json') - ->setHeader('E-Tag', $eTag) - ->setJsonContent($output) - ; - - return $this->response; - } -} diff --git a/storage/logs/.gitkeep b/storage/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/AbstractUnitTestCase.php b/tests/Unit/AbstractUnitTestCase.php index d248af8..4031277 100644 --- a/tests/Unit/AbstractUnitTestCase.php +++ b/tests/Unit/AbstractUnitTestCase.php @@ -17,6 +17,18 @@ abstract class AbstractUnitTestCase extends TestCase { + /** + * @param string $fileName + * @param string $stream + * + * @return void + */ + public function assertFileContentsContains(string $fileName, string $stream): void + { + $contents = file_get_contents($fileName); + $this->assertStringContainsString($stream, $contents); + } + /** * Return a long series of strings to be used as a password * diff --git a/tests/Unit/Domain/Hello/HelloServiceTest.php b/tests/Unit/Domain/Hello/HelloServiceTest.php new file mode 100644 index 0000000..56f216e --- /dev/null +++ b/tests/Unit/Domain/Hello/HelloServiceTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Hello; + +use PayloadInterop\DomainStatus; +use Phalcon\Api\Domain\Hello\HelloService; +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Env\EnvManager; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; + +use function ob_get_clean; +use function ob_start; +use function restore_error_handler; +use function time; + +#[BackupGlobals(true)] +final class HelloServiceTest extends AbstractUnitTestCase +{ + public function testDispatch(): void + { + $time = $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_TIME_FLOAT' => $time, + 'REQUEST_URI' => '/', + ]; + + ob_start(); + require_once EnvManager::appPath('public/index.php'); + $response = ob_get_clean(); + + $contents = json_decode($response, true); + + restore_error_handler(); + + $this->assertArrayHasKey('data', $contents); + $this->assertArrayHasKey('errors', $contents); + + $data = $contents['data']; + $errors = $contents['errors']; + + $expected = []; + $actual = $errors; + $this->assertSame($expected, $actual); + + $expected = 'Hello World!!! - '; + $actual = $data[0]; + $this->assertStringContainsString($expected, $actual); + } + + public function testService(): void + { + $container = new Container(); + /** @var HelloService $service */ + $service = $container->get(Container::HELLO_SERVICE); + + $payload = $service->__invoke(); + + $expected = DomainStatus::SUCCESS; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('results', $actual); + + $expected = 'Hello World!!! - '; + $actual = $actual['results']; + $this->assertStringContainsString($expected, $actual); + } +} diff --git a/tests/Unit/Domain/Middleware/HealthMiddlewareTest.php b/tests/Unit/Domain/Middleware/HealthMiddlewareTest.php new file mode 100644 index 0000000..c1f488e --- /dev/null +++ b/tests/Unit/Domain/Middleware/HealthMiddlewareTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Middleware; + +use Phalcon\Api\Domain\Middleware\HealthMiddleware; +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; +use Phalcon\Mvc\Micro; +use PHPUnit\Framework\Attributes\BackupGlobals; + +use function ob_get_clean; +use function ob_start; + +#[BackupGlobals(true)] +final class HealthMiddlewareTest extends AbstractUnitTestCase +{ + public function testCall(): void + { + $container = new Container(); + $application = new Micro($container); + $middleware = new HealthMiddleware(); + + $time = $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_TIME_FLOAT' => $time, + 'REQUEST_URI' => '/health', + ]; + + ob_start(); + $actual = $middleware->call($application); + $contents = ob_get_clean(); + + $this->assertTrue($actual); + + $contents = json_decode($contents, true); + + $expected = [ + 'status' => 'ok', + 'message' => 'service operational', + ]; + $actual = $contents['data']; + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Unit/Domain/Middleware/NotFoundMiddlewareTest.php b/tests/Unit/Domain/Middleware/NotFoundMiddlewareTest.php new file mode 100644 index 0000000..f0eab64 --- /dev/null +++ b/tests/Unit/Domain/Middleware/NotFoundMiddlewareTest.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Middleware; + +use Phalcon\Api\Domain\Middleware\NotFoundMiddleware; +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Http\HttpCodesEnum; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; +use Phalcon\Events\Event; +use Phalcon\Mvc\Micro; +use PHPUnit\Framework\Attributes\BackupGlobals; + +use function ob_get_clean; +use function ob_start; + +#[BackupGlobals(true)] +final class NotFoundMiddlewareTest extends AbstractUnitTestCase +{ + public function testBeforeNotFound(): void + { + $container = new Container(); + $application = new Micro($container); + $middleware = new NotFoundMiddleware(); + + $time = $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_TIME_FLOAT' => $time, + 'REQUEST_URI' => '/unknown', + ]; + + ob_start(); + $actual = $middleware->beforeNotFound( + new Event('ev1'), + $application + ); + $contents = ob_get_clean(); + + $this->assertFalse($actual); + + $contents = json_decode($contents, true); + + $expected = [HttpCodesEnum::AppResourceNotFound->error()]; + $actual = $contents['errors']; + $this->assertSame($expected, $actual); + } + + public function testCall(): void + { + $container = new Container(); + $application = new Micro($container); + $middleware = new NotFoundMiddleware(); + + $time = $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_TIME_FLOAT' => $time, + 'REQUEST_URI' => '/unknown', + ]; + + ob_start(); + $actual = $middleware->call($application); + ob_get_clean(); + + $this->assertTrue($actual); + } +} diff --git a/tests/Unit/Domain/Middleware/ResponseSenderMiddlewareTest.php b/tests/Unit/Domain/Middleware/ResponseSenderMiddlewareTest.php new file mode 100644 index 0000000..7ab105f --- /dev/null +++ b/tests/Unit/Domain/Middleware/ResponseSenderMiddlewareTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Middleware; + +use Phalcon\Api\Domain\Middleware\ResponseSenderMiddleware; +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Http\Response; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; +use Phalcon\Mvc\Micro; +use PHPUnit\Framework\Attributes\BackupGlobals; + +use function ob_get_clean; +use function ob_start; +use function uniqid; + +#[BackupGlobals(true)] +final class ResponseSenderMiddlewareTest extends AbstractUnitTestCase +{ + public function testCall(): void + { + $container = new Container(); + $application = new Micro($container); + $middleware = new ResponseSenderMiddleware(); + /** @var Response $response */ + $response = $container->getShared(Container::RESPONSE); + + $content = uniqid('content-'); + $response->setContent($content); + + $time = $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_TIME_FLOAT' => $time, + 'REQUEST_URI' => '/health', + ]; + + ob_start(); + $actual = $middleware->call($application); + $contents = ob_get_clean(); + + $this->assertTrue($actual); + + $expected = $content; + $actual = $contents; + $this->assertEquals($expected, $actual); + } +} diff --git a/tests/Unit/Domain/Services/ContainerTest.php b/tests/Unit/Domain/Services/ContainerTest.php new file mode 100644 index 0000000..1edb010 --- /dev/null +++ b/tests/Unit/Domain/Services/ContainerTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; +use Phalcon\Events\Manager as EventsManager; +use Phalcon\Filter\Filter; + +final class ContainerTest extends AbstractUnitTestCase +{ + public function testContainerEventManager(): void + { + $container = new Container(); + + $actual = $container->has(Container::EVENTS_MANAGER); + $this->assertTrue($actual); + + $eventsManager = $container->getShared(Container::EVENTS_MANAGER); + $this->assertInstanceOf(EventsManager::class, $eventsManager); + } + + public function testContainerFilter(): void + { + $container = new Container(); + + $actual = $container->has(Container::FILTER); + $this->assertTrue($actual); + + $eventsManager = $container->getShared(Container::FILTER); + $this->assertInstanceOf(Filter::class, $eventsManager); + } +} diff --git a/tests/Unit/Domain/Services/Env/Adapters/DotEnvTest.php b/tests/Unit/Domain/Services/Env/Adapters/DotEnvTest.php index 4171d27..37b47bf 100644 --- a/tests/Unit/Domain/Services/Env/Adapters/DotEnvTest.php +++ b/tests/Unit/Domain/Services/Env/Adapters/DotEnvTest.php @@ -15,7 +15,6 @@ use Phalcon\Api\Domain\Exceptions\InvalidConfigurationArgumentException; use Phalcon\Api\Domain\Services\Env\Adapters\DotEnv; -use Phalcon\Api\Domain\Services\Env\EnvFactory; use Phalcon\Api\Domain\Services\Env\EnvManager; use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; @@ -23,16 +22,39 @@ final class DotEnvTest extends AbstractUnitTestCase { private string $envFile; - protected function setUp(): void + public function testLoadExceptionForEmptyFilePath(): void { - $this->envFile = EnvManager::appPath() - . '/tests/Fixtures/Domain/Services/Env/' - ; + $this->expectException(InvalidConfigurationArgumentException::class); + $this->expectExceptionMessage( + 'The .env file does not exist at the specified path' + ); + + $dotEnv = new DotEnv(); + $options = [ + 'filePath' => '', + ]; + + $dotEnv->load($options); + } + + public function testLoadExceptionForMissingFile(): void + { + $this->expectException(InvalidConfigurationArgumentException::class); + $this->expectExceptionMessage( + 'The .env file does not exist at the specified path' + ); + + $dotEnv = new DotEnv(); + $options = [ + 'filePath' => '/does/not/exist/', + ]; + + $dotEnv->load($options); } public function testLoadSuccess(): void { - $dotEnv = new DotEnv(); + $dotEnv = new DotEnv(); $options = [ 'filePath' => $this->envFile, ]; @@ -43,7 +65,7 @@ public function testLoadSuccess(): void 'SAMPLE_TRUE' => 'true', 'SAMPLE_FALSE' => 'false', ]; - $actual = $dotEnv->load($options); + $actual = $dotEnv->load($options); $this->assertArrayHasKey('SAMPLE_STRING', $actual); $this->assertArrayHasKey('SAMPLE_INT', $actual); @@ -60,33 +82,9 @@ public function testLoadSuccess(): void $this->assertSame($expected, $actualArray); } - public function testLoadExceptionForEmptyFilePath(): void - { - $this->expectException(InvalidConfigurationArgumentException::class); - $this->expectExceptionMessage( - 'The .env file does not exist at the specified path' - ); - - $dotEnv = new DotEnv(); - $options = [ - 'filePath' => '', - ]; - - $dotEnv->load($options); - } - - public function testLoadExceptionForMissingFile(): void + protected function setUp(): void { - $this->expectException(InvalidConfigurationArgumentException::class); - $this->expectExceptionMessage( - 'The .env file does not exist at the specified path' - ); - - $dotEnv = new DotEnv(); - $options = [ - 'filePath' => '/does/not/exist/', - ]; - - $dotEnv->load($options); + $this->envFile = EnvManager::appPath() + . '/tests/Fixtures/Domain/Services/Env/'; } } diff --git a/tests/Unit/Domain/Services/Env/EnvManagerTest.php b/tests/Unit/Domain/Services/Env/EnvManagerTest.php index f39c493..0b891f2 100644 --- a/tests/Unit/Domain/Services/Env/EnvManagerTest.php +++ b/tests/Unit/Domain/Services/Env/EnvManagerTest.php @@ -13,23 +13,44 @@ namespace Phalcon\Api\Tests\Unit\Domain\Services\Env; -use Phalcon\Api\Domain\Exceptions\InvalidConfigurationArgumentException; -use Phalcon\Api\Domain\Services\Env\Adapters\DotEnv; -use Phalcon\Api\Domain\Services\Env\EnvFactory; use Phalcon\Api\Domain\Services\Env\EnvManager; use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; -use Phalcon\Container\Lazy\Env; use PHPUnit\Framework\Attributes\BackupGlobals; use ReflectionClass; #[BackupGlobals(true)] final class EnvManagerTest extends AbstractUnitTestCase { - protected function setUp(): void + public function testAppEnvReturnsDefault(): void { - $ref = new ReflectionClass(EnvManager::class); - $ref->setStaticPropertyValue('isLoaded', false); - $ref->setStaticPropertyValue('settings', []); + $expected = 'development'; + $actual = EnvManager::appEnv(); + $this->assertSame($expected, $actual); + } + + public function testAppEnvReturnsValue(): void + { + $_ENV = ['APP_ENV' => 'production']; + + $expected = 'production'; + $actual = EnvManager::appEnv(); + $this->assertSame($expected, $actual); + } + + public function testAppLogLevelReturnsDefault(): void + { + $expected = 1; + $actual = EnvManager::appLogLevel(); + $this->assertSame($expected, $actual); + } + + public function testAppLogLevelReturnsValue(): void + { + $_ENV = ['APP_LOG_LEVEL' => 5]; + + $expected = 5; + $actual = EnvManager::appLogLevel(); + $this->assertSame($expected, $actual); } public function testAppPathReturnsRoot(): void @@ -39,12 +60,28 @@ public function testAppPathReturnsRoot(): void $this->assertSame($expected, $actual); } + public function testAppTimezoneReturnsDefault(): void + { + $expected = 'UTC'; + $actual = EnvManager::appTimezone(); + $this->assertSame($expected, $actual); + } + + public function testAppTimezoneReturnsValue(): void + { + $_ENV = ['APP_TIMEZONE' => 'America/Los_Angeles']; + + $expected = 'America/Los_Angeles'; + $actual = EnvManager::appTimezone(); + $this->assertSame($expected, $actual); + } + public function testGetFromDotEnvLoad(): void { $_ENV = [ 'APP_ENV_ADAPTER' => 'dotenv', 'APP_ENV_FILE_PATH' => EnvManager::appPath() - . '/tests/Fixtures/Domain/Services/Env/' + . '/tests/Fixtures/Domain/Services/Env/', ]; $values = [ @@ -74,4 +111,11 @@ public function testGetFromDotEnvLoad(): void $actual = EnvManager::get('SAMPLE_FALSE'); $this->assertSame($expected, $actual); } + + protected function setUp(): void + { + $ref = new ReflectionClass(EnvManager::class); + $ref->setStaticPropertyValue('isLoaded', false); + $ref->setStaticPropertyValue('settings', []); + } } diff --git a/tests/Unit/Domain/Services/Http/HttpCodesEnumTest.php b/tests/Unit/Domain/Services/Http/HttpCodesEnumTest.php new file mode 100644 index 0000000..d381c16 --- /dev/null +++ b/tests/Unit/Domain/Services/Http/HttpCodesEnumTest.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\Http; + +use Phalcon\Api\Domain\Services\Http\HttpCodesEnum; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +final class HttpCodesEnumTest extends AbstractUnitTestCase +{ + public static function getExamples(): array + { + return [ + [ + HttpCodesEnum::OK, + 200, + 'OK', + ], + [ + HttpCodesEnum::BadRequest, + 400, + 'Bad Request', + ], + [ + HttpCodesEnum::NotFound, + 404, + 'Not Found', + ], + [ + HttpCodesEnum::Unauthorized, + 401, + 'Unauthorized', + ], + [ + HttpCodesEnum::AppMalformedPayload, + 3400, + 'Malformed payload', + ], + [ + HttpCodesEnum::AppRecordsNotFound, + 3401, + 'Record(s) not found', + ], + [ + HttpCodesEnum::AppResourceNotFound, + 3402, + 'Resource not found', + ], + [ + HttpCodesEnum::AppUnauthorized, + 3403, + 'Unauthorized', + ], + [ + HttpCodesEnum::AppInvalidArguments, + 3409, + 'Invalid arguments provided', + ], + ]; + } + + public function testCheckCount(): void + { + $expected = 9; + $actual = HttpCodesEnum::cases(); + $this->assertCount($expected, $actual); + } + + #[DataProvider('getExamples')] + public function testCheckItems( + HttpCodesEnum $element, + int $value, + string $text + ) { + $expected = $value; + $actual = $element->value; + $this->assertSame($expected, $actual); + + $expected = $text; + $actual = $element->text(); + $this->assertSame($expected, $actual); + + $expected = [$value => $text]; + $actual = $element->error(); + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Unit/Domain/Services/Http/ResponseTest.php b/tests/Unit/Domain/Services/Http/ResponseTest.php new file mode 100644 index 0000000..45b97d0 --- /dev/null +++ b/tests/Unit/Domain/Services/Http/ResponseTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\Http; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Http\Response; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; + +use function ob_get_clean; +use function ob_start; +use function uniqid; + +final class ResponseTest extends AbstractUnitTestCase +{ + public function testWithPayloadMeta(): void + { + $container = new Container(); + /** @var Response $response */ + $response = $container->getShared(Container::RESPONSE); + + $message = uniqid('message-'); + $key = uniqid('key-'); + $value = uniqid('value-'); + + $response + ->withCode(404, $message) + ->withPayloadData([$key => $value]) + ; + + ob_start(); + $response->render()->send(); + $data = ob_get_clean(); + + $expected = 404; + $actual = $response->getStatusCode(); + $this->assertSame($expected, $actual); + + $expected = $message; + $actual = $response->getReasonPhrase(); + $this->assertSame($expected, $actual); + + /** + * Remove the timestamp and hash because they are always changing + */ + $data = json_decode($data, true); + unset($data['meta']['timestamp']); + unset($data['meta']['hash']); + + $expected = [ + 'data' => [ + $key => $value, + ], + 'errors' => [], + 'meta' => [ + 'code' => 200, + 'message' => 'success', + ], + ]; + $actual = $data; + $this->assertSame($expected, $actual); + } + + public function testWithPayloadMetaErrors(): void + { + $container = new Container(); + /** @var Response $response */ + $response = $container->getShared(Container::RESPONSE); + + $message = uniqid('message-'); + + $response + ->withPayloadErrors([[$message]]) + ; + + ob_start(); + $response->render()->send(); + $data = ob_get_clean(); + + /** + * Remove the timestamp and hash because they are always changing + */ + $data = json_decode($data, true); + unset($data['meta']['timestamp']); + unset($data['meta']['hash']); + + $expected = [ + 'data' => [], + 'errors' => [ + [ + $message, + ], + ], + 'meta' => [ + 'code' => 3000, + 'message' => 'error', + ], + ]; + $actual = $data; + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Unit/Domain/Services/Providers/ErrorHandlerProviderTest.php b/tests/Unit/Domain/Services/Providers/ErrorHandlerProviderTest.php new file mode 100644 index 0000000..0b1154a --- /dev/null +++ b/tests/Unit/Domain/Services/Providers/ErrorHandlerProviderTest.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\Providers; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Env\EnvManager; +use Phalcon\Api\Domain\Services\Providers\ErrorHandlerProvider; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; +use ReflectionClass; + +use function date_default_timezone_get; +use function hrtime; +use function restore_error_handler; +use function trigger_error; +use function uniqid; + +use const E_ALL; + +#[BackupGlobals(true)] +final class ErrorHandlerProviderTest extends AbstractUnitTestCase +{ + public function testCheckRegistration(): void + { + $container = new Container(); + $now = hrtime(true); + $container->set( + Container::TIME, + function () use ($now) { + return $now; + }, + true + ); + + $provider = new ErrorHandlerProvider(); + $provider->register($container); + + $expected = date_default_timezone_get(); + $actual = EnvManager::appTimezone(); + $this->assertSame($expected, $actual); + + $expected = 'On'; + $actual = ini_get('display_errors'); + $this->assertSame($expected, $actual); + + $expected = E_ALL; + $actual = (int)ini_get('error_reporting'); + $this->assertSame($expected, $actual); + + restore_error_handler(); + } + + public function testRegisterSetsHandlersAndLogs(): void + { + $container = new Container(); + $now = hrtime(true); + $container->set( + Container::TIME, + function () use ($now) { + return $now; + }, + true + ); + + $provider = new ErrorHandlerProvider(); + $provider->register($container); + + $message = uniqid('msg-'); + // Trigger an error + @trigger_error($message); + + restore_error_handler(); + + /** @var string $logName */ + $logName = EnvManager::get('LOG_FILENAME', 'rest-api'); + /** @var string $logPath */ + $logPath = EnvManager::get('LOG_PATH', 'storage/logs/'); + $logFile = EnvManager::appPath($logPath) . '/' . $logName . '.log'; + + $this->assertFileContentsContains($logFile, $message); + } + + public function testRegisterShutdown(): void + { + /** @var string $logName */ + $logName = EnvManager::get('LOG_FILENAME', 'rest-api'); + /** @var string $logPath */ + $logPath = EnvManager::get('LOG_PATH', 'storage/logs/'); + $logFile = EnvManager::appPath($logPath) . '/' . $logName . '.log'; + + $_ENV['APP_ENV'] = 'development'; + $_ENV['APP_LOG_LEVEL'] = 2; + + $container = new Container(); + $now = hrtime(true); + $container->set( + Container::TIME, + function () use ($now) { + return $now; + }, + true + ); + + $provider = new ErrorHandlerProvider(); + $provider->register($container); + + // Directly call shutdown for coverage and checking if it works + $reflection = new ReflectionClass($provider); + $method = $reflection->getMethod('onShutdown'); + $method->setAccessible(true); + $shutdown = $method->invoke($provider, $container); + + restore_error_handler(); + + $this->assertTrue($shutdown); + + $this->assertFileExists($logFile); + + $message = 'Shutdown'; + $this->assertFileContentsContains($logFile, $message); + } +} diff --git a/tests/Unit/Domain/Services/Providers/RouterProviderTest.php b/tests/Unit/Domain/Services/Providers/RouterProviderTest.php new file mode 100644 index 0000000..c6952c1 --- /dev/null +++ b/tests/Unit/Domain/Services/Providers/RouterProviderTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\Providers; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Providers\RouterProvider; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; +use Phalcon\Mvc\Micro; + +final class RouterProviderTest extends AbstractUnitTestCase +{ + public function testCheckRegistration(): void + { + $container = new Container(); + $application = new Micro($container); + $container->setShared(Container::APPLICATION, $application); + + $provider = new RouterProvider(); + $provider->register($container); + + $router = $application->getRouter(); + $routes = $router->getRoutes(); + + $data = [ + [ + 'method' => 'GET', + 'pattern' => '/', + ], + [ + 'method' => 'GET', + 'pattern' => '/health', + ], + ]; + + $expected = count($data); + $actual = count($routes); + $this->assertSame($expected, $actual); + + foreach ($data as $index => $route) { + $expected = $route['method']; + $actual = $routes[$index]->getHttpMethods(); + $this->assertSame($expected, $actual); + + $expected = $route['pattern']; + $actual = $routes[$index]->getPattern(); + $this->assertSame($expected, $actual); + } + } +} diff --git a/tests/Unit/Domain/Services/Providers/RoutesEnumTest.php b/tests/Unit/Domain/Services/Providers/RoutesEnumTest.php new file mode 100644 index 0000000..e7075dd --- /dev/null +++ b/tests/Unit/Domain/Services/Providers/RoutesEnumTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\Providers; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Providers\RoutesEnum; +use Phalcon\Api\Tests\Unit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +final class RoutesEnumTest extends AbstractUnitTestCase +{ + public static function getExamples(): array + { + return [ + [ + RoutesEnum::helloGet, + '', + '/', + RoutesEnum::GET, + Container::HELLO_SERVICE, + ], + ]; + } + + public function testCheckCount(): void + { + $expected = 1; + $actual = RoutesEnum::cases(); + $this->assertCount($expected, $actual); + } + + #[DataProvider('getExamples')] + public function testCheckItems( + RoutesEnum $element, + string $value, + string $endpoint, + string $method, + string $service + ) { + $expected = $value; + $actual = $element->value; + $this->assertSame($expected, $actual); + + $expected = $endpoint; + $actual = $element->endpoint(); + $this->assertSame($expected, $actual); + + $expected = $method; + $actual = $element->method(); + $this->assertSame($expected, $actual); + + $expected = $service; + $actual = $element->service(); + $this->assertSame($expected, $actual); + } +}