├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── phpunit.xml.dist ├── src ├── Configuration.php ├── Container.php ├── Controller.php ├── Database │ ├── Connection.php │ └── Model.php ├── Events │ ├── PreControllerEvent.php │ ├── RequestEvent.php │ └── ResponseEvent.php ├── Framework.php ├── Helpers.php ├── Http │ ├── EventSubscriber │ │ └── RequestSubscriber.php │ ├── Exception │ │ └── InvalidCsrfTokenException.php │ ├── Middleware │ │ ├── AjaxMiddleware.php │ │ ├── CsrfMiddleware.php │ │ ├── MiddlewareInterface.php │ │ └── ValidationMiddleware.php │ ├── Request.php │ ├── RequestHandler.php │ ├── RequestInterface.php │ ├── Response.php │ ├── ResponseInterface.php │ ├── ResponseIterator.php │ └── Security.php ├── Logger.php ├── Resources │ └── Views │ │ ├── About.php │ │ └── Error.php ├── Routing │ ├── Command.php │ ├── Exception │ │ └── RouteNotFoundException.php │ ├── Route.php │ ├── RouteBuilder.php │ ├── RouteBuilderInterface.php │ └── RouteParam.php ├── Templating │ ├── Driver │ │ ├── BladeDriver.php │ │ ├── PlainPhpDriver.php │ │ ├── TemplateDriverInterface.php │ │ └── TwigDriver.php │ └── Template.php ├── UtilsTrait.php └── Validator │ ├── Exception │ └── ValidationConstraintException.php │ └── Validator.php └── tests ├── ConfigurationTest.php ├── ContainerTest.php ├── FrameworkTest.php └── assets └── .env /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .buildpath 3 | .project 4 | .settings/org.eclipse.wst.validation.prefs 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ingenia Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

WARNING: Under development!

