diff --git a/example/cli-scripts/sync-models.php b/example/cli-scripts/sync-models.php index c0d44f9..c10330f 100644 --- a/example/cli-scripts/sync-models.php +++ b/example/cli-scripts/sync-models.php @@ -1,13 +1,31 @@ setSourceCodeDirectory(__DIR__ . "/../src"); + + // Get the model reflections + $modelReflections = ClassLoader::$modelClassReflections; + + // Load the credentials for any and all databases used by the models + Abyss::addCredentials(new DatabaseCredentials( + host: NoxEnv::MYSQL_HOST, + username: NoxEnv::MYSQL_USERNAME, + password: NoxEnv::MYSQL_PASSWORD, + database: NoxEnv::MYSQL_DB_NAME, + port: NoxEnv::MYSQL_PORT, + )); - // Setup Abyss from this directory. - // This is only necessary when not being used with the Router - // such as from CLI (this script) or on its own - Abyss::loadConfig(__DIR__ . "/.."); $abyss = new Abyss(); // Sync the models to the current local MySQL database - $abyss->syncModels(); \ No newline at end of file + print("Synchronizing Models to MySQL database.\n"); + $abyss->syncModels($modelReflections); + print("Synchronization finished.\n"); \ No newline at end of file diff --git a/example/nox-cache.json b/example/nox-cache.json deleted file mode 100644 index 33c575b..0000000 --- a/example/nox-cache.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "text/css":2592000, - "image/jpeg":2592000, - "image/gif":2592000, - "image/png":2592000, - "image/webp":2592000, - "image/svg+xml":2592000, - "application/pdf":5184000, - "text/javascript":1296000 -} diff --git a/example/nox-mime.json b/example/nox-mime.json deleted file mode 100644 index 43050f6..0000000 --- a/example/nox-mime.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "css": "text/css", - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "js": "text/javascript", - "mjs": "text/javascript", - "gif": "image/gif", - "weba": "audio/webm", - "webm": "video/webm", - "webp": "image/webp", - "pdf": "application/pdf", - "svg": "image/svg+xml", - "png": "image/png" -} diff --git a/example/nox-request.php b/example/nox-request.php index e9b015a..d834704 100644 --- a/example/nox-request.php +++ b/example/nox-request.php @@ -1,29 +1,74 @@ loadAll( - fromDirectory: __DIR__, + // Set a static file serving directory + $nox->addStaticDirectory( + uriStub: "/", + directoryPath: __DIR__ . "/resources/static", ); - // Load the Abyss ORM configuration - // Comment this out to disable using Abyss and require a MySQL connection - Abyss::loadConfig(__DIR__); + // Support static file mime types so the browser can recognize the static files + $nox->mapExtensionToMimeType("css", "text/css"); + $nox->mapExtensionToMimeType("map", "text/plain"); + $nox->mapExtensionToMimeType("png", "image/png"); + $nox->mapExtensionToMimeType("jpg", "image/jpeg"); + $nox->mapExtensionToMimeType("jpeg", "image/jpeg"); + $nox->mapExtensionToMimeType("js", "text/javascript"); + $nox->mapExtensionToMimeType("mjs", "text/javascript"); + $nox->mapExtensionToMimeType("gif", "image/gif"); + $nox->mapExtensionToMimeType("weba", "audio/webm"); + $nox->mapExtensionToMimeType("webm", "video/webm"); + $nox->mapExtensionToMimeType("webp", "image/webp"); + $nox->mapExtensionToMimeType("pdf", "application/pdf"); + $nox->mapExtensionToMimeType("svg", "image/svg+xml"); + + // Mime caches + $nox->addCacheTimeForMime("image/png", 86400 * 60); + $nox->addCacheTimeForMime("image/jpeg", 86400 * 60); + $nox->addCacheTimeForMime("text/css", 86400 * 60); + $nox->addCacheTimeForMime("text/plain", 86400 * 60); + $nox->addCacheTimeForMime("text/javascript", 86400 * 60); + $nox->addCacheTimeForMime("text/gif", 86400 * 60); + $nox->addCacheTimeForMime("text/svg", 86400 * 60); + $nox->addCacheTimeForMime("image/webp", 86400 * 60); + + // Process static files before anything else, to keep static file serving fast + $nox->router->processRequestAsStaticFile(); + + // If the code gets here, then it's not a static file. Load the rest of the setting directories + $nox->setViewsDirectory(__DIR__ . "/resources/views"); + $nox->setLayoutsDirectory(__DIR__ . "/resources/layouts"); + $nox->setSourceCodeDirectory(__DIR__ . "/src"); - // Load the request handler - $requestHandler = new \Nox\Router\RequestHandler($router); - \Nox\Router\BaseController::$requestHandler = $requestHandler; + // Setup the Abyss ORM (MySQL ORM) + // Comment the Abyss credentials out if you do not need MySQL + // Multiple credentials can be added if you have multiple databases/schemas + Abyss::addCredentials(new DatabaseCredentials( + host: NoxEnv::MYSQL_HOST, + username: NoxEnv::MYSQL_USERNAME, + password: NoxEnv::MYSQL_PASSWORD, + database: NoxEnv::MYSQL_DB_NAME, + port: NoxEnv::MYSQL_PORT, + )); - // Process the request - $requestHandler->processRequest(); \ No newline at end of file + // Process the request as a routable request + try { + $nox->router->processRoutableRequest(); + } catch (NoMatchingRoute $e) { + // 404 + http_response_code(404); + // Process a new routable request, but change the path to our known 404 controller method route + $nox->router->requestPath = "/404"; + $nox->router->processRoutableRequest(); + } \ No newline at end of file diff --git a/example/nox.json b/example/nox.json deleted file mode 100644 index 09469e0..0000000 --- a/example/nox.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "static-directory": "/static", - "layouts-directory": "/layouts", - "views-directory": "/views", - "controllers-directory": "/controllers", - "mysql-models-directory": "/models", - - "404-route": "/404" -} \ No newline at end of file diff --git a/example/layouts/base.php b/example/resources/layouts/base.php similarity index 61% rename from example/layouts/base.php rename to example/resources/layouts/base.php index 3735ad7..7b0bebf 100644 --- a/example/layouts/base.php +++ b/example/resources/layouts/base.php @@ -1,3 +1,7 @@ +
diff --git a/example/static/shocked-penguin.png b/example/resources/static/shocked-penguin.png similarity index 100% rename from example/static/shocked-penguin.png rename to example/resources/static/shocked-penguin.png diff --git a/example/views/errors/404.html b/example/resources/views/errors/404.html similarity index 100% rename from example/views/errors/404.html rename to example/resources/views/errors/404.html diff --git a/example/views/home.html b/example/resources/views/home.html similarity index 100% rename from example/views/home.html rename to example/resources/views/home.html diff --git a/example/views/partials/.gitkeep b/example/resources/views/partials/.gitkeep similarity index 100% rename from example/views/partials/.gitkeep rename to example/resources/views/partials/.gitkeep diff --git a/example/controllers/HomeController.php b/example/src/HomeController.php similarity index 72% rename from example/controllers/HomeController.php rename to example/src/HomeController.php index 917c6d9..cd55f2d 100644 --- a/example/controllers/HomeController.php +++ b/example/src/HomeController.php @@ -1,19 +1,15 @@ v\d)/", true)] - class ExampleAPIController extends BaseController{ + #[RouteBase("/users")] + class UsersController extends BaseController{ #[Route("GET", "/")] - public function apiHomeView(): string{ - return "API home view example."; + public function usersHomeView(): string{ + return "Users microservice home view.."; } - #[Route("PUT", "/some/resource/to/put")] + #[Route("PUT", "/users")] #[UseJSON] // Lets the router know to put the response content-type as JSON and to send JSONResult as a JSON string #[ProcessRequestBody] // Parses the raw request body if it is a non-GET request - public function subView(): JSONResult{ + public function addUser(): JSONResult{ // Payload is the request body parsed into an array $payload = BaseController::$requestPayload; diff --git a/example/models/UsersModel.php b/example/src/Users/UsersModel.php similarity index 74% rename from example/models/UsersModel.php rename to example/src/Users/UsersModel.php index 376eff2..ffc1288 100644 --- a/example/models/UsersModel.php +++ b/example/src/Users/UsersModel.php @@ -1,14 +1,21 @@ mysqlDatabaseName; + } + public function getName(): string{ return $this->mysqlTableName; } diff --git a/src/ClassLoader/ClassLoader.php b/src/ClassLoader/ClassLoader.php new file mode 100644 index 0000000..e99a32e --- /dev/null +++ b/src/ClassLoader/ClassLoader.php @@ -0,0 +1,35 @@ +getControllerClassReflections(self::$allAppLoadedClasses); + self::$modelClassReflections = $modelClassIdentifier->getModelClassReflections(self::$allAppLoadedClasses); + } + + } diff --git a/src/ClassLoader/ClassScopeHelper.php b/src/ClassLoader/ClassScopeHelper.php new file mode 100644 index 0000000..b4a8292 --- /dev/null +++ b/src/ClassLoader/ClassScopeHelper.php @@ -0,0 +1,17 @@ +getAttributes( + name:Controller::class, + flags: ReflectionAttribute::IS_INSTANCEOF, + ); + + // Check if it has the Controller attribute + if (!empty($controllerAttributes)) { + $parentClass = $classReflector->getParentClass(); + // Verify it extends from the BaseController + if ( + $parentClass instanceof ReflectionClass && + $parentClass->getName() === BaseController::class + ) { + // It's a Controller class + $controllerClassReflections[] = $classReflector; + } else { + throw new ControllerMissingExtension(sprintf( + "A controller that has the #[%s] attribute must extend the %s class. Your controller class %s is missing this class extension.", + Controller::class, + BaseController::class, + $classReflector->getName(), + )); + } + } + } + + return $controllerClassReflections; + } + } \ No newline at end of file diff --git a/src/ClassLoader/Exceptions/ControllerMissingExtension.php b/src/ClassLoader/Exceptions/ControllerMissingExtension.php new file mode 100644 index 0000000..b1baa58 --- /dev/null +++ b/src/ClassLoader/Exceptions/ControllerMissingExtension.php @@ -0,0 +1,5 @@ +getAttributes( + name:Model::class, + flags: ReflectionAttribute::IS_INSTANCEOF, + ); + + // Check if it has the Model attribute + if (!empty($modelAttributes)) { + $interfaceNames = $classReflector->getInterfaceNames(); + // Verify it implements the MySQLModelInterface + if (in_array(MySQLModelInterface::class, $interfaceNames)) { + // It's a Model class + $modelClassReflections[] = $classReflector; + } else { + throw new ModelMissingImplementation(sprintf( + "A model that has the #[%s] attribute must implement the %s class. Your model class %s is missing this class implementation.", + Model::class, + MySQLModelInterface::class, + $classReflector->getName(), + )); + } + } + } + + return $modelClassReflections; + } + } \ No newline at end of file diff --git a/src/Http/ArrayPayload.php b/src/Http/ArrayPayload.php new file mode 100644 index 0000000..b9fc0e0 --- /dev/null +++ b/src/Http/ArrayPayload.php @@ -0,0 +1,7 @@ +$value){ + $textPayload = new TextPayload(); + $textPayload->name = $key; + $textPayload->contents = $value; + $requestPayload->pushPayload($textPayload); + } + + if (!empty($_FILES)){ + /** + * @var string $key + * @var array{name: string, type: string, tmp_name: string, error: int, size: int} $fileArray + */ + foreach($_FILES as $key=>$fileArray){ + if (!empty($fileArray['tmp_name'])) { + $fileContents = file_get_contents($fileArray['tmp_name']); + $fileUpload = new FileUploadPayload(); + $fileUpload->name = $key; + $fileUpload->contents = $fileContents; + $fileUpload->contentType = $fileArray['type']; + $fileUpload->fileSize = $fileArray['size']; + $fileUpload->fileName = $fileArray['name']; + $requestPayload->pushPayload($fileUpload); + } + } + } + + Request::setRequestPayload($requestPayload); }else{ $request = new Request(); BaseController::$requestPayload = $request->processRequestBody(); diff --git a/src/Http/Exceptions/NoPayloadFound.php b/src/Http/Exceptions/NoPayloadFound.php new file mode 100644 index 0000000..b864981 --- /dev/null +++ b/src/Http/Exceptions/NoPayloadFound.php @@ -0,0 +1,4 @@ +getAllFormDataFromRequest($boundary); /** @var array{headers: array, body: string} $packet */ foreach ($parsedFormData as $packet) { - /** @var array{name: string, value: string, attributes: array} $header */ - foreach ($packet['headers'] as $header) { - if (strtolower($header['name']) === "content-disposition"){ - // Fetch the name - /** @var array{name: string, value: string} $attribute */ - foreach($header['attributes'] as $attribute){ - if ($attribute['name'] === "name"){ - $formData[$attribute['value']] = $packet['body']; + + // Check if this packet is a file upload or not + if ($this->isPacketFileUpload($packet)){ + // Handle this as a file upload + $payloadName = null; + $fileUpload = new FileUploadPayload(); + $fileUpload->contents = $packet['body']; + $fileUpload->fileSize = strlen($packet['body']); + /** @var array{name: string, value: string, attributes: array} $header */ + foreach ($packet['headers'] as $header) { + if (strtolower($header['name']) === "content-disposition") { + /** @var array{name: string, value: string} $attribute */ + foreach ($header['attributes'] as $attribute) { + if ($attribute['name'] === "name") { + $payloadName = $attribute['value']; + }elseif ($attribute['name'] === "filename"){ + $fileUpload->fileName = $attribute['value']; + } + } + }elseif (strtolower($header['name']) === "content-type"){ + $fileUpload->contentType = $header['value']; + } + } + + if ($payloadName !== null){ + $fileUpload->name = $payloadName; + $formData[$payloadName] = $fileUpload; + $requestPayload->pushPayload($fileUpload); + } + }else { + // Handle this as a normal payload + /** @var array{name: string, value: string, attributes: array} $header */ + foreach ($packet['headers'] as $header) { + if (strtolower($header['name']) === "content-disposition") { + /** @var array{name: string, value: string} $attribute */ + foreach ($header['attributes'] as $attribute) { + if ($attribute['name'] === "name") { + $formData[$attribute['value']] = $packet['body']; + $textPayload = new TextPayload(); + $textPayload->name = $attribute['value']; + $textPayload->contents = $packet['body']; + $requestPayload->pushPayload($textPayload); + } } } } @@ -159,10 +227,28 @@ public function processRequestBody(): array // Kill everything http_response_code(500); exit(sprintf("JSON request body is invalid json. Error: %s", json_last_error_msg())); + }else{ + // No errors + // Turn them all into payloads + foreach($formData as $key=>$value){ + if (is_array($value)){ + $arrayPayload = new ArrayPayload(); + $arrayPayload->name = $key; + $arrayPayload->contents = $value; + $requestPayload->pushPayload($arrayPayload); + }else{ + $textPayload = new TextPayload(); + $textPayload->name = $key; + $textPayload->contents = $value; + $requestPayload->pushPayload($textPayload); + } + } } } } + self::setRequestPayload($requestPayload); + return $formData; } diff --git a/src/Http/RequestPayload.php b/src/Http/RequestPayload.php new file mode 100644 index 0000000..fb97a78 --- /dev/null +++ b/src/Http/RequestPayload.php @@ -0,0 +1,103 @@ +payloadObjects = $payloadInstances; + } + + public function pushPayload(Payload $payload): void{ + $this->payloadObjects[] = $payload; + } + + /** + * @return Payload[] + */ + public function getAllPayloads(): array{ + return $this->payloadObjects; + } + + /** + * @param string $name + * @return ArrayPayload|null + * @throws NoPayloadFound + */ + public function getArrayPayload(string $name): ArrayPayload | null{ + foreach($this->payloadObjects as $payload){ + if ($payload instanceof ArrayPayload) { + if (strtolower($payload->name) === strtolower($name)) { + return $payload; + } + } + } + + throw new NoPayloadFound( + sprintf( + "No array payload found in the request body with the name %s", + $name, + ), + ); + } + + /** + * @param string $name + * @return TextPayload + * @throws NoPayloadFound + */ + public function getTextPayload(string $name): TextPayload{ + foreach($this->payloadObjects as $payload){ + if ($payload instanceof TextPayload) { + if (strtolower($payload->name) === strtolower($name)) { + return $payload; + } + } + } + + throw new NoPayloadFound( + sprintf( + "No text payload found in the request body with the name %s", + $name, + ), + ); + } + + /** + * @param string $name + * @return TextPayload|null + */ + public function getTextPayloadNullable(string $name): TextPayload | null{ + try{ + return $this->getTextPayload($name); + }catch(NoPayloadFound){ + return null; + } + } + + /** + * @param string $name + * @return FileUploadPayload + * @throws NoPayloadFound + */ + public function getFileUploadPayload(string $name): FileUploadPayload{ + foreach($this->payloadObjects as $payload){ + if ($payload instanceof FileUploadPayload) { + if (strtolower($payload->name) === strtolower($name)) { + return $payload; + } + } + } + + throw new NoPayloadFound( + sprintf( + "No file upload payload found in the request body with the name %s", + $name, + ), + ); + } + } \ No newline at end of file diff --git a/src/Http/TextPayload.php b/src/Http/TextPayload.php new file mode 100644 index 0000000..faedf72 --- /dev/null +++ b/src/Http/TextPayload.php @@ -0,0 +1,7 @@ +supportedMimeTypes = new MimeTypes(); + $this->router = new Router( + noxInstance: $this, + requestPath: $requestPath, + requestMethod: $requestMethod + ); + $this->staticFileHandler = new StaticFileHandler( + noxInstance: $this, + ); + + // Set the renderer's Nox instance + Renderer::$noxInstance = $this; + } + + /** + * Sets the directory from which your project's PHP source code (classes and such) are houses. All PHP files + * here will be autoloaded into the environment after this call. + * @throws ReflectionException + * @throws ModelMissingImplementation + * @throws ControllerMissingExtension + */ + public function setSourceCodeDirectory(string $directoryPath): void{ + // Cache the currently defined class names + ClassScopeHelper::cacheCurrentClassNames(); + + $this->sourceCodeDirectory = $directoryPath; + + // Load all the classes and their subdirectories as well + $allFullFilePaths = []; + FileSystem::recursivelyFetchAllFileNames( + parentDirectory: $this->sourceCodeDirectory, + arrayToAddTo:$allFullFilePaths, + ); + + // Require every single one of the files + foreach($allFullFilePaths as $filePath){ + // Verify it is a PHP file before including + $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); + if ($fileExtension === "php") { + require_once $filePath; + } + } + + // Fetch all the newly defined class names + $allAppLoadedClassNames = ClassScopeHelper::getNewClassNamesDefined(); + + // Store them in the ClassLoader + ClassLoader::$allAppLoadedClasses = $allAppLoadedClassNames; + + // Sort them into controllers and models to be used by the router and ORM + ClassLoader::runClassFiltersAndSorting(); + + // Load controller reflections into the router as RoutableControllers + $this->router->loadRoutableControllersFromControllerReflections(ClassLoader::$controllerClassReflections); + } + + public function setLayoutsDirectory(string $directoryPath): void{ + $this->layoutsDirectory = $directoryPath; + } + + public function setViewsDirectory(string $directoryPath): void{ + $this->viewsDirectory = $directoryPath; + } + + + public function getLayoutsDirectory(): string{ + return $this->layoutsDirectory; + } + + public function getViewsDirectory(): string { + return $this->viewsDirectory; + } + + /** + * @param string $uriStub + * @param string $directoryPath + * @return void + * @throws ValueError + */ + public function addStaticDirectory(string $uriStub, string $directoryPath): void{ + + if (empty($uriStub)){ + throw new ValueError("The uriStub must not be an empty string. For root directory static file serving, just use a forward slash."); + } + + $this->staticDirectorySettings[] = new StaticDirectorySetting( + uriPathStub: $uriStub, + staticFilesDirectory: $directoryPath, + ); + } + + /** + * @return StaticDirectorySetting[] + */ + public function getStaticDirectorySettings(): array{ + return $this->staticDirectorySettings; + } + + public function mapExtensionToMimeType(string $fileExtension, string $mimeType): void{ + $this->supportedMimeTypes->addMimeType( + extension: $fileExtension, + contentType:$mimeType, + ); + } + + public function addCacheTimeForMime(string $mimeType, int $cacheSeconds): void{ + $this->staticFileHandler->cacheConfig[] = new MimeCache( + mimeType: $mimeType, + cacheSeconds:$cacheSeconds, + ); + } + } \ No newline at end of file diff --git a/src/ORM/Abyss.php b/src/ORM/Abyss.php index 520c7de..d426be7 100644 --- a/src/ORM/Abyss.php +++ b/src/ORM/Abyss.php @@ -2,11 +2,20 @@ namespace Nox\ORM; + use Exception; + use mysqli; + use mysqli_sql_exception; + use Nox\ORM\Exceptions\MissingDatabaseCredentials; use Nox\ORM\Exceptions\NoPrimaryKey; use Nox\ORM\Exceptions\ObjectMissingModelProperty; use Nox\ORM\Interfaces\ModelInstance; use Nox\ORM\Interfaces\MySQLModelInterface; + use Nox\ORM\MySQLDataTypes\Blob; use Nox\ORM\MySQLDataTypes\DataType; + use Nox\ORM\MySQLDataTypes\MediumText; + use Nox\ORM\MySQLDataTypes\Text; + use ReflectionClass; + use ReflectionException; require_once __DIR__ . "/Exceptions/ObjectMissingModelProperty.php"; require_once __DIR__ . "/Exceptions/NoPrimaryKey.php"; @@ -17,14 +26,11 @@ class Abyss{ * List of classes that cannot have DEFAULT values */ public const NO_DEFAULT_DATA_TYPE_CLASS_NAMES = [ - "Text","Blob","Geometry","JSON", + Text::class, + Blob::class, + MediumText::class, ]; - /** - * The directory housing MySQL table models - */ - public static ?string $modelsDirectory = null; - /** * The encoding to set the NAMES to */ @@ -35,77 +41,90 @@ class Abyss{ */ public static string $collation = "utf8mb4_unicode_ci"; + /** @var DatabaseCredentials[] */ + private static array $databaseCredentials = []; + + /** @var mysqli[] */ + private static array $connections = []; + /** * The current MySQLi resource being used */ - private static \mysqli $mysqli; + private mysqli $currentConnection; /** - * Loads the configuration files from a directory needed for the ORM - * when it is used without the Nox Router (such as a CLI script) + * Adds the credentials to Abyss and creates a MySQLi connection to the database, then stores it + * in a static cache or later use. Additionally, runs a SET NAMES %s COLLATE %s query to set + * the NAMES and COLLATE to the correlating static settings of Abyss. + * @param DatabaseCredentials $credentials + * @return void + * @throws mysqli_sql_exception */ - public static function loadConfig(string $fromDirectory): void{ - // Fetch the nox.json - $noxJson = file_get_contents($fromDirectory . "/nox.json"); - $noxConfig = json_decode($noxJson, true); + public static function addCredentials(DatabaseCredentials $credentials){ + if (self::getCredentials($credentials->database) === null) { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); // Set MySQLi to throw exceptions - if (isset($noxConfig['mysql-models-directory']) && !empty($noxConfig['mysql-models-directory'])){ - self::$modelsDirectory = $fromDirectory . $noxConfig['mysql-models-directory']; - } + self::$databaseCredentials[$credentials->database] = $credentials; - /** - * Check if the NoxEnv is loaded - */ - if (!class_exists("NoxEnv")){ - require_once $fromDirectory . "/nox-env.php"; - } + $mysqliConnection = new mysqli( + hostname: $credentials->host, + username: $credentials->username, + password: $credentials->password, + database: $credentials->database, + port: $credentials->port, + ); - } + // Set the NAMES of the connection + $mysqliConnection->query( + sprintf( + "SET NAMES %s COLLATE %s", + self::$characterEncoding, + self::$collation, + ), + ); - public function __construct(){ + self::$connections[$credentials->database] = $mysqliConnection; + } + } - // Check if the models directory is set - // If not, then this is probably being CLI'd - // and needs to be loaded in + public static function getCredentials(string $databaseName): DatabaseCredentials | null{ + if (array_key_exists($databaseName, self::$databaseCredentials)) { + return self::$databaseCredentials[$databaseName]; + }else{ + return null; + } + } - if (!isset(self::$mysqli)) { - mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); // Set MySQLi to throw exceptions - if (!isset(self::$mysqli)) { - try { - self::$mysqli = new \mysqli( - \NoxEnv::MYSQL_HOST, - \NoxEnv::MYSQL_USERNAME, - \NoxEnv::MYSQL_PASSWORD, - \NoxEnv::MYSQL_DB_NAME, - \NoxEnv::MYSQL_PORT - ); - } catch (\mysqli_sql_exception $e) { - // Rethrow it - throw $e; - } + public function __construct(){} - self::$mysqli->query( - sprintf( - "SET NAMES %s COLLATE %s", - self::$characterEncoding, - self::$collation, - ), - ); + /** + * @throws MissingDatabaseCredentials + * @throws mysqli_sql_exception + * @throws Exception + */ + public function getConnectionToDatabase(string $databaseName): mysqli{ + if (array_key_exists($databaseName, self::$connections)){ + return self::$connections[$databaseName]; + }else{ + // No existing connection, create one if the credentials are available + $credentials = self::getCredentials($databaseName); + if ($credentials === null){ + throw new MissingDatabaseCredentials("Missing DatabaseCredentials object for database {$databaseName}. Call Abyss::addCredentials() with an instance of DatabaseCredentials to add the missing credentials."); + }else{ + // Just missing the connection? Should never happen + throw new Exception("Abyss has the credentials for {$databaseName} but no connection. Please make sure the credentials were added using Abyss::addCredentials()."); } } } - public function getConnection(): \mysqli{ - return self::$mysqli; - } - /** * Instantiates a class that follows a model with possible prefilled values. + * @throws ReflectionException|ObjectMissingModelProperty */ public function instanceFromModel(MySQLModelInterface $model, array $columnValues = []): mixed{ $className = $model->getInstanceName(); $instance = new $className(); - $instanceReflection = new \ReflectionClass($instance); + $instanceReflection = new ReflectionClass($instance); $instanceProperties = $instanceReflection->getProperties(); $instancePropertyNames = []; foreach($instanceProperties as $reflectionProperty){ @@ -141,7 +160,7 @@ public function instanceFromModel(MySQLModelInterface $model, array $columnValue */ public function prefillPropertiesWithColumnDefaults(ModelInstance $modelClass): void{ $model = $modelClass::getModel(); - $instanceReflection = new \ReflectionClass($modelClass); + $instanceReflection = new ReflectionClass($modelClass); $instanceProperties = $instanceReflection->getProperties(); $instancePropertyNames = []; foreach($instanceProperties as $reflectionProperty){ @@ -179,7 +198,7 @@ public function fetchInstanceByModelPrimaryKey(MySQLModelInterface $model, mixed } } - $statement = $this->getConnection()->prepare( + $statement = $this->getConnectionToDatabase($model->getDatabaseName())->prepare( sprintf("SELECT * FROM `%s` WHERE `%s` = ?", $model->getName(), $primaryKeyName), ); $statement->bind_param($primaryKeyBindFlagType, $keyValue); @@ -315,7 +334,7 @@ public function fetchInstances( $tableName, $whereClause, $orderClause, $limitClause ); - $statement = $this->getConnection()->prepare($query); + $statement = $this->getConnectionToDatabase($model->getDatabaseName())->prepare($query); if (!empty($boundValues)) { $statement->bind_param($preparedBindDataTypes, ...$boundValues);; } @@ -354,9 +373,9 @@ public function buildSaveQuery( $preparedStatementTypeFlags .= $mysqlBoundParameterFlagType; if ($columnValue !== null) { if ($mysqlBoundParameterFlagType === "s" or $mysqlBoundParameterFlagType === "b") { - $rawQueryColumnValues[] = sprintf('"%s"', $this->getConnection()->escape_string($columnValue)); + $rawQueryColumnValues[] = sprintf('"%s"', $this->getConnectionToDatabase($model->getDatabaseName())->escape_string($columnValue)); } else { - $rawQueryColumnValues[] = sprintf("%s", $this->getConnection()->escape_string((string)$columnValue)); + $rawQueryColumnValues[] = sprintf("%s", $this->getConnectionToDatabase($model->getDatabaseName())->escape_string((string)$columnValue)); } }else{ $rawQueryColumnValues[] = "NULL"; @@ -416,7 +435,8 @@ public function buildSaveQuery( public function saveOrCreateAll(array $modelClasses): void{ if (!empty($modelClasses)) { $allQueries = []; - $primaryKeyName = $this->getPrimaryKey($modelClasses[0]::getModel()); + $model = $modelClasses[0]::getModel(); + $primaryKeyName = $this->getPrimaryKey($model); foreach ($modelClasses as $modelClass) { /** @var array{query: string, preparedStatementFlags: array, columnValues: array} $builtQuery */ @@ -427,15 +447,15 @@ classInstance: $modelClass, $allQueries[] = $builtQuery['query']; } - $this->getConnection()->multi_query(implode(";", $allQueries)); + $this->getConnectionToDatabase($model->getDatabaseName())->multi_query(implode(";", $allQueries)); $currentModelClassIndex = 0; do { - $insertID = $this->getConnection()->insert_id; + $insertID = $this->getConnectionToDatabase($model->getDatabaseName())->insert_id; if ($insertID !== 0) { $modelClasses[$currentModelClassIndex]->{$primaryKeyName} = $insertID; } ++$currentModelClassIndex; - } while ($this->getConnection()->next_result()); + } while ($this->getConnectionToDatabase($model->getDatabaseName())->next_result()); } } @@ -452,7 +472,8 @@ classInstance: $classInstance, usePreparedStatement:true, ); - $statement = $this->getConnection()->prepare($builtQuery['query']); + $model = $classInstance::getModel(); + $statement = $this->getConnectionToDatabase($model->getDatabaseName())->prepare($builtQuery['query']); $statement->bind_param( $builtQuery['preparedStatementFlags'], ...$builtQuery['columnValues'], @@ -463,7 +484,7 @@ classInstance: $classInstance, if ($statement->affected_rows > 0){ if ($result === false) { $statement->close(); - return $this->getConnection()->insert_id; + return $this->getConnectionToDatabase($model->getDatabaseName())->insert_id; } } @@ -491,7 +512,7 @@ public function deleteRowByPrimaryKey(ModelInstance $classInstance): void{ } } - $statement = $this->getConnection()->prepare( + $statement = $this->getConnectionToDatabase($model->getDatabaseName())->prepare( sprintf(" DELETE FROM `%s` WHERE `%s` = ? @@ -506,19 +527,17 @@ public function deleteRowByPrimaryKey(ModelInstance $classInstance): void{ /** * Syncs all models to the database - */ - public function syncModels(): void{ - $fileNames = array_diff(scandir(self::$modelsDirectory), ['.','..']); - foreach ($fileNames as $fileName){ - $modelPath = sprintf("%s/%s", self::$modelsDirectory, $fileName); - $className = pathinfo($fileName, PATHINFO_FILENAME); - require_once $modelPath; - $classReflection = new $className(); - $tableName = $classReflection->getName(); - if ($this->doesTableExist($tableName)){ - $this->updateExistingTable($classReflection); + * @param ReflectionClass[] $modelReflectionClasses + */ + public function syncModels(array $modelReflectionClasses): void{ + foreach ($modelReflectionClasses as $modelReflectionClass){ + /** @var MySQLModelInterface $instanceOfModel */ + $instanceOfModel = $modelReflectionClass->newInstance(); + $tableName = $instanceOfModel->getName(); + if ($this->doesTableExist($instanceOfModel, $tableName)){ + $this->updateExistingTable($instanceOfModel); }else{ - $this->createNewTable($classReflection); + $this->createNewTable($instanceOfModel); } } } @@ -554,8 +573,8 @@ private function getColumnDefinitionAsMySQLSyntax(ColumnDefinition $definition): // Column default value definition if (!$definition->autoIncrement) { // Some data types cannot have a default - $reflector = new \ReflectionClass($definition->dataType); - if (!in_array($reflector->getShortName(), self::NO_DEFAULT_DATA_TYPE_CLASS_NAMES)) { + $reflector = new ReflectionClass($definition->dataType); + if (!in_array($reflector->getName(), self::NO_DEFAULT_DATA_TYPE_CLASS_NAMES)) { if (is_string($definition->defaultValue)) { $mySQLSyntax .= sprintf(" DEFAULT \"%s\"", $definition->defaultValue); } elseif ($definition->defaultValue === null) { @@ -578,7 +597,7 @@ private function getColumnDefinitionAsMySQLSyntax(ColumnDefinition $definition): * Fetches the primary key from a model, if any */ public function getPrimaryKey(MySQLModelInterface $model): ?string{ - /** @var\NoxMySQL\ColumnDefinition $columnDefinition */ + /** @var ColumnDefinition $columnDefinition */ foreach($model->getColumns() as $columnDefinition){ if ($columnDefinition->isPrimary){ return $columnDefinition->name; @@ -605,16 +624,16 @@ private function getUniqueKey(MySQLModelInterface $model): ?string{ /** * Checks if a table already exists */ - protected function doesTableExist(string $tableName): bool{ - $result = $this->getConnection()->query(sprintf("SHOW TABLES LIKE \"%s\"", $tableName)); + protected function doesTableExist(MySQLModelInterface $model, string $tableName): bool{ + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf("SHOW TABLES LIKE \"%s\"", $tableName)); return $result->num_rows > 0; } /** * Checks if there is a UNIQUE index on a column in a table */ - protected function isColumnAUniqueIndex(string $tableName, string $columnName): bool{ - $result = $this->getConnection()->query(sprintf( + protected function isColumnAUniqueIndex(MySQLModelInterface $model, string $tableName, string $columnName): bool{ + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf( "SHOW INDEXES FROM `%s` WHERE `Column_name`='%s' AND Non_unique=0 AND Key_name != \"PRIMARY\"", $tableName, $columnName ) @@ -625,8 +644,8 @@ protected function isColumnAUniqueIndex(string $tableName, string $columnName): /** * Fetches the name of the index that has the column name */ - protected function getUniqueIndexName(string $tableName, string $columnName): string{ - $result = $this->getConnection()->query(sprintf( + protected function getUniqueIndexName(MySQLModelInterface $model, string $tableName, string $columnName): string{ + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf( "SHOW INDEXES FROM `%s` WHERE `Column_name`='%s' AND Non_unique=0 AND Key_name != \"PRIMARY\"", $tableName, $columnName ) @@ -638,8 +657,8 @@ protected function getUniqueIndexName(string $tableName, string $columnName): st /** * Checks if there is a PRIMARY KEY index on a column in a table */ - protected function isColumnAPrimaryKey(string $tableName, string $columnName): bool{ - $result = $this->getConnection()->query(sprintf( + protected function isColumnAPrimaryKey(MySQLModelInterface $model, string $tableName, string $columnName): bool{ + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf( "SHOW KEYS FROM `%s` WHERE `Column_name`='%s' AND Key_name=\"PRIMARY\"", $tableName, $columnName ) @@ -650,8 +669,8 @@ protected function isColumnAPrimaryKey(string $tableName, string $columnName): b /** * Checks if a column exists in a table */ - protected function doesColumnExistInTable(string $tableName, string $columnName): bool{ - $result = $this->getConnection()->query(sprintf( + protected function doesColumnExistInTable(MySQLModelInterface $model, string $tableName, string $columnName): bool{ + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf( "SHOW COLUMNS FROM `%s` WHERE `Field`='%s'", $tableName, $columnName ) @@ -662,9 +681,9 @@ protected function doesColumnExistInTable(string $tableName, string $columnName) /** * Checks if a column exists in a table */ - protected function getAllColumnNamesInTable(string $tableName): array{ + protected function getAllColumnNamesInTable(MySQLModelInterface $model, string $tableName): array{ $columnNames = []; - $result = $this->getConnection()->query(sprintf( + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf( "SHOW COLUMNS FROM `%s`", $tableName ) @@ -679,8 +698,8 @@ protected function getAllColumnNamesInTable(string $tableName): array{ /** * Checks if a column is set to auto increment */ - protected function isColumnSetToAutoIncrement(string $tableName, string $columnName): bool{ - $result = $this->getConnection()->query(sprintf( + protected function isColumnSetToAutoIncrement(MySQLModelInterface $model, string $tableName, string $columnName): bool{ + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf( "SHOW COLUMNS FROM `%s` WHERE `Field`='%s' AND `Extra` LIKE \"%auto_increment%\"", $tableName, $columnName ) @@ -691,8 +710,8 @@ protected function isColumnSetToAutoIncrement(string $tableName, string $columnN /** * Checks if a column is nullable */ - protected function isColumnNullable(string $tableName, string $columnName): bool{ - $result = $this->getConnection()->query(sprintf( + protected function isColumnNullable(MySQLModelInterface $model, string $tableName, string $columnName): bool{ + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf( "SHOW COLUMNS FROM `%s` WHERE `Field`='%s' AND `Null`=\"YES\"", $tableName, $columnName ) @@ -703,8 +722,8 @@ protected function isColumnNullable(string $tableName, string $columnName): bool /** * Checks if a column is set to auto increment */ - protected function getColumnDefaultValue(string $tableName, string $columnName): mixed{ - $result = $this->getConnection()->query(sprintf( + protected function getColumnDefaultValue(MySQLModelInterface $model, string $tableName, string $columnName): mixed{ + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf( "SHOW COLUMNS FROM `%s` WHERE `Field`='%s'", $tableName, $columnName ) @@ -716,9 +735,9 @@ protected function getColumnDefaultValue(string $tableName, string $columnName): /** * Checks if a column's data type definition matches the ColumnDefinition provided */ - protected function doColumnDefinitionsMatch(string $tableName, string $columnName, ColumnDefinition $columnDefinition): bool{ + protected function doColumnDefinitionsMatch(MySQLModelInterface $model, string $tableName, string $columnName, ColumnDefinition $columnDefinition): bool{ $dataTypeFromColumnDef = $this->getDataTypeDefinitionMySQLSyntax($columnDefinition->dataType); - $result = $this->getConnection()->query(sprintf( + $result = $this->getConnectionToDatabase($model->getDatabaseName())->query(sprintf( "SHOW COLUMNS FROM `%s` WHERE `Field`='%s' AND `Type`='%s'", $tableName, $columnName, $dataTypeFromColumnDef, ) @@ -744,14 +763,14 @@ protected function updateExistingTable(MySQLModelInterface $model): void{ foreach($model->getColumns() as $columnDefinition){ $columnName = $columnDefinition->name; $columnNamesDefinedByModel[] = $columnName; - if (!$this->doesColumnExistInTable($tableName, $columnName)){ + if (!$this->doesColumnExistInTable($model, $tableName, $columnName)){ // Create the whole thing $queriesToExecute .= "ALTER TABLE `$tableName` ADD COLUMN " . $this->getColumnDefinitionAsMySQLSyntax($columnDefinition) . ";\n"; }else{ // Redefine the column to make sure it matches // Since we're altering, we have to check if a PRIMARY KEY needs to be appended $primaryKeyDefinitionString = ""; - if ($columnDefinition->isPrimary && !$this->isColumnAPrimaryKey($tableName, $columnName)){ + if ($columnDefinition->isPrimary && !$this->isColumnAPrimaryKey($model, $tableName, $columnName)){ $primaryKeyDefinitionString = "PRIMARY KEY"; } $queriesToExecute .= sprintf( @@ -764,10 +783,10 @@ protected function updateExistingTable(MySQLModelInterface $model): void{ } // Is it a unique column? - if ($this->isColumnAUniqueIndex($tableName, $columnName)){ + if ($this->isColumnAUniqueIndex($model, $tableName, $columnName)){ if (!$columnDefinition->isUnique){ // Needs to be dropped - $indexName = $this->getUniqueIndexName($tableName, $columnName); + $indexName = $this->getUniqueIndexName($model, $tableName, $columnName); $queriesToExecute .= sprintf( "ALTER TABLE `%s` DROP INDEX %s;\n", $tableName, $indexName @@ -786,8 +805,8 @@ protected function updateExistingTable(MySQLModelInterface $model): void{ $previousColumnNameIterated = $columnName; } - // Get all of the columns currently in the table - $columnNamesInTable = $this->getAllColumnNamesInTable($tableName); + // Get all the columns currently in the table + $columnNamesInTable = $this->getAllColumnNamesInTable($model, $tableName); foreach($columnNamesInTable as $columnNameInTable){ if (!in_array($columnNameInTable, $columnNamesDefinedByModel)){ // Drop it @@ -795,18 +814,18 @@ protected function updateExistingTable(MySQLModelInterface $model): void{ } } - $this->getConnection()->multi_query($queriesToExecute); + $this->getConnectionToDatabase($model->getDatabaseName())->multi_query($queriesToExecute); // Remove the queries from the result stack // Otherwise "commands out of sync" will occur - while ($result = $this->getConnection()->next_result()){} + while ($result = $this->getConnectionToDatabase($model->getDatabaseName())->next_result()){} } /** * Creates a table following a model */ protected function createNewTable(MySQLModelInterface $model): void{ - $connection = $this->getConnection(); + $connection = $this->getConnectionToDatabase($model->getDatabaseName()); $tableName = $model->getName(); $tableCreationSyntax = "CREATE TABLE `$tableName`("; $columnDefinitionSyntax = ""; diff --git a/src/ORM/Attributes/Model.php b/src/ORM/Attributes/Model.php new file mode 100644 index 0000000..a06673f --- /dev/null +++ b/src/ORM/Attributes/Model.php @@ -0,0 +1,9 @@ +fetchInstanceByModelPrimaryKey( model: $thisModel, keyValue: $primaryKey, @@ -82,8 +72,8 @@ public static function queryOne( public static function count( ColumnQuery $columnQuery = null, ): int { - $abyss = new Abyss(); $model = static::getModel(); + $abyss = new Abyss(); $whereClause = ""; $preparedStatementBindFlags = ""; @@ -97,7 +87,7 @@ public static function count( $model->getName(), $whereClause, ); - $statement = $abyss->getConnection()->prepare($query); + $statement = $abyss->getConnectionToDatabase($model->getDatabaseName())->prepare($query); if ($columnQuery !== null && !empty($columnQuery->whereClauses)) { $statement->bind_param($preparedStatementBindFlags, ...$boundValues); } @@ -109,12 +99,15 @@ public static function count( } /** - * Runs a large-scale UPDATE query to save all of the - * ModelClass instances by their primary key + * Runs a large-scale UPDATE query to save all the + * ModelClass instances by their primary key. The model classes provided + * should be homogenous. */ public static function saveAll(array $modelClasses): void{ - $abyss = new Abyss(); - $abyss->saveOrCreateAll($modelClasses); + if (!empty($modelClasses)) { + $abyss = new Abyss(); + $abyss->saveOrCreateAll($modelClasses); + } } /** @@ -123,6 +116,7 @@ public static function saveAll(array $modelClasses): void{ public function __construct(ModelInstance $modelClass){ $abyss = new Abyss(); $abyss->prefillPropertiesWithColumnDefaults($modelClass); + $this->childInstance = $modelClass; } /** @@ -130,10 +124,12 @@ public function __construct(ModelInstance $modelClass){ * find the name of the primary key, find the class name's representation of it, * then set the class' primary key property value. */ - public function save():void{ + public function save(): void{ $abyss = new Abyss(); $rowID = $abyss->saveOrCreate($this); - if ($rowID !== null){ + + // 0 Here would indicate a column that doesn't generate an automatically incremented ID + if ($rowID !== null && $rowID !== 0){ $primaryKeyClassPropertyName = $abyss->getPrimaryKey($this::getModel()); if ($primaryKeyClassPropertyName) { $this->$primaryKeyClassPropertyName = $rowID; @@ -146,10 +142,30 @@ public function save():void{ * @throws NoPrimaryKey */ public function delete():void{ - $abyss = new Abyss; + $abyss = new Abyss(); $abyss->deleteRowByPrimaryKey($this); } + /** + * Fetches the MySQL column named from the PHP property defined for this ModelInstance's Model + * @throws NoColumnWithPropertyName + */ + public function getColumnName(string $propertyName): string{ + $model = $this->childInstance::getModel(); + + /** @var ColumnDefinition[] $columns */ + $columns = $model->getColumns(); + + foreach($columns as $column){ + if ($column->classPropertyName === $propertyName){ + return $column->name; + } + } + + $modelClass = $model::class; + throw new NoColumnWithPropertyName("No column with property name {$propertyName} in {$modelClass}."); + } + public static function getModel(): MySQLModelInterface { // TODO: Implement getModel() method. diff --git a/src/ORM/MySQLDataTypes/Blob.php b/src/ORM/MySQLDataTypes/Blob.php new file mode 100644 index 0000000..468b261 --- /dev/null +++ b/src/ORM/MySQLDataTypes/Blob.php @@ -0,0 +1,11 @@ +getColumns(); - - foreach($columns as $column){ - if ($column->classPropertyName === $propertyName){ - return $column->name; - } - } - - $modelClass = $model::class; - throw new NoColumnWithPropertyName("No column with property name {$propertyName} in {$modelClass}."); - } - } \ No newline at end of file diff --git a/src/ORM/ResultOrder.php b/src/ORM/ResultOrder.php index b86ee81..9cdb881 100644 --- a/src/ORM/ResultOrder.php +++ b/src/ORM/ResultOrder.php @@ -6,14 +6,21 @@ class ResultOrder{ public array $orderClauses = []; - public function __construct(){ - + /** + * Inputs a result order but using function syntax + * E.g. "RAND()" + * @param string $functionCallString + * @return $this + */ + public function byFunction(string $functionCallString): self{ + $this->orderClauses[] = $functionCallString; + return $this; } /** * Adds a column order clause. Returns itself for chaining. */ - public function by(string $columnName, string $order): ResultOrder{ + public function by(string $columnName, string $order): self{ $this->orderClauses[] = sprintf("`%s` %s", $columnName, $order); return $this; } diff --git a/src/RenderEngine/Renderer.php b/src/RenderEngine/Renderer.php index 2fa76d9..1e8103d 100644 --- a/src/RenderEngine/Renderer.php +++ b/src/RenderEngine/Renderer.php @@ -6,6 +6,7 @@ require_once __DIR__ . "/Parser.php"; require_once __DIR__ . "/../Router/ViewSettings.php"; + use Nox\Nox; use Nox\RenderEngine\Exceptions\LayoutDoesNotExist; use Nox\RenderEngine\Exceptions\ParseError; use Nox\RenderEngine\Exceptions\ViewFileDoesNotExist; @@ -13,8 +14,7 @@ class Renderer{ - public ?string $fileLocation; - public static ?ViewSettings $viewSettings; + public static Nox $noxInstance; /** * Retrieves the rendered result of the view file. @@ -26,7 +26,9 @@ class Renderer{ * @throws ViewFileDoesNotExist */ public static function renderView(string $viewFileName, array $viewScope = []): string{ - $fileLocation = sprintf("%s/%s", self::$viewSettings->viewsFolder, $viewFileName); + $viewsFolder = self::$noxInstance->getViewsDirectory(); + $layoutsFolder = self::$noxInstance->getLayoutsDirectory(); + $fileLocation = sprintf("%s/%s", $viewsFolder, $viewFileName); if (!realpath($fileLocation)){ throw new ViewFileDoesNotExist(sprintf("No view file at file path: %s", $fileLocation)); @@ -36,14 +38,14 @@ public static function renderView(string $viewFileName, array $viewScope = []): $parser->parse(); $layoutFileName = $parser->directives['@Layout']; - $layoutFilePath = sprintf("%s/%s", self::$viewSettings->layoutsFolder, $layoutFileName); + $layoutFilePath = sprintf("%s/%s", $layoutsFolder, $layoutFileName); if (!realpath($layoutFilePath)){ throw new LayoutDoesNotExist( sprintf( - "The layout %s does not exist in the folder %s", + "The layout %s does not exist in the directory %s", $layoutFileName, - self::$viewSettings->layoutsFolder + $layoutsFolder ) ); } diff --git a/src/Router/BaseController.php b/src/Router/BaseController.php index 1a85e32..f26e625 100644 --- a/src/Router/BaseController.php +++ b/src/Router/BaseController.php @@ -1,7 +1,10 @@ router->route($this); + return $this->router->routeCurrentRequest($this); } /** diff --git a/src/Router/RoutableController.php b/src/Router/RoutableController.php new file mode 100644 index 0000000..3745a23 --- /dev/null +++ b/src/Router/RoutableController.php @@ -0,0 +1,11 @@ +dynamicRoutes[] = $dynamicRoute; - } + public function processRequestAsStaticFile(): void{ + if ($this->requestMethod === "get") { + $mimeType = $this->noxInstance->staticFileHandler->getStaticFileMime($this->requestPath); + // Do not serve unknown mime types + if ($mimeType !== null) { + $staticFilePath = $this->noxInstance->staticFileHandler->getFullStaticFilePath($this->requestPath); + if ($staticFilePath !== null) { + if (file_exists($staticFilePath) && !is_dir($staticFilePath)){ + /** + * Set the cache-control header for the given mime type if there is a cache + * setting for that mime type. + */ + $cacheTime = $this->noxInstance->staticFileHandler->getCacheTimeForMime($mimeType); + if ($cacheTime !== null) { + header(sprintf("cache-control: max-age=%d", $cacheTime)); + } - /** - * Sets the controllers folder - */ - public function setControllersFolder(string $path): void{ - $this->controllersFolder = $path; + header("content-type: $mimeType"); + print(file_get_contents(realpath($staticFilePath))); + exit(); + } + } + } + } } /** - * Loads all necessary components for the router to function - * from the provided directory + * Registers a dynamic route. All dynamic routes are attempted after the attribute MVC routes. */ - public function loadAll( - string $fromDirectory, - ): void{ - // Fetch the nox.json - $noxJson = file_get_contents($fromDirectory . "/nox.json"); - $noxConfig = json_decode($noxJson, true); - $this->noxConfig = $noxConfig; - - if ($noxConfig === null){ - throw new InvalidJSON("nox.json syntax is invalid."); - } - - // Fetch the nox-cache.json for cache config - $cacheJson = file_get_contents($fromDirectory . "/nox-cache.json"); - $cacheConfig = json_decode($cacheJson, true); - - if ($cacheConfig === null){ - throw new InvalidJSON("nox-cache.json syntax is invalid."); - } - - // Fetch the nox-mime.json for recognized static mime types to serve - $mimeJson = file_get_contents($fromDirectory . "/nox-mime.json"); - $mimeTypesConfig = json_decode($mimeJson, true); - - if ($mimeTypesConfig === null){ - throw new InvalidJSON("nox-mime.json syntax is invalid."); - } - - /** - * Create the mime types instance to give to the static file handler - */ - $mimeTypes = new MimeTypes; - - foreach($mimeTypesConfig as $extension=>$contentType){ - $mimeTypes->addMimeType($extension, $contentType); - } - - /** - * Setup the static file serving - */ - $this->staticFileHandler = new StaticFileHandler; - $this->staticFileHandler->mimeTypes = $mimeTypes; - - // Check for the single static directory definition - if (array_key_exists("static-directory", $noxConfig)) { - $this->staticFileHandler->addStaticFileDirectory( - $fromDirectory . $noxConfig['static-directory'], - "", - ); - }else{ - // Support multiple static directory definition via directory alias prepends - foreach($noxConfig['static-directories'] as $uriAlias=>$directoryPath) { - $this->staticFileHandler->addStaticFileDirectory( - $fromDirectory . $directoryPath, - $uriAlias, - ); - } - } - $this->staticFileHandler->setCacheConfig($cacheConfig); - - /** - * Setup the layouts and views folder - */ - $this->viewSettings = new ViewSettings; - $this->viewSettings->setLayoutsFolder($fromDirectory . $noxConfig['layouts-directory']); - $this->viewSettings->setViewsFolder($fromDirectory . $noxConfig['views-directory']); - - /** - * Set our own controllers folder - */ - $this->setControllersFolder($fromDirectory . $noxConfig['controllers-directory']); - $this->loadMVCControllers(); - - /** - * Set the ViewSettings as a static property of the Renderer - */ - Renderer::$viewSettings = $this->viewSettings; - - /** - * Set the models directory for the ORM, if it's not blank - */ - if (!empty($noxConfig['mysql-models-directory'])){ - Abyss::$modelsDirectory = $fromDirectory . $noxConfig['mysql-models-directory']; - } + public function addDynamicRoute(DynamicRoute $dynamicRoute): void{ + $this->dynamicRoutes[] = $dynamicRoute; } /** - * Loads the MVC controller classes - * from the controllers folder - * @throws \ReflectionException + * Process the request as routable - as in, controllers should be handling the current request. + * @return void + * @throws NoMatchingRoute */ - public function loadMVCControllers( - string $innerDirectory = "" - ): void{ - if ($innerDirectory === ""){ - $fileNames = array_diff( - scandir( - $this->controllersFolder - ), - ['.','..'], - ); - }else{ - $fileNames = array_diff( - scandir( - sprintf( - "%s/%s", - $this->controllersFolder, - $innerDirectory - ) - ), - ['.','..'], + public function processRoutableRequest(){ + $routeResult = $this->routeCurrentRequest(); + if ($routeResult instanceof Rewrite){ + // Set the response code provided + http_response_code($routeResult->statusCode); + // Change the request path of this router + $this->requestPath = $routeResult->path; + // Recursively call this method + $this->processRoutableRequest(); + exit(); + }elseif ($routeResult instanceof Redirect){ + http_response_code($routeResult->statusCode); + header( + sprintf("location: %s", $routeResult->path) ); - } - - foreach ($fileNames as $controllerFileName){ - if ($innerDirectory === ""){ - $controllerPath = sprintf( - "%s/%s", - $this->controllersFolder, - $controllerFileName, - ); - }else{ - $controllerPath = sprintf( - "%s/%s/%s", - $this->controllersFolder, - $innerDirectory, - $controllerFileName, - ); - } - - if (is_dir($controllerPath)){ - $this->loadMVCControllers( - innerDirectory: sprintf("%s/%s", $innerDirectory, $controllerFileName), - ); - }else{ - // Steps to find out which classes were defined in the file and if they are controllers - // 1) Use get_declared_classes() - // 2) Require the controller path - // 3) Use get_declared_classes() again, then array_diff() to find which new classes were added - // 4) Using a reflection, find out if any of them extend the BaseController - - // Is the iterated file a PHP file? - $fileExtension = pathinfo($controllerFileName, PATHINFO_EXTENSION); - if ($fileExtension === "php") { - $currentDefinedClasses = get_declared_classes(); - require_once $controllerPath; - $nowDefinedClasses = get_declared_classes(); - $newClassNames = array_diff($nowDefinedClasses, $currentDefinedClasses); - if (!empty($newClassNames)){ - foreach($newClassNames as $className) { - $isStrictController = false; // When it has the #[Controller] attribute - $classReflector = new \ReflectionClass($className); - $attributes = $classReflector->getAttributes( - name:Controller::class, - flags:\ReflectionAttribute::IS_INSTANCEOF, - ); - - // Check if it has the Controller class - if (!empty($attributes)){ - // It has the Controller attribute - $isStrictController = true; - } - - $parentClass = $classReflector->getParentClass(); - if ($isStrictController) { - if ( - $parentClass instanceof \ReflectionClass && - $parentClass->getName() === BaseController::class - ) { - // It's a Controller class - $controllerMethods = $classReflector->getMethods(\ReflectionMethod::IS_PUBLIC); - $this->routableMethods[] = [ - new $className(), - $controllerMethods, - $this->requestPath, - ]; - } else { - // Strictly defined controllers must extend the BaseController - throw new StrictControllerMissingExtension(sprintf( - "A controller that has the #[%s] attribute must extend the %s class. Your controller class %s is missing this class extension.", - Controller::class, - BaseController::class, - $classReflector->getName(), - )); - } - }else{ - if ( - $parentClass instanceof \ReflectionClass && - $parentClass->getName() === BaseController::class - ) { - // It's a Controller class, but not a strict controller - $controllerMethods = $classReflector->getMethods(\ReflectionMethod::IS_PUBLIC); - $this->routableMethods[] = [ - new $className(), - $controllerMethods, - $this->requestPath, - ]; - } - } - } - } - } - } + exit(); + }elseif ($routeResult !== null){ + // Successful. Output the result of the request + print($routeResult); + exit(); } } /** - * Will filter out the methods that do not have the right base + * Will filter out the controllers that do not have the right base matched against the current router request path. + * @return RoutableController[] */ - private function filterOutRoutesWithNonMatchingBase(array $routableMethods): array{ - $filteredRoutableMethods = []; - foreach($this->routableMethods as $methodData){ - $classInstance = $methodData[0]; + private function getControllersMatchingRequestBase(): array{ + $filteredRoutableControllers = []; + foreach($this->routableControllers as $routableController){ try { - $baselessRequestPath = $this->getBaselessRouteForClass(new \ReflectionClass($classInstance)); - $filteredRoutableMethods[] = [ - $classInstance, - $methodData[1], - $baselessRequestPath, - ]; - }catch(RouteBaseNoMatch $e){ + $baselessRequestPath = $this->getBaselessRouteForClass($routableController->reflectionClass); + $routableController->baselessRequestPath = $baselessRequestPath; + $filteredRoutableControllers[] = $routableController; + }catch(RouteBaseNoMatch){ continue; } } - return $filteredRoutableMethods; + return $filteredRoutableControllers; } /** - * Checks if a class can be routed. - * Currently only checks for the presence and validity RouteBase attribute. + * Checks if a class can be routed against the current router requestPath. + * Currently, this only checks for the presence and validity RouteBase attribute. * @throws RouteBaseNoMatch */ public function getBaselessRouteForClass(\ReflectionClass $classReflection): string{ @@ -305,7 +144,7 @@ public function getBaselessRouteForClass(\ReflectionClass $classReflection): str foreach($attributes as $attributeReflection){ $attributeName = $attributeReflection->getName(); - if ($attributeName === "Nox\\Router\\Attributes\\RouteBase"){ + if ($attributeName === RouteBase::class){ $hasRouteBase = true; // Check if the route base matches the current request URI @@ -323,11 +162,11 @@ public function getBaselessRouteForClass(\ReflectionClass $classReflection): str $didMatch = preg_match_all($routeBaseAttribute->uri, $this->requestPath, $matches); if ($didMatch === 1){ - // Add the matches to the requests GET array + // Add the matches to the requests BaseController requestParameters array foreach ($matches as $name=>$match){ if (is_string($name)){ if (isset($match[0])){ - $_GET[$name] = $match[0]; + BaseController::$requestParameters[$name] = $match[0]; } } } @@ -353,14 +192,13 @@ public function getBaselessRouteForClass(\ReflectionClass $classReflection): str * has no RouteBase, then null is returned */ public function getRouteBaseFromClass(\ReflectionClass $reflectionClass): ?object{ - $attributes = $reflectionClass->getAttributes(); + $routeBaseAttributes = $reflectionClass->getAttributes( + name: RouteBase::class, + flags: ReflectionAttribute::IS_INSTANCEOF, + ); - /** @var \ReflectionAttribute $attribute */ - foreach($attributes as $attribute){ - if ($attribute->getName() === "Nox\\Router\\Attributes\\RouteBase"){ - /** @var RouteBase $attrInstance */ - return $attribute->newInstance(); - } + if (!empty($routeBaseAttributes)){ + return $routeBaseAttributes[0]->newInstance(); } return null; @@ -371,99 +209,89 @@ public function getRouteBaseFromClass(\ReflectionClass $reflectionClass): ?objec * An accessible route is determined by the calling HTTP session. * For example, a user logged in will see different available routes * returned here than a user that is not logged in (should that route method - * implement an attribute that denies unauthenticated session). + * implement an attribute that denies unauthenticated session). Routes that utilize regular expressions + * are not included here, because it is impossible to tell - from the framework side - all the possible + * ways that regular expression route could lead to a valid page. */ - public function getAllAccessibleRouteURIs( - bool $includeRegexRoutes = false, - ): array{ + public function getAllNonRegExURIs(): array{ $availableURIs = []; - /** @var array $methodData */ - foreach ($this->routableMethods as $methodData){ - $classInstance = $methodData[0]; - $classReflection = new \ReflectionClass($classInstance); + foreach ($this->routableControllers as $routableController){ // Get the route base, if there is one /** @var RouteBase $routeBase */ - $routeBase = $this->getRouteBaseFromClass($classReflection); + $routeBase = $this->getRouteBaseFromClass($routableController->reflectionClass); $baseUri = ""; if ($routeBase){ if ($routeBase->isRegex === false){ $baseUri = $routeBase->uri; }else{ - if ($routeBase->isRegex && $includeRegexRoutes){ - $baseUri = $routeBase->uri; - }else{ + // Skip regular expression routes entirely + if ($routeBase->isRegex){ continue; } } } - /** @var \ReflectionMethod[] $methods */ - $methods = $methodData[1]; + /** @var ReflectionMethod[] $methods */ + $publicControllerReflectionMethods = $routableController->reflectionClass->getMethods(filter: ReflectionMethod::IS_PUBLIC); + + foreach($publicControllerReflectionMethods as $reflectionMethod){ + $allURIsForThisControllerMethod = []; - /** @var \ReflectionMethod $method */ - foreach($methods as $method){ // Get the attributes (if any) of the method - $attributes = $method->getAttributes(); - - // Variables to keep track of route-affecting attributes - // and if they allow the route to pass. - $numMethodsToBeApproved = 0; - $numMethodsApproved = 0; - $thisMethodURI = null; - foreach($attributes as $attribute){ - $routeAttribute = $attribute->newInstance(); - if ($attribute->getName() === "Nox\\Router\\Attributes\\Route"){ - /** @var Route $routeAttribute */ - if ($routeAttribute->isRegex === true && $includeRegexRoutes) { - $thisMethodURI = $baseUri . $routeAttribute->uri; - }elseif ($routeAttribute->isRegex === false){ - $thisMethodURI = $baseUri . $routeAttribute->uri; - }else{ - // Skip this method - // It's a regex route but includeRegexRoutes is false - continue; - } - }else{ - if ($routeAttribute instanceof RouteAttribute){ - ++$numMethodsToBeApproved; - if ($routeAttribute->getAttributeResponse()->isRouteUsable){ - ++$numMethodsApproved; - } - } + $routeAttributes = $reflectionMethod->getAttributes( + name: Route::class, + flags:ReflectionAttribute::IS_INSTANCEOF, + ); + + foreach($routeAttributes as $routeAttribute) { + /** @var RouteAttribute $routeAttribute */ + $routeAttribute = $routeAttribute->newInstance(); + if ($routeAttribute->isRegex === false) { + $allURIsForThisControllerMethod[] = $baseUri . $routeAttribute->uri; + } + } + + // Now check if there are RouteAttribute attribute instances on this method + // That would prevent this route from being seen by whatever criteria it has. + $routeAttributeAttributes = $reflectionMethod->getAttributes( + name: RouteAttribute::class, + flags:ReflectionAttribute::IS_INSTANCEOF, + ); + $routeAttributesPassed = 0; + foreach($routeAttributeAttributes as $reflectionAttribute){ + $instanceOfRouteAttribute = $reflectionAttribute->newInstance(); + if ($instanceOfRouteAttribute->getAttributeResponse()->isRouteUsable){ + ++$routeAttributesPassed; } } // Did this route's other methods match to the needed amount to be approved // As in, is this route usable/accessible by the current HTTP session that // calls this function in the first place? - if ($numMethodsToBeApproved === $numMethodsApproved){ - if ($thisMethodURI !== null) { - $availableURIs[] = $thisMethodURI; + if ($routeAttributesPassed === count($routeAttributeAttributes)){ + // Add all URIs found here to the total available URIs + foreach($allURIsForThisControllerMethod as $uri){ + $availableURIs[] = $uri; } } } } // Now check all the dynamic route methods - /** @var DynamicRoute $dynamicRoute */ - foreach($this->dynamicRoutes as $dynamicRoute){ - // Check the onRenderCheck callback - if ($dynamicRoute->onRouteCheck !== null) { - /** @var DynamicRouteResponse $dynamicRouteResponse */ - $dynamicRouteResponse = $dynamicRoute->onRouteCheck->call(new BaseController); - if (!$dynamicRouteResponse->isRouteUsable) { - // Skip this dynamic route - continue; + foreach($this->dynamicRoutes as $dynamicRoute) { + if (!$dynamicRoute->isRegex){ + // Check the onRenderCheck callback + if ($dynamicRoute->onRouteCheck !== null) { + /** @var DynamicRouteResponse $dynamicRouteResponse */ + $dynamicRouteResponse = $dynamicRoute->onRouteCheck->call(new BaseController); + if (!$dynamicRouteResponse->isRouteUsable) { + // Skip this dynamic route + continue; + } } - } - if ($dynamicRoute->isRegex){ - if ($includeRegexRoutes){ - $availableURIs[] = $dynamicRoute->requestPath; - } - }else{ $availableURIs[] = $dynamicRoute->requestPath; } } @@ -472,222 +300,182 @@ public function getAllAccessibleRouteURIs( } /** - * Routes a request to a controller - * @throws RouteMethodMustHaveANonNullReturn - * @throws \ReflectionException + * @param ReflectionClass[] $controllerReflections + * @return void + */ + public function loadRoutableControllersFromControllerReflections(array $controllerReflections): void{ + foreach ($controllerReflections as $reflection){ + $this->routableControllers[] = new RoutableController( + reflectionClass: $reflection, + baselessRequestPath: null, // Defined later during the routeCurrentRequest() + ); + } + } + + /** + * Routes a request to a controller method, if one matches the set criteria + * @throws ReflectionException + * @throws NoMatchingRoute */ - public function route( - RequestHandler $currentRequestHandler, - ): mixed{ + public function routeCurrentRequest(): mixed{ $requestMethod = $this->requestMethod; - $filteredRoutableMethods = $this->filterOutRoutesWithNonMatchingBase($this->routableMethods); + + // Get all methods from classes that have either no #[RouteBase] or classes that + // have #[RouteBase] that match the current router request + $filteredRoutableControllers = $this->getControllersMatchingRequestBase(); // Go through all the methods collected from the controller classes - foreach ($filteredRoutableMethods as $methodData){ - $classInstance = $methodData[0]; - $methods = $methodData[1]; + foreach ($filteredRoutableControllers as $routableController){ + $classInstance = $routableController->reflectionClass->newInstance(); + $controllerPublicMethods = $routableController->reflectionClass->getMethods(filter: ReflectionMethod::IS_PUBLIC); // The request path here will be modified if the class // the Route attribute is in has a RouteBase. // The base, at this point, is already checked and the $requestPath // below will have the base chopped off - $requestPath = $methodData[2]; + $requestPathToCheckMethodsWith = $routableController->baselessRequestPath; // Because when a base is chopped off, it is possible // for the $requestPath to be "" even though // the router constructor checks for this. // Fix it here - if (empty($requestPath)){ - $requestPath = "/"; + if (empty($requestPathToCheckMethodsWith)){ + $requestPathToCheckMethodsWith = "/"; } // The router will first find all methods // that have a matching route. // Then, later, it will verify any additional attributes // also pass. Otherwise, no route is returned/invoked - $routeMethodsToAttempt = []; + $routeReflectionMethodsToAttempt = []; // Loop through the methods - foreach($methods as $method){ + foreach($controllerPublicMethods as $controllerPublicReflectionMethod) { // Get the attributes (if any) of the method - $attributes = $method->getAttributes(); - - /** - * To be defined eventually... - */ - $routeClass = null; - $routeMethod = null; - $attemptRouting = false; + $routeAttributes = $controllerPublicReflectionMethod->getAttributes( + name: Route::class, + flags: ReflectionAttribute::IS_INSTANCEOF + ); // Loop through attributes and only check the route here - /** @var \ReflectionAttribute $attribute */ - foreach ($attributes as $attribute){ - $attrName = $attribute->getName(); - - // Check if this attribute name is "Route" - if ($attrName === Route::class){ - $routeAttribute = $attribute->newInstance(); - - // Check if the first argument (request method arg) - // matches the server request method - if (strtolower($routeAttribute->method) === strtolower($requestMethod)){ - - // Is the route a regular expression? - if ($routeAttribute->isRegex === false){ - // No, it is a plain string match - if ($routeAttribute->uri === $requestPath){ - $routeMethodsToAttempt[] = $method; - } - }else{ - // Yes, it needs to be matched against the URI - $didMatch = preg_match_all($routeAttribute->uri, $requestPath, $matches); - if ($didMatch === 1){ - // Add the matches to the requests GET array - foreach ($matches as $name=>$match){ - if (is_string($name)){ - if (isset($match[0])){ - // Define the matched parameter into the BaseController::$requestParameters - BaseController::$requestParameters[$name] = $match[0]; - - // TODO Deprecate/Remove this - $_GET[$name] = $match[0]; - } + /** @var ReflectionAttribute $attribute */ + foreach ($routeAttributes as $routeAttribute) { + $routeAttributeInstance = $routeAttribute->newInstance(); + + // Check if the first argument (request method arg) + // matches the server request method + if (strtolower($routeAttributeInstance->method) === strtolower($requestMethod)) { + + // Is the route a regular expression? + if ($routeAttributeInstance->isRegex === false) { + // No, it is a plain string match + if ($routeAttributeInstance->uri === $requestPathToCheckMethodsWith) { + $routeReflectionMethodsToAttempt[] = $controllerPublicReflectionMethod; + } + } else { + // Yes, it needs to be matched against the URI + $didMatch = preg_match_all($routeAttributeInstance->uri, $requestPathToCheckMethodsWith, $matches); + if ($didMatch === 1) { + // Add the matches to the requests GET array + foreach ($matches as $name => $match) { + if (is_string($name)) { + if (isset($match[0])) { + // Define the matched parameter into the BaseController::$requestParameters + BaseController::$requestParameters[$name] = $match[0]; } } - - $routeMethodsToAttempt[] = $method; } + + $routeReflectionMethodsToAttempt[] = $controllerPublicReflectionMethod; } } } } + } - // Loop through the methods that routes matched - // and run their additional attributes, if any. - // The first one to pass all should be invoked as the correct - // route. - $acceptedRoutes = []; - - /** @var \ReflectionMethod $routableMethod */ - foreach ($routeMethodsToAttempt as $routableMethod){ - $attributes = $routableMethod->getAttributes(); - - // Keep track of which attributes are RouteAttribute instances - $neededToRoute = 0; - - // Keep track of which RouteAttributes approve of this request - $passedAttributes = 0; - - foreach ($attributes as $attribute){ - $attributeClass = new \ReflectionClass($attribute->getName()); - $attributeParentClassName = $attributeClass->getParentClass(); - if ($attributeParentClassName instanceof \ReflectionClass && $attributeParentClassName->getName() === RouteAttribute::class){ - /** @var RouteAttribute $attrInstance */ - $attrInstance = $attribute->newInstance(); - ++$neededToRoute; - - $attributeResponse = $attrInstance->getAttributeResponse(); - if ($attributeResponse->isRouteUsable){ - ++$passedAttributes; - }else{ - // This attribute says the route is not currently usable. - - // However, a route can alter the current HTTP response - // Check if this AttributeResponse is doing so - if ($attributeResponse->responseCode !== null){ - http_response_code($attributeResponse->responseCode); - if ($attributeResponse->newRequestPath !== null){ - // There is a new request path - // Instantiate a new request handler now and handle it - // A new router must also be created - $newRouter = new Router( - $attributeResponse->newRequestPath, - $this->requestMethod, - ); - $newRouter->staticFileHandler = $this->staticFileHandler; - $newRouter->viewSettings = $this->viewSettings; - $newRouter->noxConfig = $this->noxConfig; - $newRouter->controllersFolder = $this->controllersFolder; - $newRouter->loadMVCControllers(); - $newRequestHandler = new RequestHandler($newRouter); - $newRequestHandler->processRequest(); - exit(); - }else{ - // A response code was set, but no new request path. - // Just return a blank string in this case. - return ""; - } - }else{ - // Break this current loop and move on to the next. - // The route isn't usable, but the attribute response - // did not change the HTTP response code or rewrite the route path - break 1; - } - } - } - } + foreach ($routeReflectionMethodsToAttempt as $reflectionMethod){ + // Keep track of which RouteAttributes approve of this request + $passedAttributes = 0; - // If the number of valid RouteAttribute attributes equals the number - // found on this route method, then invoke this route controller - if ($passedAttributes === $neededToRoute){ + $noxRouteAttributes = $reflectionMethod->getAttributes( + name: RouteAttribute::class, + flags:ReflectionAttribute::IS_INSTANCEOF, + ); - /** - * Check any Attributes that extend the internal Nox attribute ChosenRouteAttribute - * which are attributes that should only run for chosen routes - as they can affect the - * response. - * @since 1.5.0 - */ - foreach($attributes as $attribute){ - $attributeClass = new \ReflectionClass($attribute->getName()); - $attributeParentClassName = $attributeClass->getParentClass(); - if ($attributeParentClassName instanceof \ReflectionClass) { - if ($attributeParentClassName->getName() === ChosenRouteAttribute::class) { - $attribute->newInstance(); - } - } - } + foreach ($noxRouteAttributes as $attribute){ + /** @var RouteAttribute $attrInstance */ + $attrInstance = $attribute->newInstance(); + $attributeResponse = $attrInstance->getAttributeResponse(); + if ($attributeResponse->isRouteUsable){ + ++$passedAttributes; + }else{ + // This attribute says the route is not currently usable. - $routeReturn = $routableMethod->invoke($classInstance); - if ($routeReturn === null){ - // A route must have a return type, otherwise - // returning null here would make the request handler - // think this is a 404 - throw new RouteMethodMustHaveANonNullReturn( - sprintf( - "A route was matched and the method %s::%s was called, but null was returned. All route methods must have a non-null return type.", - $classInstance::class, - $routableMethod->name, - ) - ); + // However, a route can alter the current HTTP response + // Check if this AttributeResponse is doing so + if ($attributeResponse->responseCode !== null){ + http_response_code($attributeResponse->responseCode); + if ($attributeResponse->newRequestPath !== null){ + // There is a new request path + // Rewrite this request + $this->requestPath = $attributeResponse->newRequestPath; + $this->requestMethod = "get"; + return $this->routeCurrentRequest(); + }else{ + // A response code was set, but no new request path. + // Just return a blank string in this case. + return ""; + } }else{ + // Break this current loop and move on to the next. + // The route isn't usable, but the attribute response + // did not change the HTTP response code or rewrite the route path + break 1; + } + } + } - // Check if the routeReturn is an object that implements the ArrayLike interface - // If so, convert it to an array - if (is_object($routeReturn)){ - if ($routeReturn instanceof ArrayLike){ - $routeReturn = $routeReturn->toArray(); - } - } + // If the number of valid RouteAttribute attributes equals the number + // found on this route method, then invoke this route controller + if ($passedAttributes === count($noxRouteAttributes)){ + /** + * Check any Attributes that extend the internal Nox attribute ChosenRouteAttribute + * which are attributes that should only run for chosen routes - as they can affect the + * response. + * @since 1.5.0 + */ + $chosenRouteAttributes = $reflectionMethod->getAttributes( + name: ChosenRouteAttribute::class, + flags:ReflectionAttribute::IS_INSTANCEOF, + ); + + foreach($chosenRouteAttributes as $chosenRouteAttribute){ + $chosenRouteAttribute->newInstance(); + } - // Check if arrays should be output as JSON - if (is_array($routeReturn) && BaseController::$outputArraysAsJSON){ - return json_encode($routeReturn); - } + // Invoke the controller's chosen public method + $routeReturn = $reflectionMethod->invoke($classInstance); + // Check if the routeReturn is an object that implements the ArrayLike interface + // If so, convert it to an array + if ($routeReturn instanceof ArrayLike){ + $routeReturn = $routeReturn->toArray(); + } - return $routeReturn; - } + // Check if arrays should be output as JSON + if (is_array($routeReturn) && BaseController::$outputArraysAsJSON){ + return json_encode($routeReturn); } + + return $routeReturn; } } } - // If nothing was returned at this point, now check all of the dynamic routes + // If nothing was returned at this point, now check all the dynamic routes // that are manually added - /** @var DynamicRoute $dynamicRoute */ foreach($this->dynamicRoutes as $dynamicRoute){ // Check if this route can be processed - if ($dynamicRoute->requestMethod === $this->requestMethod) { if ($dynamicRoute->onRouteCheck !== null) { /** @var DynamicRouteResponse $dynamicRouteResponse */ @@ -695,26 +483,21 @@ public function route( if ($dynamicRouteResponse->isRouteUsable === true) { // All good } else { + // Route is marked as not usable + // Is it changing the response code or request path? if ($dynamicRouteResponse->responseCode !== null || $dynamicRouteResponse->newRequestPath !== null) { + if ($dynamicRouteResponse->responseCode !== null) { http_response_code($dynamicRouteResponse->responseCode); } + if ($dynamicRouteResponse->newRequestPath !== null) { // There is a new request path // Instantiate a new request handler now and handle it // A new router must also be created - $newRouter = new Router( - $dynamicRouteResponse->newRequestPath, - $this->requestMethod, - ); - $newRouter->staticFileHandler = $this->staticFileHandler; - $newRouter->viewSettings = $this->viewSettings; - $newRouter->noxConfig = $this->noxConfig; - $newRouter->controllersFolder = $this->controllersFolder; - $newRouter->loadMVCControllers(); - $newRequestHandler = new RequestHandler($newRouter); - $newRequestHandler->processRequest(); - exit(); + $this->requestPath = $dynamicRouteResponse->newRequestPath; + $this->requestMethod = "get"; + return $this->routeCurrentRequest(); } }else{ // Just skip this route @@ -726,16 +509,7 @@ public function route( // If we're here, then this route can be checked against the current URI if ($dynamicRoute->isRegex === false) { if ($this->requestPath === $dynamicRoute->requestPath) { - $renderReturn = $dynamicRoute->onRender->call(new BaseController); - if ($renderReturn === null){ - throw new RouteMethodMustHaveANonNullReturn( - sprintf( - "A dynamic route was matched and called for the route %s, but the dynamic route's onRender callback returned null. All dynamic route callbacks must return a non-null value.", - $this->requestPath, - ) - ); - } - return $renderReturn; + return $dynamicRoute->onRender->call(new BaseController); } }else{ // Regex checks @@ -747,27 +521,16 @@ public function route( if (isset($match[0])){ // Define the matched parameter into the BaseController::$requestParameters BaseController::$requestParameters[$name] = $match[0]; - - // TODO Deprecate/Remove this - $_GET[$name] = $match[0]; } } } - $renderReturn = $dynamicRoute->onRender->call(new BaseController); - if ($renderReturn === null){ - throw new RouteMethodMustHaveANonNullReturn( - sprintf( - "A dynamic route was matched and called for the route %s, but the dynamic route's onRender callback returned null. All dynamic route callbacks must return a non-null value.", - $this->requestPath, - ) - ); - } - return $renderReturn; + + return $dynamicRoute->onRender->call(new BaseController); } } } } - return null; + throw new NoMatchingRoute(); } } diff --git a/src/Router/StaticDirectorySetting.php b/src/Router/StaticDirectorySetting.php new file mode 100644 index 0000000..6f7f05b --- /dev/null +++ b/src/Router/StaticDirectorySetting.php @@ -0,0 +1,9 @@ +staticDirectories[$uriAlias] = $directoryPath; - } + public function __construct( + public Nox $noxInstance, + ){} /** * Fetches the full path to a static file * @param string $filePath - * @return string + * @return null | string */ - public function getFullStaticFilePath(string $filePath): ?string{ - // Support for the single-static directory setting - if ($this->staticDirectory !== null) { - return sprintf("%s/%s", $this->staticDirectory, $filePath); - }else{ - // Always process the global (blank) uriAlias last, if it exists - foreach($this->staticDirectories as $uriAlias=>$directoryPath){ - if (!empty($uriAlias) && $uriAlias !== "/") { - if (str_starts_with($filePath, $uriAlias)) { - // Replace the alias directory - $newPath = substr($filePath, strlen($uriAlias)); - return sprintf("%s%s", $directoryPath, $newPath); - } + public function getFullStaticFilePath(string $filePath): null | string{ + // Always process the global (blank) uriAlias last, if it exists + $rootStaticServingSetting = null; + foreach($this->noxInstance->getStaticDirectorySettings() as $staticDirectorySetting){ + if ($staticDirectorySetting->uriPathStub !== "/") { + if (str_starts_with($filePath, $staticDirectorySetting->uriPathStub)) { + // Replace the alias directory + $newPath = substr($filePath, strlen($staticDirectorySetting->uriPathStub)); + return sprintf("%s%s", $staticDirectorySetting->staticFilesDirectory, $newPath); } - } - - - if (array_key_exists("", $this->staticDirectories) && array_key_exists("/", $this->staticDirectories)){ - throw new \Exception("You cannot have both an empty string key and a forward slash key in your static-directories nox.json settings. Please remove one of them."); - } - - // Process the blank or single forward slash uriAlias last, here - // Because otherwise, it would always override and match any URI route and other static directory aliases would never match - if (array_key_exists("", $this->staticDirectories)){ - return sprintf("%s/%s", $this->staticDirectories[""], $filePath); - } - - if (array_key_exists("/", $this->staticDirectories)){ - return sprintf("%s/%s", $this->staticDirectories["/"], $filePath); + }else{ + // This is a setting that serves from root URIs (/). It must be processed last so that + // other static directories can have a chance to be served + $rootStaticServingSetting = $staticDirectorySetting; } } - return null; - } - - /** - * Whether or not a static file exists at the path - * @param string $filePath - * @return bool - */ - public function doesStaticFileExist(string $filePath): bool{ - $fullPath = $this->getFullStaticFilePath($filePath); - if ($fullPath) { - return file_exists($fullPath) && !is_dir($fullPath); - }else{ - return false; + if ($rootStaticServingSetting !== null){ + return sprintf("%s%s", $rootStaticServingSetting->staticFilesDirectory, $filePath); } - } - /** - * Sets the array of seconds for key mime types - */ - public function setCacheConfig(array $cacheConfig): void{ - $this->cacheConfig = $cacheConfig; + return null; } /** - * Gets the cache time, in seconds, of a MIME type. - * Will be null if no cache config exists for the given mime - * @param string $mime - * @return int|null - */ - public function getCacheTimeForMime(string $mime): ?int{ - if (isset($this->cacheConfig[$mime])){ - return (int) $this->cacheConfig[$mime]; + * Gets the cache time, in seconds, of a MIME type. + * Will be null if no cache config exists for the given mime + * @param string $mimeType + * @return int|null + */ + public function getCacheTimeForMime(string $mimeType): ?int{ + foreach($this->cacheConfig as $mimeCache){ + if ($mimeCache->mimeType === $mimeType){ + return $mimeCache->cacheSeconds; + } } return null; } /** - * Gets the mime type of the file based on the extension - * @param string $filePath - * @return string|null - */ - public function getStaticFileMime(string $filePath): ?string{ - $extension = pathinfo($filePath, PATHINFO_EXTENSION); - $extension_lowered = strtolower($extension); - if ($extension !== ""){ - if (array_key_exists($extension_lowered, $this->mimeTypes->recognizedExtensions)){ - return $this->mimeTypes->recognizedExtensions[$extension_lowered]; - }else{ - return null; + * Gets the mime type of the file based on the extension + * @param string $filePath + * @return string|null + */ + public function getStaticFileMime(string $filePath): null | string{ + $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); + $fileExtension_lowered = strtolower($fileExtension); + if ($fileExtension !== "") { + foreach ($this->noxInstance->supportedMimeTypes->recognizedExtensions as $extension => $mimeType) { + if (strtolower($extension) === strtolower($fileExtension_lowered)) { + return $mimeType; + } } - }else{ - return null; } - } - /** - * Gets the mime type of the file based on the extension - * @param string $filePath - */ - public function getStaticFileContents(string $filePath): string{ - $fullPath = $this->getFullStaticFilePath($filePath); - return file_get_contents($fullPath); + return null; } } diff --git a/src/Utils/FileSystem.php b/src/Utils/FileSystem.php index a693e94..4d61ae5 100644 --- a/src/Utils/FileSystem.php +++ b/src/Utils/FileSystem.php @@ -7,7 +7,6 @@ class FileSystem{ * Recursively deletes a directory, all subfolder, and all files. */ public static function recursivelyDeleteDirectory(string $directoryPath): void{ - $contents = array_diff(scandir($directoryPath), ['.', '..']); $dir = opendir($directoryPath); while(false !== ( $file = readdir($dir)) ) { if (( $file != '.' ) && ( $file != '..' )) { @@ -23,6 +22,29 @@ public static function recursivelyDeleteDirectory(string $directoryPath): void{ rmdir($directoryPath); } + /** + * Recursively fetches a directory's files and any descendant folder files + */ + public static function recursivelyFetchAllFileNames( + string $parentDirectory, + array &$arrayToAddTo, + ): array{ + $dir = opendir($parentDirectory); + while(false !== ( $file = readdir($dir)) ) { + if (( $file != '.' ) && ( $file != '..' )) { + $fullPath = $parentDirectory . '/' . $file; + if (is_dir($fullPath)) { + self::recursivelyFetchAllFileNames($fullPath, $arrayToAddTo); + }else{ + $arrayToAddTo[] = $fullPath; + } + } + } + + closedir($dir); + return $arrayToAddTo; + } + /** * Copy a file, or recursively copy a folder and its contents * @author Aidan Lister