diff --git a/app/Application.php b/app/Application.php new file mode 100644 index 0000000..46d8c93 --- /dev/null +++ b/app/Application.php @@ -0,0 +1,14 @@ +response->view('subnet'); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 15e4192..fcdd64b 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,11 @@ "psr-4": { "App\\": "app/", "Core\\": "src/" - } + }, + "files": [ + "src/Helpers/helpers.php" + ] + }, "config": { "optimize-autoloader": true diff --git a/config/routes.php b/config/routes.php index 33b550b..fd1f717 100644 --- a/config/routes.php +++ b/config/routes.php @@ -2,7 +2,10 @@ use App\Controllers\Api\SubnetController; use App\Controllers\HomeController; -use Core\Router\Router; +use App\Controllers\TestController; +use Core\Routing\Route; -Router::get('/', HomeController::class, 'index'); -Router::post('/api/subnet', SubnetController::class, 'data'); \ No newline at end of file +Route::get('/', HomeController::class, 'index'); +Route::post('/api/subnet', SubnetController::class, 'data'); +Route::get('/test/{id}', TestController::class, 'test'); +Route::get('/test/{id}/update', TestController::class, 'test'); \ No newline at end of file diff --git a/public/index.php b/public/index.php index a3c2da1..978295c 100644 --- a/public/index.php +++ b/public/index.php @@ -1,15 +1,8 @@ handle(); \ No newline at end of file diff --git a/src/Bootstrap.php b/src/Bootstrap.php new file mode 100644 index 0000000..f434d27 --- /dev/null +++ b/src/Bootstrap.php @@ -0,0 +1,45 @@ +run(); + + return Bootstrap::getInstance(); + } + + /** + * Get the instance + * + * @return \Core\Factory\BootstrapFactory + */ + public static function getInstance(): BootstrapFactory + { + return self::$instance; + } + + /** + * Create new factory instance + * + * @return \Core\Factory\BootstrapFactory + */ + protected function run(): BootstrapFactory + { + return new BootstrapFactory(); + } +} \ No newline at end of file diff --git a/src/Exceptions/ExceptionHandler.php b/src/Exceptions/ExceptionHandler.php new file mode 100644 index 0000000..29524fc --- /dev/null +++ b/src/Exceptions/ExceptionHandler.php @@ -0,0 +1,120 @@ +pushHandler(self::handler()); + + self::$reporter = $reporter; + } + + return self::$reporter; + } + + /** + * Get correct handler + * + * @return \Whoops\Handler\Handler + */ + private static function handler(): Handler + { + if (env('debug')) { + if (request()?->is('post')) { + return new JsonResponseHandler(); + } + + return new PrettyPageHandler(); + } + + return new PlainTextHandler(); + } + + /** + * Catch all exceptions + * + * @return void + */ + public function register(): void + { + self::reporter()->register(); + } + + /** + * Catch single exception + * + * @param \Throwable $exception + * @return never + */ + public function handle(Throwable $exception): never + { + self::reporter()->handleException($exception); + + exit(0); + } + + /** + * Make new exception + * + * @param null $abstract + * @param string|null $message + * @return never + */ + public function make(mixed $abstract = null, string|null $message = null): never + { + if(is_string($abstract)) { + $abstract = app()->make($abstract, $message); + } + + if(is_subclass_of($abstract, 'Exception')) { + $this->handle($abstract); + } + + exit(0); + } + +} \ No newline at end of file diff --git a/src/Exceptions/Exceptions.php b/src/Exceptions/Exceptions.php deleted file mode 100644 index ceb5f31..0000000 --- a/src/Exceptions/Exceptions.php +++ /dev/null @@ -1,81 +0,0 @@ -pushHandler(self::handler($request)); - - self::$instance = $instance; - } - - return self::$instance; - } - - /** - * Get correct handler - * - * @param \Core\Http\Request|null $request - * @return \Whoops\Handler\Handler - */ - private static function handler(Request|null $request): Handler - { - if (Env::get('debug')) { - if ($request?->is('post')) { - return new JsonResponseHandler(); - } - - return new PrettyPageHandler(); - } - - return new PlainTextHandler(); - } - - /** - * Catch all exceptions - * - * @param \Core\Http\Request $request - * @return void - */ - public static function catch(Request $request): void - { - self::instance($request)->register(); - } - - /** - * Catch single exception - * - * @param \Throwable $exception - * @return void - */ - public static function catchOne(Throwable $exception): void - { - self::instance()->handleException($exception); - } -} \ No newline at end of file diff --git a/src/Exceptions/Exceptions/ClassNotFoundException.php b/src/Exceptions/Exceptions/ClassNotFoundException.php new file mode 100644 index 0000000..494b2b4 --- /dev/null +++ b/src/Exceptions/Exceptions/ClassNotFoundException.php @@ -0,0 +1,5 @@ +request = app()->make(Request::class, [$_POST + $_FILES]); + + // Register exceptions handler for error reporting + app()->make(ExceptionHandler::class)->register(); + + // Load routes + require '../config/routes.php'; + + try { + // Boot application + app()->make(Application::class)->bootstrap(); + + // Dispatch router + app()->make(RouteDispatcher::class)->dispatch($this->request); + } catch (\Exception $e) { + exceptions()->handle($e); + } + } + + /** + * @param string $abstract + * @param mixed $arguments + * @return mixed + */ + public function make(string $abstract, mixed $arguments = []): mixed + { + return $this->resolve($abstract, $arguments); + } + + /** + * @param string $abstract + * @param mixed $arguments + * @return mixed|null + */ + private function resolve(string $abstract, mixed $arguments = []): mixed + { + if (class_exists($abstract)) { + try { + $reflection = new \ReflectionClass($abstract); + return $reflection->newInstanceArgs($arguments); + } catch (\ReflectionException $e) { + exceptions()->handle($e); + } + } + + exceptions()->make(ClassNotFoundException::class, sprintf("Class '%s' not found", $abstract)); + } + + /** + * Get the request instance + * + * @return \Core\Http\Request + */ + public function request(): Request + { + return $this->request; + } + + /** + * Get path to file/folder in resources folder + * + * @param string $path + * @return string + */ + public function resourcePath(string $path = ''): string + { + return "../resources/" . $path; + } +} \ No newline at end of file diff --git a/src/Helpers/helpers.php b/src/Helpers/helpers.php new file mode 100644 index 0000000..4475eb5 --- /dev/null +++ b/src/Helpers/helpers.php @@ -0,0 +1,64 @@ +make($abstract, $arguments); + } +} + +if(!function_exists('env')) +{ + /** + * Get env variable + * + * @param string $key + * @return bool + */ + function env(string $key): mixed + { + return Env::get($key); + } +} + +if (!function_exists('request')) { + /** + * Get the request instance + * + * @return \Core\Http\Request + */ + function request(): Request + { + return app()->request(); + } +} + +if (!function_exists('exceptions')) { + /** + * Get error handler instance + * + * @return \Core\Exceptions\ExceptionHandler + */ + function exceptions(): ExceptionHandler + { + return ExceptionHandler::instance(); + } +} diff --git a/src/Controllers/Controller.php b/src/Http/Controllers/Controller.php similarity index 93% rename from src/Controllers/Controller.php rename to src/Http/Controllers/Controller.php index e8f1cec..d428fcd 100644 --- a/src/Controllers/Controller.php +++ b/src/Http/Controllers/Controller.php @@ -1,6 +1,6 @@ data = $data; + } + /** * Get request method * @@ -43,7 +55,7 @@ class Request */ public final function has(string $param): bool { - return isset($_POST[$param]); + return isset($this->data[$param]); } /** @@ -56,11 +68,11 @@ class Request public final function get(string|null $param = null, mixed $default = null): mixed { if($param == null) { - return $_POST; + return $this->data; } if($this->has($param)) { - return $_POST[$param]; + return $this->data[$param]; } return $default; diff --git a/src/Http/Response.php b/src/Http/Response.php index 99d4fcb..bb5d38d 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -2,9 +2,9 @@ namespace Core\Http; -use Core\View\Render; -use Core\View\Render\HtmlRender; -use Core\View\Render\JsonRender; +use Core\Http\View\Engine\HtmlEngine; +use Core\Http\View\Engine\JsonEngine; +use Core\Http\View\Render; class Response { @@ -25,20 +25,20 @@ class Response * Render HTML * * @param string $view - * @return \Core\View\Render + * @return \Core\Http\View\Render */ public function view(string $view): Render { - return (new HtmlRender())->view($view); + return (new HtmlEngine())->view($view); } /** * Render JSON * - * @return \Core\View\Render + * @return \Core\Http\View\Render */ public function json(): Render { - return new JsonRender(); + return new JsonEngine(); } } \ No newline at end of file diff --git a/src/View/Render/HtmlRender.php b/src/Http/View/Engine/HtmlEngine.php similarity index 64% rename from src/View/Render/HtmlRender.php rename to src/Http/View/Engine/HtmlEngine.php index e0a53d7..c094be6 100644 --- a/src/View/Render/HtmlRender.php +++ b/src/Http/View/Engine/HtmlEngine.php @@ -1,10 +1,10 @@ view) . '.php'; + $viewsPath = app()->resourcePath('views/' . str_replace('.', '/', $this->view) . '.php'); if (file_exists($viewsPath)) { extract($this->data); diff --git a/src/View/Render/JsonRender.php b/src/Http/View/Engine/JsonEngine.php similarity index 70% rename from src/View/Render/JsonRender.php rename to src/Http/View/Engine/JsonEngine.php index 3dc68b5..f97d63c 100644 --- a/src/View/Render/JsonRender.php +++ b/src/Http/View/Engine/JsonEngine.php @@ -1,10 +1,11 @@ $controller, - 'action' => $action, - ]; - } - - /** - * Dispatch router and run application - * - * @return void - */ - public static function dispatch(): void - { - // Init request - $request = new Request(); - $url = $request->url(); - $method = $request->method(); - - // Capture all exceptions - Exceptions::catch($request); - - try { - if (array_key_exists($url, self::$routes[$method])) { - $controllerName = self::$routes[$method][$url]['controller']; - $actionName = self::$routes[$method][$url]['action']; - - // Check for controller existence - if (!class_exists($controllerName)) { - throw new Exception(sprintf("Controller '%s' missing", $controllerName)); - } - - // Create controller object - $controller = new $controllerName($request); - - // Check for method on controller - if (!method_exists($controller, $actionName)) { - throw new Exception(sprintf("Method '%s' not found on '%s'", $actionName, $controllerName)); - } - - // Build response object of action - $response = $controller->$actionName(); - - // Render action - if ($response instanceof Render) { - $response->render(); - } - } else { - throw new Exception(sprintf("No route found for: %s", $url)); - } - } catch (Exception $e) { - Exceptions::catchOne($e); - } - } -} \ No newline at end of file diff --git a/src/Routing/Route.php b/src/Routing/Route.php new file mode 100644 index 0000000..86c4a28 --- /dev/null +++ b/src/Routing/Route.php @@ -0,0 +1,32 @@ + $controller, + 'action' => $action, + 'original' => $route, + ]; + } + + /** + * Reveal the treasure trove of routes. + * + * @return array + */ + public static function retrieve(): array + { + return self::$routes; + } + + /** + * Convert a route pattern into a regex for matching requests. + * + * @param string $route + * @return string + */ + protected static function convertToRegex(string $route): string + { + $regex = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<\1>[a-zA-Z0-9_-]+)', $route); + return '#^' . $regex . '$#'; + } + + /** + * Retrieve a specific route by method and URL. + * + * @param string $method + * @param string $url + * @return array|null + */ + public static function find(string $method, string $url): ?array + { + foreach (self::$routes[$method] ?? [] as $routeRegex => $route) { + if (preg_match($routeRegex, $url, $matches)) { + $route['params'] = array_filter( + $matches, + fn($key) => !is_numeric($key), + ARRAY_FILTER_USE_KEY + ); + return $route; + } + } + + return null; + } + + /** + * Purge all routes from the collection. + * + * @return void + */ + public static function clear(): void + { + self::$routes = []; + } +} \ No newline at end of file diff --git a/src/Routing/RouteDispatcher.php b/src/Routing/RouteDispatcher.php new file mode 100644 index 0000000..13dc276 --- /dev/null +++ b/src/Routing/RouteDispatcher.php @@ -0,0 +1,144 @@ +request = $request; + + try { + $route = $this->findMatchingRoute(); + $controller = $this->instantiateController($route['controller']); + $action = $this->validateActionExists($controller, $route['action']); + $params = $this->resolveParameters($controller, $action, $route['params'] ?? []); + + $this->executeAction($controller, $action, $params); + } catch (NotFoundHttpException $e) { + $this->handleException($e, 404, 'page not found'); + } catch (Exception $e) { + $this->handleException($e, 500, 'something went wrong'); + } + } + + /** + * Locate a matching route for the incoming request. + * + * @return array + * @throws \Core\Exceptions\Exceptions\NotFoundHttpException + */ + private function findMatchingRoute(): array + { + $url = $this->request->url(); + $method = $this->request->method(); + + $route = RouteCollection::find($method, $url); + if ($route) { + return $route; + } + + throw new NotFoundHttpException(sprintf("No route found for: %s", $url)); + } + + /** + * Create an instance of the requested controller. + * + * @param string $controllerName + * @return object + * @throws \Core\Exceptions\Exceptions\NotFoundHttpException + */ + private function instantiateController(string $controllerName): object + { + if (!class_exists($controllerName)) { + throw new NotFoundHttpException(sprintf("Controller '%s' missing", $controllerName)); + } + + return new $controllerName($this->request); + } + + /** + * Validate that the action exists in the controller. + * + * @param object $controller + * @param string $actionName + * @return string + * @throws \Core\Exceptions\Exceptions\NotFoundHttpException + */ + private function validateActionExists(object $controller, string $actionName): string + { + if (!method_exists($controller, $actionName)) { + throw new NotFoundHttpException(sprintf("Method '%s' not found on '%s'", $actionName, get_class($controller))); + } + + return $actionName; + } + + /** + * Validate and resolve parameters for the controller action. + * + * @param object $controller + * @param string $action + * @param array $params + * @return array + * @throws \Exception + */ + private function resolveParameters(object $controller, string $action, array $params): array + { + return RouteValidator::resolve($controller, $action, $params); + } + + /** + * Execute the resolved action on the controller with validated parameters. + * + * @param object $controller + * @param string $action + * @param array $params + * @return void + */ + private function executeAction(object $controller, string $action, array $params): void + { + $response = $controller->$action(...$params); + + if ($response instanceof Render) { + $response->render(); + } + } + + /** + * Handle exceptions gracefully. + * + * @param Exception $e + * @param int $statusCode + * @param string $message + * @return void + */ + private function handleException(Exception $e, int $statusCode, string $message): void + { + if (env('debug')) { + exceptions()->handle($e); + } + + http_response_code($statusCode); + echo $message; + } +} \ No newline at end of file diff --git a/src/Routing/RouteValidator.php b/src/Routing/RouteValidator.php new file mode 100644 index 0000000..e1139a6 --- /dev/null +++ b/src/Routing/RouteValidator.php @@ -0,0 +1,89 @@ +getParameters(); + $validatedParams = []; + + foreach ($methodParameters as $methodParameter) { + $paramName = $methodParameter->getName(); + $paramType = $methodParameter->getType(); + $isOptional = $methodParameter->isOptional(); + $defaultValue = $isOptional ? $methodParameter->getDefaultValue() : null; + + if (!array_key_exists($paramName, $params)) { + if ($isOptional) { + $validatedParams[$paramName] = $defaultValue; + continue; + } + throw new NotFoundHttpException(sprintf("Missing parameter '%s' for action '%s'", $paramName, $action)); + } + + $value = $params[$paramName]; + $validatedParams[$paramName] = self::validateType($paramName, $value, $paramType, $action); + } + + return $validatedParams; + } + + /** + * Validate and cast a parameter based on its type. + * + * @param string $paramName + * @param mixed $value + * @param ReflectionNamedType|null $paramType + * @param string $action + * @return mixed + * @throws \Core\Exceptions\Exceptions\NotFoundHttpException + */ + private static function validateType(string $paramName, mixed $value, ?ReflectionNamedType $paramType, string $action): mixed + { + if (!$paramType || $paramType->allowsNull() && $value === null) { + return $value; // No type or allows null, return as-is. + } + + $typeName = $paramType->getName(); + + switch ($typeName) { + case 'int': + if (!ctype_digit((string)$value)) { + throw new NotFoundHttpException(sprintf("Invalid type for parameter '%s'. Expected integer, got '%s'", $paramName, gettype($value))); + } + return (int)$value; + + case 'float': + if (!is_numeric($value)) { + throw new NotFoundHttpException(sprintf("Invalid type for parameter '%s'. Expected float, got '%s'", $paramName, gettype($value))); + } + return (float)$value; + + case 'string': + if (!is_string($value)) { + throw new NotFoundHttpException(sprintf("Invalid type for parameter '%s'. Expected string, got '%s'", $paramName, gettype($value))); + } + return $value; + + default: + throw new NotFoundHttpException(sprintf("Unsupported type '%s' for parameter '%s'", $typeName, $paramName)); + } + } +} \ No newline at end of file diff --git a/src/Routing/Router.php b/src/Routing/Router.php new file mode 100644 index 0000000..6d90c2a --- /dev/null +++ b/src/Routing/Router.php @@ -0,0 +1,22 @@ +