6 | 7 | **Luthier Framework** is a versatile PHP micro-framework for build APIs and small websites quickly. When we say "micro" we mean REALLY micro: in fact, only Composer and a single .php file is required to start. 8 | 9 | ### Features 10 | 11 | * Based on the Symfony components 12 | * Easy to learn and extend 13 | * Powerful and flexible router with middleware support 14 | * CSRF protection 15 | * JSON and XML response helpers 16 | * Validator with translated error messages 17 | * Dependency Injection container 18 | * Command Line Interface command creation 19 | * Built-in plain PHP template engine with Twig and Blade integration 20 | 21 | ### Requirements 22 | 23 | * PHP >= 7.1.8 24 | * Composer 25 | 26 | ### Installation 27 | 28 | Get Luthier Framework with composer: 29 | 30 | ``` 31 | composer require luthier/framework 32 | ``` 33 | 34 | ### Usage 35 | 36 | Basic example: 37 | 38 | ```php 39 | get('/', function(){ 47 | $this->response->write("Hello world!"); 48 | }); 49 | 50 | $app->group('api', function(){ 51 | 52 | $this->get('/', function(){ 53 | json_response(['message' => 'Welcome to Luthier Framework!']); 54 | }); 55 | $this->get('about', function(){ 56 | json_response(['version' => Luthier\Framework::VERSION]); 57 | }); 58 | 59 | }); 60 | 61 | $app->run(); 62 | ``` 63 | 64 | Defining routes: 65 | 66 | ```php 67 | $app->get('foo/', function(){ 68 | // Default template engine (will search for /foo.php file) 69 | view('foo'); 70 | }); 71 | 72 | $app->post('bar/', function(){ 73 | view('bar'); 74 | }); 75 | 76 | $app->match(['get','post'], 'baz/', function(){ 77 | view('baz'); 78 | }); 79 | ``` 80 | 81 | Router parameters: 82 | 83 | ```php 84 | $app->get('hello/{name}', function($name){ 85 | $this->response->write("Hello $name!"); 86 | }); 87 | 88 | // Optional parameters 89 | 90 | $app->get('about/{category?}', function($category = 'animals'){ 91 | $this->response->write("Category: category"); 92 | }); 93 | 94 | // Regex parameters 95 | 96 | $app->get('website/{((en|es|fr)):lang}', function($lang){ 97 | $this->response->write($lang); 98 | }); 99 | ``` 100 | 101 | Route middleware: 102 | 103 | ```php 104 | // Global middleware: 105 | 106 | $app->middleware(function($request, $response, $next){ 107 | $response->write('Global
'); 108 | $next($request, $response); 109 | }); 110 | 111 | // Global middleware (but not assigned to any route yet) 112 | 113 | $app->middleware('test', function($request, $response, $next){ 114 | $response->write('Before route
'); 115 | $next($request, $response); 116 | $response->write('After route
'); 117 | }); 118 | 119 | $this->get('/', function(){ 120 | $this->response->write('Route
') 121 | })->middleware('test'); // <- assign the 'test' middleware to this route 122 | 123 | ``` 124 | 125 | ### Documentation 126 | 127 | Coming soon! 128 | 129 | ### Related projects 130 | 131 | * [Luthier CI](https://github.com/ingeniasoftware/luthier-ci): Improved routing, middleware support, authentication tools and more for CodeIgniter 3 framework 132 | * [SimpleDocs](https://github.com/ingeniasoftware/simpledocs): Dynamic documentation library for PHP which uses Markdown files 133 | 134 | ### Donate 135 | 136 | If you love our work, consider support us on [Patreon](https://patreon.com/ingenia) 137 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "luthier/framework", 3 | "type" : "library", 4 | "description" : "Versatile PHP micro-framework for build APIs and websites quickly", 5 | "license" : "MIT", 6 | "homepage" : "https://luthier.ingenia.me/framework/en/", 7 | "authors" : [{ 8 | "name" : "Anderson Salas", 9 | "email" : "anderson@ingenia.me", 10 | "homepage" : "https://ingenia.me", 11 | "role" : "Lead developer" 12 | } 13 | ], 14 | "keywords" : [ 15 | "framework", 16 | "micro-framework", 17 | "routing", 18 | "middleware", 19 | "dependency-injection", 20 | "luthier" 21 | ], 22 | "minimum-stability" : "stable", 23 | "require" : { 24 | "php" : ">=7.1.8", 25 | "symfony/http-foundation" : "^4.0", 26 | "symfony/http-kernel" : "^4.0", 27 | "symfony/routing" : "^4.0", 28 | "symfony/dotenv" : "^4.0", 29 | "symfony/event-dispatcher" : "^4.0", 30 | "symfony/console" : "^4.0", 31 | "symfony/validator" : "^4.0", 32 | "symfony/translation" : "^4.0", 33 | "pimple/pimple" : "~3.0", 34 | "filp/whoops" : "^2.2", 35 | "monolog/monolog" : "~1.0", 36 | "envms/fluentpdo" : "^1.1", 37 | "symfony/config" : "~3.4|~4.0", 38 | "spatie/array-to-xml" : "~2.0" 39 | }, 40 | "require-dev" : { 41 | "phpunit/phpunit" : "^7", 42 | "twig/twig" : "^2.0", 43 | "illuminate/view" : "~5.0", 44 | "league/plates" : "~3.0" 45 | }, 46 | "support" : { 47 | "email" : "anderson@ingenia.me" 48 | }, 49 | "autoload" : { 50 | "psr-4" : { 51 | "Luthier\\" : "src/" 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | tests 16 | 17 | 18 | 19 | 20 | ./src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class Configuration 26 | { 27 | 28 | /** 29 | * @var array 30 | */ 31 | protected $config; 32 | 33 | /** 34 | * @var bool 35 | */ 36 | protected $envLoaded = false; 37 | 38 | /** 39 | * @var string 40 | */ 41 | protected $envFolder; 42 | 43 | /** 44 | * The default application configuration 45 | * 46 | * @var array 47 | */ 48 | protected static $defaultConfig = [ 49 | // General configuration 50 | 'APP_ENV' => 'development', 51 | 'APP_NAME' => 'Luthier', 52 | 'APP_URL' => null, 53 | 'APP_LOG' => null, 54 | 'APP_CACHE' => null, 55 | 'APP_PATH' => null, 56 | 'APP_LANG' => 'en', 57 | // Session & Cookiesconfiguration 58 | 'SESSION_NAME' => 'luthier_session', 59 | // Database configuration 60 | 'DB_TYPE' => 'mysql', 61 | 'DB_HOST' => 'localhost', 62 | 'DB_USER' => 'root', 63 | 'DB_PASS' => null, 64 | 'DB_NAME' => null, 65 | 'DB_MDNS' => null, 66 | // Template configuration 67 | 'TEMPLATE_DRIVER' => 'default', 68 | 'TEMPLATE_DIR' => null, 69 | // CSRF protection configuration 70 | 'CSRF_TOKEN_NAME' => null, 71 | 'CSRF_TOKEN_LIFETIME' => 3600, 72 | 'CSRF_TOKEN_COOKIE_DOMAIN' => null, 73 | 'CSRF_TOKEN_COOKIE_PATH' => null, 74 | 'CSRF_TOKEN_LENGTH' => 16 75 | ]; 76 | 77 | /** 78 | * @param array $config Application configuration array 79 | * @param string $envFolder Application .env file path 80 | */ 81 | public function __construct(?array $config = [], ?string $envFolder = null) 82 | { 83 | $this->config = $config ?? []; 84 | $this->envFolder = $envFolder; 85 | 86 | if($envFolder !== null) 87 | $this->fetchEnvFile(); 88 | } 89 | 90 | /** 91 | * Parses the provided application configuration 92 | * 93 | * The .env file configuration has more precedence that the application configuration 94 | * array, so any configuration provided by the first source will no be overwritten by 95 | * the second. 96 | * 97 | * Keep this in mind when you use both an .env file and a configuration array 98 | * in your application. 99 | * 100 | * @return array 101 | */ 102 | public function parse() 103 | { 104 | $config = []; 105 | 106 | // Failsafe base configuration 107 | foreach (self::$defaultConfig as $name => $default) { 108 | $config[$name] = $this->getConfigValue($name, $default); 109 | } 110 | 111 | // All other configuration 112 | foreach ($this->config as $name => $value) { 113 | if (! isset($config[$name])) { 114 | $config[$name] = $value; 115 | } 116 | } 117 | 118 | $this->parsed = $config; 119 | 120 | return $config; 121 | } 122 | 123 | /** 124 | * @return array 125 | */ 126 | public static function getDefaultConfig() 127 | { 128 | return self::$defaultConfig; 129 | } 130 | 131 | /** 132 | * @return array 133 | */ 134 | public function getConfig() 135 | { 136 | return $this->config; 137 | } 138 | 139 | /** 140 | * @return string 141 | */ 142 | public function getEnvFolder() 143 | { 144 | return $this->envFolder; 145 | } 146 | 147 | /** 148 | * @param array $config 149 | * 150 | * @return self 151 | */ 152 | public function setConfig(array $config) 153 | { 154 | $this->config = $config; 155 | return $this; 156 | } 157 | 158 | /** 159 | * @param string $envFolder 160 | * 161 | * @return self 162 | */ 163 | public function setEnvFolder(string $envFolder) 164 | { 165 | $this->envFolder = $envFolder; 166 | $this->fetchEnvFile(); 167 | return $this; 168 | } 169 | 170 | private function fetchEnvFile() 171 | { 172 | if($this->envLoaded) 173 | return; 174 | 175 | if ($this->envFolder !== NULL) { 176 | try { 177 | (new Dotenv())->load(($this->envFolder !== NULL ? $this->envFolder . '/' : '') . '.env'); 178 | } catch (PathException $e) { 179 | throw new \Exception('Unable to find your application .env file. Does the file exists?'); 180 | } catch (\Exception $e) { 181 | throw new \Exception('Unable to parse your application .env file'); 182 | } 183 | } 184 | 185 | $this->envLoaded = true; 186 | } 187 | 188 | /** 189 | * @param string $name 190 | * @param mixed $default 191 | * 192 | * @return mixed 193 | */ 194 | public function getConfigValue(string $name, $default = null) 195 | { 196 | $this->fetchEnvFile(); 197 | 198 | if ($this->envFolder !== NULL && getenv($name) !== FALSE) { 199 | // Empty strings are considered NULL 200 | return !empty(getenv($name)) ? getEnv($name) : null; 201 | } else if (isset($this->config[$name])) { 202 | return $this->config[$name]; 203 | } else { 204 | return $default; 205 | } 206 | } 207 | } -------------------------------------------------------------------------------- /src/Container.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | class Container 30 | { 31 | 32 | /** 33 | * @var \Pimple\Container 34 | */ 35 | protected $container; 36 | 37 | /** 38 | * @var array 39 | */ 40 | protected $publicAliases = []; 41 | 42 | /** 43 | * @var array 44 | */ 45 | protected $privateAliases = []; 46 | 47 | /** 48 | * Gets the default container 49 | * 50 | * @var array 51 | */ 52 | protected static $defaultContainer = [ 53 | // (name) => [ (type), (class), (serviceLocatorAliases) ] 54 | 'router' => [ 55 | 'service', 56 | \Luthier\Routing\RouteBuilder::class, 57 | [] 58 | ], 59 | 'request_handler' => [ 60 | 'service', 61 | \Luthier\Http\RequestHandler::class, 62 | [ 63 | '@PRIVATE_SERVICES' 64 | ] 65 | ], 66 | 'dispatcher' => [ 67 | 'service', 68 | \Symfony\Component\EventDispatcher\EventDispatcher::class, 69 | [] 70 | ], 71 | 'request' => [ 72 | 'service', 73 | \Luthier\Http\Request::class, 74 | [] 75 | ], 76 | 'response' => [ 77 | 'service', 78 | \Luthier\Http\Response::class, 79 | [] 80 | ], 81 | 'logger' => [ 82 | 'service', 83 | \Luthier\Logger::class, 84 | [] 85 | ], 86 | 'database' => [ 87 | 'service', 88 | \Luthier\Database\Connection::class, 89 | [] 90 | ], 91 | 'template' => [ 92 | 'service', 93 | \Luthier\Templating\Template::class, 94 | [] 95 | ], 96 | 'validator' => [ 97 | 'service', 98 | \Luthier\Validator\Validator::class, 99 | [] 100 | ], 101 | 'security' => [ 102 | 'service', 103 | \Luthier\Http\Security::class, 104 | [] 105 | ], 106 | ]; 107 | 108 | public function __construct() 109 | { 110 | $this->container = new PimpleContainer(); 111 | } 112 | 113 | /** 114 | * Gets the default Luthier Framework container 115 | * 116 | * @return array 117 | */ 118 | public static function getDefaultContainer() 119 | { 120 | return self::$defaultContainer; 121 | } 122 | 123 | /** 124 | * Gets a service callback from a string (in this case, a fully qualified class name) 125 | * 126 | * @param string $name The service class name 127 | */ 128 | private function getServiceCallback(string $service, array $locatorAliases = []) 129 | { 130 | $locatorAliases = array_merge( 131 | array_keys(Configuration::getDefaultConfig()), 132 | array_keys(self::getDefaultContainer()), 133 | ['translator'], 134 | $locatorAliases, 135 | $this->publicAliases 136 | ); 137 | 138 | return function ($container) use ($service, $locatorAliases) { 139 | return new $service(new ServiceLocator($container, $locatorAliases)); 140 | }; 141 | } 142 | 143 | /** 144 | * Determines if a service is public or private based on the provided 145 | * name 146 | * 147 | * @param string $name 148 | * @param mixed $class 149 | * @param bool $isPublic 150 | */ 151 | private function parseItem(&$name, $class, &$isPublic) 152 | { 153 | if (substr($name, 0, 1) == '.') { 154 | $name = substr($name, 1); 155 | $isPublic = false; 156 | } 157 | 158 | if ($isPublic && ! in_array($name, $this->publicAliases)) { 159 | $this->publicAliases[] = $name; 160 | } else { 161 | if ($class !== null && is_string($class)) { 162 | $this->privateAliases[] = [ 163 | $name, 164 | $class 165 | ]; 166 | } 167 | } 168 | } 169 | 170 | /** 171 | * Registers a new service in the container 172 | * 173 | * @param string $name Service name 174 | * @param callable|string $service Service callback 175 | * @param array $locatorAliases Array of service aliases of the service locator 176 | * @param bool $isPublic Set the service as public (will be available globally in the application) 177 | * 178 | * @return self 179 | */ 180 | public function service(string $name, $service, array $locatorAliases = [], bool $isPublic = true) 181 | { 182 | $this->parseItem($name, $service, $isPublic); 183 | $this->container[$name] = is_string($service) 184 | ? $this->getServiceCallback($service, $locatorAliases) 185 | : $service; 186 | 187 | return $this; 188 | } 189 | 190 | /** 191 | * Registers a new factory in the container 192 | * 193 | * @param string $name Factory name 194 | * @param string|callable $factory Factory callback 195 | * @param array $locatorAliases Array of service aliases of the service locator 196 | * 197 | * @return self 198 | */ 199 | public function factory(string $name, $factory, array $locatorAliases = []) 200 | { 201 | $this->parseItem($name, $factory); 202 | $this->container[$name] = $this->container->factory( 203 | is_string($factory) 204 | ? $this->getServiceCallback($factory, $locatorAliases) 205 | : $factory 206 | ); 207 | 208 | return $this; 209 | } 210 | 211 | /** 212 | * Registers a new parameter in the container 213 | * 214 | * All parameters are PUBLIC and their names will be converted to UPPERCASE 215 | * 216 | * @param string $name Parameter name 217 | * @param mixed $value Parameter value 218 | * @param bool $private Mark the parameter as private 219 | * 220 | * @return self 221 | */ 222 | public function parameter(string $name, $value) 223 | { 224 | $name = strtoupper($name); 225 | 226 | if (! in_array($name, $this->publicAliases)) { 227 | $this->publicAliases[] = $name; 228 | } 229 | 230 | $this->container[$name] = is_callable($value) 231 | ? $this->container->protect($value) 232 | : $value; 233 | 234 | return $this; 235 | } 236 | 237 | /** 238 | * @return array 239 | */ 240 | public function getPrivateAliases() 241 | { 242 | return $this->privateAliases; 243 | } 244 | 245 | /** 246 | * Gets a service/parameter from the container by its id 247 | * 248 | * @param string $id 249 | * 250 | * @return mixed 251 | */ 252 | public function get($id) 253 | { 254 | if ($this->has($id)) { 255 | return $this->container[$id]; 256 | } else { 257 | throw new \Exception("The '$id' service/parameter is not defined in the container"); 258 | } 259 | } 260 | 261 | /** 262 | * Checks the existence of the provided service/parameter id 263 | * 264 | * @param string $id 265 | * 266 | * @return bool 267 | */ 268 | public function has($id) 269 | { 270 | return isset($this->container[$id]); 271 | } 272 | } -------------------------------------------------------------------------------- /src/Controller.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class Controller 26 | { 27 | 28 | /** 29 | * @var \Pimple\Psr11\Container 30 | */ 31 | protected $container; 32 | 33 | /** 34 | * @var \Luthier\Http\Request 35 | */ 36 | protected $request; 37 | 38 | /** 39 | * @var \Luthier\Http\Response 40 | */ 41 | protected $response; 42 | 43 | /** 44 | * @var \Luthier\Routing\Route 45 | */ 46 | protected $route; 47 | 48 | /** 49 | * Controller init 50 | * 51 | * @param ContainerInterface $container 52 | * @param Request $request 53 | * @param Response $response 54 | * @param Route $route 55 | * 56 | * @internal 57 | * 58 | * @return self 59 | */ 60 | public function init(ContainerInterface $container, Request $request, Response $response, Route $route) 61 | { 62 | $this->container = $container; 63 | $this->request = $request; 64 | $this->response = $response; 65 | $this->route = $route; 66 | return $this; 67 | } 68 | 69 | /** 70 | * __get() magic method 71 | * 72 | * @param string $property 73 | * 74 | * @throws \Exception 75 | * 76 | * @return mixed 77 | */ 78 | public function __get($property) 79 | { 80 | if ($this->container->has($property)) { 81 | return $this->container->get($property); 82 | } else { 83 | throw new \Exception("Trying to get undefined property $property"); 84 | } 85 | } 86 | 87 | /** 88 | * Generates a route URL 89 | * 90 | * @param string $name Route name 91 | * @param array $params Route parameters 92 | * 93 | * @return string 94 | */ 95 | public function route(string $name, array $params = []) 96 | { 97 | return $this->container->get('router')->getRouteByName($name, $params); 98 | } 99 | } -------------------------------------------------------------------------------- /src/Database/Connection.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class Connection extends \PDO 22 | { 23 | 24 | /** 25 | * @var ContainerInterface 26 | */ 27 | private $container; 28 | 29 | /** 30 | * @param ContainerInterface $container 31 | */ 32 | public function __construct(ContainerInterface $container) 33 | { 34 | $driver = $container->get('DB_TYPE'); 35 | $host = $container->get('DB_HOST'); 36 | $username = $container->get('DB_USER'); 37 | $password = $container->get('DB_PASS'); 38 | $database = $container->get('DB_NAME'); 39 | 40 | $this->container = $container; 41 | 42 | parent::__construct("$driver:host=$host;dbname=$database", $username, $password, [ 43 | \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION 44 | ]); 45 | } 46 | 47 | /** 48 | * Loads a (namespaced) model 49 | * 50 | * @param string $model 51 | * 52 | * @return Model 53 | */ 54 | public function model(string $model) 55 | { 56 | $namespace = $this->container->get('DB_MDNS'); 57 | 58 | if (! empty($namespace)) { 59 | $model = $namespace . '\\' . $model; 60 | } 61 | 62 | return new $model($this->container, $this); 63 | } 64 | } -------------------------------------------------------------------------------- /src/Database/Model.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class Model 24 | { 25 | /** 26 | * @var Connection 27 | */ 28 | protected $db; 29 | 30 | /** 31 | * @var ContainerInterface 32 | */ 33 | protected $container; 34 | 35 | /** 36 | * @param ContainerInterface $container 37 | * @param Connection $conection 38 | */ 39 | public function __construct(ContainerInterface $container, Connection $connection) 40 | { 41 | $this->db = new FluentPDO($connection); 42 | 43 | if ($container->get('APP_ENV') == 'development') { 44 | $this->db->debug = function ($builder) use ($container) { 45 | $query = $builder->getQuery(false); 46 | 47 | foreach ($builder->getParameters() as $value) { 48 | $fullQuery = preg_replace('/\?/', $value, $query, 1); 49 | } 50 | 51 | $container->get('logger')->debug($fullQuery, [ 52 | 'DATABASE' 53 | ]); 54 | }; 55 | } 56 | 57 | $this->container = $container; 58 | } 59 | } -------------------------------------------------------------------------------- /src/Events/PreControllerEvent.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class PreControllerEvent extends Event 23 | { 24 | 25 | /** 26 | * @var ContainerInterface 27 | */ 28 | private $container; 29 | 30 | /** 31 | * @var array 32 | */ 33 | private $middlewareStack; 34 | 35 | /** 36 | * @var callable 37 | */ 38 | private $callback; 39 | 40 | /** 41 | * @var array 42 | */ 43 | private $arguments; 44 | 45 | /** 46 | * @param ContainerInterface $container 47 | * @param array $middlewareStack 48 | * @param callable $callback 49 | * @param array $arguments 50 | */ 51 | public function __construct(ContainerInterface $container, array &$middlewareStack, callable &$callback, array &$arguments) 52 | { 53 | $this->container = $container; 54 | $this->middlewareStack = &$middlewareStack; 55 | $this->callback = &$callback; 56 | $this->arguments = &$arguments; 57 | } 58 | 59 | /** 60 | * @return \Luthier\Http\Request 61 | */ 62 | public function getRequest() 63 | { 64 | return $this->container->get('request'); 65 | } 66 | 67 | /** 68 | * @return \Luthier\Http\Response 69 | */ 70 | public function getResponse() 71 | { 72 | return $this->container->get('response'); 73 | } 74 | 75 | /** 76 | * @return ContainerInterface 77 | */ 78 | public function getContainer() 79 | { 80 | return $this->container; 81 | } 82 | 83 | /** 84 | * @return array 85 | */ 86 | public function getMiddlewareStack() 87 | { 88 | return $this->middlewareStack; 89 | } 90 | 91 | /** 92 | * @return callable 93 | */ 94 | public function getCallback() 95 | { 96 | return $this->callback; 97 | } 98 | 99 | /** 100 | * @return array 101 | */ 102 | public function getArguments() 103 | { 104 | return $this->arguments; 105 | } 106 | 107 | /** 108 | * @param array $middlewareStack 109 | */ 110 | public function setMiddlewareStack(array $middlewareStack) 111 | { 112 | $this->middlewareStack = $middlewareStack; 113 | } 114 | 115 | /** 116 | * @param callable $callback 117 | */ 118 | public function setCallback(callable $callback) 119 | { 120 | $this->callback = $callback; 121 | } 122 | 123 | /** 124 | * @param array $arguments 125 | */ 126 | public function setArguments(array $arguments) 127 | { 128 | $this->arguments = $arguments; 129 | } 130 | } -------------------------------------------------------------------------------- /src/Events/RequestEvent.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class RequestEvent extends Event 24 | { 25 | 26 | /** 27 | * @var ContainerInterface 28 | */ 29 | private $container; 30 | 31 | /** 32 | * @param ContainerInterface $container 33 | */ 34 | public function __construct(ContainerInterface $container) 35 | { 36 | $this->container = $container; 37 | } 38 | 39 | /** 40 | * @return \Luthier\Http\RequestInterface; 41 | */ 42 | public function getRequest() 43 | { 44 | return $this->container->get('request'); 45 | } 46 | 47 | /** 48 | * @return \Luthier\Http\ResponseInterface; 49 | */ 50 | public function getResponse() 51 | { 52 | return $this->container->get('response'); 53 | } 54 | 55 | /** 56 | * @return ContainerInterface 57 | */ 58 | public function getContainer() 59 | { 60 | return $this->container; 61 | } 62 | } -------------------------------------------------------------------------------- /src/Events/ResponseEvent.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class ResponseEvent extends Event 23 | { 24 | 25 | /** 26 | * @var ContainerInterface 27 | */ 28 | private $container; 29 | 30 | /** 31 | * @param ContainerInterface $container 32 | */ 33 | public function __construct(ContainerInterface $container) 34 | { 35 | $this->container = $container; 36 | } 37 | 38 | /** 39 | * @return \Luthier\Http\RequestInterface; 40 | */ 41 | public function getRequest() 42 | { 43 | return $this->container->get('request'); 44 | } 45 | 46 | /** 47 | * @return \Luthier\Http\ResponseInterface; 48 | */ 49 | public function getResponse() 50 | { 51 | return $this->container->get('response'); 52 | } 53 | 54 | /** 55 | * @return ContainerInterface 56 | */ 57 | public function getContainer() 58 | { 59 | return $this->container; 60 | } 61 | } -------------------------------------------------------------------------------- /src/Framework.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | class Framework 27 | { 28 | use UtilsTrait; 29 | 30 | const VERSION = '0.1.1'; 31 | 32 | /** 33 | * @var \Symfony\Component\HttpFoundation\Request 34 | */ 35 | protected $sfRequest; 36 | 37 | /** 38 | * @var Container 39 | */ 40 | protected $container; 41 | 42 | /** 43 | * @var Container 44 | */ 45 | protected static $staticContainer; 46 | 47 | /** 48 | * @var Configuration 49 | */ 50 | protected $config; 51 | 52 | /** 53 | * @var string 54 | */ 55 | protected $appPath; 56 | 57 | /** 58 | * @var \Whoops\Run 59 | */ 60 | protected $whoops; 61 | 62 | /** 63 | * @param mixed $config Application configuration 64 | * @param Container $container Application container 65 | * @param SfRequest $request Symfony request 66 | * @param string $appPath Application base path (by default the current working directory) 67 | * 68 | * @throws \Exception 69 | */ 70 | public function __construct($config = null, ?Container $container = null, ?SfRequest $request = null, ?string $appPath = null) 71 | { 72 | $this->container = $container ?? new Container(); 73 | $this->sfRequest = $request ?? SfRequest::createFromGlobals(); 74 | $this->appPath = $appPath ?? getcwd(); 75 | 76 | self::$staticContainer = &$this->container; 77 | 78 | if (is_array($config) || $config === null) { 79 | $this->config = new Configuration($config); 80 | } else if ($config instanceof Configuration) { 81 | $this->config = $config; 82 | } else { 83 | throw new \Exception("You must provide an array of configuration values or an instance of the Luthier\Configuration class"); 84 | } 85 | 86 | // Auto .env config file setting 87 | if ($this->config->getEnvFolder() === null && file_exists($this->appPath . '/.env')) { 88 | $this->config->setEnvFolder($this->appPath); 89 | } 90 | 91 | // Setting the APP_PATH property 92 | if (! $this->container->has('APP_PATH') || ($this->container->has('APP_PATH') && empty($this->container->get('APP_PATH')))) { 93 | $this->container->parameter('APP_PATH', $this->appPath); 94 | } 95 | 96 | // Setting the APP_URL property 97 | if (! $this->container->has('APP_URL') || ($this->container->has('APP_URL') && empty($this->container->get('APP_PATH')))) { 98 | $this->container->parameter('APP_URL', $this->config->getConfigValue('APP_URL', null)); 99 | } 100 | 101 | // At this point, only a few services are required, the rest will be 102 | // loaded during the application startup 103 | foreach (['router','dispatcher','response','request'] as $name) { 104 | if (! $this->container->has($name)) { 105 | [$type,$class,$aliases] = Container::getDefaultContainer()[$name]; 106 | $this->container->{$type}($name, $class, $aliases); 107 | } 108 | } 109 | } 110 | 111 | /** 112 | * __call() magic method 113 | * 114 | * @return mixed 115 | */ 116 | public function __call($method, $args) 117 | { 118 | return call_user_func_array([$this->container->get('router'),$method], $args); 119 | } 120 | 121 | /** 122 | * __get() magic method 123 | * 124 | * @return mixed 125 | */ 126 | public function __get($property) 127 | { 128 | if ($this->container->has($property)) { 129 | return $this->container->get($property); 130 | } else { 131 | throw new \Exception("Trying to get undefined property Luthier\Framework::$property"); 132 | } 133 | } 134 | 135 | /** 136 | * Sets the application Symfony request 137 | * 138 | * @param \Symfony\Component\HttpFoundation\Request $request Symfony Request object 139 | * 140 | * @return self 141 | */ 142 | public function setRequest(SfRequest $request) 143 | { 144 | $this->sfRequest = $request; 145 | return $this; 146 | } 147 | 148 | /** 149 | * Sets the application dependency container 150 | * 151 | * @param \Luthier\Container $container The dependency injection container 152 | * 153 | * @return mixed 154 | */ 155 | public function setContainer(Container $container) 156 | { 157 | $this->container = $container; 158 | return $this; 159 | } 160 | 161 | public static function getContainer() 162 | { 163 | return self::$staticContainer; 164 | } 165 | 166 | /** 167 | * Sets the application configuration 168 | * 169 | * @param Configuration $config 170 | * 171 | * @return self 172 | */ 173 | public function setConfig(Configuration $config) 174 | { 175 | $this->config = $config; 176 | return $this; 177 | } 178 | 179 | /** 180 | * Luthier Framework error handler 181 | * 182 | * @param int $type 183 | * @param string $message 184 | * @param string $file 185 | * @param string $line 186 | * 187 | * @internal 188 | * 189 | * @return int 190 | */ 191 | public function errorHandler($level, $message, $file = null, $line = null) 192 | { 193 | $error = $message; 194 | 195 | if (! empty($file)) { 196 | $error .= ' at ' . $file . ' on line ' . $line; 197 | } 198 | 199 | $this->container->get('logger')->error($error); 200 | 201 | if ($this->whoops === null) { 202 | $this->errorResponse($this->sfRequest)->send(); 203 | } else { 204 | $this->whoops->handleError($level, $message, $file, $line); 205 | } 206 | 207 | exit(1); 208 | } 209 | 210 | /** 211 | * Internal exception handler 212 | * 213 | * @param \Exception $exception 214 | * 215 | * @internal 216 | * @return void 217 | */ 218 | public function exceptionHandler($exception) 219 | { 220 | $error = 'Uncaught exception ' . get_class($exception) . ': ' . $exception->getMessage() . ' at ' . $exception->getFile() . ' on line ' . $exception->getLine() . PHP_EOL . 'Stack trace: ' . PHP_EOL . $exception->getTraceAsString(); 221 | $this->container->get('logger')->error($error); 222 | 223 | if ($this->whoops === null) { 224 | $this->errorResponse($this->sfRequest)->send(); 225 | } else { 226 | $this->whoops->handleException($exception); 227 | } 228 | 229 | exit(2); 230 | } 231 | 232 | /** 233 | * Shutdown handler 234 | * 235 | * @internal 236 | * @return void 237 | */ 238 | public function shutdownHandler() 239 | { 240 | if ($error = error_get_last() and $error !== null && $error['type'] !== E_STRICT) { 241 | $this->errorHandler($error['type'], $error['message']); 242 | } 243 | exit(0); 244 | } 245 | 246 | /** 247 | * Configures the application 248 | * 249 | * @throws \Exception 250 | * 251 | * @internal 252 | * @return void 253 | */ 254 | private function configure() 255 | { 256 | // Let's parse the provided configuration at this point 257 | $config = $this->config->parse(); 258 | 259 | foreach ($config as $name => $value) { 260 | // Some configuration parameters (such APP_PATH) maybe are available 261 | // at this point, so we need to check if they aren't present in our 262 | // container 263 | if (! $this->container->has($name)) { 264 | $this->container->parameter($name, $value); 265 | } 266 | } 267 | 268 | // Container services/factories/parameters definition 269 | foreach (Container::getDefaultContainer() as $name => [$type,$class,$locatorAliases]) { 270 | if (! $this->container->has($name)) { 271 | $this->container->{$type}($name, $class, $locatorAliases); 272 | } 273 | } 274 | 275 | // Our container private services will be available as typehinted arguments 276 | // within the Router/RequestHandler 277 | $this->container->parameter('@PRIVATE_SERVICES', $this->container->getPrivateAliases()); 278 | 279 | // Adding the (core) Request event subscriber 280 | $this->dispatcher->addSubscriber(new Http\EventSubscriber\RequestSubscriber()); 281 | 282 | // The translation service is a special case: we use the Symfony translator component internally, 283 | if (! $this->container->has('translator')) { 284 | $this->container->service('translator', new \Symfony\Component\Translation\Translator($config['APP_LANG']), []); 285 | } 286 | 287 | if (! ($this->container->get('translator') instanceof \Symfony\Component\Translation\Translator)) { 288 | throw new \Exception("The translation service must be an instance of " . \Symfony\Component\Translation\Translator::class . "class"); 289 | } 290 | 291 | // PHP runtime configuration 292 | switch ($config['APP_ENV']) { 293 | case 'development': 294 | error_reporting(- 1); 295 | ini_set('display_errors', 1); 296 | break; 297 | case 'production': 298 | error_reporting(0); 299 | ini_set('display_errors', 0); 300 | break; 301 | default: 302 | throw new \Exception('The application environment is not configured correctly'); 303 | } 304 | 305 | // Error/exception reporting configuration 306 | if (! defined('LUTHIER_TEST_MODE') && $config['APP_ENV'] == 'development') { 307 | 308 | $this->whoops = new \Whoops\Run(); 309 | 310 | $handler = ! $this->isCli() 311 | ? new \Whoops\Handler\PrettyPageHandler() 312 | : new \Whoops\Handler\PlainTextHandler(); 313 | 314 | $this->whoops->pushHandler($handler); 315 | } 316 | 317 | set_error_handler([$this,'errorHandler']); 318 | set_exception_handler([$this,'exceptionHandler']); 319 | register_shutdown_function([$this,'shutdownHandler']); 320 | 321 | // Some useful helpers: 322 | require __DIR__ . '/Helpers.php'; 323 | } 324 | 325 | /** 326 | * Runs the application 327 | * 328 | * @return void 329 | */ 330 | public function run() 331 | { 332 | // Lets configure our application... 333 | $this->configure(); 334 | 335 | $this->logger->debug('Luthier Framework v' . self::VERSION . ' (PHP ' . phpversion() . ') APP_PATH="' . $this->container->get('APP_PATH') . '"', ['CORE']); 336 | 337 | if (! $this->isCli()) { 338 | $this->runHttp(); 339 | } else { 340 | $this->runCli(); 341 | } 342 | } 343 | 344 | /** 345 | * Runs the application in HTTP mode 346 | * 347 | * @return void 348 | */ 349 | private function runHttp() 350 | { 351 | $requestHandler = $this->container->get('request_handler'); 352 | 353 | if (! empty($this->container->get('APP_CACHE'))) { 354 | $cacheFolder = $this->container->get('APP_PATH') . '/' . $this->container->get('APP_CACHE'); 355 | 356 | if (! file_exists($cacheFolder)) { 357 | mkdir($cacheFolder . '/http', null, true); 358 | } 359 | 360 | $this->logger->debug('HttpKernel cache folder set to "' . $cacheFolder . '/http"', ['CORE']); 361 | $requestHandler = new HttpKernel\HttpCache\HttpCache($requestHandler, new HttpKernel\HttpCache\Store($cacheFolder . '/http')); 362 | } 363 | 364 | $requestHandler->handle($this->sfRequest)->send(); 365 | } 366 | 367 | /** 368 | * Runs the application in CLI mode 369 | * 370 | * @return void 371 | */ 372 | private function runCli() 373 | { 374 | $commandLoader = new FactoryCommandLoader($this->container->get('router')->getCommands()); 375 | $application = new Application(); 376 | $application->setCommandLoader($commandLoader); 377 | $application->run(); 378 | } 379 | } 380 | 381 | -------------------------------------------------------------------------------- /src/Helpers.php: -------------------------------------------------------------------------------- 1 | get('request')->getRequest()->attributes->get('_orig_route'); 25 | } 26 | 27 | return \Luthier\Framework::getContainer()->get('router')->getRouteByName($name, $args, $absoluteUrl); 28 | } 29 | 30 | } 31 | 32 | if (!function_exists('url')) { 33 | 34 | /** 35 | * Generates an application URL 36 | * 37 | * @param string $url 38 | * 39 | * @see \Luthier\Http\RequestInterface::baseUrl() 40 | * 41 | * @return string 42 | */ 43 | function url(string $url = '') 44 | { 45 | return \Luthier\Framework::getContainer()->get('')->baseUrl($url); 46 | } 47 | 48 | } 49 | 50 | 51 | if (!function_exists('redirect')) { 52 | 53 | /** 54 | * Sets the current response as a RedirectResponse to the given URL and 55 | * parameters 56 | * 57 | * @param string $url 58 | * @param int $status 59 | * @param array $headers 60 | * 61 | * @return void 62 | */ 63 | function redirect(string $url = '', int $status = 302, array $headers = []) 64 | { 65 | \Luthier\Framework::getContainer()->get('response')->redirect($url, $status, $headers); 66 | } 67 | 68 | } 69 | 70 | if (!function_exists('route_redirect')) { 71 | 72 | /** 73 | * Sets the current response as a RedirectResponse to the URL of the given 74 | * route name 75 | * 76 | * @param string $name 77 | * @param array $params 78 | * @param number $status 79 | * @param array $headers 80 | * 81 | * @return void 82 | */ 83 | function route_redirect($name, $params = [], $status = 302, $headers = []) 84 | { 85 | \Luthier\Framework::getContainer()->get('response')->routeRedirect($name, $params, $status, $headers); 86 | } 87 | 88 | } 89 | 90 | if (!function_exists('json')) { 91 | 92 | /** 93 | * Sets the current response as a JsonResponse with the given array of data 94 | * 95 | * @param array $data 96 | * @param number $status 97 | * @param array $headers 98 | * 99 | * @return void 100 | */ 101 | function json_response(array $data, $status = 200, $headers = []) 102 | { 103 | \Luthier\Framework::getContainer()->get('response')->json($data, $status, $headers); 104 | } 105 | 106 | } 107 | 108 | if (!function_exists('xml_response')) { 109 | 110 | /** 111 | * Sets the current response as a XML response with the given array of data 112 | * 113 | * @param array $data 114 | * @param number $status 115 | * @param array $headers 116 | * 117 | * @return void 118 | */ 119 | function xml_response(array $data, ?string $rootName = null, ?bool $translateSpaces = true, $status = 200, $headers = []) 120 | { 121 | \Luthier\Framework::getContainer()->get('response')->xml($data, $rootName, $translateSpaces, $status, $headers); 122 | } 123 | 124 | } 125 | 126 | if (!function_exists('view')) { 127 | 128 | /** 129 | * Renders a template 130 | * 131 | * @param string $template 132 | * @param array $vars 133 | * @param bool $return 134 | * 135 | * @return string|null 136 | */ 137 | function view(string $template, array $vars = [], $return = false) 138 | { 139 | \Luthier\Framework::getContainer()->get('template')->render($template, $vars, $return); 140 | } 141 | 142 | } 143 | 144 | if (!function_exists('csrf_field')) { 145 | 146 | /** 147 | * Renders a hidden HTML input tag with the CSRF field 148 | * 149 | * @return string 150 | */ 151 | function csrf_field() 152 | { 153 | 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/Http/EventSubscriber/RequestSubscriber.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class RequestSubscriber implements EventSubscriberInterface 23 | { 24 | /** 25 | * @param Events\RequestEvent $event 26 | */ 27 | public function onRequest(Events\RequestEvent $event) 28 | { 29 | $request = $event->getRequest(); 30 | $this->setRequestMethodFromField($request); 31 | $this->parseJsonBody($request); 32 | } 33 | 34 | /** 35 | * If the request is a POST request and contains a field named "_method" 36 | * we will set the request method to the "_method" field value, allowing 37 | * us to use other methods than GET and POST via traditional html forms 38 | * 39 | * @param \Luthier\Http\Request $request 40 | * @internal 41 | */ 42 | private function setRequestMethodFromField($request) 43 | { 44 | if (strtolower($request->getMethod()) == "post" && !$request->isAjax()) { 45 | $method = $request->post("_method"); 46 | if (!empty($method)) { 47 | $request->setMethod(strtoupper($method)); 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * JSON body parsing. Useful when we recieve a request with a JSON 54 | * payload (Content-Type: application/json). 55 | * 56 | * @param \Luthier\Http\Request $request 57 | * @internal 58 | */ 59 | private function parseJsonBody($request) 60 | { 61 | if ($request->getContentType() === 'json') { 62 | $jsonBody = json_decode($request->getContent(), true); 63 | $request->getRequest()->request->add($jsonBody); 64 | } 65 | } 66 | 67 | public static function getSubscribedEvents() 68 | { 69 | return [ 70 | 'request' => ['onRequest', 0], 71 | ]; 72 | } 73 | } -------------------------------------------------------------------------------- /src/Http/Exception/InvalidCsrfTokenException.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class InvalidCsrfTokenException extends \Exception 21 | { 22 | } -------------------------------------------------------------------------------- /src/Http/Middleware/AjaxMiddleware.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class AjaxMiddleware implements MiddlewareInterface 22 | { 23 | 24 | public function run($request, $response, $next) 25 | { 26 | if (! $request->isAjax()) { 27 | throw new NotFoundHttpException('This route is only available under AJAX requests'); 28 | } 29 | 30 | $next($request, $response); 31 | } 32 | } -------------------------------------------------------------------------------- /src/Http/Middleware/CsrfMiddleware.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class CsrfMiddleware implements MiddlewareInterface 24 | { 25 | use UtilsTrait; 26 | 27 | /** 28 | * @var ContainerInterface 29 | */ 30 | protected $container; 31 | 32 | /** 33 | * @param ContainerInterface $container 34 | */ 35 | public function __construct(ContainerInterface $container) 36 | { 37 | $this->container = $container; 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | * 43 | * @see \Luthier\Http\Middleware\MiddlewareInterface::run() 44 | */ 45 | public function run($request, $response, $next) 46 | { 47 | $csrf = $this->container->get('security'); 48 | 49 | if ($csrf->getCsrfTokenName() !== null) { 50 | try 51 | { 52 | $csrf->verifyCsrfToken(); 53 | return $next($request, $response); 54 | } 55 | catch(InvalidCsrfTokenException $e) 56 | { 57 | return $this->errorResponse($request->getRequest() ,400, null, 'Your request does not contain a valid CSRF token'); 58 | } 59 | } else { 60 | $next($request, $response); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/Http/Middleware/MiddlewareInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | interface MiddlewareInterface 20 | { 21 | /** 22 | * Runs the middleware 23 | * 24 | * @param \Luthier\Http\RequestInterface $request 25 | * @param \Luthier\Http\ResponseInterface $response 26 | * @param \Closure $next 27 | */ 28 | public function run($request, $response, $next); 29 | } 30 | -------------------------------------------------------------------------------- /src/Http/Middleware/ValidationMiddleware.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class ValidationMiddleware implements MiddlewareInterface 24 | { 25 | use UtilsTrait; 26 | 27 | /** 28 | * @var ContainerInterface 29 | */ 30 | protected $container; 31 | 32 | /** 33 | * @param ContainerInterface $container 34 | */ 35 | public function __construct(ContainerInterface $container) 36 | { 37 | $this->container = $container; 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | * 43 | * @see \Luthier\Http\Middleware\MiddlewareInterface::run() 44 | */ 45 | public function run($request, $response, $next) 46 | { 47 | try 48 | { 49 | $validationErrors = $request->sessionFlash->get('@VALIDATION_ERRORS'); 50 | 51 | if (!$request->isAjax() && is_array($validationErrors) && !empty($validationErrors)) { 52 | $this->container->get('validator')->setValidationErrors($validationErrors); 53 | } 54 | 55 | $next($request, $response); 56 | } 57 | catch(ValidationConstraintException $e) 58 | { 59 | $validationErrors = $this->container->get('validator')->getValidationErrors(); 60 | $validationMessage = 'Your request fails the validation constraints'; 61 | 62 | if (! $request->isAjax()) { 63 | 64 | $referer = $request->header('referer'); 65 | if (!empty($referer) && $referer != $request->getRequest()->getUri()){ 66 | $request->sessionFlash->set( 67 | '@VALIDATION_ERRORS', 68 | $validationErrors 69 | ); 70 | return $response->redirect($referer); 71 | } 72 | 73 | return $this->errorResponse($request->getRequest(), 400, null, $validationMessage); 74 | } 75 | 76 | return $response->json([ 77 | 'error' => $validationMessage, 78 | 'fields' => $validationErrors, 79 | ],400); 80 | 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /src/Http/Request.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | class Request implements RequestInterface 27 | { 28 | use UtilsTrait; 29 | 30 | /** 31 | * Symfony request object 32 | * 33 | * @var \Symfony\Component\HttpFoundation\Request 34 | */ 35 | protected $request; 36 | 37 | /** 38 | * @var \Psr\Container\ContainerInterface 39 | */ 40 | protected $container; 41 | 42 | /** 43 | * @param ContainerInterface $container 44 | */ 45 | public function __construct(ContainerInterface $container) 46 | { 47 | $this->container = $container; 48 | } 49 | 50 | /** 51 | * {@inheritDoc} 52 | * 53 | * @see \Luthier\Http\RequestInterface::getRequest() 54 | */ 55 | public function getRequest() 56 | { 57 | return $this->request; 58 | } 59 | 60 | /** 61 | * {@inheritDoc} 62 | * 63 | * @see \Luthier\Http\RequestInterface::setRequest() 64 | */ 65 | public function setRequest(SfRequest $request = null) 66 | { 67 | $this->request = $request ?? SfRequest::createFromGlobals(); 68 | 69 | return $this; 70 | } 71 | 72 | public function __get($property) 73 | { 74 | if (($property == 'session' || $property == 'sessionFlash') && !$this->request->hasSession()) { 75 | // Session start 76 | $sessionStorage = new NativeSessionStorage(); 77 | $sessionStorage->setName($this->container->get('SESSION_NAME')); 78 | $this->request->setSession(new Session($sessionStorage)); 79 | } 80 | 81 | if ($property == 'session') { 82 | return $this->request->getSession(); 83 | } else if($property== 'sessionFlash') { 84 | return $this->request->getSession()->getFlashBag(); 85 | } else { 86 | throw new \Exception("Try to get undefined Request::$property property"); 87 | } 88 | } 89 | 90 | public function __call($method, $args) 91 | { 92 | $httpContainers = [ 93 | 'attributes' => 'attributes', 94 | 'post' => 'request', 95 | 'request' => 'request', 96 | 'get' => 'query', 97 | 'server' => 'server', 98 | 'file' => 'files', 99 | 'header' => 'headers', 100 | 'cookie' => 'cookies', 101 | 'session' => null, 102 | 'sessionFlash' => null, 103 | ]; 104 | 105 | if (in_array($method, array_keys($httpContainers))) { 106 | $name = $args[0] ?? NULL; 107 | $default = $args[1] ?? NULL; 108 | 109 | if ($method == 'session') { 110 | return $name !== NULL 111 | ? ($this->request->hasSession() 112 | ? $this->request->getSession()->get($name, $default) 113 | : null) 114 | : ($this->request->hasSession() 115 | ? $this->request->getSession()->all() 116 | : []); 117 | } else if($method == 'sessionFlash') { 118 | return $name !== NULL 119 | ? ($this->request->hasSession() 120 | ? $this->request->getSession()->getFlashBag()->get($name, $default ?? []) 121 | : null) 122 | : ($this->request->hasSession() 123 | ? $this->request->getSession()->getFlashBag()->all() 124 | : []); 125 | } else { 126 | return $name !== NULL 127 | ? $this->request->{$httpContainers[$method]}->get($name, $default) 128 | : $this->request->{$httpContainers[$method]}->all(); 129 | } 130 | } else if(method_exists($this->request, $method)) { 131 | return call_user_func_array([$this->request, $method], $args); 132 | } else { 133 | throw new \BadMethodCallException("Call to undefined method Request::{$method}()"); 134 | } 135 | } 136 | 137 | /** 138 | * Checks if the current request is an AJAX request (alias of Request::isXmlHttpRequest()) 139 | * 140 | * @return bool 141 | */ 142 | public function isAjax() 143 | { 144 | return $this->request->isXmlHttpRequest(); 145 | } 146 | 147 | /** 148 | * {@inheritDoc} 149 | * 150 | * @see \Luthier\Http\RequestInterface::baseUrl() 151 | */ 152 | public function baseUrl(string $url = '') : string 153 | { 154 | if ($this->container->get('APP_URL') !== null) { 155 | return $this->container->get('APP_URL') . '/' . trim($url, '/'); 156 | } 157 | 158 | return trim($this->request->getScheme() 159 | . '://' . $this->request->getHost() 160 | . $this->request->getBasePath() 161 | . '/' . $url, '/'); 162 | } 163 | } -------------------------------------------------------------------------------- /src/Http/RequestHandler.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | class RequestHandler implements HttpKernelInterface 36 | { 37 | use UtilsTrait; 38 | 39 | /** 40 | * @var \Luthier\Container 41 | */ 42 | protected $container; 43 | 44 | /** 45 | * @var \Luthier\Routing\RouteBuilder 46 | */ 47 | protected $router; 48 | 49 | /** 50 | * @var \Symfony\Component\Routing\Matcher\UrlMatcher 51 | */ 52 | protected $matcher; 53 | 54 | /** 55 | * @var \Symfony\Component\HttpKernel\Controller\ControllerResolver 56 | */ 57 | protected $controllerResolver; 58 | 59 | /** 60 | * @var \Symfony\Component\HttpKernel\Controller\ArgumentResolver 61 | */ 62 | protected $argumentResolver; 63 | 64 | /** 65 | * Luthier Request object 66 | * 67 | * @var \Luthier\Http\Request 68 | */ 69 | protected $request; 70 | 71 | /** 72 | * Luthier Response object 73 | * 74 | * @var \Luthier\Http\Response 75 | */ 76 | protected $response; 77 | 78 | /** 79 | * @var \Symfony\Component\EventDispatcher\EventDispatcher 80 | */ 81 | protected $dispatcher; 82 | 83 | /** 84 | * @var \Monolog\Logger 85 | */ 86 | protected $logger; 87 | 88 | /** 89 | * @param ContainerInterface $container Dependecy container 90 | */ 91 | public function __construct(ContainerInterface $container) 92 | { 93 | $this->container = $container; 94 | $this->router = $container->get('router'); 95 | $this->logger = $container->get('logger'); 96 | $this->dispatcher = $container->get('dispatcher'); 97 | $this->request = $container->get('request'); 98 | $this->response = $container->get('response'); 99 | $this->matcher = new UrlMatcher($this->router->getRoutes(), $this->router->getRequestContext()); 100 | $this->argumentResolver = new ArgumentResolver(); 101 | $this->controllerResolver = new ControllerResolver(); 102 | } 103 | 104 | /** 105 | * @param SfRequest $request 106 | * @param array $match 107 | * @param Route $route 108 | * 109 | * @throws \Exception 110 | * 111 | * @return array ([$callback, $arguments]) 112 | */ 113 | private function resolveController(SfRequest &$request, array $match, Route $route) 114 | { 115 | // Both the (Luthier) Request and Response objects will be available 116 | // as typehinted parameters of the route callbacks/methods, just in case that 117 | // the developer prefers using it instead of the $this->request/$this->response 118 | // properties 119 | $match['request'] = $this->request; 120 | $match['response'] = $this->response; 121 | 122 | foreach ($this->container->get('@PRIVATE_SERVICES') as [ 123 | $name, 124 | $class 125 | ]) { 126 | if (class_exists($class)) { 127 | $match[$name] = new $class($this->container); 128 | } 129 | } 130 | 131 | $request->attributes->add($match); 132 | 133 | $callback = $this->controllerResolver->getController($request); 134 | 135 | if ($callback instanceof \Closure) { 136 | // Is the route action a closure? Let's bind it to a new instance of 137 | // Luthier\Controller 138 | $callback = \Closure::bind($callback, (new Controller())->init($this->container, $this->request, $this->response, $route), Controller::class); 139 | } else if (isset($callback[0]) && $callback[0] instanceof Controller) { 140 | // Is the route action an instance of Luthier\Controller? then set the 141 | // container, request, response and current route 142 | $callback[0]->init($this->container, $this->request, $this->response, $route); 143 | } else { 144 | if (! isset($callback[0]) && ! is_object($callback[0])) { 145 | // Not a closure or an object? sorry, we will not handle that 146 | throw new \Exception("The route does not contain a valid callback"); 147 | } 148 | } 149 | 150 | $arguments = $this->argumentResolver->getArguments($request, $callback); 151 | 152 | return [ 153 | $callback, 154 | $arguments 155 | ]; 156 | } 157 | 158 | /** 159 | * @param string $url 160 | * @param array $arguments 161 | * @param Route $route 162 | * 163 | * @return void 164 | */ 165 | private function parseArguments(string $url, array &$arguments, Route $route) 166 | { 167 | $offset = 0; 168 | 169 | foreach ($arguments as $i => $arg) { 170 | if ($arg === null) { 171 | unset($arguments[$i]); 172 | } 173 | } 174 | 175 | foreach (explode('/', trim($url, '/')) as $i => $urlSegment) { 176 | $routeSegment = explode('/', $route->getFullPath())[$i]; 177 | if (substr($routeSegment, 0, 1) == '{' && substr($routeSegment, - 1) == '}') { 178 | $route->params[$offset]->value = $urlSegment; 179 | $offset ++; 180 | } 181 | } 182 | } 183 | 184 | /** 185 | * @throws NotFoundHttpException 186 | * 187 | * @return \Symfony\Component\HttpFoundation\Response 188 | */ 189 | private function welcomeScreen() 190 | { 191 | if ($this->container->get('APP_ENV') == 'production') { 192 | throw new NotFoundHttpException('Your application does not contain any route'); 193 | } 194 | 195 | ob_start(); 196 | require __DIR__ . '/../Resources/Views/About.php'; 197 | $responseBody = ob_get_clean(); 198 | 199 | return new SfResponse($responseBody); 200 | } 201 | 202 | /** 203 | * @param SfRequest $request 204 | * @param \Exception $exception 205 | * 206 | * @return void 207 | */ 208 | private function handle404(SfRequest $request, \Exception $exception) 209 | { 210 | $env = $this->container->get('APP_ENV'); 211 | 212 | $this->logger->warning('HTTP 404: Not found (' . $request->getMethod() . ' ' . $request->getUri() . ')', [ 213 | "REQUEST_HANDLER" 214 | ]); 215 | 216 | if ($env == 'development') { 217 | throw $exception; 218 | } 219 | 220 | $httpNotFoundCallback = $this->router->getHttpNotFoundCallback(); 221 | 222 | Response::getRealResponse($httpNotFoundCallback !== null ? call_user_func_array($httpNotFoundCallback, [ 223 | $this->request, 224 | $this->response 225 | ]) : $this->errorResponse($request, 404, 'Not found', 'The requested resource is not available or has been moved to another location'), $this->response); 226 | } 227 | 228 | /** 229 | * @param SfRequest $request 230 | * @param Route $route 231 | * @param \Exception $exception 232 | * 233 | * @return void 234 | */ 235 | private function handle405(SfRequest $request, ?Route $route, \Exception $exception) 236 | { 237 | $this->logger->warning('HTTP 405: Method not allowed (' . $request->getMethod() . ' ' . $request->getUri() . ')', [ 238 | "REQUEST_HANDLER" 239 | ]); 240 | 241 | if ($this->container->get('APP_ENV') == 'development') { 242 | throw $exception; 243 | } 244 | 245 | $httpMethodNotAllowedCallback = $this->router->getHttpMethodNotAllowedCallback(); 246 | 247 | Response::getRealResponse($httpMethodNotAllowedCallback !== null ? call_user_func_array($httpMethodNotAllowedCallback, [ 248 | $this->request, 249 | $this->response, 250 | $route 251 | ]) : $this->errorResponse($request, 405, 'Not allowed', 'The request method is not allowed for this resource'), $this->response); 252 | } 253 | 254 | /** 255 | * @param mixed $finalResponse 256 | * @param Route $route 257 | * @param \Exception $exception 258 | * 259 | * @throws \Exception 260 | * 261 | * @return void 262 | */ 263 | private function handleException(?Route $route, \Exception $exception) 264 | { 265 | $errorHandler = $this->router->getErrorHandler(); 266 | 267 | if ($errorHandler !== NULL) { 268 | Response::getRealResponse(call_user_func_array($errorHandler, [ 269 | $exception, 270 | $this->request, 271 | $this->response, 272 | $route 273 | ]), $this->response); 274 | 275 | $this->logger->error(get_class($exception) . ':' . $exception->getMessage(), [ 276 | "REQUEST_HANDLER" 277 | ]); 278 | } else { 279 | throw $exception; 280 | } 281 | } 282 | 283 | /** 284 | * {@inheritDoc} 285 | * 286 | * @see \Symfony\Component\HttpKernel\HttpKernelInterface::handle() 287 | */ 288 | public function handle(SfRequest $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) 289 | { 290 | $this->request->setRequest($request); 291 | $this->response->setResponse(); 292 | $this->logger->debug($request->getMethod() . ' ' . $request->getUri(), [ 293 | 'REQUEST_HANDLER' 294 | ]); 295 | 296 | // Dispatch the 'request' event 297 | $this->dispatcher->dispatch('request', new Events\RequestEvent($this->container)); 298 | 299 | $this->matcher->getContext()->fromRequest($request); 300 | 301 | try { 302 | 303 | if ($this->router->count() == 0) { 304 | return $this->welcomeScreen(); 305 | } 306 | 307 | $match = $this->matcher->match($request->getPathInfo()); 308 | 309 | /** @var \Luthier\Routing\Route */ 310 | $route = $match['_orig_route']; 311 | 312 | $this->router->setCurrentRoute($route); 313 | 314 | $this->logger->debug('Matched route ' . (! empty($route->getName()) ? '"' . $route->getName() . '"' : '') . ' (' . (! is_string($route->getAction()) ? '[anonymous@closure]' : $route->getAction()) . ') ' . 'for path "' . $route->getFullPath() . '" by router ' . get_class($this->router), [ 315 | 'REQUEST_HANDLER' 316 | ]); 317 | 318 | [ 319 | $callback, 320 | $arguments 321 | ] = $this->resolveController($request, $match, $route); 322 | 323 | $this->parseArguments($request->getPathInfo(), $arguments, $route); 324 | 325 | $middlewareStack = $this->router->getMiddlewareStack($route); 326 | 327 | // Dispatch the 'pre_controller' event 328 | $this->dispatcher->dispatch('pre_controller', new Events\PreControllerEvent($this->container, $middlewareStack, $callback, $arguments)); 329 | 330 | // Now with the route callback/arguments and the middleware stack, we can 331 | // start iterating it 332 | ResponseIterator::handle($this->router, $middlewareStack, $callback, $arguments, $this->request, $this->response); 333 | 334 | } catch (ResourceNotFoundException | NotFoundHttpException $exception) { 335 | $this->handle404($request, $exception); 336 | } catch (MethodNotAllowedException | MethodNotAllowedHttpException $exception) { 337 | $this->handle405($request, $route ?? null, $exception); 338 | } catch (\Exception $exception) { 339 | $this->handleException($route ?? null, $exception); 340 | } 341 | 342 | // Dispatch the 'response' event 343 | $this->dispatcher->dispatch('response', new Events\ResponseEvent($this->container)); 344 | 345 | return $this->response->getResponse(); 346 | } 347 | } 348 | 349 | -------------------------------------------------------------------------------- /src/Http/RequestInterface.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | interface RequestInterface 23 | { 24 | 25 | /** 26 | * Gets the Symfony Request 27 | * 28 | * @return \Symfony\Component\HttpFoundation\Request 29 | */ 30 | public function getRequest(); 31 | 32 | /** 33 | * Sets the Symfony Request 34 | * 35 | * @param Request $request 36 | */ 37 | public function setRequest(Request $request); 38 | 39 | /** 40 | * Gets the application base url 41 | * 42 | * @param string $url 43 | * 44 | * @return string 45 | */ 46 | public function baseUrl(string $url = ''): string; 47 | } -------------------------------------------------------------------------------- /src/Http/Response.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | class Response implements ResponseInterface 31 | { 32 | 33 | /** 34 | * @var \Symfony\Component\HttpFoundation\Response 35 | */ 36 | protected $response; 37 | 38 | /** 39 | * @var RequestInterface 40 | */ 41 | protected $request; 42 | 43 | /** 44 | * 45 | * @var \Psr\Container\ContainerInterface; 46 | */ 47 | protected $container; 48 | 49 | /** 50 | * @var \Luthier\Routing\RouteBuilder; 51 | */ 52 | protected $router; 53 | 54 | /** 55 | * @param ContainerInterface $container 56 | */ 57 | public function __construct(ContainerInterface $container) 58 | { 59 | $this->container = $container; 60 | $this->request = $container->get('request'); 61 | $this->router = $container->get('router'); 62 | } 63 | 64 | /** 65 | * {@inheritDoc} 66 | * 67 | * @see \Luthier\Http\ResponseInterface::getResponse() 68 | */ 69 | public function getResponse() 70 | { 71 | return $this->response; 72 | } 73 | 74 | /** 75 | * {@inheritDoc} 76 | * 77 | * @see \Luthier\Http\ResponseInterface::setResponse() 78 | */ 79 | public function setResponse(SfResponse $response = null) 80 | { 81 | $this->response = $response ?? new SfResponse(); 82 | return $this; 83 | } 84 | 85 | public function __call($method, $args) 86 | { 87 | if (method_exists($this->response, $method)) { 88 | return call_user_func_array([ 89 | $this->response, 90 | $method 91 | ], $args); 92 | } else { 93 | throw new \BadMethodCallException("Call to undefined method Response::{$method}()"); 94 | } 95 | } 96 | 97 | public function __get($property) 98 | { 99 | if($property == 'headers'){ 100 | return $this->response->headers; 101 | } 102 | 103 | throw new \Exception("Try to get undefined Request::$property property"); 104 | } 105 | 106 | /** 107 | * Updates the internal Symfony Response object of this class if the 108 | * provided response is an instance of a Symfony Response too 109 | * 110 | * @param mixed $responseResult Response of an intermediary request 111 | * @param self $masterResponse The master response to be compared 112 | * 113 | * @internal 114 | * 115 | * @return void 116 | */ 117 | public static function getRealResponse($responseResult, self $masterResponse) 118 | { 119 | if ($responseResult instanceof SfResponse) { 120 | $masterResponse->setResponse($responseResult); 121 | } 122 | } 123 | 124 | /** 125 | * {@inheritDoc} 126 | * 127 | * @see \Luthier\Http\ResponseInterface::json() 128 | */ 129 | public function json($data, int $status = 200, array $headers = []) 130 | { 131 | $headers = array_merge($headers, $this->response->headers->all()); 132 | $this->response = new JsonResponse( 133 | is_array($data) 134 | ? json_encode($data) 135 | : $data, 136 | $status, 137 | $headers, 138 | ! is_string($data) 139 | ); 140 | 141 | return $this; 142 | } 143 | 144 | /** 145 | * Sets the response to a XML response with the given array of data 146 | * 147 | * @param array $data Array of data to be pased to XML 148 | * @param string $rootName Document root name 149 | * @param bool $translateSpaces Translate spaces to underscores? 150 | * @param int $status HTTP Stat 151 | * @param array $headers 152 | * 153 | * @return self 154 | */ 155 | public function xml(array $data, ?string $rootName = null, ?bool $translateSpaces = true, int $status = 200, array $headers = []) 156 | { 157 | $this->response->setContent(ArrayToXml::convert($data, $rootName ?? '', $translateSpaces ?? true)); 158 | $this->response->setStatusCode($status); 159 | $this->response->headers->add($headers); 160 | $this->response->headers->add(['Content-Type' => 'application/xml']); 161 | 162 | return $this; 163 | } 164 | 165 | /** 166 | * {@inheritDoc} 167 | * 168 | * @see \Luthier\Http\ResponseInterface::write() 169 | */ 170 | public function write(string $content) 171 | { 172 | $this->response->setContent($this->response->getContent() . $content); 173 | 174 | return $this; 175 | } 176 | 177 | /** 178 | * {@inheritDoc} 179 | * 180 | * @see \Luthier\Http\ResponseInterface::redirect() 181 | */ 182 | public function redirect(string $url, int $status = 302, array $headers = []) 183 | { 184 | if (substr($url, 0, 7) !== 'http://' && substr($url, 0, 8) !== 'https://') { 185 | $url = $this->request->baseUrl() . '/' . trim($url, '/'); 186 | } 187 | $headers = array_merge($headers, $this->response->headers->all()); 188 | $this->response = new RedirectResponse($url, $status, $headers); 189 | 190 | return $this; 191 | } 192 | 193 | /** 194 | * {@inheritDoc} 195 | * 196 | * @see \Luthier\Http\ResponseInterface::routeRedirect() 197 | */ 198 | public function routeRedirect(string $route, array $params = [], int $status = 302, array $headers = []) 199 | { 200 | return $this->redirect($this->router->getRouteByName($route, $params), $status, $headers); 201 | } 202 | 203 | /** 204 | * Sets the response to a streamed response 205 | * 206 | * @param callable $callback Callback that produces the response 207 | * @param int $status HTTP status code 208 | * @param array $headers Additional HTTP headers 209 | * 210 | * @return self 211 | */ 212 | public function stream(callable $callback, int $status = 200, array $headers = []) 213 | { 214 | $headers = array_merge($headers, $this->response->headers->all()); 215 | $this->response = new StreamedResponse($callback, $status, $headers); 216 | return $this; 217 | } 218 | 219 | /** 220 | * Sets the response to a file stream response 221 | * 222 | * @param mixed $file File that will be streamed 223 | * @param int $status HTTP status code 224 | * @param array $headers Additional HTTP headers 225 | * @param bool $public Set the file as public 226 | * @param string $contentDisposition File content disposition 227 | * @param bool $autoEtag Add E-Tag automatically 228 | * @param bool $autoLastModified Set the Last Modified property automatically 229 | * 230 | * @return self 231 | */ 232 | public function file($file, int $status = 200, array $headers = [], bool $public = true, string $contentDisposition = null, bool $autoEtag = false, bool $autoLastModified = true) 233 | { 234 | $headers = array_merge($headers, $this->response->headers->all()); 235 | $this->response = new BinaryFileResponse($file, $status, $headers, $public, $contentDisposition, $autoEtag, $autoLastModified); 236 | return $this; 237 | } 238 | } -------------------------------------------------------------------------------- /src/Http/ResponseInterface.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | interface ResponseInterface 23 | { 24 | 25 | /** 26 | * Gets the Symfony Response 27 | */ 28 | public function getResponse(); 29 | 30 | /** 31 | * Sets the Symfony Response 32 | * 33 | * @param Response $response 34 | */ 35 | public function setResponse(Response $request); 36 | 37 | /** 38 | * Writes a text to the response 39 | * 40 | * @param string $string 41 | */ 42 | public function write(string $string); 43 | 44 | /** 45 | * Sets the current response as a RedirectResponse with the provided parameters 46 | * 47 | * @param string $url The url to be redirected 48 | * @param int $status HTTP status code 49 | * @param array $headers Additional HTTP headers 50 | */ 51 | public function redirect(string $url, int $status = 302, array $headers = []); 52 | 53 | /** 54 | * Sets the current response as a RedirectResponse to a specific route URL 55 | * 56 | * @param string $route Route name 57 | * @param array $params Route parameters 58 | * @param int $status HTTP status code 59 | * @param array $headers Additional HTTP headers 60 | */ 61 | public function routeRedirect(string $route, array $params = [], int $status = 302, array $headers = []); 62 | 63 | /** 64 | * Sets the response to a JSON response 65 | * 66 | * @param string|array $data Json data 67 | * @param int $status HTTP status code 68 | * @param array $headers Additional HTTP headers 69 | */ 70 | public function json($data, int $status = 200, array $headers = []); 71 | } -------------------------------------------------------------------------------- /src/Http/ResponseIterator.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class ResponseIterator 24 | { 25 | /** 26 | * @var int 27 | */ 28 | private static $index; 29 | 30 | /** 31 | * @var array 32 | */ 33 | private static $stack; 34 | 35 | /** 36 | * @var array 37 | */ 38 | private static $callback; 39 | 40 | /** 41 | * @var \Closure 42 | */ 43 | private static $router; 44 | 45 | /** 46 | * @param string|callable $middleware 47 | * 48 | * @return \Closure 49 | */ 50 | private static function getMiddleware($middleware) 51 | { 52 | return self::$router->getMiddleware($middleware); 53 | } 54 | 55 | /** 56 | * Iterates over the provided request stack 57 | * 58 | * @param array $stack Request stack 59 | * @param callable $callback Route callback 60 | * @param array $arguments Route arguments 61 | * @param Request $request Luthier request 62 | * @param Response $response Luthier response 63 | * 64 | * @return \Symfony\Component\HttpFoundation\Response|mixed 65 | */ 66 | public static function handle(RouteBuilder $router, array $stack, callable $callback, array $arguments, Request $request, Response $response) 67 | { 68 | self::$router = $router; 69 | self::$index = 0; 70 | self::$stack = $stack; 71 | self::$callback = [$callback, $arguments]; 72 | 73 | if (count($stack) > 0) { 74 | $middleware = self::getMiddleware(self::$stack[0]); 75 | Response::getRealResponse($middleware($request, $response, function($request, $response){ 76 | return \Luthier\Http\ResponseIterator::next($request, $response); 77 | }), $response); 78 | } else { 79 | Response::getRealResponse($callback(...$arguments), $response); 80 | } 81 | } 82 | 83 | /** 84 | * Returns the current middleware in the queue, or null if no more middleware 85 | * left 86 | * 87 | * @return array callable|null 88 | */ 89 | public static function iterate() 90 | { 91 | return isset(self::$stack[++self::$index]) 92 | ? self::$stack[self::$index] 93 | : null; 94 | } 95 | 96 | /** 97 | * Returns a callable of the next middleware in the queue based in the current 98 | * iterator index 99 | * 100 | * @param Request $request 101 | * @param Response $response 102 | * 103 | * @return callable|null 104 | */ 105 | public static function next(Request $request, Response $response) 106 | { 107 | if ($response->getResponse() instanceof RedirectResponse) { 108 | return; 109 | } 110 | 111 | $middleware = self::iterate(); 112 | 113 | if ($middleware === NULL) { 114 | [$callback, $arguments] = self::$callback; 115 | return $callback(...$arguments); 116 | } else { 117 | $middleware = self::getMiddleware($middleware); 118 | return $middleware($request, $response, function($request,$response){ 119 | return \Luthier\Http\ResponseIterator::next($request, $response); 120 | }); 121 | } 122 | } 123 | } -------------------------------------------------------------------------------- /src/Http/Security.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class Security 25 | { 26 | /** 27 | * @var ContainerInterface 28 | */ 29 | protected $container; 30 | 31 | /** 32 | * @var Response 33 | */ 34 | protected $response; 35 | 36 | /** 37 | * @var Request 38 | */ 39 | protected $request; 40 | 41 | /** 42 | * @var string 43 | */ 44 | protected $csrfTokenName; 45 | 46 | /** 47 | * @var string 48 | */ 49 | protected $csrfTokenHash; 50 | 51 | /** 52 | * @var int 53 | */ 54 | protected $csrfTokenCookieLifetime; 55 | 56 | /** 57 | * @var string 58 | */ 59 | protected $csrfTokenCookiePath; 60 | 61 | /** 62 | * @var string 63 | */ 64 | protected $csrfTokenCookieDomain; 65 | 66 | /** 67 | * @param ContainerInterface $container 68 | */ 69 | public function __construct(ContainerInterface $container) 70 | { 71 | $this->container = $container; 72 | 73 | $csrfTokenName = $container->get('CSRF_TOKEN_NAME'); 74 | 75 | $this->response = $container->get('response'); 76 | $this->request = $container->get('request'); 77 | 78 | if ($csrfTokenName !== null) { 79 | $this->csrfTokenName = $csrfTokenName; 80 | $this->initializeCsrfProtection(); 81 | } 82 | } 83 | 84 | /** 85 | * @internal 86 | */ 87 | private function initializeCsrfProtection() 88 | { 89 | $this->csrfTokenCookieLifetime = (int) $this->container->get('CSRF_TOKEN_LIFETIME'); 90 | $this->csrfTokenCookieDomain = $this->container->get('CSRF_TOKEN_COOKIE_DOMAIN'); 91 | $this->csrfTokenCookiePath = $this->container->get('CSRF_TOKEN_COOKIE_PATH'); 92 | 93 | $this->setCsrfHash(); 94 | } 95 | 96 | /** 97 | * Gets the current CSRF token hash 98 | * 99 | * @return string 100 | */ 101 | public function getCsrfTokenHash() 102 | { 103 | return $this->csrfTokenHash; 104 | } 105 | 106 | /** 107 | * Gets the current CSRF token name 108 | * 109 | * @return string 110 | */ 111 | public function getCsrfTokenName() 112 | { 113 | return $this->csrfTokenName; 114 | } 115 | 116 | /** 117 | * Checks if the request contains a valid CSRF token 118 | * 119 | * @throws Exception\InvalidCsrfTokenException 120 | * 121 | * @return Response|null 122 | */ 123 | public function verifyCsrfToken() 124 | { 125 | if ( strtoupper($this->request->getMethod()) === "GET" ) { 126 | $this->response->headers->setCookie( 127 | new Cookie( 128 | $this->csrfTokenName, 129 | $this->csrfTokenHash, 130 | time() + $this->csrfTokenCookieLifetime, 131 | $this->csrfTokenCookiePath, 132 | $this->csrfTokenCookieDomain, 133 | $this->request->isSecure() 134 | ) 135 | ); 136 | 137 | return; 138 | } 139 | 140 | $requestToken = $this->request->getRequest()->request->get($this->csrfTokenName); 141 | 142 | if (! ($requestToken !== null && hash_equals($requestToken, $this->csrfTokenHash))) { 143 | throw new Exception\InvalidCsrfTokenException("Invalid or missing CSRF token"); 144 | } 145 | } 146 | 147 | /** 148 | * Generates cryptographically secure pseudo-random bytes 149 | * 150 | * @param int $length 151 | * @throws \Exception 152 | * @return string 153 | */ 154 | public function getRandomBytes(int $length) 155 | { 156 | if (function_exists('random_bytes')) { 157 | return bin2hex(random_bytes($length)); 158 | } 159 | if (function_exists('mcrypt_create_iv')) { 160 | return bin2hex(mcrypt_create_iv($length, MCRYPT_DEV_URANDOM)); 161 | } 162 | if (function_exists('openssl_random_pseudo_bytes')) { 163 | return bin2hex(openssl_random_pseudo_bytes($length)); 164 | } 165 | 166 | throw new \Exception("No random bytes generator functions available!"); 167 | } 168 | 169 | private function setCsrfHash() 170 | { 171 | if ($this->csrfTokenHash !== null) { 172 | return; 173 | } 174 | 175 | $tokenCookie = $this->request->cookie($this->csrfTokenName, null); 176 | 177 | if (!empty($tokenCookie) && preg_match('#^[0-9a-f]{32}$#iS', $tokenCookie)) { 178 | $this->csrfTokenHash = $tokenCookie; 179 | } else { 180 | $length = $this->container->get('CSRF_TOKEN_LENGTH'); 181 | $this->csrfTokenHash = $this->getRandomBytes($length); 182 | } 183 | } 184 | } -------------------------------------------------------------------------------- /src/Logger.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class Logger extends MonologLogger 25 | { 26 | 27 | /** 28 | * @param ContainerInterface $container 29 | */ 30 | public function __construct(ContainerInterface $container) 31 | { 32 | if (! empty($container->get('APP_LOG'))) { 33 | $log = $container->get('APP_PATH') . '/' . $container->get('APP_LOG'); 34 | 35 | if (! file_exists($log)) { 36 | mkdir($log, null, true); 37 | } 38 | 39 | $log .= '/' . date('Y-m-d') . '.log'; 40 | 41 | if (! file_exists($log)) { 42 | file_put_contents($log, ''); 43 | } 44 | 45 | $this->name = $container->get('APP_NAME'); 46 | 47 | $handler = new StreamHandler( 48 | $log, 49 | $container->get('APP_ENV') == 'development' 50 | ? MonologLogger::DEBUG 51 | : MonologLogger::ERROR 52 | ); 53 | $handler->setFormatter(new LineFormatter(null, null, true, true)); 54 | 55 | $this->handlers = [$handler]; 56 | $this->processors = []; 57 | } else { 58 | // Failsafe instance construct 59 | parent::__construct("default"); 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/Resources/Views/About.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Luthier Framework 6 | 7 | 8 | 9 | 45 | 46 | 47 |
48 | Luthier Framwork 49 |

Luthier Framework

50 |

Version

51 | 56 |
57 | 58 | -------------------------------------------------------------------------------- /src/Resources/Views/Error.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <?= $title ?? 'Ups!' ?> 6 | 7 | 29 | 30 | 31 |
32 |

33 |

34 |
35 | 36 | -------------------------------------------------------------------------------- /src/Routing/Command.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | class Command 28 | { 29 | 30 | /** 31 | * @var string 32 | */ 33 | protected $name; 34 | 35 | /** 36 | * @var callable|string 37 | */ 38 | protected $callback; 39 | 40 | /** 41 | * @var string 42 | */ 43 | protected $description = ''; 44 | 45 | /** 46 | * @var string 47 | */ 48 | protected $help = ''; 49 | 50 | /** 51 | * @var array 52 | */ 53 | protected $params = []; 54 | 55 | /** 56 | * @param string $name Command name 57 | * @param callable $callback Command callback 58 | */ 59 | public function __construct(string $name, callable $callback) 60 | { 61 | $this->name = $name; 62 | $this->callback = $callback; 63 | } 64 | 65 | /** 66 | * Sets the parameter description 67 | * 68 | * @param string $description Description 69 | * 70 | * @return self 71 | */ 72 | public function description(string $description) 73 | { 74 | $this->description = $description; 75 | return $this; 76 | } 77 | 78 | /** 79 | * Sets the parameter help 80 | * 81 | * @param string $help 82 | * 83 | * @return self 84 | */ 85 | public function help(string $help) 86 | { 87 | $this->help = $help; 88 | return $this; 89 | } 90 | 91 | /** 92 | * Adds a new command parameter 93 | * 94 | * @param string $name Parameter name 95 | * @param mixed $shortcuts Parameter shortcuts 96 | * @param string $description Parameter description 97 | * @param string $mode Parameter mode (none|required|optional|array) 98 | * @param mixed $default Set the parameter default value 99 | * 100 | * @throws \Exception 101 | * 102 | * @return self 103 | */ 104 | public function param(string $name, $shortcuts = null, string $description = '', string $mode = 'required', $default = null) 105 | { 106 | $modes = [ 107 | 'none' => InputOption::VALUE_NONE, 108 | 'required' => InputOption::VALUE_REQUIRED, 109 | 'optional' => InputOption::VALUE_OPTIONAL, 110 | 'array' => InputOption::VALUE_IS_ARRAY 111 | ]; 112 | 113 | if (! in_array($mode, $modes)) { 114 | throw new \Exception("Unknown command parameter '$mode' mode"); 115 | } 116 | 117 | $this->params[] = new InputOption($name, $shortcuts, $mode, $description, $default); 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * Gets the command name 124 | * 125 | * @return string 126 | */ 127 | public function getName() 128 | { 129 | return $this->name; 130 | } 131 | 132 | /** 133 | * Gets the command callback 134 | * 135 | * @return string|callable 136 | */ 137 | public function getCallback() 138 | { 139 | return $this->callback; 140 | } 141 | 142 | /** 143 | * Gets the command description 144 | * 145 | * @return string 146 | */ 147 | public function getDescription() 148 | { 149 | return $this->description; 150 | } 151 | 152 | /** 153 | * Gets the command help 154 | * 155 | * @return string 156 | */ 157 | public function getHelp() 158 | { 159 | return $this->help; 160 | } 161 | 162 | /** 163 | * Gets the command parameters 164 | * 165 | * @return array 166 | */ 167 | public function getParams() 168 | { 169 | return $this->params; 170 | } 171 | 172 | /** 173 | * Compiles the command to a Symfony Application command 174 | * 175 | * @return \Symfony\Component\Console\Command\Command 176 | */ 177 | public function compile() 178 | { 179 | $_command = &$this; 180 | 181 | return new Class($_command) extends SfCommand { 182 | 183 | private $_command; 184 | 185 | public function __construct($_command) 186 | { 187 | $this->_command = $_command; 188 | parent::__construct($_command->getName()); 189 | } 190 | 191 | public function configure() 192 | { 193 | $this->setName($this->_command->getName()); 194 | $this->setDescription($this->_command->getDescription()); 195 | $this->setHelp($this->_command->getHelp()); 196 | if (! empty($this->_command->getParams())) { 197 | $this->setDefinition(new InputDefinition($this->_command->getParams())); 198 | } 199 | } 200 | 201 | public function execute(InputInterface $input, OutputInterface $output) 202 | { 203 | $callback = \Closure::bind($this->_command->getCallback(), $this, SfCommand::class); 204 | call_user_func_array($callback, [ 205 | $input, 206 | $output 207 | ]); 208 | } 209 | }; 210 | } 211 | } -------------------------------------------------------------------------------- /src/Routing/Exception/RouteNotFoundException.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class RouteNotFoundException extends \Exception 20 | { 21 | } -------------------------------------------------------------------------------- /src/Routing/Route.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class Route 22 | { 23 | 24 | /** 25 | * Relative path 26 | * 27 | * @var string 28 | */ 29 | private $path; 30 | 31 | /** 32 | * Full (absolute) path 33 | * 34 | * @var string 35 | */ 36 | private $fullPath; 37 | 38 | /** 39 | * @var string 40 | */ 41 | private $name; 42 | 43 | /** 44 | * Accepted HTTP Verbs 45 | * 46 | * @var string[] 47 | * 48 | * @access private 49 | */ 50 | private $methods = []; 51 | 52 | /** 53 | * @var string|callable 54 | */ 55 | private $action; 56 | 57 | /** 58 | * @var array 59 | */ 60 | private $middleware = []; 61 | 62 | /** 63 | * @var string 64 | */ 65 | private $namespace = ''; 66 | 67 | /** 68 | * @var string 69 | */ 70 | private $prefix = ''; 71 | 72 | /** 73 | * @var string 74 | */ 75 | private $host = ''; 76 | 77 | /** 78 | * @var string[] 79 | */ 80 | private $schemes = []; 81 | 82 | /** 83 | * @var RouteParam[] Array of route parameters 84 | */ 85 | public $params = []; 86 | 87 | /** 88 | * @var int 89 | */ 90 | public $paramOffset; 91 | 92 | /** 93 | * @var bool 94 | */ 95 | public $hasOptionalParams = false; 96 | 97 | /** 98 | * @param array|string $methods Route methods 99 | * @param array $route Route arguments 100 | * 101 | * @throws \Exception 102 | */ 103 | public function __construct($methods, array $route) 104 | { 105 | if ($methods == 'any') { 106 | $methods = RouteBuilder::HTTP_VERBS; 107 | } elseif (is_string($methods)) { 108 | $methods = [ 109 | strtoupper($methods) 110 | ]; 111 | } else { 112 | array_shift($route); 113 | } 114 | 115 | foreach ($methods as $method) { 116 | $this->methods[] = strtoupper($method); 117 | } 118 | 119 | [ 120 | $path, 121 | $action 122 | ] = $route; 123 | 124 | $this->path = trim($path, '/') == '' ? '/' : trim($path, '/'); 125 | 126 | if (! is_callable($action) && count(explode('@', $action)) != 2) { 127 | throw new \Exception('Route action must be in controller@method syntax or be a valid callback'); 128 | } 129 | 130 | $this->action = $action; 131 | 132 | $attributes = isset($route[2]) && is_array($route[2]) ? $route[2] : NULL; 133 | 134 | if (! empty(RouteBuilder::getContext('prefix'))) { 135 | $prefixes = RouteBuilder::getContext('prefix'); 136 | foreach ($prefixes as $prefix) { 137 | $this->prefix .= trim($prefix, '/') != '' ? '/' . trim($prefix, '/') : ''; 138 | } 139 | $this->prefix = trim($this->prefix, '/'); 140 | } 141 | 142 | if (! empty(RouteBuilder::getContext('namespace'))) { 143 | $namespaces = RouteBuilder::getContext('namespace'); 144 | foreach ($namespaces as $namespace) { 145 | $this->namespace .= trim($namespace, '/') != '' ? '/' . trim($namespace, '/') : ''; 146 | } 147 | $this->namespace = trim($this->namespace, '/'); 148 | } 149 | 150 | if (! empty(RouteBuilder::getContext('middleware')['route'])) { 151 | $middleware = RouteBuilder::getContext('middleware')['route'][0]; 152 | foreach ($middleware as $_middleware) { 153 | if (! in_array($_middleware, $this->middleware)) { 154 | $this->middleware[] = $_middleware; 155 | } 156 | } 157 | } 158 | 159 | if (! empty(RouteBuilder::getContext('host'))) { 160 | $host = RouteBuilder::getContext('host')[0]; 161 | $this->host = $host; 162 | } 163 | 164 | if (! empty(RouteBuilder::getContext('schemes'))) { 165 | $schemes = RouteBuilder::getContext('schemes'); 166 | if (! empty($schemes)) { 167 | $this->schemes = $schemes[0]; 168 | } 169 | } 170 | 171 | if ($attributes !== NULL) { 172 | if (isset($attributes['namespace'])) { 173 | $this->namespace = (! empty($this->namespace) ? '/' : '') . trim($attributes['namespace'], '/'); 174 | } 175 | if (isset($attributes['prefix'])) { 176 | $this->prefix .= (! empty($this->prefix) ? '/' : '') . trim($attributes['prefix'], '/'); 177 | } 178 | if (isset($attributes['middleware'])) { 179 | if (is_string($attributes['middleware'])) { 180 | $attributes['middleware'] = [ 181 | $attributes['middleware'] 182 | ]; 183 | } 184 | $this->middleware = array_merge($this->middleware, array_unique($attributes['middleware'])); 185 | } 186 | } 187 | 188 | $params = []; 189 | $this->fullPath = []; 190 | 191 | $_fullpath = trim($this->prefix, '/') != '' ? $this->prefix . '/' . $this->path : $this->path; 192 | 193 | $_fullpath = trim($_fullpath, '/') == '' ? '/' : trim($_fullpath, '/'); 194 | 195 | foreach (explode('/', $_fullpath) as $i => $segment) { 196 | if (preg_match('/^\{(.*)\}$/', $segment)) { 197 | if ($this->paramOffset === null) { 198 | $this->paramOffset = $i; 199 | } 200 | 201 | $param = new RouteParam($segment); 202 | 203 | if (in_array($param->getName(), $params)) { 204 | throw new \Exception('Duplicate route parameter "' . $param->getName() . '" in route "' . $this->path . '"'); 205 | } 206 | 207 | $params[] = $param->getName(); 208 | 209 | if ($param->isOptional()) { 210 | $this->hasOptionalParams = true; 211 | } else { 212 | if ($this->hasOptionalParams) { 213 | throw new \Exception('Required "' . $param->getName() . '" route parameter is not allowed at this position in "' . $this->path . '" route'); 214 | } 215 | } 216 | 217 | $this->params[] = $param; 218 | $this->fullPath[] = '{' . $param->getName() . ($param->isOptional() ? '?' : '') . '}'; 219 | } else { 220 | $this->fullPath[] = $segment; 221 | } 222 | } 223 | 224 | $this->fullPath = implode('/', $this->fullPath); 225 | } 226 | 227 | /** 228 | * Compiles the route to a Symfony route 229 | * 230 | * @return array ([string $name, \Symfony\Component\Routing\Route $route]) 231 | */ 232 | public function compile() 233 | { 234 | $path = $this->fullPath; 235 | 236 | if (is_callable($this->action)) { 237 | $controller = $this->action; 238 | } else { 239 | $controller = (! empty($this->namespace) ? $this->namespace . '\\' : '') . implode('::', explode('@', $this->action)); 240 | } 241 | 242 | $defaults = [ 243 | '_controller' => $controller, 244 | '_orig_route' => $this 245 | ]; 246 | 247 | $requirements = []; 248 | 249 | foreach ($this->params as $param) { 250 | $requirements[$param->getName()] = $param->getRegex(); 251 | } 252 | 253 | $options = []; 254 | $host = $this->host; 255 | $schemes = $this->schemes; 256 | $methods = $this->methods; 257 | 258 | return [ 259 | $this->name, 260 | new SfRoute($path, $defaults, $requirements, $options, $host, $schemes, $methods) 261 | ]; 262 | } 263 | 264 | /** 265 | * Gets (or sets) a route parameter 266 | * 267 | * @param string $name Parameter name 268 | * @param mixed $value Parameter value 269 | * 270 | * @return mixed|void 271 | */ 272 | public function param(string $name, $value = null) 273 | { 274 | foreach ($this->params as &$param) { 275 | if ($name == $param->getName()) { 276 | if ($value !== null) { 277 | $param->value = $value; 278 | } 279 | return $param->value; 280 | } 281 | } 282 | } 283 | 284 | /** 285 | * Sets the route name 286 | * 287 | * @param string $name Route name 288 | * 289 | * @return self 290 | */ 291 | public function name(string $name) 292 | { 293 | $this->name = $name; 294 | return $this; 295 | } 296 | 297 | /** 298 | * Adds a middleware to route 299 | * 300 | * @param mixed $middleware Route middleware 301 | * 302 | * @return self 303 | */ 304 | public function middleware($middleware) 305 | { 306 | if (is_array($middleware)) { 307 | $this->middleware = array_merge($this->middleware, $middleware); 308 | } else { 309 | $this->middleware[] = $middleware; 310 | } 311 | return $this; 312 | } 313 | 314 | /** 315 | * Sets the route host 316 | * 317 | * @param string $host Route host 318 | * 319 | * @return self 320 | */ 321 | public function host(string $host) 322 | { 323 | $this->host = $host; 324 | return $this; 325 | } 326 | 327 | /** 328 | * Sets the route accepted HTTP schemes 329 | * 330 | * @param array $schemes 331 | * 332 | * @return self 333 | */ 334 | public function schemes(array $schemes) 335 | { 336 | $this->schemes = $schemes; 337 | return $this; 338 | } 339 | 340 | /** 341 | * Adds the built-in AJAX middleware to the current route 342 | * 343 | * @return self 344 | */ 345 | public function ajax() 346 | { 347 | $this->middleware[] = new \Luthier\Http\Middleware\AjaxMiddleware(); 348 | return $this; 349 | } 350 | 351 | /** 352 | * Checks if the route has a specific parameter 353 | * 354 | * @param string $name Parameter name 355 | * 356 | * @return bool 357 | */ 358 | public function hasParam(string $name) 359 | { 360 | foreach ($this->params as &$param) { 361 | if ($name == $param->getName()) { 362 | return true; 363 | } 364 | } 365 | return false; 366 | } 367 | 368 | /** 369 | * Gets the route name 370 | * 371 | * @return string 372 | * 373 | * @access public 374 | */ 375 | public function getName() 376 | { 377 | return $this->name; 378 | } 379 | 380 | /** 381 | * Sets route name [alias of Route::name()] 382 | * 383 | * @param string $name Route name 384 | * 385 | * @return self 386 | */ 387 | public function setName(string $name) 388 | { 389 | return $this->name($name); 390 | } 391 | 392 | /** 393 | * Gets route path 394 | * 395 | * @return string 396 | */ 397 | public function getPath() 398 | { 399 | return $this->path; 400 | } 401 | 402 | /** 403 | * Get route full (absolute) path 404 | * 405 | * @return string 406 | */ 407 | public function getFullPath() 408 | { 409 | return $this->fullPath; 410 | } 411 | 412 | /** 413 | * Gets route prefix 414 | * 415 | * @return string 416 | */ 417 | public function getPrefix() 418 | { 419 | return $this->prefix; 420 | } 421 | 422 | /** 423 | * Gets route action 424 | * 425 | * @return string|callable 426 | * 427 | */ 428 | public function getAction() 429 | { 430 | return $this->action; 431 | } 432 | 433 | /** 434 | * Gets route middleware 435 | * 436 | * @return array 437 | */ 438 | public function getMiddleware() 439 | { 440 | return $this->middleware; 441 | } 442 | 443 | /** 444 | * Gets route namespace 445 | * 446 | * @return string 447 | */ 448 | public function getNamespace() 449 | { 450 | return $this->namespace; 451 | } 452 | 453 | /** 454 | * Gets route accepted HTTP verbs 455 | * 456 | * @return array 457 | */ 458 | public function getMethods() 459 | { 460 | return $this->methods; 461 | } 462 | 463 | /** 464 | * Gets route host 465 | * 466 | * @return string 467 | */ 468 | public function getHost() 469 | { 470 | return $this->host; 471 | } 472 | 473 | /** 474 | * Gets route accepted HTTP schemes 475 | * 476 | * @return array 477 | */ 478 | public function getSchemes() 479 | { 480 | return $this->schemes; 481 | } 482 | 483 | /** 484 | * Gets all route sticky parameters 485 | * 486 | * @return array 487 | */ 488 | public function getStickyParams() 489 | { 490 | $sticky = []; 491 | 492 | foreach ($this->params as $param) { 493 | if ($param->isSticky()) { 494 | $sticky[] = $param->getName(); 495 | } 496 | } 497 | 498 | return $sticky; 499 | } 500 | 501 | /** 502 | * Sets the route path 503 | * 504 | * @param string $path Route path 505 | * 506 | * @return self 507 | */ 508 | public function setPath(string $path) 509 | { 510 | $this->path = $path; 511 | return $this; 512 | } 513 | 514 | /** 515 | * Sets the route action 516 | * 517 | * @param string|callable $action Route action 518 | * 519 | * @return self 520 | */ 521 | public function setAction($action) 522 | { 523 | $this->action = $action; 524 | return $this; 525 | } 526 | 527 | /** 528 | * Sets the route host 529 | * 530 | * @param string $host 531 | * 532 | * @return self 533 | */ 534 | public function setHost(string $host) 535 | { 536 | return $this->host($host); 537 | } 538 | 539 | /** 540 | * Sets the route accepted HTTP schemes (method chaining) 541 | * 542 | * @param array $schemes Accepted HTTP schemes 543 | * 544 | * @return self 545 | */ 546 | public function setSchemes(array $schemes) 547 | { 548 | return $this->schemes($schemes); 549 | } 550 | } -------------------------------------------------------------------------------- /src/Routing/RouteBuilder.php: -------------------------------------------------------------------------------- 1 | 46 | */ 47 | class RouteBuilder implements RouteBuilderInterface 48 | { 49 | 50 | const HTTP_VERBS = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS','TRACE']; 51 | 52 | /** 53 | * @var ContainerInterface 54 | */ 55 | protected $container; 56 | 57 | /** 58 | * @var array $context 59 | */ 60 | protected static $context = [ 61 | 'middleware' => [ 62 | 'route' => [], 63 | 'global' => [], 64 | 'alias' => [] 65 | ], 66 | 'namespace' => [], 67 | 'prefix' => [], 68 | 'params' => [], 69 | 'host' => [], 70 | 'schemes' => [] 71 | ]; 72 | 73 | /** 74 | * @var Command[] $commands 75 | */ 76 | protected $commands = []; 77 | 78 | /** 79 | * @var Route[] $routes 80 | */ 81 | protected $routes = []; 82 | 83 | /** 84 | * @var array $names 85 | */ 86 | protected $names = []; 87 | 88 | /** 89 | * @var RouteCollection $routeCollection 90 | */ 91 | protected $routeCollection; 92 | 93 | /** 94 | * @var UrlGenerator $routeGenerator 95 | */ 96 | protected $routeGenerator; 97 | 98 | /** 99 | * @var RequestContext $requestContext 100 | */ 101 | protected $requestContext; 102 | 103 | /** 104 | * HTTP/1.1 404: Not Found error callback 105 | * 106 | * @var callable $httpNotFoundCallback 107 | */ 108 | protected $httpNotFoundCallback; 109 | 110 | /** 111 | * HTTP/1.1 405: Method Not Allowed error callback 112 | * 113 | * @var callable $httpNotAllowedCallback 114 | */ 115 | protected $httpMethodNotAllowedCallback; 116 | 117 | /** 118 | * Error Handler used by the Request Handler (HttpKernel) 119 | * 120 | * @var callable $errorCallback 121 | */ 122 | protected $errorHandler; 123 | 124 | /** 125 | * @var \Luthier\Routing\Route 126 | */ 127 | protected $currentRoute; 128 | 129 | /** 130 | * @var bool $isSandboxed 131 | */ 132 | protected static $isSandboxed = false; 133 | 134 | public function __construct(ContainerInterface $container) 135 | { 136 | $this->container = $container; 137 | $this->routeCollection = new RouteCollection(); 138 | $this->requestContext = new RequestContext(); 139 | 140 | // Built-in middleware 141 | $this->middleware('ajax', AjaxMiddleware::class); 142 | $this->middleware('csrf', CsrfMiddleware::class); 143 | $this->middleware('validation', ValidationMiddleware::class); 144 | } 145 | 146 | public function __call($callback, array $attributes) 147 | { 148 | if ($callback == 'command') { 149 | [$name,$_callback] = $attributes; 150 | 151 | $command = new LuthierCommand($name, $_callback); 152 | 153 | if (isset($this->commands[$command->getName()])) { 154 | echo 'ERROR: Duplicated ' . $command->getName() . ' command!' . PHP_EOL; 155 | exit(- 1); 156 | } 157 | 158 | $this->commands[$command->getName()] = function () use ($command) { 159 | return $command->compile(); 160 | }; 161 | 162 | return $command; 163 | } else if (in_array(strtoupper($callback), self::HTTP_VERBS) || in_array($callback, ['match', 'any'])) { 164 | 165 | if ($callback == 'match') { 166 | $methods = $attributes[0]; 167 | } else { 168 | $methods = $callback; 169 | } 170 | 171 | $route = new LuthierRoute($methods, $attributes); 172 | $this->routes[] = $route; 173 | return $route; 174 | } else { 175 | throw new \BadMethodCallException("Call to undefined method Luthier\RouteBuilder::{$callback}() "); 176 | } 177 | } 178 | 179 | /** 180 | * Creates a new route group 181 | * 182 | * Two syntax are accepted: 183 | * 184 | * // Only prefix and sub-routes 185 | * $app->group('prefix', function(){ ... }); 186 | * 187 | * // Both prefix and shared attributes: 188 | * $app->group('prefix, [ 'attrs' ], function(){ ... }); 189 | * 190 | * Inside the sub-routes definition callback, the router instance is binded to 191 | * the `$this` variable 192 | * 193 | * @param string $prefix Group URL prefix 194 | * @param mixed $attributes Group shared attributes 195 | * @param mixed $routes Group sub-routes definition callback 196 | * 197 | * @throws \Exception 198 | * 199 | * @return void 200 | */ 201 | public function group(string $prefix, $attributes, $routes = null) 202 | { 203 | if ($routes === null && is_callable($attributes)) { 204 | $routes = $attributes; 205 | $attributes = []; 206 | } 207 | 208 | self::$context['prefix'][] = $prefix; 209 | 210 | if (isset($attributes['namespace'])) { 211 | self::$context['namespace'][] = $attributes['namespace']; 212 | } 213 | 214 | if (isset($attributes['schemes'])) { 215 | self::$context['schemes'][] = $attributes['schemes']; 216 | } 217 | 218 | if (isset($attributes['middleware'])) { 219 | if (! is_array($attributes['middleware']) && ! is_string($attributes['middleware'])) { 220 | throw new \Exception('Route group middleware must be an array o a string'); 221 | } 222 | 223 | if (is_string($attributes['middleware'])) { 224 | $attributes['middleware'] = [$attributes['middleware']]; 225 | } 226 | 227 | self::$context['middleware']['route'][] = $attributes['middleware']; 228 | } 229 | 230 | if (isset($attributes['host'])) { 231 | self::$context['host'] = $attributes['host']; 232 | } 233 | 234 | $routes = \Closure::bind($routes, $this, RouteBuilder::class); 235 | $routes(); 236 | 237 | array_pop(self::$context['prefix']); 238 | 239 | if (isset($attributes['namespace'])) { 240 | array_pop(self::$context['namespace']); 241 | } 242 | 243 | if (isset($attributes['middleware'])) { 244 | array_pop(self::$context['middleware']['route']); 245 | } 246 | 247 | if (isset($attributes['schemes'])) { 248 | array_pop(self::$context['schemes']); 249 | } 250 | 251 | if (isset($attributes['host'])) { 252 | self::$context['host'] = NULL; 253 | } 254 | } 255 | 256 | /** 257 | * Defines (or runs) a global middleware 258 | * 259 | * The following syntax is accepted: 260 | * 261 | * // Defining a global middlware 262 | * 263 | * # Closure: 264 | * $app->middleware('alias', function(Request $request, Response $response, Closure $next){ ... }); 265 | * 266 | * # Callable: 267 | * $app->middleware('alias', 'middleware_callable'); 268 | * 269 | * // Runing a global middleware 270 | * 271 | * # From alias: 272 | * $app->middleware('alias'); 273 | * 274 | * # From a closure: 275 | * $app->middleware(function(Request $request, Response $response, Closure $next){ ... }); 276 | * 277 | * You can also pass an array of middleware closures/alias to be executed: 278 | * 279 | * $app->middleware(['foo','bar','baz']); 280 | * 281 | * @param string|callable|array $middleware Middleware to be defined or executed 282 | * 283 | * @throws \InvalidArgumentException 284 | * 285 | * @return void 286 | */ 287 | public function middleware($middleware) 288 | { 289 | if (count(func_get_args()) == 2) { 290 | [$name,$middleware] = func_get_args(); 291 | 292 | if (! is_string($name)) { 293 | throw new \InvalidArgumentException("The middleware alias must be a string"); 294 | } 295 | 296 | if (! is_callable($middleware) && ! class_exists($middleware)) { 297 | throw new \InvalidArgumentException("Invalid middleware definition. Must be a valid callback." . (is_string($middleware) ? " (Does the '$middleware' class exists?)" : '')); 298 | } 299 | 300 | self::$context['middleware']['alias'][$name] = $middleware; 301 | } else { 302 | if (! is_array($middleware)) { 303 | $middleware = [$middleware]; 304 | } 305 | 306 | if(self::$isSandboxed) 307 | { 308 | $routeMiddleware = isset(self::$context['middleware']['route'][0]) ? self::$context['middleware']['route'][0] : []; 309 | self::$context['middleware']['route'][0] = !is_array($routeMiddleware) ? $middleware : array_merge($routeMiddleware, $middleware); 310 | return; 311 | } 312 | 313 | foreach ($middleware as $_middleware) { 314 | if (! in_array($_middleware, self::$context['middleware']['global'])) { 315 | self::$context['middleware']['global'][] = $_middleware; 316 | } 317 | } 318 | } 319 | } 320 | 321 | /** 322 | * {@inheritDoc} 323 | * 324 | * @see \Luthier\Routing\RouteBuilderInterface::getRoutes() 325 | */ 326 | public function getRoutes(): RouteCollection 327 | { 328 | foreach ($this->routes as $i => $luthierRoute) { 329 | [$name,$route] = $luthierRoute->compile(); 330 | 331 | if (empty($name)) { 332 | $name = '__unnamed_route_' . str_pad($i, 3, '0', STR_PAD_LEFT); 333 | } else { 334 | if (isset($this->names[$name])) { 335 | throw new \Exception("Duplicated '$name' route"); 336 | } 337 | $this->names[$name] = $luthierRoute->getStickyParams(); 338 | } 339 | 340 | $this->routeCollection->add($name, $route); 341 | } 342 | 343 | $this->routeGenerator = new UrlGenerator($this->routeCollection, $this->requestContext); 344 | 345 | return $this->routeCollection; 346 | } 347 | 348 | /** 349 | * Gets the defined application commands 350 | * 351 | * @return Command[] 352 | */ 353 | public function getCommands() 354 | { 355 | return $this->commands; 356 | } 357 | 358 | /** 359 | * Gets the current Route Builder context 360 | * 361 | * @param string $name Context index 362 | * 363 | * @return array|array[] 364 | */ 365 | public static function getContext(string $name) 366 | { 367 | return self::$context[$name]; 368 | } 369 | 370 | /** 371 | * {@inheritDoc} 372 | * 373 | * @see \Luthier\Routing\RouteBuilderInterface::getRouteByName() 374 | */ 375 | public function getRouteByName(string $name, array $args = [], bool $absoluteUrl = true): string 376 | { 377 | $route = $this->currentRoute; 378 | 379 | if (! isset($this->names[$name])) { 380 | throw new \Exception("Undefined \"$name\" route"); 381 | } 382 | 383 | foreach ($this->names[$name] as $stickyParam) { 384 | if ($route->hasParam($stickyParam)) { 385 | $args[$stickyParam] = $route->param($stickyParam); 386 | } 387 | } 388 | 389 | $generated = $this->routeGenerator->generate($name, $args, $absoluteUrl ? UrlGeneratorInterface::ABSOLUTE_URL : NULL); 390 | 391 | // If the APP_URL property is set, we'll generate the URLs based on that 392 | // value: 393 | $baseUrl = $this->container->get('APP_URL'); 394 | if (!empty($baseUrl)) { 395 | $context = $this->routeGenerator->getContext(); 396 | $offset = strpos($generated, $context->getBaseUrl()); 397 | $segments = str_ireplace($context->getBaseUrl(), '', substr($generated, $offset)); 398 | return $baseUrl . $segments; 399 | } 400 | 401 | // If not, Symfony will generate the URL for us: 402 | return $generated; 403 | } 404 | 405 | /** 406 | * Gets the current Symfony Routing RequestContext object 407 | * 408 | * @return \Symfony\Component\Routing\RequestContext 409 | */ 410 | public function getRequestContext() 411 | { 412 | return $this->requestContext; 413 | } 414 | 415 | /** 416 | * Returns a valid middleware callable from the given parameter 417 | * 418 | * @param callable|string|object $middleware 419 | * @throws \Exception 420 | * 421 | * @return callable 422 | */ 423 | public function getMiddleware($middleware) 424 | { 425 | if (is_callable($middleware)) { 426 | if ($middleware instanceof \Closure) { 427 | $container = $this->container; 428 | $middleware = \Closure::bind($middleware, $container, ContainerInterface::class); 429 | } 430 | 431 | return $middleware; 432 | } 433 | 434 | if (is_string($middleware)) { 435 | if (isset(self::$context['middleware']['alias'][$middleware])) { 436 | return self::getMiddleware(self::$context['middleware']['alias'][$middleware]); 437 | } 438 | 439 | if (class_exists($middleware)) { 440 | $middleware = new $middleware($this->container); 441 | } else { 442 | throw new \Exception("Unknown \"$middleware\" middleware class/alias"); 443 | } 444 | } 445 | 446 | if (! $middleware instanceof MiddlewareInterface) { 447 | throw new \Exception('The middleware "' . get_class($middleware) . '" MUST implement the ' . MiddlewareInterface::class . ' interface'); 448 | } 449 | 450 | return function ($request, $response, $next) use ($middleware) { 451 | return $middleware->run($request, $response, $next); 452 | }; 453 | } 454 | 455 | /** 456 | * @param \Closure $callback 457 | * 458 | * @return \Closure 459 | */ 460 | private function bindContainer(\Closure $callback) 461 | { 462 | return \Closure::bind($callback, $this->container, ContainerInterface::class); 463 | } 464 | 465 | /** 466 | * Sets the callback to be invoked when a HTTP 404 error occurs 467 | * 468 | * @param callable $callback 469 | * 470 | * @return \Luthier\Routing\RouteBuilder 471 | */ 472 | public function setHttpNotFoundCallback(\Closure $callback) 473 | { 474 | $this->httpNotFoundCallback = $this->bindContainer($callback); 475 | return $this; 476 | } 477 | 478 | /** 479 | * Sets the callback to be invoked when a HTTP 405 error ocurrs 480 | * 481 | * @param callable $callback 482 | * 483 | * @return \Luthier\Routing\RouteBuilder 484 | */ 485 | public function setMethodHttpNotAllowedCallback(\Closure $callback) 486 | { 487 | $this->httpMethodNotAllowedCallback = $this->bindContainer($callback); 488 | return $this; 489 | } 490 | 491 | /** 492 | * Sets the callback to be invoked when a error/exception occurs 493 | * 494 | * @param callable $callback 495 | * 496 | * @return self 497 | */ 498 | public function setErrorHandler(\Closure $callback) 499 | { 500 | $this->errorHandler = $this->bindContainer($callback); 501 | return $this; 502 | } 503 | 504 | /** 505 | * @param LuthierRoute $route 506 | * 507 | * @return self 508 | */ 509 | public function setCurrentRoute(LuthierRoute $route) 510 | { 511 | $this->currentRoute = $route; 512 | return $this; 513 | } 514 | 515 | /** 516 | * Gets the httpNotFoundCallback property 517 | * 518 | * @return callable|null 519 | */ 520 | public function getHttpNotFoundCallback() 521 | { 522 | return $this->httpNotFoundCallback; 523 | } 524 | 525 | /** 526 | * Gets the httpMethodNotAllowedCallback property 527 | * 528 | * @return callable|null 529 | */ 530 | public function getHttpMethodNotAllowedCallback() 531 | { 532 | return $this->httpMethodNotAllowedCallback; 533 | } 534 | 535 | /** 536 | * Gets the Error Handler callback 537 | * 538 | * @return callable|null 539 | */ 540 | public function getErrorHandler() 541 | { 542 | return $this->errorHandler; 543 | } 544 | 545 | /** 546 | * Gets an array of all middleware registered for current request 547 | * 548 | * @return array 549 | */ 550 | public function getMiddlewareStack(LuthierRoute $route) 551 | { 552 | $routeMiddleware = $route->getMiddleware() ?? []; 553 | $globalMiddleware = self::$context['middleware']['global']; 554 | return array_merge($globalMiddleware, $routeMiddleware); 555 | } 556 | 557 | /** 558 | * Counts the defined routes 559 | * 560 | * @return int 561 | */ 562 | public function count() 563 | { 564 | return count($this->routes); 565 | } 566 | 567 | /** 568 | * Adds an external routing file. The $router variable will be available to define new routes within that file. 569 | * 570 | * @param string $filename External file 571 | * @param string $prefix Global prefix 572 | * @param array $attributes Global attributes 573 | */ 574 | public function addRoutes(string $filename, string $prefix = '', array $attributes = []) 575 | { 576 | self::$isSandboxed = true; 577 | 578 | $appPath = $this->container->get('APP_PATH'); 579 | 580 | (function($router) use($appPath, $filename, $prefix, $attributes){ 581 | $router->group($prefix, $attributes, function() use($router, $appPath, $filename){ 582 | require($appPath . '/' . $filename . (substr($filename, -4) == '.php' ? '' : '.php') ); 583 | }); 584 | })($this); 585 | 586 | array_pop(self::$context['middleware']['route']); 587 | 588 | self::$isSandboxed = false; 589 | } 590 | } -------------------------------------------------------------------------------- /src/Routing/RouteBuilderInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | interface RouteBuilderInterface 20 | { 21 | 22 | /** 23 | * Returns a Symfony RouteCollection object with all compiled Luthier routes 24 | * 25 | * @return RouteCollection 26 | */ 27 | public function getRoutes(): RouteCollection; 28 | 29 | /** 30 | * Gets a route URL by its name, or throws an exception if an undefined route was requested 31 | * 32 | * @param string $name Route name 33 | * @param array $args Route parameters 34 | * @param bool $absoluteUrl Build an absolute url 35 | * @throws \Exception 36 | * 37 | * @return string 38 | */ 39 | public function getRouteByName(string $name, array $args = [], bool $absoluteUrl = true): string; 40 | } -------------------------------------------------------------------------------- /src/Routing/RouteParam.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class RouteParam 20 | { 21 | 22 | /** 23 | * @var string 24 | */ 25 | private $name; 26 | 27 | /** 28 | * Actual parameter regex 29 | * 30 | * @var string 31 | */ 32 | private $regex; 33 | 34 | /** 35 | * @var string 36 | */ 37 | private $placeholder; 38 | 39 | /** 40 | * Original segment of the parameter 41 | * 42 | * @var string 43 | */ 44 | private $segment; 45 | 46 | /** 47 | * @var bool 48 | */ 49 | private $optional; 50 | 51 | /** 52 | * @var bool 53 | */ 54 | private $sticky; 55 | 56 | /** 57 | * @var mixed 58 | */ 59 | public $value; 60 | 61 | /** 62 | * Parameter segment -> placeholder conversion array 63 | * 64 | * @var string[] 65 | */ 66 | private static $placeholderPatterns = [ 67 | '{num:[a-zA-Z0-9-_]*(\?}|})' => '(:num)', // (:num) route 68 | '{any:[a-zA-Z0-9-_]*(\?}|})' => '(:any)', // (:any) route 69 | '{[a-zA-Z0-9-_]*(\?}|})' => '(:any)' // Everything else 70 | ]; 71 | 72 | /** 73 | * Parameter placeholder -> regex conversion array 74 | * 75 | * @var string[] 76 | */ 77 | private static $placeholderReplacements = [ 78 | '/\(:any\)/' => '[^/]+', 79 | '/\(:num\)/' => '[0-9]+' 80 | ]; 81 | 82 | /** 83 | * Gets the placeholder -> regex conversion array 84 | * 85 | * @return string[] 86 | */ 87 | public static function getPlaceholderReplacements() 88 | { 89 | return self::$placeholderReplacements; 90 | } 91 | 92 | /** 93 | * @param string $segment Route segment 94 | */ 95 | public function __construct(string $segment) 96 | { 97 | $this->segment = $segment; 98 | 99 | $matches = []; 100 | 101 | if (preg_match('/{\((.*)\):[a-zA-Z0-9-_]*(\?}|})/', $segment, $matches)) { 102 | $this->placeholder = $matches[1]; 103 | $this->regex = $matches[1]; 104 | $name = preg_replace('/\((.*)\):/', '', $segment, 1); 105 | } else { 106 | foreach (self::$placeholderPatterns as $regex => $replacement) { 107 | $parsedSegment = preg_replace('/' . $regex . '/', $replacement, $segment); 108 | 109 | if ($segment != $parsedSegment) { 110 | $this->placeholder = $replacement; 111 | $this->regex = preg_replace(array_keys(self::$placeholderReplacements), array_values(self::$placeholderReplacements), $replacement, 1); 112 | $name = preg_replace(['/num:/','/any:/'], '', $segment, 1); 113 | break; 114 | } 115 | } 116 | } 117 | 118 | $this->optional = substr($segment, - 2, 1) == '?'; 119 | $this->name = substr($name, 1, ! $this->optional ? - 1 : - 2); 120 | $this->sticky = substr($this->name, 0, 1) == '_'; 121 | } 122 | 123 | /** 124 | * Gets the segment name 125 | * 126 | * @return string 127 | */ 128 | public function getName() 129 | { 130 | return $this->name; 131 | } 132 | 133 | /** 134 | * Gets the original segment string 135 | * 136 | * @return string 137 | */ 138 | public function getSegment() 139 | { 140 | return $this->segment; 141 | } 142 | 143 | /** 144 | * Gets the segment regex 145 | * 146 | * @return string 147 | */ 148 | public function getRegex() 149 | { 150 | return $this->regex; 151 | } 152 | 153 | /** 154 | * Gets the segment placeholder 155 | * 156 | * @return string 157 | */ 158 | public function getPlaceholder() 159 | { 160 | return $this->placeholder; 161 | } 162 | 163 | /** 164 | * Checks if the segment is optional 165 | * 166 | * @return bool 167 | */ 168 | public function isOptional() 169 | { 170 | return $this->optional; 171 | } 172 | 173 | /** 174 | * Checks if the segment is sticky 175 | * 176 | * @return bool 177 | */ 178 | public function isSticky() 179 | { 180 | return $this->sticky; 181 | } 182 | } -------------------------------------------------------------------------------- /src/Templating/Driver/BladeDriver.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class BladeDriver implements TemplateDriverInterface 26 | { 27 | 28 | /** 29 | * @var ContainerInterface 30 | */ 31 | protected $container; 32 | 33 | /** 34 | * @var \Illuminate\Container\Container 35 | */ 36 | protected $illuminateContainer; 37 | 38 | /** 39 | * @var array 40 | */ 41 | protected $globals = []; 42 | 43 | /** 44 | * @var array 45 | */ 46 | protected $functions = []; 47 | 48 | /** 49 | * @var array 50 | */ 51 | protected $directories = []; 52 | 53 | /** 54 | * @var \Illuminate\View\Factory 55 | */ 56 | protected $blade; 57 | 58 | /** 59 | * @var \Illuminate\View\Engines\CompilerEngine 60 | */ 61 | protected $engine; 62 | 63 | /** 64 | * @var bool 65 | */ 66 | protected $booted = false; 67 | 68 | /** 69 | * @param ContainerInterface $container 70 | */ 71 | public function __construct(ContainerInterface $container) 72 | { 73 | $this->container = $container; 74 | $this->directories[] = $container->get('APP_PATH') . '/' . $container->get('TEMPLATE_DIR'); 75 | } 76 | 77 | private function boot() 78 | { 79 | if ($this->booted) { 80 | return; 81 | } 82 | $this->booted = true; 83 | $config = []; 84 | 85 | $directories = $this->directories; 86 | $cache = ! empty($this->container->get('APP_CACHE')) ? $this->container->get('APP_PATH') . '/' . $this->container->get('APP_CACHE') . '/templates' : false; 87 | 88 | if (! file_exists($cache)) { 89 | mkdir($config['cache'], null, true); 90 | } 91 | 92 | $illuminateContainer = new Container(); 93 | 94 | $illuminateContainer->bindIf('files', function () { 95 | return new Filesystem(); 96 | }, true); 97 | 98 | $illuminateContainer->bindIf('events', function () { 99 | return new Dispatcher(); 100 | }, true); 101 | 102 | $illuminateContainer->bindIf('config', function () use ($directories, $cache) { 103 | return ['view.paths' => $directories,'view.compiled' => $cache]; 104 | }, true); 105 | 106 | (new ViewServiceProvider($illuminateContainer))->register(); 107 | 108 | $this->illuminateContainer = $illuminateContainer; 109 | $this->blade = $this->illuminateContainer['view']; 110 | $this->engine = $this->illuminateContainer->make('view.engine.resolver')->resolve('blade'); 111 | 112 | $instance = &$this; 113 | 114 | // 115 | // "$_" variable 116 | // 117 | // Lambda function used for invoke other registered functions 118 | // for this template engine. 119 | // 120 | $this->globals['_'] = function ($name, ...$args) use ($instance) { 121 | if (isset($instance->functions[$name])) { 122 | return $instance->functions[$name](...$args); 123 | } 124 | throw new \Exception("Call to undefined template function $name()"); 125 | }; 126 | } 127 | 128 | /** 129 | * {@inheritDoc} 130 | * 131 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addFunction() 132 | */ 133 | public function addFunction(string $name, callable $callback, bool $rawHtml = false) 134 | { 135 | $this->functions[$name] = $callback; 136 | } 137 | 138 | /** 139 | * {@inheritDoc} 140 | * 141 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addGlobal() 142 | */ 143 | public function addGlobal(string $name, $value) 144 | { 145 | $this->globals[$name] = $value; 146 | } 147 | 148 | /** 149 | * {@inheritDoc} 150 | * 151 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addFilter() 152 | */ 153 | public function addFilter(string $name, callable $callback, bool $rawHtml = false) 154 | { 155 | $this->addFunction($name, $callback); 156 | } 157 | 158 | /** 159 | * {@inheritDoc} 160 | * 161 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addDirectory() 162 | */ 163 | public function addDirectory(string $dir) 164 | { 165 | $this->directories[] = $dir; 166 | } 167 | 168 | /** 169 | * {@inheritDoc} 170 | * 171 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::render() 172 | */ 173 | public function render(string $template, array $vars = [], bool $return = false) 174 | { 175 | $this->boot(); 176 | 177 | $view = $this->blade->make($template, array_merge($vars, $this->globals), [])->render(); 178 | 179 | if (! $return) { 180 | $this->container->get('response')->write($view); 181 | } 182 | 183 | return $view; 184 | } 185 | } -------------------------------------------------------------------------------- /src/Templating/Driver/PlainPhpDriver.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class PlainPhpDriver implements TemplateDriverInterface 22 | { 23 | 24 | /** 25 | * @var ContainerInterface 26 | */ 27 | protected $container; 28 | 29 | /** 30 | * @var array 31 | */ 32 | protected $globals = []; 33 | 34 | /** 35 | * @var array 36 | */ 37 | protected $functions = []; 38 | 39 | /** 40 | * @var array 41 | */ 42 | protected $directories = []; 43 | 44 | /** 45 | * @var array 46 | */ 47 | protected $blocks = []; 48 | 49 | /** 50 | * @var mixed 51 | */ 52 | protected $parentBlock; 53 | 54 | /** 55 | * @var array 56 | */ 57 | protected $extending = []; 58 | 59 | /** 60 | * @var self 61 | */ 62 | protected static $instance; 63 | 64 | public static function __callStatic($method, $args) 65 | { 66 | if (isset(self::$instance->functions[$method])) { 67 | 68 | return call_user_func_array(self::$instance->functions[$method], $args); 69 | 70 | } else if ($method == 'extends') { 71 | 72 | if (count($args) < 1) { 73 | throw new \InvalidArgumentException("The 'extends' function expects at least 2 arguments, " . count($args) . " provided"); 74 | } 75 | 76 | $template = $args[0]; 77 | $vars = $args[1] ?? []; 78 | 79 | self::$instance->extending[] = [$template,$vars]; 80 | 81 | } else if ($method == 'block') { 82 | 83 | if (count($args) < 1) { 84 | throw new \InvalidArgumentException("The 'block' function expects at least 2 arguments, " . count($args) . " provided"); 85 | } 86 | 87 | $name = $args[0]; 88 | $content = $args[1] ?? null; 89 | 90 | if (! isset(self::$instance->blocks[$name])) { 91 | self::$instance->blocks[$name] = $content; 92 | } else { 93 | if (is_callable(self::$instance->blocks[$name])) { 94 | return call_user_func(self::$instance->blocks[$name]); 95 | } 96 | 97 | return self::$instance->blocks[$name]; 98 | } 99 | 100 | } else { 101 | throw new \BadMethodCallException('Undefined "' . $method . '" template function'); 102 | } 103 | } 104 | 105 | /** 106 | * @param ContainerInterface $container 107 | */ 108 | public function __construct(ContainerInterface $container) 109 | { 110 | $this->container = $container; 111 | $this->directories[] = $container->get('APP_PATH') . '/' . $container->get('TEMPLATE_DIR'); 112 | $this->registerFunctions(); 113 | self::$instance = &$this; 114 | } 115 | 116 | private function registerFunctions() 117 | { 118 | $instance = &$this; 119 | 120 | // 121 | // "$_" variable 122 | // 123 | // Lambda function used for invoke other registered functions 124 | // for this template engine. 125 | // 126 | $this->globals['_'] = function ($name, ...$args) use ($instance) { 127 | if (isset($instance->functions[$name])) { 128 | return $instance->functions[$name](...$args); 129 | } 130 | throw new \Exception("Call to undefined template function $name()"); 131 | }; 132 | 133 | // 134 | // "$_b" variable 135 | // 136 | // Lambda function used for define and set blocks within the templates 137 | // 138 | $this->globals['_b'] = function ($name, $content = null) use ($instance) { 139 | if (! isset($instance->blocks[$name])) { 140 | if ($content !== null) { 141 | return $instance->blocks[$name] = $content; 142 | } 143 | } else { 144 | if (is_callable($instance->blocks[$name])) { 145 | return call_user_func($instance->blocks[$name]); 146 | } 147 | return $instance->blocks[$name]; 148 | } 149 | }; 150 | 151 | // 152 | // "$_e" variable 153 | // 154 | // Lambda function used to extend another templates 155 | // 156 | $this->globals['_e'] = function ($template, $vars = []) use ($instance) { 157 | $instance->extending[] = [$template,$vars]; 158 | }; 159 | } 160 | 161 | /** 162 | * {@inheritDoc} 163 | * 164 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::render() 165 | */ 166 | public function render(string $template, array $vars = [], bool $return = false) 167 | { 168 | $filename = null; 169 | 170 | foreach ($this->directories as $dir) { 171 | $match = $dir . '/' . $template . (! substr($template, - 4) != '.php' ? '.php' : ''); 172 | if (file_exists($match)) { 173 | $filename = $match; 174 | break; 175 | } 176 | } 177 | 178 | if ($filename === null) { 179 | throw new \Exception("Unable to find template file '$template' (Looked at " . implode(', ', $this->directories) . '")'); 180 | } 181 | 182 | extract(array_merge($vars, $this->globals)); 183 | 184 | ob_start(); 185 | require $filename; 186 | $view = ob_get_clean(); 187 | 188 | $extending = array_shift($this->extending); 189 | 190 | if (! empty($extending)) { 191 | [$template,$vars] = $extending; 192 | return $this->render($template, $vars, $return); 193 | } 194 | 195 | if ($return) { 196 | return $view; 197 | } 198 | 199 | $this->container->get('response')->write($view); 200 | } 201 | 202 | /** 203 | * {@inheritDoc} 204 | * 205 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addFunction() 206 | */ 207 | public function addFunction(string $name, callable $callback, bool $rawHtml = false) 208 | { 209 | $this->functions[$name] = $callback; 210 | } 211 | 212 | /** 213 | * {@inheritDoc} 214 | * 215 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addFilter() 216 | */ 217 | public function addFilter(string $name, callable $callback, bool $rawHtml = false) 218 | { 219 | $this->addFunction($name, $callback); 220 | } 221 | 222 | /** 223 | * {@inheritDoc} 224 | * 225 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addGlobal() 226 | */ 227 | public function addGlobal(string $name, $value) 228 | { 229 | $this->globals[$name] = $value; 230 | } 231 | 232 | /** 233 | * {@inheritDoc} 234 | * 235 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addDirectory() 236 | */ 237 | public function addDirectory(string $dir) 238 | { 239 | $this->directories[] = $dir; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/Templating/Driver/TemplateDriverInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | interface TemplateDriverInterface 20 | { 21 | 22 | /** 23 | * Renders a template 24 | * 25 | * @param string $template 26 | * @param array $vars 27 | * @param bool $return 28 | * 29 | * @return Response 30 | */ 31 | public function render(string $template, array $vars = [], bool $return = false); 32 | 33 | /** 34 | * Registers a global function 35 | * 36 | * @param string $name Function name/alias 37 | * @param callable $callback Function callback 38 | * @param bool $rawHtml Set if this function will return raw (unescaped HTML output) 39 | * 40 | * @return void 41 | */ 42 | public function addFunction(string $name, callable $callback, bool $rawHtml = false); 43 | 44 | /** 45 | * Registers a global filter (If the template engine does not support this feature, must 46 | * be emulated) 47 | * 48 | * @param string $name 49 | * @param callable $callback 50 | * @param bool $rawHtml 51 | * 52 | * @return void 53 | */ 54 | public function addFilter(string $name, callable $callback, bool $rawHtml = false); 55 | 56 | /** 57 | * Registers a global variable 58 | * 59 | * @param string $name 60 | * @param mixed $value 61 | */ 62 | public function addGlobal(string $name, $value); 63 | 64 | /** 65 | * Adds a directory to search templates 66 | * 67 | * @param string $dir 68 | */ 69 | public function addDirectory(string $dir); 70 | } -------------------------------------------------------------------------------- /src/Templating/Driver/TwigDriver.php: -------------------------------------------------------------------------------- 1 | container = $container; 44 | $this->loader = new \Twig_Loader_Filesystem([$container->get('APP_PATH') . '/' . $container->get('TEMPLATE_DIR')]); 45 | 46 | $config = []; 47 | $config['cache'] = ! empty($container->get('APP_CACHE')) ? $container->get('APP_PATH') . '/' . $container->get('APP_CACHE') . '/templates' : false; 48 | $config['debug'] = $container->get('APP_ENV') === 'development'; 49 | 50 | $this->twig = new \Twig_Environment($this->loader, $config); 51 | } 52 | 53 | /** 54 | * {@inheritDoc} 55 | * 56 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addFunction() 57 | */ 58 | public function addFunction(string $name, callable $callback, bool $rawHtml = false) 59 | { 60 | $this->twig->addFunction(new \Twig_Function($name, $callback, ! $rawHtml ? [] : ['is_safe' => ['html']])); 61 | } 62 | 63 | /** 64 | * {@inheritDoc} 65 | * 66 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addGlobal() 67 | */ 68 | public function addGlobal(string $name, $value) 69 | { 70 | $this->twig->addGlobal($name, $value); 71 | } 72 | 73 | /** 74 | * {@inheritDoc} 75 | * 76 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addFilter() 77 | */ 78 | public function addFilter(string $name, callable $callback, bool $rawHtml = false) 79 | { 80 | $this->twig->addFilter(new \Twig_Filter($name, $callback, ! $rawHtml ? [] : ['is_safe' => ['html']])); 81 | } 82 | 83 | /** 84 | * {@inheritDoc} 85 | * 86 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::addDirectory() 87 | */ 88 | public function addDirectory(string $dir) 89 | { 90 | $this->loader->addPath($dir); 91 | } 92 | 93 | /** 94 | * {@inheritDoc} 95 | * 96 | * @see \Luthier\Templating\Driver\TemplateDriverInterface::render() 97 | */ 98 | public function render(string $template, array $vars = [], bool $return = false) 99 | { 100 | if (! substr($template, - 10) != '.html.twig') { 101 | $template .= '.html.twig'; 102 | } 103 | 104 | $output = $this->twig->render($template, $vars); 105 | 106 | if (! $return) { 107 | $this->container->get('response')->write($output); 108 | } 109 | 110 | return $output; 111 | } 112 | } -------------------------------------------------------------------------------- /src/Templating/Template.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class Template 24 | { 25 | 26 | /** 27 | * @var TemplateDriverInterface 28 | */ 29 | protected $driver; 30 | 31 | /** 32 | * @var ContainerInterface 33 | */ 34 | private $container; 35 | 36 | /** 37 | * @param ContainerInterface $container 38 | */ 39 | public function __construct(ContainerInterface $container) 40 | { 41 | $this->container = $container; 42 | 43 | $driver = $container->get('TEMPLATE_DRIVER') ?? 'default'; 44 | 45 | // PLEASE NOTE: Only the (default) Plain PHP driver works out-of-the-box 46 | // with Luthier Framework. In order to use the other drivers you 47 | // MUST download the respective template engine with Composer. 48 | $defaultDrivers = ['default' => \Luthier\Templating\Driver\PlainPhpDriver::class,'twig' => \Luthier\Templating\Driver\TwigDriver::class,'blade' => \Luthier\Templating\Driver\BladeDriver::class]; 49 | 50 | if (isset($defaultDrivers[$driver])) { 51 | $driver = $defaultDrivers[$driver]; 52 | } 53 | 54 | $this->driver = new $driver($container); 55 | 56 | $this->configure(); 57 | } 58 | 59 | /** 60 | * Configures the template engine, providing a consistent behavior across engines 61 | */ 62 | private function configure() 63 | { 64 | if (! ($this->driver instanceof TemplateDriverInterface)) { 65 | throw new \Exception("The " . get_class($this->driver) . '" MUST implement the ' . TemplateDriverInterface::class . ' interface'); 66 | } 67 | 68 | // Globals variables 69 | $this->driver->addGlobal('request', $this->container->get('request')); 70 | $this->driver->addGlobal('app', $this->container); 71 | 72 | // Built-in functions 73 | // 74 | // (This is a compatibility layer between plain PHP and some compiled 75 | // php template engines such Twig) 76 | $container = $this->container; 77 | 78 | $this->driver->addFunction('route', function ($name, $args = []) { 79 | return call_user_func_array('route', [$name,$args]); 80 | }); 81 | 82 | $this->driver->addFunction('url', function ($url = '') use ($container) { 83 | return call_user_func_array([$this->container->get('request'),'baseUrl'], [$url]); 84 | }); 85 | 86 | $this->driver->addFunction('validation_errors', function($field = null) use($container){ 87 | return call_user_func_array([$container->get('validator'), 'getValidationErrors'], [$field]); 88 | }); 89 | 90 | $this->driver->addFunction('csrf_field', function() use($container){ 91 | $tokenName = $container->get('security')->getCsrfTokenName(); 92 | $tokenHash = $container->get('security')->getCsrfTokenHash(); 93 | if (empty($tokenName) || empty($tokenHash)) { 94 | return; 95 | } 96 | return ''; 97 | }, true); 98 | } 99 | 100 | public function __call($method, $args) 101 | { 102 | if (method_exists($this->driver, $method)) { 103 | return call_user_func_array([$this->driver,$method], $args); 104 | } 105 | throw new \BadMethodCallException('Call to undefined method ' . get_class($this->driver) . '::' . $method); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/UtilsTrait.php: -------------------------------------------------------------------------------- 1 | isXmlHttpRequest()) { 48 | return new JsonResponse(['error' => $message], $status); 49 | } else { 50 | ob_start(); 51 | require __DIR__ . '/Resources/Views/Error.php'; 52 | $responseBody = ob_get_clean(); 53 | return new Response($responseBody, $status); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /src/Validator/Exception/ValidationConstraintException.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class ValidationConstraintException extends \Exception 21 | { 22 | } -------------------------------------------------------------------------------- /src/Validator/Validator.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | class Validator 28 | { 29 | const OPERATORS = ['>=', '<=', '>', '<', '===', '==', '!==', '!=']; 30 | 31 | /** 32 | * @var ContainerInterface 33 | */ 34 | protected $container; 35 | 36 | /** 37 | * @var \Symfony\Component\Validator\Validator\ValidatorInterface 38 | */ 39 | protected $validator; 40 | 41 | /** 42 | * @var \Symfony\Component\Translation\Translator 43 | */ 44 | protected $translator; 45 | 46 | /** 47 | * @var array 48 | */ 49 | protected $rules = []; 50 | 51 | /** 52 | * @var array 53 | */ 54 | protected $validationErrors = []; 55 | 56 | /** 57 | * @param ContainerInterface $container 58 | */ 59 | public function __construct(ContainerInterface $container) 60 | { 61 | $this->container = $container; 62 | $locale = $this->container->get('APP_LANG'); 63 | $translator = $this->container->get('translator'); 64 | $translator->addLoader('xlf', new XliffFileLoader()); 65 | $translator->addResource('xlf', realpath( __DIR__ . '/../../vendor/symfony/validator/Resources/translations/validators.' . $locale . '.xlf'), $locale, "validators"); 66 | $this->translator = $translator; 67 | 68 | $this->reset(true); 69 | } 70 | 71 | /** 72 | * Resets the validator 73 | * 74 | * @param bool $preserveErrors Preserve the validation errors? 75 | * 76 | * @return void 77 | */ 78 | public function reset(bool $preserveErrors = false) 79 | { 80 | $validator = Validation::createValidatorBuilder(); 81 | $validator->setTranslator($this->translator); 82 | $validator->setTranslationDomain('validators'); 83 | 84 | $this->validator = $validator->getValidator(); 85 | $this->rules = []; 86 | 87 | if(!$preserveErrors) { 88 | $this->validationErrors = []; 89 | } 90 | } 91 | 92 | /** 93 | * Adds a new validation rule to the validator 94 | * 95 | * @param string $field 96 | * @param string|array $constraints 97 | * @param string[] $messages 98 | * 99 | * @return self 100 | */ 101 | public function rule(string $field, $constraints, array $messages = []) 102 | { 103 | if(!is_string($constraints) && !is_array($constraints)){ 104 | throw new \InvalidArgumentException("Validation constraints must be declared as a string or an array"); 105 | } 106 | 107 | $this->rules[] = [ 108 | $field, 109 | $constraints, 110 | $messages 111 | ]; 112 | return $this; 113 | } 114 | 115 | /** 116 | * @return \Symfony\Component\Validator\Validator\ValidatorInterface 117 | */ 118 | public function getValidator() 119 | { 120 | return $this->validator; 121 | } 122 | 123 | /** 124 | * Runs the validator 125 | * 126 | * Returns TRUE if no constraints violations occurred, FALSE instead 127 | * 128 | * @param array $data Data to be validated 129 | * @param array $constraints Constraints 130 | * @param bool $bailAll Stop on first constraint error on each field? 131 | * 132 | * @return bool 133 | */ 134 | public function validate($data = [], array $constraints = [], bool $bailAll = false) 135 | { 136 | if(!is_array($data) && (!($data instanceof Request))){ 137 | throw new \InvalidArgumentException('Validation data must be an array or an instance of ' . Request::class); 138 | } 139 | 140 | if($data instanceof Request){ 141 | $data = $data->getRequest()->request->all(); 142 | } 143 | 144 | $validationErrors = []; 145 | 146 | if (!empty($constraints)) { 147 | foreach ($constraints as $field => $validation) { 148 | if (is_array($validation)) { 149 | $rules = $validation[0]; 150 | $messages = $validation[1] ?? []; 151 | } else { 152 | $rules = $validation; 153 | $messages = []; 154 | } 155 | $this->rule($field,$rules,$messages); 156 | } 157 | } 158 | 159 | foreach ($this->rules as [ 160 | $field, 161 | $rules, 162 | $messages 163 | ]) { 164 | $this->messages = $messages; 165 | $required = false; 166 | $matches = null; 167 | $nullable = false; 168 | $sfRules = []; 169 | $bail = $bailAll; 170 | $error = false; 171 | 172 | if (is_string($rules)) { 173 | $rules = explode('|', $rules); 174 | } 175 | 176 | foreach ($rules as $name => $params) { 177 | if(is_string($params) && is_int($name)){ 178 | // String based constraint definition: 179 | $name = $params; 180 | if (count(explode(':', $name)) > 1) { 181 | [ 182 | $name, 183 | $params 184 | ] = explode(':', $name); 185 | $params = explode(',', $params); 186 | } else { 187 | 188 | $isOperator = false; 189 | 190 | foreach(self::OPERATORS as $operator){ 191 | if(count(explode($operator, $name)) > 1){ 192 | $params = explode($operator, $name); 193 | $params = [ $params[1] ]; 194 | $name = $operator; 195 | $isOperator = true; 196 | break; 197 | } 198 | } 199 | 200 | if(!$isOperator){ 201 | $params = []; 202 | } 203 | } 204 | } else { 205 | // Array based constraint definition: 206 | if (is_scalar($params) || is_array($params)) { 207 | if (is_scalar($params)) { 208 | $params = [$params]; 209 | } 210 | } else { 211 | $params = []; 212 | } 213 | } 214 | 215 | // Special 'required' pseudo-constraint 216 | if ($name === 'required') { 217 | $required = true; 218 | continue; 219 | } 220 | 221 | // Special 'matches' pseudo-constraint 222 | if ($name === 'matches' && isset($params[0]) && is_string($params[0])){ 223 | $matches = $params[0]; 224 | continue; 225 | } 226 | 227 | // Is a nullable field? 228 | if ($name === 'nullable') { 229 | $nullable = true; 230 | continue; 231 | } 232 | 233 | // The validation must stop at first error? 234 | if ($name === 'bail') { 235 | $bail = true; 236 | continue; 237 | } 238 | 239 | // Get the actual Symfony Constraint object 240 | $sfRules[] = $this->getSfConstraint($name, $params, $messages[$name] ?? null); 241 | } 242 | 243 | if ($required && !$nullable) { 244 | $sfRules[] = $this->getSfConstraint('not_blank', [], $messages[$name] ?? null); 245 | $sfRules[] = $this->getSfConstraint('not_null', [], $messages[$name] ?? null); 246 | } 247 | 248 | $violations = new ConstraintViolationList(); 249 | 250 | if ($required && ! isset($data[$field])) { 251 | $error = true; 252 | $errorMessage = $messages['required'] ?? "This field is missing."; 253 | $translatedError = $this->translator->trans($errorMessage, [], "validators"); 254 | $violations->add(new ConstraintViolation( 255 | $translatedError, 256 | $translatedError, 257 | [], 258 | null, 259 | null, 260 | null, 261 | null, 262 | null, 263 | new Constraints\Required()) 264 | ); 265 | } 266 | 267 | if ($matches !== null && (!isset($data[$matches]) || !isset($data[$field]) || $data[$matches] != $data[$field]) && (!$bail || ($bail && !$error))) { 268 | $error = true; 269 | $errorMessage = $messages['matches'] ?? "This value should be equal to {{ compared_value }}."; 270 | $violations->add(new ConstraintViolation( 271 | $this->translator->trans($errorMessage, ["{{ compared_value }}" => '[' . $matches . ']'], "validators"), 272 | $this->translator->trans($errorMessage, [], "validators"), 273 | [ 274 | "{{ value }}" => $data[$field] ?? null, 275 | "{{ compared_value }}" => $data[$matches] ?? null, 276 | "{{ compared_value_type }}" => "string" 277 | ], 278 | $data[$field] ?? null, 279 | null, 280 | $data[$field] ?? null, 281 | null, 282 | null, 283 | new Constraints\EqualTo($matches)) 284 | ); 285 | } 286 | 287 | if(!$bail || ($bail && !$error)) 288 | { 289 | foreach ($sfRules as $sfRule) { 290 | $violation = $this->validator->validate($data[$field] ?? null, $sfRule); 291 | $violations->addAll($violation); 292 | if (count($violation) > 0 && $bail) { 293 | break; 294 | } 295 | } 296 | } 297 | 298 | foreach ($violations as $violation) { 299 | $validationErrors[$field][] = $violation->getMessage(); 300 | } 301 | } 302 | 303 | $this->validationErrors = $validationErrors; 304 | 305 | return empty($validationErrors); 306 | } 307 | 308 | /** 309 | * Same as validate(), but throws an exception if a constraint violation 310 | * occurs 311 | * 312 | * @param array $data Data to be validated 313 | * @param array $constraints Constraints 314 | * @param bool $bailAll Stop on first constraint error on each field? 315 | * 316 | * @throws \Exception 317 | * 318 | * @return void 319 | */ 320 | public function validateOrFail($data = [], array $constraints = [], bool $bailAll = false) 321 | { 322 | if($this->validate($data, $constraints, $bailAll) !== true){ 323 | throw new Exception\ValidationConstraintException("One or more constraint violations occurred with the submited data"); 324 | } 325 | } 326 | 327 | /** 328 | * Gets the validation error list 329 | * 330 | * @param string $field Specific field name 331 | * 332 | * @return array 333 | */ 334 | public function getValidationErrors(string $field = null) 335 | { 336 | return $field !== null 337 | ? $this->validationErrors[$field] ?? [] 338 | : $this->validationErrors; 339 | } 340 | 341 | /** 342 | * Sets the validation errors list 343 | * 344 | * @param array $errors 345 | */ 346 | public function setValidationErrors(array $errors) 347 | { 348 | $this->validationErrors = $errors; 349 | } 350 | 351 | /** 352 | * Compiles the given rule and parameters to a Symfony validator's constraint 353 | * 354 | * @param string $name 355 | * @param array $params 356 | * @param string $message 357 | * 358 | * @return \Symfony\Component\Validator\Constraint 359 | */ 360 | private function getSfConstraint(string $name, array $params, ?string $message) 361 | { 362 | $constraint = null; 363 | 364 | // Constraint instances 365 | switch ($name) { 366 | /* 367 | * (Not) Blank validation 368 | */ 369 | case 'not_blank': 370 | $constraint = new Constraints\NotBlank(); 371 | break; 372 | /* 373 | * (Not) Null validation 374 | */ 375 | case 'not_null': 376 | $constraint = new Constraints\NotNull(); 377 | break; 378 | /* 379 | * Regex validation 380 | */ 381 | case 'regex': 382 | $constraint = new Constraints\Regex([ 383 | 'pattern' => $params[0] 384 | ]); 385 | break; 386 | /* 387 | * String lenght validation (min) 388 | */ 389 | case 'min_length': 390 | $constraint = new Constraints\Length([ 391 | 'min' => (int) $params[0] 392 | ]); 393 | break; 394 | /* 395 | * String lenght validation (max) 396 | */ 397 | case 'max_length': 398 | $constraint = new Constraints\Length([ 399 | 'max' => (int) $params[0] 400 | ]); 401 | break; 402 | /* 403 | * String collection ('enum' like) validation 404 | */ 405 | case 'in_list': 406 | $constraint = new Constraints\Choice($params); 407 | break; 408 | /* 409 | * Email validation 410 | */ 411 | case 'email': 412 | $constraint = new Constraints\Email(); 413 | break; 414 | /* 415 | * Numeric integer validation 416 | */ 417 | case 'integer': 418 | $constraint = new Constraints\Regex([ 419 | 'pattern' => '/^[\-+]?[0-9]+$/' 420 | ]); 421 | break; 422 | /* 423 | * Numeric decimal validation 424 | */ 425 | case 'decimal': 426 | $constraint = new Constraints\Regex([ 427 | 'pattern' => '/^[\-+]?[0-9]*[\.,][0-9]+$/' 428 | ]); 429 | break; 430 | /* 431 | * Numeric validation (both integer and decimal) 432 | */ 433 | case 'number': 434 | $constraint = new Constraints\Regex([ 435 | 'pattern' => '/^[\-+]?([0-9]+|[0-9]*[\.,][0-9]+)$/' 436 | ]); 437 | break; 438 | /* 439 | * Numeric range validation 440 | */ 441 | case 'in_range': 442 | $constraint = new Constraints\Range([ 443 | 'min' => (int) $params[0], 444 | 'max' => (int) $params[1], 445 | ]); 446 | break; 447 | /* 448 | * Numeric Greater Than or Equal (>=) validation 449 | */ 450 | case 'greater_than_equal_to': 451 | case 'gte': 452 | case '>=': 453 | $constraint = new Constraints\GreaterThanOrEqual($params[0]); 454 | break; 455 | /* 456 | * Numeric Greater Than (>) validation 457 | */ 458 | case 'greater_than': 459 | case 'gt': 460 | case '>': 461 | $constraint = new Constraints\GreaterThan($params[0]); 462 | break; 463 | /* 464 | * Numeric Lesser Than or Equal (<=) validation 465 | */ 466 | case 'less_than_equal_to': 467 | case 'lte': 468 | case '<=': 469 | $constraint = new Constraints\LessThanOrEqual($params[0]); 470 | break; 471 | /* 472 | * Numeric Lesser Than (<) validation 473 | */ 474 | case 'less_than': 475 | case 'lt': 476 | case '<': 477 | $constraint = new Constraints\LessThan($params[0]); 478 | break; 479 | /* 480 | * String identical (===) validation 481 | */ 482 | case 'identical': 483 | case '===': 484 | $constraint = new Constraints\IdenticalTo($params[0]); 485 | break; 486 | /* 487 | * String equal (==) validation 488 | */ 489 | case 'equal': 490 | case '==': 491 | $constraint = new Constraints\EqualTo($params[0]); 492 | break; 493 | /* 494 | * String not identical (!==) validation 495 | */ 496 | case 'not_identical': 497 | case '!==': 498 | $constraint = new Constraints\NotIdenticalTo($params[0]); 499 | break; 500 | /* 501 | * String not equal (!=) validation 502 | */ 503 | case 'not_equal': 504 | case '!=': 505 | $constraint = new Constraints\NotEqualTo($params[0]); 506 | break; 507 | default: 508 | throw new \Exception('Unknown validation rule "' . $name . '"'); 509 | } 510 | 511 | // Translated error messages 512 | switch ($name) { 513 | case 'range': 514 | $translatedMessage = $this->translator->trans( 515 | !empty($message) ? $message : $constraint->minMessage, 516 | [], 517 | "validators" 518 | ); 519 | $constraint->minMessage = $translatedMessage; 520 | $constraint->maxMessage = $translatedMessage; 521 | break; 522 | case 'min_length': 523 | $constraint->minMessage = $this->translator->trans( 524 | !empty($message) ? $message : $constraint->minMessage, 525 | [], 526 | "validators" 527 | ); 528 | break; 529 | case 'max_length': 530 | $constraint->maxMessage = $this->translator->trans( 531 | !empty($message) ? $message : $constraint->maxMessage, 532 | [], 533 | "validators" 534 | ); 535 | break; 536 | default: 537 | $constraint->message = $this->translator->trans( 538 | !empty($message) ? $message : $constraint->message, 539 | [], 540 | "validators" 541 | ); 542 | } 543 | 544 | return $constraint; 545 | } 546 | } -------------------------------------------------------------------------------- /tests/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | 'development']); 13 | $envDirInstance = new Configuration(null, __DIR__ . '/assets'); 14 | 15 | $this->assertSame( 16 | array_keys(Configuration::getDefaultConfig()), 17 | array_keys($defaultInstance->parse()) 18 | ); 19 | 20 | $this->assertSame( 21 | array_keys(Configuration::getDefaultConfig()), 22 | array_keys($configArrayInstance->parse()) 23 | ); 24 | 25 | $this->assertSame( 26 | array_keys(Configuration::getDefaultConfig()), 27 | array_keys($envDirInstance->parse()) 28 | ); 29 | } 30 | 31 | public function testEnvDirNullValues() 32 | { 33 | $instance = new Configuration(null, __DIR__ . '/assets'); 34 | foreach(Configuration::getDefaultConfig() as $name => $default) 35 | { 36 | $this->assertSame($default, $instance->parse()[$name]); 37 | } 38 | } 39 | 40 | public function testConfigurationPrecedence() 41 | { 42 | $instance = new Configuration(['APP_ENV' => 'production'], __DIR__ . '/assets'); 43 | $this->assertSame($instance->parse()['APP_ENV'], 'development'); 44 | } 45 | 46 | public function testInexistentEnvFileException() 47 | { 48 | $this->expectException(\Exception::class); 49 | $instance = new Configuration(null, __DIR__ . '/inexistent'); 50 | $instance->parse(); 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /tests/ContainerTest.php: -------------------------------------------------------------------------------- 1 | service('router', \Luthier\Routing\RouteBuilder::class); 22 | 23 | foreach([$defaultInstance, $customInstance] as $container) 24 | { 25 | foreach(Container::getDefaultContainer() as $name => $service) 26 | { 27 | if(!$container->has($name)) 28 | { 29 | $container->service($name, $service); 30 | } 31 | } 32 | } 33 | 34 | $this->assertSame( 35 | array_keys(Container::getDefaultContainer()), 36 | $defaultInstance->getServices() 37 | ); 38 | 39 | $this->assertSame( 40 | array_keys(Container::getDefaultContainer()), 41 | $customInstance->getServices() 42 | ); 43 | } 44 | } -------------------------------------------------------------------------------- /tests/FrameworkTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Routing\Route::class, $instance->__call($verb, ['foo', function(){}])); 26 | } 27 | 28 | // Multiple HTTP verb route definition 29 | $this->assertInstanceOf(Routing\Route::class, $instance->__call('match', [[], 'foo', function(){}])); 30 | 31 | // Route group 32 | foreach( 33 | [ 34 | /* Short group syntax */ 35 | ['prefix', function(){}], 36 | /* Extended group syntax */ 37 | ['prefix', ['middleware' => [], 'namespace' => ''], function(){} ], 38 | ['prefix', ['middleware' => []], function(){} ], 39 | ['prefix', ['namespace' => ''], function(){} ], 40 | ] 41 | as $syntax) 42 | { 43 | $this->assertNull($instance->__call('group', $syntax)); 44 | } 45 | 46 | // Route middleware 47 | foreach( 48 | [ 49 | 'foo', /* Running a middleware alias */ 50 | function(){}, /* Running a middleware closure */ 51 | ['foo','bar','baz'], /* Running an array of middleware aliases */ 52 | [ 53 | function(){}, 54 | function(){}, 55 | function(){} 56 | ], /* Running an array of middleware closures */ 57 | ['foo', function(){}], /* Running an array of middleware closures/aliases */ 58 | ['foo' => 'bar' ], /* Defining a middleware alias of a callable */ 59 | ['foo' => function(){} ], /* Defining a middleware alias of a closure */ 60 | ] 61 | as $syntax 62 | ) 63 | { 64 | $this->assertNull($instance->__call('middleware', [$syntax])); 65 | } 66 | } 67 | } 68 | 69 | -------------------------------------------------------------------------------- /tests/assets/.env: -------------------------------------------------------------------------------- 1 | # .env 2 | APP_ENV=development 3 | APP_NAME=Luthier 4 | APP_URL= 5 | APP_KEY= 6 | --------------------------------------------------------------------------------