From 87b646f8d2b26dd01f13f4c51de540370d6cd50f Mon Sep 17 00:00:00 2001 From: Ron Rise Date: Wed, 12 Nov 2025 23:08:23 -0500 Subject: [PATCH] chore: added documentation --- docker-compose.yml | 1 + src/Annotations/Async/HandlesMessage.php | 14 +- src/Annotations/Events/ListensFor.php | 13 ++ src/Annotations/Guards/Jwt.php | 36 ++++- src/Annotations/Guards/Scope.php | 4 +- src/Async/Brokers/Kafka.php | 7 + src/Async/Consumer.php | 142 ++++++++++-------- src/Cli/App.php | 2 + src/Cli/Commands/Queue/Start.php | 2 - src/Cli/Commands/Queue/TestJob.php | 34 +++++ src/Controllers/Controller.php | 7 + src/Controllers/ControllerInterface.php | 5 + src/Controllers/HealthcheckController.php | 7 + src/Events/Listeners/Database/Connected.php | 4 + src/Events/Listeners/Listener.php | 5 + src/Events/Listeners/ListenerInterface.php | 9 ++ src/Helpers/Env.php | 4 + src/Helpers/Ulid.php | 9 ++ src/Http/Middleware/JwtMiddleware.php | 53 ++++++- src/Http/Middleware/Middleware.php | 38 ++++- src/Http/Middleware/ScopeMiddleware.php | 38 ++++- src/Kernel.php | 13 ++ src/Log/Logger.php | 112 +++++++++++++- src/Services/Facade.php | 5 +- .../BrokerServiceProvider.php | 17 +++ .../DispatcherServiceProvider.php | 5 + .../LoggerServiceProvider.php | 5 + .../ServiceProviders/RedisServiceProvider.php | 7 + 28 files changed, 514 insertions(+), 84 deletions(-) create mode 100644 src/Cli/Commands/Queue/TestJob.php diff --git a/docker-compose.yml b/docker-compose.yml index 5bfca2e..587c7b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,6 +83,7 @@ services: postgres: condition: service_healthy environment: + QUEUE_BROKER: kafka PHP_IDE_CONFIG: serverName=localhost WORKERS: 1 DEBUG: 1 diff --git a/src/Annotations/Async/HandlesMessage.php b/src/Annotations/Async/HandlesMessage.php index 9717932..2c9ec09 100644 --- a/src/Annotations/Async/HandlesMessage.php +++ b/src/Annotations/Async/HandlesMessage.php @@ -6,16 +6,28 @@ namespace Siteworxpro\App\Annotations\Async; use Attribute; +/** + * Attribute to mark a class as a handler for a specific message class in an async workflow. + * + * Repeatable: attach multiple times to handle multiple message classes. + */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] readonly class HandlesMessage { + /** + * Create a new HandlesMessage attribute. + * + * @param class-string $messageClass Fully-qualified class name of the message handled. + */ public function __construct( public string $messageClass, ) { } /** - * @return string + * Get the fully-qualified message class this handler processes. + * + * @return class-string */ public function getMessageClass(): string { diff --git a/src/Annotations/Events/ListensFor.php b/src/Annotations/Events/ListensFor.php index 60b8f2e..1f85665 100644 --- a/src/Annotations/Events/ListensFor.php +++ b/src/Annotations/Events/ListensFor.php @@ -6,9 +6,22 @@ namespace Siteworxpro\App\Annotations\Events; use Attribute; +/** + * Attribute to mark a class as an event listener for a specific event class. + * + * Apply this attribute to classes that subscribe to domain or application events. + * Repeatable: can be attached multiple times to the same class to listen for multiple events. + * + * Targets: class only. + */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] readonly class ListensFor { + /** + * Initialize the ListensFor attribute. + * + * @param class-string $eventClass Fully-qualified class name of the event to listen for. + */ public function __construct(public string $eventClass) { } diff --git a/src/Annotations/Guards/Jwt.php b/src/Annotations/Guards/Jwt.php index 779963e..8d35e7d 100644 --- a/src/Annotations/Guards/Jwt.php +++ b/src/Annotations/Guards/Jwt.php @@ -7,22 +7,47 @@ namespace Siteworxpro\App\Annotations\Guards; use Attribute; use Siteworxpro\App\Services\Facades\Config; +/** + * Attribute to guard classes or methods with JWT claim requirements. + * + * Apply this attribute to a class or method to declare the expected JWT issuer and/or audience. + * If either the issuer or audience is an empty string, the value will be resolved from configuration: + * - `jwt.issuer` + * - `jwt.audience` + * + * Targets: class or method. + */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] readonly class Jwt { + /** + * Initialize the Jwt attribute with optional overrides for expected JWT claims. + * + * @param string $issuer Optional expected JWT issuer (`iss`). Empty string uses `Config::get('jwt.issuer')`. + * @param string $audience Optional expected JWT audience (`aud`). Empty string uses `Config::get('jwt.audience')`. + */ public function __construct( private string $issuer = '', private string $audience = '', ) { } + /** + * Get the required audience from configuration, ignoring any local override. + * + * @return string The globally configured audience or an empty string if not set. + */ public function getRequiredAudience(): string { return Config::get('jwt.audience') ?? ''; } /** - * @return string + * Get the expected audience for validation. + * + * Returns the constructor-provided audience when non-empty; otherwise falls back to `jwt.audience` config. + * + * @return string The audience value to enforce. */ public function getAudience(): string { @@ -33,6 +58,13 @@ readonly class Jwt return $this->audience; } + /** + * Get the expected issuer for validation. + * + * Returns the constructor-provided issuer when non-empty; otherwise falls back to `jwt.issuer` config. + * + * @return string The issuer value to enforce. + */ public function getIssuer(): string { if ($this->issuer === '') { @@ -41,4 +73,4 @@ readonly class Jwt return $this->issuer; } -} \ No newline at end of file +} diff --git a/src/Annotations/Guards/Scope.php b/src/Annotations/Guards/Scope.php index 8e29943..2fdd8c5 100644 --- a/src/Annotations/Guards/Scope.php +++ b/src/Annotations/Guards/Scope.php @@ -11,8 +11,8 @@ readonly class Scope { public function __construct( private array $scopes = [] - ) {} - + ) { + } public function getScopes(): array { diff --git a/src/Async/Brokers/Kafka.php b/src/Async/Brokers/Kafka.php index 95f30fc..2ce6a19 100644 --- a/src/Async/Brokers/Kafka.php +++ b/src/Async/Brokers/Kafka.php @@ -60,6 +60,13 @@ class Kafka extends Broker return null; } + if ($kafkaMessage->err === RD_KAFKA_RESP_ERR_UNKNOWN_TOPIC_OR_PART) { + throw new \RuntimeException( + "Topic '{$queue->queueName()}' or partition does not exist. Kafka does not auto-create topics" . + " unless configured to do so." + ); + } + /** @var string | null $messageData */ $messageData = $kafkaMessage->payload; if ($messageData !== null) { diff --git a/src/Async/Consumer.php b/src/Async/Consumer.php index 26d9fda..0dc834e 100644 --- a/src/Async/Consumer.php +++ b/src/Async/Consumer.php @@ -5,87 +5,96 @@ declare(ticks=1); namespace Siteworxpro\App\Async; use Siteworxpro\App\Annotations\Async\HandlesMessage; +use Siteworxpro\App\Async\Messages\Message; use Siteworxpro\App\Async\Queues\Queue; +use Siteworxpro\App\Services\Facades\Broker; use Siteworxpro\App\Services\Facades\Logger; +/** + * Long-running process that listens to queues, pops messages, and dispatches them to handlers. + */ class Consumer { private static bool $shutDown = false; + /** @var array */ private const array QUEUES = [ 'default' => Queues\DefaultQueue::class, ]; + /** @var Queue[] */ private array $queues = []; + /** @var array message FQCN => handler FQCNs */ private array $handlers = []; private const string HANDLER_NAMESPACE = 'Siteworxpro\\App\\Async\\Handlers\\'; + /** + * @param string[] $queues Optional list of queue names (keys from self::QUEUES) + */ public function __construct(array $queues = []) { - if ($queues === []) { - $queues = self::QUEUES; - } else { - $mappedQueues = []; - foreach ($queues as $queueName) { - if (isset(self::QUEUES[$queueName])) { - $mappedQueues[] = self::QUEUES[$queueName]; - } else { - throw new \InvalidArgumentException("Queue '$queueName' is not defined."); - } - } - $queues = $mappedQueues; - } + $queueClasses = $queues === [] + ? array_values(self::QUEUES) + : array_map( + static function (string $name): string { + if (!isset(self::QUEUES[$name])) { + throw new \InvalidArgumentException("Queue '$name' is not defined."); + } + return self::QUEUES[$name]; + }, + $queues + ); - - foreach ($queues as $queueClass) { - $this->queues[] = new $queueClass(); + foreach ($queueClasses as $class) { + $this->queues[] = new $class(); } $this->registerHandlers(); } + /** + * Discover handler classes under `Handlers` and register them via HandlesMessage attributes. + */ private function registerHandlers(): void { - $recursiveIterator = new \RecursiveIteratorIterator( + $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator(__DIR__ . '/Handlers/') ); - foreach ($recursiveIterator as $file) { - if ($file->isFile() && $file->getExtension() === 'php') { - $relativePath = str_replace(__DIR__ . '/Handlers/', '', $file->getPathname()); - $className = self::HANDLER_NAMESPACE . str_replace('/', '\\', substr($relativePath, 0, -4)); + /** @var \SplFileInfo $file */ + foreach ($it as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + $relative = str_replace(__DIR__ . '/Handlers/', '', $file->getPathname()); + $class = self::HANDLER_NAMESPACE . str_replace('/', '\\', substr($relative, 0, -4)); - if (class_exists($className)) { - $reflection = new \ReflectionClass($className); - $attributes = $reflection->getAttributes(HandlesMessage::class); - foreach ($attributes as $attribute) { - $instance = $attribute->newInstance(); - $messageClass = $instance->getMessageClass(); - $this->handlers[$messageClass][] = $className; - } - } + if (!class_exists($class)) { + continue; + } + + $ref = new \ReflectionClass($class); + foreach ($ref->getAttributes(HandlesMessage::class) as $attr) { + $messageClass = $attr->newInstance()->getMessageClass(); + $this->handlers[$messageClass][] = $class; } } } /** - * @param $signal + * Signal handler used to initiate graceful or immediate shutdown. */ - public static function handleSignal($signal): void + public static function handleSignal(int $signal): void { switch ($signal) { - // Graceful case SIGINT: case SIGTERM: case SIGHUP: self::$shutDown = true; - - break; - - // Not Graceful + return; case SIGKILL: exit(9); } @@ -96,6 +105,9 @@ class Consumer return self::$shutDown; } + /** + * Start the consumer main loop. + */ public function start(): void { if (!\function_exists('pcntl_signal')) { @@ -103,10 +115,11 @@ class Consumer } Logger::info('Starting queue consumer...'); + Logger::info('Using Broker: ' . Broker::getFacadeRoot()::class); - \pcntl_signal(SIGINT, [self::class, 'handleSignal']); - \pcntl_signal(SIGTERM, [self::class, 'handleSignal']); - \pcntl_signal(SIGHUP, [self::class, 'handleSignal']); + foreach ([SIGINT, SIGTERM, SIGHUP] as $sig) { + \pcntl_signal($sig, [self::class, 'handleSignal']); + } while (true) { if ($this->shouldShutDown()) { @@ -118,40 +131,43 @@ class Consumer foreach ($this->queues as $queue) { Logger::info('Listening to queue: ' . $queue->queueName()); $message = $queue->pop(); - if ($message) { - Logger::info('Processing message of type: ' . get_class($message)); - - $handlers = $this->getHandlerForMessage($message); - - foreach ($handlers as $handler) { - $handler($message); - } + if (!$message) { + continue; } + + Logger::info('Processing message of type: ' . get_class($message)); + + foreach ($this->getHandlersForMessage($message) as $handler) { + $handler($message); + } + + // Continue polling from the top of the loop after processing a message. + continue 2; } + // Avoid busy-looping when no messages are available. sleep(1); } } - private function getHandlerForMessage($message): array + /** + * @return callable[] Handler instances invokable with the message + */ + private function getHandlersForMessage(Message $message): array { - $callables = []; - $messageClass = get_class($message); - if (isset($this->handlers[$messageClass])) { - $handlerClasses = $this->handlers[$messageClass]; - foreach ($handlerClasses as $handlerClass) { - if (class_exists($handlerClass)) { - $handlerInstance = new $handlerClass(); - - $callables[] = $handlerInstance; - } - } - - return $callables; + if (!isset($this->handlers[$messageClass])) { + throw new \RuntimeException("No handler found for message class: $messageClass"); } - throw new \RuntimeException("No handler found for message class: $messageClass"); + $callables = []; + foreach ($this->handlers[$messageClass] as $handlerClass) { + if (class_exists($handlerClass)) { + $callables[] = new $handlerClass(); + } + } + + return $callables; } } diff --git a/src/Cli/App.php b/src/Cli/App.php index 29df015..9e419e9 100644 --- a/src/Cli/App.php +++ b/src/Cli/App.php @@ -7,6 +7,7 @@ namespace Siteworxpro\App\Cli; use Ahc\Cli\Application; use Siteworxpro\App\Cli\Commands\DemoCommand; use Siteworxpro\App\Cli\Commands\Queue\Start; +use Siteworxpro\App\Cli\Commands\Queue\TestJob; use Siteworxpro\App\Kernel; use Siteworxpro\App\Services\Facades\Config; @@ -24,6 +25,7 @@ class App $this->app->add(new DemoCommand()); $this->app->add(new Start()); + $this->app->add(new TestJob()); } public function run(): int diff --git a/src/Cli/Commands/Queue/Start.php b/src/Cli/Commands/Queue/Start.php index c688f48..76440c8 100644 --- a/src/Cli/Commands/Queue/Start.php +++ b/src/Cli/Commands/Queue/Start.php @@ -24,8 +24,6 @@ class Start extends Command implements CommandInterface $queues = explode(',', $this->values()['queues']); } - SayHelloMessage::dispatch("hello from queue consumer!"); - $consumer = new Consumer($queues); $consumer->start(); diff --git a/src/Cli/Commands/Queue/TestJob.php b/src/Cli/Commands/Queue/TestJob.php new file mode 100644 index 0000000..7fb0764 --- /dev/null +++ b/src/Cli/Commands/Queue/TestJob.php @@ -0,0 +1,34 @@ +getRandomness(); diff --git a/src/Http/Middleware/JwtMiddleware.php b/src/Http/Middleware/JwtMiddleware.php index 2f4690d..ed15165 100644 --- a/src/Http/Middleware/JwtMiddleware.php +++ b/src/Http/Middleware/JwtMiddleware.php @@ -27,18 +27,44 @@ use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Services\Facades\Config; use Siteworxpro\HttpStatus\CodesEnum; +/** + * JWT authorization middleware. + * + * Applies JWT validation to controller actions annotated with `Jwt` attribute. + * Flow: + * - Resolve the targeted controller and method for the current route. + * - If the method has `Jwt`, read the `Authorization` header and parse the Bearer token. + * - Validate signature, time constraints, issuer\(\) and audience\(\) based on attribute and config. + * - On success, attach all token claims to the request as attributes. + * - On failure, return a 401 JSON response with validation errors. + * + * Configuration: + * - `jwt.signing_key`: key material or `file://` path to key. + * - `jwt.strict_validation`: bool toggling strict vs loose time validation. + */ class JwtMiddleware extends Middleware { /** - * @throws \JsonException - * @throws \Exception + * Process the incoming request. + * + * If the matched controller method is annotated with `Jwt`, validates the token and + * augments the request with claims on success. Otherwise, just delegates to the next handler. + * + * @param ServerRequestInterface $request PSR-7 request instance. + * @param RequestHandlerInterface|Dispatcher $handler Next middleware or route dispatcher. + * + * @return ResponseInterface Response produced by the next handler or a 401 JSON response. + * + * @throws \JsonException On JSON error response encoding issues. + * @throws \Exception On unexpected reflection or JWT parsing issues. */ public function process( ServerRequestInterface $request, RequestHandlerInterface|Dispatcher $handler ): ResponseInterface { - $callable = $this->extractRouteCallable($request, $handler); + // Resolve the callable \[Controller, method] for the current route. + $callable = $this->extractRouteCallable($handler); if ($callable === null) { return $handler->handle($request); } @@ -51,12 +77,15 @@ class JwtMiddleware extends Middleware if ($reflectionClass->hasMethod($method)) { $reflectionMethod = $reflectionClass->getMethod($method); + // Read `Jwt` attribute on the controller method. $attributes = $reflectionMethod->getAttributes(Jwt::class); + // If no `Jwt` attribute, do not enforce auth here. if (empty($attributes)) { return $handler->handle($request); } + // Extract Bearer token from Authorization header. $token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization')); if (empty($token)) { @@ -66,6 +95,7 @@ class JwtMiddleware extends Middleware ], CodesEnum::UNAUTHORIZED); } + // Aggregate required issuers and audience from attributes. $requiredIssuers = []; $requiredAudience = ''; @@ -81,6 +111,7 @@ class JwtMiddleware extends Middleware } try { + // Parse and validate the token with signature, time, issuer and audience constraints. $jwt = new JwtFacade()->parse( $token, $this->getSignedWith(), @@ -91,6 +122,7 @@ class JwtMiddleware extends Middleware new PermittedFor($requiredAudience) ); } catch (RequiredConstraintsViolated $exception) { + // Collect human-readable violations to return to the client. $violations = []; foreach ($exception->violations() as $violation) { $violations[] = $violation->getMessage(); @@ -102,12 +134,14 @@ class JwtMiddleware extends Middleware 'errors' => $violations ], CodesEnum::UNAUTHORIZED); } catch (InvalidTokenStructure) { + // Token could not be parsed due to malformed structure. return JsonResponseFactory::createJsonResponse([ 'status_code' => 401, 'message' => 'Unauthorized: Invalid token', ], CodesEnum::UNAUTHORIZED); } + // Expose all token claims as request attributes for downstream consumers. foreach ($jwt->claims()->all() as $item => $value) { $request = $request->withAttribute($item, $value); } @@ -117,6 +151,17 @@ class JwtMiddleware extends Middleware return $handler->handle($request); } + /** + * Build the signature validation constraint from configured key. + * + * - If the configured key content includes the string `PUBLIC KEY`, use RSA SHA-256. + * - Otherwise assume an HMAC SHA-256 shared secret. + * - Supports raw key strings or `file://` paths. + * + * @return SignedWith Signature constraint used during JWT parsing. + * + * @throws \RuntimeException When no signing key is configured. + */ private function getSignedWith(): SignedWith { $key = Config::get('jwt.signing_key'); @@ -125,12 +170,14 @@ class JwtMiddleware extends Middleware throw new \RuntimeException('JWT signing key is not configured.'); } + // Load key either from file or raw text. if (str_starts_with($key, 'file://')) { $key = InMemory::file(substr($key, 7)); } else { $key = InMemory::plainText($key); } + // Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC. if (str_contains($key->contents(), 'PUBLIC KEY')) { return new SignedWith(new Sha256(), $key); } diff --git a/src/Http/Middleware/Middleware.php b/src/Http/Middleware/Middleware.php index bbb0696..1c9f054 100644 --- a/src/Http/Middleware/Middleware.php +++ b/src/Http/Middleware/Middleware.php @@ -9,30 +9,60 @@ use League\Route\Route; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Base middleware helper for extracting route callables. + * + * This abstract middleware provides a utility method to inspect a League\Route + * dispatcher and obtain the underlying route callable as a [class, method] tuple. + * + * @package Siteworxpro\App\Http\Middleware + */ abstract class Middleware implements MiddlewareInterface { - - protected function extractRouteCallable($request, RequestHandlerInterface | Dispatcher $handler): array|null - { + /** + * Extract the route callable [class, method] from a League\Route dispatcher. + * + * When the provided handler is a League\Route\Dispatcher, this inspects its + * middleware stack, looks at the last segment (the resolved Route), and + * attempts to normalize its callable into a [class, method] pair. + * + * Supported callable forms: + * - array callable: [object|class-string, method-string] + * - string callable: "ClassName::methodName" + * + * Returns null when the handler is not a Dispatcher, the stack is empty, + * or the callable cannot be parsed. + * + * @param RequestHandlerInterface|Dispatcher $handler The downstream handler or dispatcher. + * + * @return array{0: class-string|object|null, 1: string|null}|null Tuple of [class|object, method] or null. + */ + protected function extractRouteCallable( + RequestHandlerInterface|Dispatcher $handler + ): array|null { + // Only proceed if this is a League\Route dispatcher. if (!$handler instanceof Dispatcher) { return null; } /** @var Route | null $lastSegment */ + // Retrieve the last middleware in the stack, which should be the Route. $lastSegment = array_last($handler->getMiddlewareStack()); if ($lastSegment === null) { return null; } + // Obtain the callable associated with the route. $callable = $lastSegment->getCallable(); $class = null; $method = null; + // Handle array callable: [object|class-string, 'method'] if (is_array($callable) && count($callable) === 2) { [$class, $method] = $callable; } elseif (is_string($callable)) { - // Handle the case where the callable is a string (e.g., 'ClassName::methodName') + // Handle string callable: 'ClassName::methodName' [$class, $method] = explode('::', $callable); } diff --git a/src/Http/Middleware/ScopeMiddleware.php b/src/Http/Middleware/ScopeMiddleware.php index b4ee8e7..cb4a7b8 100644 --- a/src/Http/Middleware/ScopeMiddleware.php +++ b/src/Http/Middleware/ScopeMiddleware.php @@ -13,36 +13,65 @@ use Siteworxpro\App\Controllers\Controller; use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\HttpStatus\CodesEnum; +/** + * Middleware that enforces scope-based access control on controller actions. + * + * It inspects PHP 8 attributes of type \`Scope\` applied to the resolved controller method, + * compares the required scopes with the user scopes provided on the request attribute \`scopes\`, + * and returns a 403 JSON response when any required scope is missing. + * + * If the route callable cannot be resolved, or no scope is required, the request is passed through. + * + * @see Scope + */ class ScopeMiddleware extends Middleware { /** - * @throws \JsonException + * Resolve the route callable, read any \`Scope\` attributes, and enforce required scopes. + * + * Expected user scopes are provided on the request under the attribute name \`scopes\` + * as an array of strings. + * + * @param ServerRequestInterface $request Incoming PSR-7 request (expects \`scopes\` attribute). + * @param RequestHandlerInterface|Dispatcher $handler Next handler or League\Route dispatcher. + * + * @return ResponseInterface A 403 JSON response when scopes are insufficient; otherwise the handler response. + * + * @throws \JsonException If encoding the JSON error response fails. + * @throws \ReflectionException If reflection on the controller or method fails. */ public function process( ServerRequestInterface $request, RequestHandlerInterface | Dispatcher $handler ): ResponseInterface { - $callable = $this->extractRouteCallable($request, $handler); + // Attempt to resolve the route's callable [Controller instance, method name]. + $callable = $this->extractRouteCallable($handler); if ($callable === null) { + // If no callable is available, delegate to the next handler. return $handler->handle($request); } - /** @var Controller $class */ + /** @var Controller $class Controller instance resolved from the route. */ [$class, $method] = $callable; + // Ensure the controller exists and the method is defined before reflecting. if (class_exists($class::class)) { $reflectionClass = new \ReflectionClass($class); if ($reflectionClass->hasMethod($method)) { $reflectionMethod = $reflectionClass->getMethod($method); + + // Fetch all Scope attributes declared on the method. $attributes = $reflectionMethod->getAttributes(Scope::class); foreach ($attributes as $attribute) { - /** @var Scope $scopeInstance */ + /** @var Scope $scopeInstance Concrete Scope attribute instance. */ $scopeInstance = $attribute->newInstance(); $requiredScopes = $scopeInstance->getScopes(); + // Retrieve user scopes from the request (defaults to an empty array). $userScopes = $request->getAttribute('scopes', []); + // Deny if any required scope is missing from the user's scopes. if ( array_any( $requiredScopes, @@ -59,6 +88,7 @@ class ScopeMiddleware extends Middleware } } + // All checks passed; continue down the middleware pipeline. return $handler->handle($request); } } diff --git a/src/Kernel.php b/src/Kernel.php index 8237102..3fb2e66 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -14,8 +14,21 @@ use Siteworxpro\App\Services\ServiceProviders\DispatcherServiceProvider; use Siteworxpro\App\Services\ServiceProviders\LoggerServiceProvider; use Siteworxpro\App\Services\ServiceProviders\RedisServiceProvider; +/** + * Class Kernel + * + * The Kernel class is responsible for bootstrapping the application by + * initializing service providers and setting up the database connection. + * + * @package Siteworxpro\App + */ class Kernel { + /** + * List of service providers to be registered during bootstrapping. + * + * @var array + */ private static array $serviceProviders = [ LoggerServiceProvider::class, RedisServiceProvider::class, diff --git a/src/Log/Logger.php b/src/Log/Logger.php index 178e760..00c0a25 100644 --- a/src/Log/Logger.php +++ b/src/Log/Logger.php @@ -11,12 +11,44 @@ use Psr\Log\LogLevel; use RoadRunner\Logger\Logger as RRLogger; use Spiral\Goridge\RPC\RPC; +/** + * Logger implementation that conforms to PSR-3 (`Psr\Log\LoggerInterface`). + * + * Behavior: + * - If environment indicates RoadRunner RPC (`$_SERVER['RR_RPC']`), logs are forwarded + * to a RoadRunner RPC logger (`RoadRunner\Logger\Logger`) created via Goridge RPC. + * - Otherwise, logs are written to `php://stdout` using Monolog with a JSON formatter. + * - Messages below the configured threshold are ignored (level filtering). + * + * Supported PSR-3 levels are mapped to an internal numeric ordering in `$levels`. + * When using the RPC logger, levels are translated to the respective RPC methods + * (debug, info, warning, error). When using Monolog, the numeric mapping is used + * as the numeric level passed to Monolog's `log` method. + */ class Logger implements LoggerInterface { + /** + * RoadRunner RPC logger instance when running under RoadRunner. + * + * @var RRLogger|null + */ private ?RRLogger $rpcLogger = null; + /** + * Monolog logger used as a fallback to write JSON-formatted logs to stdout. + * + * @var \Monolog\Logger + */ private \Monolog\Logger $monologLogger; + /** + * Numeric ordering for PSR-3 log levels. + * + * Lower numbers represent higher severity. This mapping is used for filtering + * messages according to the configured minimum level and for Monolog numeric level. + * + * @var array + */ private array $levels = [ LogLevel::EMERGENCY => 0, LogLevel::ALERT => 1, @@ -28,10 +60,21 @@ class Logger implements LoggerInterface LogLevel::DEBUG => 7, ]; + /** + * Create a new Logger. + * + * @param string $level Minimum level to log (PSR-3 level string). Messages with + * a higher numeric value in `$levels` will be ignored. + * + * The default is `LogLevel::DEBUG` (log everything). + * + * If `$_SERVER['RR_RPC']` is set, an RPC connection will be attempted at + * $_SERVER['RR_RPC'] and a RoadRunner RPC logger will be used. + */ public function __construct(private readonly string $level = LogLevel::DEBUG) { if (isset($_SERVER['RR_RPC'])) { - $rpc = RPC::create('tcp://127.0.0.1:6001'); + $rpc = RPC::create($_SERVER['RR_RPC']); $this->rpcLogger = new RRLogger($rpc); } @@ -40,46 +83,113 @@ class Logger implements LoggerInterface $this->monologLogger->pushHandler(new StreamHandler('php://stdout')->setFormatter($formatter)); } + /** + * System is unusable. + * + * @param \Stringable|string $message + * @param array $context + */ public function emergency(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); } + /** + * Action must be taken immediately. + * + * @param \Stringable|string $message + * @param array $context + */ public function alert(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); } + /** + * Critical conditions. + * + * @param \Stringable|string $message + * @param array $context + */ public function critical(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); } + /** + * Runtime errors that do not require immediate action but should typically be logged and monitored. + * + * @param \Stringable|string $message + * @param array $context + */ public function error(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); } + /** + * Exceptional occurrences that are not errors. + * + * @param \Stringable|string $message + * @param array $context + */ public function warning(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); } + /** + * Normal but significant events. + * + * @param \Stringable|string $message + * @param array $context + */ public function notice(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); } + /** + * Interesting events. + * + * @param \Stringable|string $message + * @param array $context + */ public function info(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); } + /** + * Detailed debug information. + * + * @param \Stringable|string $message + * @param array $context + */ public function debug(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); } + /** + * Logs with an arbitrary level. + * + * Behavior details: + * - If the provided `$level` maps to a numeric value greater than the configured + * minimum level, the message is discarded (filtered). + * - If an RPC logger is available, the message is forwarded to the RPC logger + * using a method chosen by level (debug, info, warning, error). + * - Otherwise, the message is written to Monolog using the numeric mapping. + * + * Notes: + * - `$level` should be a PSR-3 level string (values defined in `Psr\Log\LogLevel`). + * - If an unknown level string is passed, accessing `$this->levels[$level]` may + * trigger a PHP notice or undefined index. Ensure callers use valid PSR-3 levels. + * + * @param mixed $level PSR-3 log level (string) + * @param \Stringable|string $message + * @param array $context + */ public function log($level, \Stringable|string $message, array $context = []): void { if ($this->levels[$level] > $this->levels[$this->level]) { diff --git a/src/Services/Facade.php b/src/Services/Facade.php index 7e8140f..ffae566 100644 --- a/src/Services/Facade.php +++ b/src/Services/Facade.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Siteworxpro\App\Services; use Illuminate\Contracts\Container\Container; +use Illuminate\Support\HigherOrderTapProxy; use Illuminate\Support\Testing\Fakes\Fake; use Mockery; use Mockery\Expectation; @@ -57,9 +58,9 @@ class Facade /** * Convert the facade into a Mockery spy. * - * @return MockInterface + * @return HigherOrderTapProxy | MockInterface */ - public static function spy(): MockInterface + public static function spy(): HigherOrderTapProxy | MockInterface { if (! static::isMock()) { $class = static::getMockableClass(); diff --git a/src/Services/ServiceProviders/BrokerServiceProvider.php b/src/Services/ServiceProviders/BrokerServiceProvider.php index dafe190..28db53b 100644 --- a/src/Services/ServiceProviders/BrokerServiceProvider.php +++ b/src/Services/ServiceProviders/BrokerServiceProvider.php @@ -8,8 +8,25 @@ use Illuminate\Support\ServiceProvider; use Siteworxpro\App\Async\Brokers\Broker; use Siteworxpro\App\Services\Facades\Config; +/** + * Class BrokerServiceProvider + * + * This service provider is responsible for binding the Broker implementation + * to the Laravel service container based on configuration settings. + * + * @package Siteworxpro\App\Services\ServiceProviders + */ class BrokerServiceProvider extends ServiceProvider { + /** + * Register services. + * + * This method binds the Broker interface to a specific implementation + * based on the configuration defined in 'queue.broker' and 'queue.broker_config'. + * + * @return void + * @throws \RuntimeException if the specified broker class does not exist. + */ public function register(): void { $this->app->singleton(Broker::class, function (): Broker { diff --git a/src/Services/ServiceProviders/DispatcherServiceProvider.php b/src/Services/ServiceProviders/DispatcherServiceProvider.php index 6e373c2..081ab11 100644 --- a/src/Services/ServiceProviders/DispatcherServiceProvider.php +++ b/src/Services/ServiceProviders/DispatcherServiceProvider.php @@ -7,6 +7,11 @@ namespace Siteworxpro\App\Services\ServiceProviders; use Illuminate\Support\ServiceProvider; use Siteworxpro\App\Events\Dispatcher; +/** + * Class DispatcherServiceProvider + * + * @package Siteworxpro\App\Services\ServiceProviders + */ class DispatcherServiceProvider extends ServiceProvider { public function register(): void diff --git a/src/Services/ServiceProviders/LoggerServiceProvider.php b/src/Services/ServiceProviders/LoggerServiceProvider.php index 768d9c3..512757a 100644 --- a/src/Services/ServiceProviders/LoggerServiceProvider.php +++ b/src/Services/ServiceProviders/LoggerServiceProvider.php @@ -7,6 +7,11 @@ namespace Siteworxpro\App\Services\ServiceProviders; use Illuminate\Support\ServiceProvider; use Siteworxpro\App\Log\Logger; +/** + * Class LoggerServiceProvider + * + * @package Siteworxpro\App\Services\ServiceProviders + */ class LoggerServiceProvider extends ServiceProvider { public function register(): void diff --git a/src/Services/ServiceProviders/RedisServiceProvider.php b/src/Services/ServiceProviders/RedisServiceProvider.php index c22b5a5..748deb8 100644 --- a/src/Services/ServiceProviders/RedisServiceProvider.php +++ b/src/Services/ServiceProviders/RedisServiceProvider.php @@ -8,6 +8,13 @@ use Illuminate\Support\ServiceProvider; use Predis\Client; use Siteworxpro\App\Services\Facades\Config; +/** + * Class RedisServiceProvider + * + * This service provider registers a Redis client as a singleton in the application container. + * + * @package Siteworxpro\App\Services\ServiceProviders + */ class RedisServiceProvider extends ServiceProvider { public function register(): void