From 35a6e4266f8ee9da5473ff50bbe9fe727f894117 Mon Sep 17 00:00:00 2001 From: Fauvet Matthis Date: Thu, 27 Jun 2024 10:07:19 +0200 Subject: [PATCH] Le commit un peu tardif de l'api --- api/CONTRIBUTING.md | 58 ++ api/LICENSE | 21 + api/README.md | 59 ++ api/composer.json | Bin 0 -> 31204 bytes api/flight/Engine.php | 942 ++++++++++++++++++++++ api/flight/Flight.php | 146 ++++ api/flight/autoload.php | 10 + api/flight/commands/ControllerCommand.php | 91 +++ api/flight/commands/RouteCommand.php | 126 +++ api/flight/core/Dispatcher.php | 504 ++++++++++++ api/flight/core/Loader.php | 241 ++++++ api/flight/database/PdoWrapper.php | 150 ++++ api/flight/net/Request.php | 417 ++++++++++ api/flight/net/Response.php | 473 +++++++++++ api/flight/net/Route.php | 266 ++++++ api/flight/net/Router.php | 330 ++++++++ api/flight/template/View.php | 203 +++++ api/flight/util/Collection.php | 223 +++++ api/flight/util/ReturnTypeWillChange.php | 8 + api/index.php | 79 ++ api/model/model.php | 131 +++ api/phpcs.xml.dist | 53 ++ 22 files changed, 4531 insertions(+) create mode 100644 api/CONTRIBUTING.md create mode 100644 api/LICENSE create mode 100644 api/README.md create mode 100644 api/composer.json create mode 100644 api/flight/Engine.php create mode 100644 api/flight/Flight.php create mode 100644 api/flight/autoload.php create mode 100644 api/flight/commands/ControllerCommand.php create mode 100644 api/flight/commands/RouteCommand.php create mode 100644 api/flight/core/Dispatcher.php create mode 100644 api/flight/core/Loader.php create mode 100644 api/flight/database/PdoWrapper.php create mode 100644 api/flight/net/Request.php create mode 100644 api/flight/net/Response.php create mode 100644 api/flight/net/Route.php create mode 100644 api/flight/net/Router.php create mode 100644 api/flight/template/View.php create mode 100644 api/flight/util/Collection.php create mode 100644 api/flight/util/ReturnTypeWillChange.php create mode 100644 api/index.php create mode 100644 api/model/model.php create mode 100644 api/phpcs.xml.dist diff --git a/api/CONTRIBUTING.md b/api/CONTRIBUTING.md new file mode 100644 index 0000000..4b1af6d --- /dev/null +++ b/api/CONTRIBUTING.md @@ -0,0 +1,58 @@ +## Contributing to the Flight Framework + +Thanks for being willing to contribute to the Flight! The goal of Flight is to keep the implementation of things simple and free of outside dependencies. +You should only bring in the depedencies you want in your project right? Right. + +### Overarching Guidelines + +Flight aims to be simple and fast. Anything that compromises either of those two things will be heavily scrutinized and/or rejected. Other things to consider when making a contribution: + +* **Dependencies** - We strive to be dependency free in Flight. Yes even polyfills, yes even `Interface` only repos like `psr/container`. The fewer dependencies, the fewer your exposed attack vectors. + +* **Coding Standards** - We use PSR1 coding standards enforced by PHPCS. Some standards that either need additional configuration or need to be manually done are: + * PHPStan is at level 6. + * `===` instead of truthy or falsey statements like `==` or `!is_array()`. + +* **PHP 7.4 Focused** - We do not make PHP 8+ focused enhancements on the framework as the focus is maintaining PHP 7.4. + +* **Core functionality vs Plugin** - Have a conversation with us in the [chatroom](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host) to know if your idea is worth makes sense in the framework or in a plugin. + +* **Testing** - Until automated testing is put into place, any PRs must pass unit testing in PHP 7.4 and PHP 8.2+. Additionally you need to run `composer test-server` and `composer test-server-v2` and ensure all the header links work correctly. + +#### **Did you find a bug?** + +* **Do not open up a GitHub issue if the bug is a security vulnerability**. Instead contact maintainers directly via email to safely pass in the information related to the security vuln. + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/flightphp/core/issues). + +* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/flightphp/core/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. + +#### **Did you write a patch that fixes a bug?** + +* Open a new GitHub pull request with the patch. + +* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. + +#### **Did you fix whitespace, format code, or make a purely cosmetic patch?** + +Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of Flight will generally not be accepted. + +#### **Do you intend to add a new feature or change an existing one?** + +* Hop into the [chatroom](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host) for Flight and let's have a conversation about the feature you want to add. It could be amazing, or it might make more sense as an extension/plugin. If you create a PR without having a conversation with maintainers, it likely will be closed without review. + +* Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. + +#### **Do you have questions about the source code?** + +* Ask any question about how to use Flight in the in the [Flight Matrix chat room](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host). + +#### **Do you want to contribute to the Flight documentation?** + +* Please see the [Flight Documentation repo on GitHub](https://github.com/flightphp/docs). + +Flight is a volunteer effort. We encourage you to pitch in and join! + +Thanks! :heart: :heart: :heart: + +Flight Team diff --git a/api/LICENSE b/api/LICENSE new file mode 100644 index 0000000..cfbe851 --- /dev/null +++ b/api/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2011 Mike Cao + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..a749fdb --- /dev/null +++ b/api/README.md @@ -0,0 +1,59 @@ +[![Version](http://poser.pugx.org/flightphp/core/version)](https://packagist.org/packages/flightphp/core) +[![Monthly Downloads](http://poser.pugx.org/flightphp/core/d/monthly)](https://packagist.org/packages/flightphp/core) +![PHPStan: Level 6](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) +[![License](http://poser.pugx.org/flightphp/core/license)](https://packagist.org/packages/flightphp/core) +[![PHP Version Require](http://poser.pugx.org/flightphp/core/require/php)](https://packagist.org/packages/flightphp/core) +![Matrix](https://img.shields.io/matrix/flight-php-framework%3Amatrix.org?server_fqdn=matrix.org&style=social&logo=matrix) + +# What is Flight? + +Flight is a fast, simple, extensible framework for PHP. Flight enables you to +quickly and easily build RESTful web applications. + +# Basic Usage + +```php +// if installed with composer +require 'vendor/autoload.php'; +// or if installed manually by zip file +// require 'flight/Flight.php'; + +Flight::route('/', function () { + echo 'hello world!'; +}); + +Flight::start(); +``` + +## Skeleton App + +You can also install a skeleton app. Go to [flightphp/skeleton](https://github.com/flightphp/skeleton) for instructions on how to get started! + +# Documentation + +We have our own documentation website that is built with Flight (naturally). Learn more about the framework at [docs.flightphp.com](https://docs.flightphp.com). + +# Community + +Chat with us on Matrix IRC [#flight-php-framework:matrix.org](https://matrix.to/#/#flight-php-framework:matrix.org) + +# Upgrading From v2 + +If you have a current project on v2, you should be able to upgrade to v2 with no issues depending on how your project was built. If there are any issues with upgrade, they are documented in the [migrating to v3](https://docs.flightphp.com/learn/migrating-to-v3) documentation page. It is the intention of Flight to maintain longterm stability of the project and to not add rewrites with major version changes. + +# Requirements + +> [!IMPORTANT] +> Flight requires `PHP 7.4` or greater. + +**Note:** PHP 7.4 is supported because at the current time of writing (2024) PHP 7.4 is the default version for some LTS Linux distributions. Forcing a move to PHP >8 would cause a lot of heartburn for those users. + +The framework also supports PHP >8. + +# Roadmap + +To see the current and future roadmap for the Flight Framework, visit the [project roadmap](https://github.com/orgs/flightphp/projects/1/views/1) + +# License + +Flight is released under the [MIT](http://docs.flightphp.com/license) license. diff --git a/api/composer.json b/api/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..17bfba4cb5a56eb572d7e78dfd2a7705446322e6 GIT binary patch literal 31204 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 Fe*mxGbua(` literal 0 HcmV?d00001 diff --git a/api/flight/Engine.php b/api/flight/Engine.php new file mode 100644 index 0000000..a1378a6 --- /dev/null +++ b/api/flight/Engine.php @@ -0,0 +1,942 @@ + + * + * # Core methods + * @method void start() Starts engine + * @method void stop() Stops framework and outputs current response + * @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) Stops processing and returns a given response. + * + * # Routing + * @method Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Routes a URL to a callback function with all applicable methods + * @method void group(string $pattern, callable $callback, array $group_middlewares = []) + * Groups a set of routes together under a common prefix. + * @method Route post(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Routes a POST URL to a callback function. + * @method Route put(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Routes a PUT URL to a callback function. + * @method Route patch(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Routes a PATCH URL to a callback function. + * @method Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Routes a DELETE URL to a callback function. + * @method Router router() Gets router + * @method string getUrl(string $alias) Gets a url from an alias + * + * # Views + * @method void render(string $file, ?array $data = null, ?string $key = null) Renders template + * @method View view() Gets current view + * + * # Request-Response + * @method Request request() Gets current request + * @method Response response() Gets current response + * @method void error(Throwable $e) Sends an HTTP 500 response for any errors. + * @method void notFound() Sends an HTTP 404 response when a URL is not found. + * @method void redirect(string $url, int $code = 303) Redirects the current request to another URL. + * @method void json(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * Sends a JSON response. + * @method void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * Sends a JSON response and immediately halts the request. + * @method void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * Sends a JSONP response. + * + * # HTTP caching + * @method void etag(string $id, ('strong'|'weak') $type = 'strong') Handles ETag HTTP caching. + * @method void lastModified(int $time) Handles last modified HTTP caching. + * + * phpcs:disable PSR2.Methods.MethodDeclaration.Underscore + */ +class Engine +{ + /** + * @var array List of methods that can be extended in the Engine class. + */ + private const MAPPABLE_METHODS = [ + 'start', 'stop', 'route', 'halt', 'error', 'notFound', + 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp', + 'post', 'put', 'patch', 'delete', 'group', 'getUrl' + ]; + + /** @var array Stored variables. */ + protected array $vars = []; + + /** Class loader. */ + protected Loader $loader; + + /** Event dispatcher. */ + protected Dispatcher $dispatcher; + + /** If the framework has been initialized or not. */ + protected bool $initialized = false; + + public function __construct() + { + $this->loader = new Loader(); + $this->dispatcher = new Dispatcher(); + $this->init(); + } + + /** + * Handles calls to class methods. + * + * @param string $name Method name + * @param array $params Method parameters + * + * @throws Exception + * @return mixed Callback results + */ + public function __call(string $name, array $params) + { + $callback = $this->dispatcher->get($name); + + if (\is_callable($callback)) { + return $this->dispatcher->run($name, $params); + } + + if (!$this->loader->get($name)) { + throw new Exception("$name must be a mapped method."); + } + + $shared = empty($params) || $params[0]; + + return $this->loader->load($name, $shared); + } + + ////////////////// + // Core Methods // + ////////////////// + + /** Initializes the framework. */ + public function init(): void + { + $initialized = $this->initialized; + $self = $this; + + if ($initialized) { + $this->vars = []; + $this->loader->reset(); + $this->dispatcher->reset(); + } + + // Add this class to Dispatcher + $this->dispatcher->setEngine($this); + + // Register default components + $this->loader->register('request', Request::class); + $this->loader->register('response', Response::class); + $this->loader->register('router', Router::class); + + $this->loader->register('view', View::class, [], function (View $view) use ($self) { + $view->path = $self->get('flight.views.path'); + $view->extension = $self->get('flight.views.extension'); + }); + + foreach (self::MAPPABLE_METHODS as $name) { + $this->dispatcher->set($name, [$this, "_$name"]); + } + + // Default configuration settings + $this->set('flight.base_url'); + $this->set('flight.case_sensitive', false); + $this->set('flight.handle_errors', true); + $this->set('flight.log_errors', false); + $this->set('flight.views.path', './views'); + $this->set('flight.views.extension', '.php'); + $this->set('flight.content_length', true); + $this->set('flight.v2.output_buffering', false); + + // Startup configuration + $this->before('start', function () use ($self) { + // Enable error handling + if ($self->get('flight.handle_errors')) { + set_error_handler([$self, 'handleError']); + set_exception_handler([$self, 'handleException']); + } + + // Set case-sensitivity + $self->router()->case_sensitive = $self->get('flight.case_sensitive'); + // Set Content-Length + $self->response()->content_length = $self->get('flight.content_length'); + // This is to maintain legacy handling of output buffering + // which causes a lot of problems. This will be removed + // in v4 + $self->response()->v2_output_buffering = $this->get('flight.v2.output_buffering'); + }); + + $this->initialized = true; + } + + /** + * Custom error handler. Converts errors into exceptions. + * + * @param int $errno Error number + * @param string $errstr Error string + * @param string $errfile Error file name + * @param int $errline Error file line number + * + * @return false + * @throws ErrorException + */ + public function handleError(int $errno, string $errstr, string $errfile, int $errline): bool + { + if ($errno & error_reporting()) { + throw new ErrorException($errstr, $errno, 0, $errfile, $errline); + } + + return false; + } + + /** + * Custom exception handler. Logs exceptions. + * + * @param Throwable $e Thrown exception + */ + public function handleException(Throwable $e): void + { + if ($this->get('flight.log_errors')) { + error_log($e->getMessage()); // @codeCoverageIgnore + } + + $this->error($e); + } + + /** + * Registers the container handler + * + * @param callable|object $containerHandler Callback function or PSR-11 Container object that sets the container and how it will inject classes + * + * @return void + */ + public function registerContainerHandler($containerHandler): void + { + $this->dispatcher->setContainerHandler($containerHandler); + } + + /** + * Maps a callback to a framework method. + * + * @param string $name Method name + * @param callable $callback Callback function + * + * @throws Exception If trying to map over a framework method + */ + public function map(string $name, callable $callback): void + { + if (method_exists($this, $name)) { + throw new Exception('Cannot override an existing framework method.'); + } + + $this->dispatcher->set($name, $callback); + } + + /** + * Registers a class to a framework method. + * + * # Usage example: + * ``` + * $app = new Engine; + * $app->register('user', User::class); + * + * $app->user(); # <- Return a User instance + * ``` + * + * @param string $name Method name + * @param class-string $class Class name + * @param array $params Class initialization parameters + * @param ?Closure(T $instance): void $callback Function to call after object instantiation + * + * @template T of object + * @throws Exception If trying to map over a framework method + */ + public function register(string $name, string $class, array $params = [], ?callable $callback = null): void + { + if (method_exists($this, $name)) { + throw new Exception('Cannot override an existing framework method.'); + } + + $this->loader->register($name, $class, $params, $callback); + } + + /** Unregisters a class to a framework method. */ + public function unregister(string $methodName): void + { + $this->loader->unregister($methodName); + } + + /** + * Adds a pre-filter to a method. + * + * @param string $name Method name + * @param Closure(array &$params, string &$output): (void|false) $callback + */ + public function before(string $name, callable $callback): void + { + $this->dispatcher->hook($name, 'before', $callback); + } + + /** + * Adds a post-filter to a method. + * + * @param string $name Method name + * @param Closure(array &$params, string &$output): (void|false) $callback + */ + public function after(string $name, callable $callback): void + { + $this->dispatcher->hook($name, 'after', $callback); + } + + /** + * Gets a variable. + * + * @param ?string $key Variable name + * + * @return mixed Variable value or `null` if `$key` doesn't exists. + */ + public function get(?string $key = null) + { + if ($key === null) { + return $this->vars; + } + + return $this->vars[$key] ?? null; + } + + /** + * Sets a variable. + * + * @param string|iterable $key + * Variable name as `string` or an iterable of `'varName' => $varValue` + * @param mixed $value Ignored if `$key` is an `iterable` + */ + public function set($key, $value = null): void + { + if (\is_iterable($key)) { + foreach ($key as $k => $v) { + $this->vars[$k] = $v; + } + + return; + } + + $this->vars[$key] = $value; + } + + /** + * Checks if a variable has been set. + * + * @param string $key Variable name + * + * @return bool Variable status + */ + public function has(string $key): bool + { + return isset($this->vars[$key]); + } + + /** + * Unsets a variable. If no key is passed in, clear all variables. + * + * @param ?string $key Variable name, if `$key` isn't provided, it clear all variables. + */ + public function clear(?string $key = null): void + { + if ($key === null) { + $this->vars = []; + return; + } + + unset($this->vars[$key]); + } + + /** + * Adds a path for class autoloading. + * + * @param string $dir Directory path + */ + public function path(string $dir): void + { + $this->loader->addDirectory($dir); + } + + /** + * Processes each routes middleware. + * + * @param Route $route The route to process the middleware for. + * @param string $eventName If this is the before or after method. + */ + protected function processMiddleware(Route $route, string $eventName): bool + { + $atLeastOneMiddlewareFailed = false; + + // Process things normally for before, and then in reverse order for after. + $middlewares = $eventName === Dispatcher::FILTER_BEFORE + ? $route->middleware + : array_reverse($route->middleware); + $params = $route->params; + + foreach ($middlewares as $middleware) { + // Assume that nothing is going to be executed for the middleware. + $middlewareObject = false; + + // Closure functions can only run on the before event + if ($eventName === Dispatcher::FILTER_BEFORE && is_object($middleware) === true && ($middleware instanceof Closure)) { + $middlewareObject = $middleware; + + // If the object has already been created, we can just use it if the event name exists. + } elseif (is_object($middleware) === true) { + $middlewareObject = method_exists($middleware, $eventName) === true ? [ $middleware, $eventName ] : false; + + // If the middleware is a string, we need to create the object and then call the event. + } elseif (is_string($middleware) === true && method_exists($middleware, $eventName) === true) { + $resolvedClass = null; + + // if there's a container assigned, we should use it to create the object + if ($this->dispatcher->mustUseContainer($middleware) === true) { + $resolvedClass = $this->dispatcher->resolveContainerClass($middleware, $params); + // otherwise just assume it's a plain jane class, so inject the engine + // just like in Dispatcher::invokeCallable() + } elseif (class_exists($middleware) === true) { + $resolvedClass = new $middleware($this); + } + + // If something was resolved, create an array callable that will be passed in later. + if ($resolvedClass !== null) { + $middlewareObject = [ $resolvedClass, $eventName ]; + } + } + + // If nothing was resolved, go to the next thing + if ($middlewareObject === false) { + continue; + } + + // This is the way that v3 handles output buffering (which captures output correctly) + $useV3OutputBuffering = + $this->response()->v2_output_buffering === false && + $route->is_streamed === false; + + if ($useV3OutputBuffering === true) { + ob_start(); + } + + // Here is the array callable $middlewareObject that we created earlier. + // It looks bizarre but it's really calling [ $class, $method ]($params) + // Which loosely translates to $class->$method($params) + $middlewareResult = $middlewareObject($params); + + if ($useV3OutputBuffering === true) { + $this->response()->write(ob_get_clean()); + } + + // If you return false in your middleware, it will halt the request + // and throw a 403 forbidden error by default. + if ($middlewareResult === false) { + $atLeastOneMiddlewareFailed = true; + break; + } + } + + return $atLeastOneMiddlewareFailed; + } + + //////////////////////// + // Extensible Methods // + //////////////////////// + /** + * Starts the framework. + * + * @throws Exception + */ + public function _start(): void + { + $dispatched = false; + $self = $this; + $request = $this->request(); + $response = $this->response(); + $router = $this->router(); + + // Allow filters to run + $this->after('start', function () use ($self) { + $self->stop(); + }); + + if ($response->v2_output_buffering === true) { + // Flush any existing output + if (ob_get_length() > 0) { + $response->write(ob_get_clean()); // @codeCoverageIgnore + } + + // Enable output buffering + // This is closed in the Engine->_stop() method + ob_start(); + } + + // Route the request + $failedMiddlewareCheck = false; + + while ($route = $router->route($request)) { + $params = array_values($route->params); + + // Add route info to the parameter list + if ($route->pass) { + $params[] = $route; + } + + // If this route is to be streamed, we need to output the headers now + if ($route->is_streamed === true) { + if (count($route->streamed_headers) > 0) { + $response->status($route->streamed_headers['status'] ?? 200); + unset($route->streamed_headers['status']); + foreach ($route->streamed_headers as $header => $value) { + $response->header($header, $value); + } + } + + $response->header('X-Accel-Buffering', 'no'); + $response->header('Connection', 'close'); + + // We obviously don't know the content length right now. This must be false. + $response->content_length = false; + $response->sendHeaders(); + $response->markAsSent(); + } + + // Run any before middlewares + if (count($route->middleware) > 0) { + $atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'before'); + if ($atLeastOneMiddlewareFailed === true) { + $failedMiddlewareCheck = true; + break; + } + } + + $useV3OutputBuffering = + $this->response()->v2_output_buffering === false && + $route->is_streamed === false; + + if ($useV3OutputBuffering === true) { + ob_start(); + } + + // Call route handler + $continue = $this->dispatcher->execute( + $route->callback, + $params + ); + + if ($useV3OutputBuffering === true) { + $response->write(ob_get_clean()); + } + + // Run any before middlewares + if (count($route->middleware) > 0) { + // process the middleware in reverse order now + $atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'after'); + + if ($atLeastOneMiddlewareFailed === true) { + $failedMiddlewareCheck = true; + break; + } + } + + $dispatched = true; + + if (!$continue) { + break; + } + + $router->next(); + + $dispatched = false; + } + + // HEAD requests should be identical to GET requests but have no body + if ($request->method === 'HEAD') { + $response->clearBody(); + } + + if ($failedMiddlewareCheck === true) { + $this->halt(403, 'Forbidden', empty(getenv('PHPUNIT_TEST'))); + } elseif ($dispatched === false) { + // Get the previous route and check if the method failed, but the URL was good. + $lastRouteExecuted = $router->executedRoute; + if ($lastRouteExecuted !== null && $lastRouteExecuted->matchUrl($request->url) === true && $lastRouteExecuted->matchMethod($request->method) === false) { + $this->halt(405, 'Method Not Allowed', empty(getenv('PHPUNIT_TEST'))); + } else { + $this->notFound(); + } + } + } + + /** + * Sends an HTTP 500 response for any errors. + * + * @param Throwable $e Thrown exception + */ + public function _error(Throwable $e): void + { + $msg = sprintf( + <<500 Internal Server Error +

%s (%s)

+
%s
+ HTML, + $e->getMessage(), + $e->getCode(), + $e->getTraceAsString() + ); + + try { + $this->response() + ->clearBody() + ->status(500) + ->write($msg) + ->send(); + // @codeCoverageIgnoreStart + } catch (Throwable $t) { + exit($msg); + } + // @codeCoverageIgnoreEnd + } + + /** + * Stops the framework and outputs the current response. + * + * @param ?int $code HTTP status code + * + * @throws Exception + * @deprecated 3.5.3 This method will be removed in v4 + */ + public function _stop(?int $code = null): void + { + $response = $this->response(); + + if ($response->sent() === false) { + if ($code !== null) { + $response->status($code); + } + + if ($response->v2_output_buffering === true && ob_get_length() > 0) { + $response->write(ob_get_clean()); + } + + $response->send(); + } + } + + /** + * Routes a URL to a callback function. + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function + * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias The alias for the route + */ + public function _route(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route + { + return $this->router()->map($pattern, $callback, $pass_route, $alias); + } + + /** + * Routes a URL to a callback function. + * + * @param string $pattern URL pattern to match + * @param callable $callback Callback function that includes the Router class as first parameter + * @param array $group_middlewares The middleware to be applied to the route + */ + public function _group(string $pattern, callable $callback, array $group_middlewares = []): void + { + $this->router()->group($pattern, $callback, $group_middlewares); + } + + /** + * Routes a URL to a callback function. + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback + * + * @return Route + */ + public function _post(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route + { + return $this->router()->map('POST ' . $pattern, $callback, $pass_route, $route_alias); + } + + /** + * Routes a URL to a callback function. + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback + * + * @return Route + */ + public function _put(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route + { + return $this->router()->map('PUT ' . $pattern, $callback, $pass_route, $route_alias); + } + + /** + * Routes a URL to a callback function. + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback + * + * @return Route + */ + public function _patch(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route + { + return $this->router()->map('PATCH ' . $pattern, $callback, $pass_route, $route_alias); + } + + /** + * Routes a URL to a callback function. + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback + * + * @return Route + */ + public function _delete(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route + { + return $this->router()->map('DELETE ' . $pattern, $callback, $pass_route, $route_alias); + } + + /** + * Stops processing and returns a given response. + * + * @param int $code HTTP status code + * @param string $message Response message + * @param bool $actuallyExit Whether to actually exit the script or just send response + */ + public function _halt(int $code = 200, string $message = '', bool $actuallyExit = true): void + { + $this->response() + ->clearBody() + ->status($code) + ->write($message) + ->send(); + if ($actuallyExit === true) { + exit(); // @codeCoverageIgnore + } + } + + /** Sends an HTTP 404 response when a URL is not found. */ + public function _notFound(): void + { + $output = '

404 Not Found

The page you have requested could not be found.

'; + + $this->response() + ->clearBody() + ->status(404) + ->write($output) + ->send(); + } + + /** + * Redirects the current request to another URL. + * + * @param int $code HTTP status code + */ + public function _redirect(string $url, int $code = 303): void + { + $base = $this->get('flight.base_url'); + + if ($base === null) { + $base = $this->request()->base; + } + + // Append base url to redirect url + if ($base !== '/' && strpos($url, '://') === false) { + $url = $base . preg_replace('#/+#', '/', '/' . $url); + } + + $this->response() + ->clearBody() + ->status($code) + ->header('Location', $url) + ->send(); + } + + /** + * Renders a template. + * + * @param string $file Template file + * @param ?array $data Template data + * @param ?string $key View variable name + * + * @throws Exception If template file wasn't found + */ + public function _render(string $file, ?array $data = null, ?string $key = null): void + { + if ($key !== null) { + $this->view()->set($key, $this->view()->fetch($file, $data)); + return; + } + + $this->view()->render($file, $data); + } + + /** + * Sends a JSON response. + * + * @param mixed $data JSON data + * @param int $code HTTP status code + * @param bool $encode Whether to perform JSON encoding + * @param string $charset Charset + * @param int $option Bitmask Json constant such as JSON_HEX_QUOT + * + * @throws Exception + */ + public function _json( + $data, + int $code = 200, + bool $encode = true, + string $charset = 'utf-8', + int $option = 0 + ): void { + $json = $encode ? json_encode($data, $option) : $data; + + $this->response() + ->status($code) + ->header('Content-Type', 'application/json; charset=' . $charset) + ->write($json); + if ($this->response()->v2_output_buffering === true) { + $this->response()->send(); + } + } + + /** + * Sends a JSON response and halts execution immediately. + * + * @param mixed $data JSON data + * @param int $code HTTP status code + * @param bool $encode Whether to perform JSON encoding + * @param string $charset Charset + * @param int $option Bitmask Json constant such as JSON_HEX_QUOT + * + * @throws Exception + */ + public function _jsonHalt( + $data, + int $code = 200, + bool $encode = true, + string $charset = 'utf-8', + int $option = 0 + ): void { + $this->json($data, $code, $encode, $charset, $option); + $jsonBody = $this->response()->getBody(); + if ($this->response()->v2_output_buffering === false) { + $this->response()->clearBody(); + $this->response()->send(); + } + $this->halt($code, $jsonBody, empty(getenv('PHPUNIT_TEST'))); + } + + /** + * Sends a JSONP response. + * + * @param mixed $data JSON data + * @param string $param Query parameter that specifies the callback name. + * @param int $code HTTP status code + * @param bool $encode Whether to perform JSON encoding + * @param string $charset Charset + * @param int $option Bitmask Json constant such as JSON_HEX_QUOT + * + * @throws Exception + */ + public function _jsonp( + $data, + string $param = 'jsonp', + int $code = 200, + bool $encode = true, + string $charset = 'utf-8', + int $option = 0 + ): void { + $json = $encode ? json_encode($data, $option) : $data; + $callback = $this->request()->query[$param]; + + $this->response() + ->status($code) + ->header('Content-Type', 'application/javascript; charset=' . $charset) + ->write($callback . '(' . $json . ');'); + if ($this->response()->v2_output_buffering === true) { + $this->response()->send(); + } + } + + /** + * Handles ETag HTTP caching. + * + * @param string $id ETag identifier + * @param 'strong'|'weak' $type ETag type + */ + public function _etag(string $id, string $type = 'strong'): void + { + $id = (($type === 'weak') ? 'W/' : '') . $id; + + $this->response()->header('ETag', '"' . str_replace('"', '\"', $id) . '"'); + + if ( + isset($_SERVER['HTTP_IF_NONE_MATCH']) && + $_SERVER['HTTP_IF_NONE_MATCH'] === $id + ) { + $this->response()->clear(); + $this->halt(304, '', empty(getenv('PHPUNIT_TEST'))); + } + } + + /** + * Handles last modified HTTP caching. + * + * @param int $time Unix timestamp + */ + public function _lastModified(int $time): void + { + $this->response()->header('Last-Modified', gmdate('D, d M Y H:i:s \G\M\T', $time)); + + if ( + isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && + strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $time + ) { + $this->response()->clear(); + $this->halt(304, '', empty(getenv('PHPUNIT_TEST'))); + } + } + + /** + * Gets a url from an alias that's supplied. + * + * @param string $alias the route alias. + * @param array $params The params for the route if applicable. + */ + public function _getUrl(string $alias, array $params = []): string + { + return $this->router()->getUrlByAlias($alias, $params); + } +} diff --git a/api/flight/Flight.php b/api/flight/Flight.php new file mode 100644 index 0000000..ecba040 --- /dev/null +++ b/api/flight/Flight.php @@ -0,0 +1,146 @@ + + * + * # Core methods + * @method static void start() Starts the framework. + * @method static void path(string $path) Adds a path for autoloading classes. + * @method static void stop(?int $code = null) Stops the framework and sends a response. + * @method static void halt(int $code = 200, string $message = '', bool $actuallyExit = true) + * Stop the framework with an optional status code and message. + * @method static void register(string $name, string $class, array $params = [], ?callable $callback = null) + * Registers a class to a framework method. + * @method static void unregister(string $methodName) + * Unregisters a class to a framework method. + * @method static void registerContainerHandler(callable|object $containerHandler) Registers a container handler. + * + * # Routing + * @method static Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Maps a URL pattern to a callback with all applicable methods. + * @method static void group(string $pattern, callable $callback, callable[] $group_middlewares = []) + * Groups a set of routes together under a common prefix. + * @method static Route post(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Routes a POST URL to a callback function. + * @method static Route put(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Routes a PUT URL to a callback function. + * @method static Route patch(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Routes a PATCH URL to a callback function. + * @method static Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * Routes a DELETE URL to a callback function. + * @method static Router router() Returns Router instance. + * @method static string getUrl(string $alias, array $params = []) Gets a url from an alias + * + * @method static void map(string $name, callable $callback) Creates a custom framework method. + * + * @method static void before(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * Adds a filter before a framework method. + * @method static void after(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * Adds a filter after a framework method. + * + * @method static void set(string|iterable $key, mixed $value) Sets a variable. + * @method static mixed get(?string $key) Gets a variable. + * @method static bool has(string $key) Checks if a variable is set. + * @method static void clear(?string $key = null) Clears a variable. + * + * # Views + * @method static void render(string $file, ?array $data = null, ?string $key = null) + * Renders a template file. + * @method static View view() Returns View instance. + * + * # Request-Response + * @method static Request request() Returns Request instance. + * @method static Response response() Returns Response instance. + * @method static void redirect(string $url, int $code = 303) Redirects to another URL. + * @method static void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * Sends a JSON response. + * @method static void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * Sends a JSON response and immediately halts the request. + * @method static void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * Sends a JSONP response. + * @method static void error(Throwable $exception) Sends an HTTP 500 response. + * @method static void notFound() Sends an HTTP 404 response. + * + * # HTTP caching + * @method static void etag(string $id, ('strong'|'weak') $type = 'strong') Performs ETag HTTP caching. + * @method static void lastModified(int $time) Performs last modified HTTP caching. + */ +class Flight +{ + /** Framework engine. */ + private static Engine $engine; + + /** Whether or not the app has been initialized. */ + private static bool $initialized = false; + + /** + * Don't allow object instantiation + * + * @codeCoverageIgnore + * @return void + */ + private function __construct() + { + } + + /** + * Forbid cloning the class + * + * @codeCoverageIgnore + * @return void + */ + private function __clone() + { + } + + /** + * Handles calls to static methods. + * + * @param string $name Method name + * @param array $params Method parameters + * + * @return mixed Callback results + * @throws Exception + */ + public static function __callStatic(string $name, array $params) + { + return self::app()->{$name}(...$params); + } + + /** @return Engine Application instance */ + public static function app(): Engine + { + if (!self::$initialized) { + require_once __DIR__ . '/autoload.php'; + + self::setEngine(new Engine()); + self::$initialized = true; + } + + return self::$engine; + } + + /** + * Set the engine instance + * + * @param Engine $engine Vroom vroom! + */ + public static function setEngine(Engine $engine): void + { + self::$engine = $engine; + } +} diff --git a/api/flight/autoload.php b/api/flight/autoload.php new file mode 100644 index 0000000..0a31c86 --- /dev/null +++ b/api/flight/autoload.php @@ -0,0 +1,10 @@ + $config JSON config from .runway-config.json + */ + public function __construct(array $config) + { + parent::__construct('make:controller', 'Create a controller', $config); + $this->argument('', 'The name of the controller to create (with or without the Controller suffix)'); + } + + /** + * Executes the function + * + * @return void + */ + public function execute(string $controller) + { + $io = $this->app()->io(); + if (isset($this->config['app_root']) === false) { + $io->error('app_root not set in .runway-config.json', true); + return; + } + + if (!preg_match('/Controller$/', $controller)) { + $controller .= 'Controller'; + } + + $controllerPath = getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controller . '.php'; + if (file_exists($controllerPath) === true) { + $io->error($controller . ' already exists.', true); + return; + } + + if (is_dir(dirname($controllerPath)) === false) { + $io->info('Creating directory ' . dirname($controllerPath), true); + mkdir(dirname($controllerPath), 0755, true); + } + + $file = new PhpFile(); + $file->setStrictTypes(); + + $namespace = new PhpNamespace('app\\controllers'); + $namespace->addUse('flight\\Engine'); + + $class = new ClassType($controller); + $class->addProperty('app') + ->setVisibility('protected') + ->setType('flight\\Engine') + ->addComment('@var Engine'); + $method = $class->addMethod('__construct') + ->addComment('Constructor') + ->setVisibility('public') + ->setBody('$this->app = $app;'); + $method->addParameter('app') + ->setType('flight\\Engine'); + + $namespace->add($class); + $file->addNamespace($namespace); + + $this->persistClass($controller, $file); + + $io->ok('Controller successfully created at ' . $controllerPath, true); + } + + /** + * Saves the class name to a file + * + * @param string $controllerName Name of the Controller + * @param PhpFile $file Class Object from Nette\PhpGenerator + * + * @return void + */ + protected function persistClass(string $controllerName, PhpFile $file) + { + $printer = new \Nette\PhpGenerator\PsrPrinter(); + file_put_contents(getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controllerName . '.php', $printer->printFile($file)); + } +} diff --git a/api/flight/commands/RouteCommand.php b/api/flight/commands/RouteCommand.php new file mode 100644 index 0000000..a34b821 --- /dev/null +++ b/api/flight/commands/RouteCommand.php @@ -0,0 +1,126 @@ + $config JSON config from .runway-config.json + */ + public function __construct(array $config) + { + parent::__construct('routes', 'Gets all routes for an application', $config); + + $this->option('--get', 'Only return GET requests'); + $this->option('--post', 'Only return POST requests'); + $this->option('--delete', 'Only return DELETE requests'); + $this->option('--put', 'Only return PUT requests'); + $this->option('--patch', 'Only return PATCH requests'); + } + + /** + * Executes the function + * + * @return void + */ + public function execute() + { + $io = $this->app()->io(); + + if (isset($this->config['index_root']) === false) { + $io->error('index_root not set in .runway-config.json', true); + return; + } + + $io->bold('Routes', true); + + $cwd = getcwd(); + + $index_root = $cwd . '/' . $this->config['index_root']; + + // This makes it so the framework doesn't actually execute + Flight::map('start', function () { + return; + }); + include($index_root); + $routes = Flight::router()->getRoutes(); + $arrayOfRoutes = []; + foreach ($routes as $route) { + if ($this->shouldAddRoute($route) === true) { + $middlewares = []; + if (!empty($route->middleware)) { + try { + $middlewares = array_map(function ($middleware) { + $middleware_class_name = explode("\\", get_class($middleware)); + return preg_match("/^class@anonymous/", end($middleware_class_name)) ? 'Anonymous' : end($middleware_class_name); + }, $route->middleware); + } catch (\TypeError $e) { + $middlewares[] = 'Bad Middleware'; + } finally { + if (is_string($route->middleware) === true) { + $middlewares[] = $route->middleware; + } + } + } + + $arrayOfRoutes[] = [ + 'Pattern' => $route->pattern, + 'Methods' => implode(', ', $route->methods), + 'Alias' => $route->alias ?? '', + 'Streamed' => $route->is_streamed ? 'Yes' : 'No', + 'Middleware' => !empty($middlewares) ? implode(",", $middlewares) : '-' + ]; + } + } + $io->table($arrayOfRoutes, [ + 'head' => 'boldGreen' + ]); + } + + /** + * Whether or not to add the route based on the request + * + * @param Route $route Flight Route object + * + * @return boolean + */ + public function shouldAddRoute(Route $route) + { + $boolval = false; + + $showAll = !$this->get && !$this->post && !$this->put && !$this->delete && !$this->patch; + if ($showAll === true) { + $boolval = true; + } else { + $methods = [ 'GET', 'POST', 'PUT', 'DELETE', 'PATCH' ]; + foreach ($methods as $method) { + $lowercaseMethod = strtolower($method); + if ( + $this->{$lowercaseMethod} === true && + ( + $route->methods[0] === '*' || + in_array($method, $route->methods, true) === true + ) + ) { + $boolval = true; + break; + } + } + } + return $boolval; + } +} diff --git a/api/flight/core/Dispatcher.php b/api/flight/core/Dispatcher.php new file mode 100644 index 0000000..20e27b8 --- /dev/null +++ b/api/flight/core/Dispatcher.php @@ -0,0 +1,504 @@ + + */ +class Dispatcher +{ + public const FILTER_BEFORE = 'before'; + public const FILTER_AFTER = 'after'; + + /** Exception message if thrown by setting the container as a callable method. */ + protected ?Throwable $containerException = null; + + /** @var ?Engine $engine Engine instance. */ + protected ?Engine $engine = null; + + /** @var array Mapped events. */ + protected array $events = []; + + /** + * Method filters. + * + * @var array &$params, mixed &$output): (void|false)>>> + */ + protected array $filters = []; + + /** + * This is a container for the dependency injection. + * + * @var null|ContainerInterface|(callable(string $classString, array $params): (null|object)) + */ + protected $containerHandler = null; + + /** + * Sets the dependency injection container handler. + * + * @param ContainerInterface|(callable(string $classString, array $params): (null|object)) $containerHandler + * Dependency injection container. + * + * @throws InvalidArgumentException If $containerHandler is not a `callable` or instance of `Psr\Container\ContainerInterface`. + */ + public function setContainerHandler($containerHandler): void + { + $containerInterfaceNS = '\Psr\Container\ContainerInterface'; + + if ( + is_a($containerHandler, $containerInterfaceNS) + || is_callable($containerHandler) + ) { + $this->containerHandler = $containerHandler; + + return; + } + + throw new InvalidArgumentException( + "\$containerHandler must be of type callable or instance $containerInterfaceNS" + ); + } + + public function setEngine(Engine $engine): void + { + $this->engine = $engine; + } + + /** + * Dispatches an event. + * + * @param string $name Event name. + * @param array $params Callback parameters. + * + * @return mixed Output of callback + * @throws Exception If event name isn't found or if event throws an `Exception`. + */ + public function run(string $name, array $params = []) + { + $this->runPreFilters($name, $params); + $output = $this->runEvent($name, $params); + + return $this->runPostFilters($name, $output); + } + + /** + * @param array &$params + * + * @return $this + * @throws Exception + */ + protected function runPreFilters(string $eventName, array &$params): self + { + $thereAreBeforeFilters = !empty($this->filters[$eventName][self::FILTER_BEFORE]); + + if ($thereAreBeforeFilters) { + $this->filter($this->filters[$eventName][self::FILTER_BEFORE], $params, $output); + } + + return $this; + } + + /** + * @param array &$params + * + * @return void|mixed + * @throws Exception + */ + protected function runEvent(string $eventName, array &$params) + { + $requestedMethod = $this->get($eventName); + + if ($requestedMethod === null) { + throw new Exception("Event '$eventName' isn't found."); + } + + return $this->execute($requestedMethod, $params); + } + + /** + * @param mixed &$output + * + * @return mixed + * @throws Exception + */ + protected function runPostFilters(string $eventName, &$output) + { + static $params = []; + + $thereAreAfterFilters = !empty($this->filters[$eventName][self::FILTER_AFTER]); + + if ($thereAreAfterFilters) { + $this->filter($this->filters[$eventName][self::FILTER_AFTER], $params, $output); + } + + return $output; + } + + /** + * Assigns a callback to an event. + * + * @param string $name Event name. + * @param callable(): (void|mixed) $callback Callback function. + * + * @return $this + */ + public function set(string $name, callable $callback): self + { + $this->events[$name] = $callback; + + return $this; + } + + /** + * Gets an assigned callback. + * + * @param string $name Event name. + * + * @return null|(callable(): (void|mixed)) $callback Callback function. + */ + public function get(string $name): ?callable + { + return $this->events[$name] ?? null; + } + + /** + * Checks if an event has been set. + * + * @param string $name Event name. + * + * @return bool If event exists or doesn't exists. + */ + public function has(string $name): bool + { + return isset($this->events[$name]); + } + + /** + * Clears an event. If no name is given, all events will be removed. + * + * @param ?string $name Event name. + */ + public function clear(?string $name = null): void + { + if ($name !== null) { + unset($this->events[$name]); + unset($this->filters[$name]); + + return; + } + + $this->reset(); + } + + /** + * Hooks a callback to an event. + * + * @param string $name Event name + * @param 'before'|'after' $type Filter type. + * @param callable(array &$params, mixed &$output): (void|false)|callable(mixed &$output): (void|false) $callback + * + * @return $this + */ + public function hook(string $name, string $type, callable $callback): self + { + static $filterTypes = [self::FILTER_BEFORE, self::FILTER_AFTER]; + + if (!in_array($type, $filterTypes, true)) { + $noticeMessage = "Invalid filter type '$type', use " . join('|', $filterTypes); + + trigger_error($noticeMessage, E_USER_NOTICE); + } + + if ($type === self::FILTER_AFTER) { + $callbackInfo = new ReflectionFunction($callback); + $parametersNumber = $callbackInfo->getNumberOfParameters(); + + if ($parametersNumber === 1) { + /** @disregard &$params in after filters are deprecated. */ + $callback = fn (array &$params, &$output) => $callback($output); + } + } + + $this->filters[$name][$type][] = $callback; + + return $this; + } + + /** + * Executes a chain of method filters. + * + * @param array &$params, mixed &$output): (void|false)> $filters + * Chain of filters. + * @param array $params Method parameters. + * @param mixed $output Method output. + * + * @throws Exception If an event throws an `Exception` or if `$filters` contains an invalid filter. + */ + public function filter(array $filters, array &$params, &$output): void + { + foreach ($filters as $key => $callback) { + if (!is_callable($callback)) { + throw new InvalidArgumentException("Invalid callable \$filters[$key]."); + } + + $continue = $callback($params, $output); + + if ($continue === false) { + break; + } + } + } + + /** + * Executes a callback function. + * + * @param callable-string|(callable(): mixed)|array{class-string|object, string} $callback + * Callback function. + * @param array $params Function parameters. + * + * @return mixed Function results. + * @throws Exception If `$callback` also throws an `Exception`. + */ + public function execute($callback, array &$params = []) + { + if ( + is_string($callback) === true + && (strpos($callback, '->') !== false || strpos($callback, '::') !== false) + ) { + $callback = $this->parseStringClassAndMethod($callback); + } + + return $this->invokeCallable($callback, $params); + } + + /** + * Parses a string into a class and method. + * + * @param string $classAndMethod Class and method + * + * @return array{0: class-string|object, 1: string} Class and method + */ + public function parseStringClassAndMethod(string $classAndMethod): array + { + $classParts = explode('->', $classAndMethod); + + if (count($classParts) === 1) { + $classParts = explode('::', $classParts[0]); + } + + return $classParts; + } + + /** + * Calls a function. + * + * @param callable $func Name of function to call. + * @param array &$params Function parameters. + * + * @return mixed Function results. + * @deprecated 3.7.0 Use invokeCallable instead + */ + public function callFunction(callable $func, array &$params = []) + { + return $this->invokeCallable($func, $params); + } + + /** + * Invokes a method. + * + * @param array{0: class-string|object, 1: string} $func Class method. + * @param array &$params Class method parameters. + * + * @return mixed Function results. + * @throws TypeError For nonexistent class name. + * @deprecated 3.7.0 Use invokeCallable instead. + */ + public function invokeMethod(array $func, array &$params = []) + { + return $this->invokeCallable($func, $params); + } + + /** + * Invokes a callable (anonymous function or Class->method). + * + * @param array{0: class-string|object, 1: string}|callable $func Class method. + * @param array &$params Class method parameters. + * + * @return mixed Function results. + * @throws TypeError For nonexistent class name. + * @throws InvalidArgumentException If the constructor requires parameters. + * @version 3.7.0 + */ + public function invokeCallable($func, array &$params = []) + { + // If this is a directly callable function, call it + if (is_array($func) === false) { + $this->verifyValidFunction($func); + + return call_user_func_array($func, $params); + } + + [$class, $method] = $func; + + $mustUseTheContainer = $this->mustUseContainer($class); + + if ($mustUseTheContainer === true) { + $resolvedClass = $this->resolveContainerClass($class, $params); + + if ($resolvedClass) { + $class = $resolvedClass; + } + } + + $this->verifyValidClassCallable($class, $method, $resolvedClass ?? null); + + // Class is a string, and method exists, create the object by hand and inject only the Engine + if (is_string($class)) { + $class = new $class($this->engine); + } + + return call_user_func_array([$class, $method], $params); + } + + /** + * Handles invalid callback types. + * + * @param callable-string|(callable(): mixed)|array{0: class-string|object, 1: string} $callback + * Callback function. + * + * @throws InvalidArgumentException If `$callback` is an invalid type. + */ + protected function verifyValidFunction($callback): void + { + if (is_string($callback) && !function_exists($callback)) { + throw new InvalidArgumentException('Invalid callback specified.'); + } + } + + + /** + * Verifies if the provided class and method are valid callable. + * + * @param class-string|object $class The class name. + * @param string $method The method name. + * @param object|null $resolvedClass The resolved class. + * + * @throws Exception If the class or method is not found. + */ + protected function verifyValidClassCallable($class, $method, $resolvedClass): void + { + $exception = null; + + // Final check to make sure it's actually a class and a method, or throw an error + if (is_object($class) === false && class_exists($class) === false) { + $exception = new Exception("Class '$class' not found. Is it being correctly autoloaded with Flight::path()?"); + + // If this tried to resolve a class in a container and failed somehow, throw the exception + } elseif (!$resolvedClass && $this->containerException !== null) { + $exception = $this->containerException; + + // Class is there, but no method + } elseif (is_object($class) === true && method_exists($class, $method) === false) { + $classNamespace = get_class($class); + $exception = new Exception("Class found, but method '$classNamespace::$method' not found."); + } + + if ($exception !== null) { + $this->fixOutputBuffering(); + + throw $exception; + } + } + + /** + * Resolves the container class. + * + * @param class-string $class Class name. + * @param array &$params Class constructor parameters. + * + * @return ?object Class object. + */ + public function resolveContainerClass(string $class, array &$params) + { + // PSR-11 + if ( + is_a($this->containerHandler, '\Psr\Container\ContainerInterface') + && $this->containerHandler->has($class) + ) { + return $this->containerHandler->get($class); + } + + // Just a callable where you configure the behavior (Dice, PHP-DI, etc.) + if (is_callable($this->containerHandler)) { + /* This is to catch all the error that could be thrown by whatever + container you are using */ + try { + return ($this->containerHandler)($class, $params); + + // could not resolve a class for some reason + } catch (Exception $exception) { + // If the container throws an exception, we need to catch it + // and store it somewhere. If we just let it throw itself, it + // doesn't properly close the output buffers and can cause other + // issues. + // This is thrown in the verifyValidClassCallable method. + $this->containerException = $exception; + } + } + + return null; + } + + /** + * Checks to see if a container should be used or not. + * + * @param string|object $class the class to verify + * + * @return boolean + */ + public function mustUseContainer($class): bool + { + return $this->containerHandler !== null && ( + (is_object($class) === true && strpos(get_class($class), 'flight\\') === false) + || is_string($class) + ); + } + + /** Because this could throw an exception in the middle of an output buffer, */ + protected function fixOutputBuffering(): void + { + // Cause PHPUnit has 1 level of output buffering by default + if (ob_get_level() > (getenv('PHPUNIT_TEST') ? 1 : 0)) { + ob_end_clean(); + } + } + + /** + * Resets the object to the initial state. + * + * @return $this + */ + public function reset(): self + { + $this->events = []; + $this->filters = []; + + return $this; + } +} diff --git a/api/flight/core/Loader.php b/api/flight/core/Loader.php new file mode 100644 index 0000000..1824b9c --- /dev/null +++ b/api/flight/core/Loader.php @@ -0,0 +1,241 @@ + + */ +class Loader +{ + /** + * Registered classes. + * + * @var array, ?callable}> $classes + */ + protected array $classes = []; + + /** + * If this is disabled, classes can load with underscores + */ + protected static bool $v2ClassLoading = true; + + /** + * Class instances. + * + * @var array + */ + protected array $instances = []; + + /** + * Autoload directories. + * + * @var array + */ + protected static array $dirs = []; + + /** + * Registers a class. + * + * @param string $name Registry name + * @param class-string|Closure(): T $class Class name or function to instantiate class + * @param array $params Class initialization parameters + * @param ?Closure(T $instance): void $callback $callback Function to call after object instantiation + * + * @template T of object + */ + public function register(string $name, $class, array $params = [], ?callable $callback = null): void + { + unset($this->instances[$name]); + + $this->classes[$name] = [$class, $params, $callback]; + } + + /** + * Unregisters a class. + * + * @param string $name Registry name + */ + public function unregister(string $name): void + { + unset($this->classes[$name]); + } + + /** + * Loads a registered class. + * + * @param string $name Method name + * @param bool $shared Shared instance + * + * @throws Exception + * + * @return ?object Class instance + */ + public function load(string $name, bool $shared = true): ?object + { + $obj = null; + + if (isset($this->classes[$name])) { + [0 => $class, 1 => $params, 2 => $callback] = $this->classes[$name]; + + $exists = isset($this->instances[$name]); + + if ($shared) { + $obj = ($exists) ? + $this->getInstance($name) : + $this->newInstance($class, $params); + + if (!$exists) { + $this->instances[$name] = $obj; + } + } else { + $obj = $this->newInstance($class, $params); + } + + if ($callback && (!$shared || !$exists)) { + $ref = [&$obj]; + \call_user_func_array($callback, $ref); + } + } + + return $obj; + } + + /** + * Gets a single instance of a class. + * + * @param string $name Instance name + * + * @return ?object Class instance + */ + public function getInstance(string $name): ?object + { + return $this->instances[$name] ?? null; + } + + /** + * Gets a new instance of a class. + * + * @param class-string|Closure(): class-string $class Class name or callback function to instantiate class + * @param array $params Class initialization parameters + * + * @template T of object + * + * @throws Exception + * + * @return T Class instance + */ + public function newInstance($class, array $params = []) + { + if (\is_callable($class)) { + return \call_user_func_array($class, $params); + } + + return new $class(...$params); + } + + /** + * Gets a registered callable + * + * @param string $name Registry name + * + * @return mixed Class information or null if not registered + */ + public function get(string $name) + { + return $this->classes[$name] ?? null; + } + + /** + * Resets the object to the initial state. + */ + public function reset(): void + { + $this->classes = []; + $this->instances = []; + } + + // Autoloading Functions + + /** + * Starts/stops autoloader. + * + * @param bool $enabled Enable/disable autoloading + * @param string|iterable $dirs Autoload directories + */ + public static function autoload(bool $enabled = true, $dirs = []): void + { + if ($enabled) { + spl_autoload_register([__CLASS__, 'loadClass']); + } else { + spl_autoload_unregister([__CLASS__, 'loadClass']); // @codeCoverageIgnore + } + + if (!empty($dirs)) { + self::addDirectory($dirs); + } + } + + /** + * Autoloads classes. + * + * Classes are not allowed to have underscores in their names. + * + * @param string $class Class name + */ + public static function loadClass(string $class): void + { + $replace_chars = self::$v2ClassLoading === true ? ['\\', '_'] : ['\\']; + $classFile = str_replace($replace_chars, '/', $class) . '.php'; + + foreach (self::$dirs as $dir) { + $filePath = "$dir/$classFile"; + + if (file_exists($filePath)) { + require_once $filePath; + return; + } + } + } + + /** + * Adds a directory for autoloading classes. + * + * @param string|iterable $dir Directory path + */ + public static function addDirectory($dir): void + { + if (\is_array($dir) || \is_object($dir)) { + foreach ($dir as $value) { + self::addDirectory($value); + } + } elseif (\is_string($dir)) { + if (!\in_array($dir, self::$dirs, true)) { + self::$dirs[] = $dir; + } + } + } + + + /** + * Sets the value for V2 class loading. + * + * @param bool $value The value to set for V2 class loading. + * + * @return void + */ + public static function setV2ClassLoading(bool $value): void + { + self::$v2ClassLoading = $value; + } +} diff --git a/api/flight/database/PdoWrapper.php b/api/flight/database/PdoWrapper.php new file mode 100644 index 0000000..297121a --- /dev/null +++ b/api/flight/database/PdoWrapper.php @@ -0,0 +1,150 @@ +runQuery("SELECT * FROM table WHERE something = ?", [ $something ]); + * while($row = $statement->fetch()) { + * // ... + * } + * + * $db->runQuery("INSERT INTO table (name) VALUES (?)", [ $name ]); + * $db->runQuery("UPDATE table SET name = ? WHERE id = ?", [ $name, $id ]); + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return PDOStatement + */ + public function runQuery(string $sql, array $params = []): PDOStatement + { + $processed_sql_data = $this->processInStatementSql($sql, $params); + $sql = $processed_sql_data['sql']; + $params = $processed_sql_data['params']; + $statement = $this->prepare($sql); + $statement->execute($params); + return $statement; + } + + /** + * Pulls one field from the query + * + * Ex: $id = $db->fetchField("SELECT id FROM table WHERE something = ?", [ $something ]); + * + * @param string $sql - Ex: "SELECT id FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return mixed + */ + public function fetchField(string $sql, array $params = []) + { + $result = $this->fetchRow($sql, $params); + $data = $result->getData(); + return reset($data); + } + + /** + * Pulls one row from the query + * + * Ex: $row = $db->fetchRow("SELECT * FROM table WHERE something = ?", [ $something ]); + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return Collection + */ + public function fetchRow(string $sql, array $params = []): Collection + { + $sql .= stripos($sql, 'LIMIT') === false ? ' LIMIT 1' : ''; + $result = $this->fetchAll($sql, $params); + return count($result) > 0 ? $result[0] : new Collection(); + } + + /** + * Pulls all rows from the query + * + * Ex: $rows = $db->fetchAll("SELECT * FROM table WHERE something = ?", [ $something ]); + * foreach($rows as $row) { + * // ... + * } + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return array + */ + public function fetchAll(string $sql, array $params = []) + { + $processed_sql_data = $this->processInStatementSql($sql, $params); + $sql = $processed_sql_data['sql']; + $params = $processed_sql_data['params']; + $statement = $this->prepare($sql); + $statement->execute($params); + $results = $statement->fetchAll(); + if (is_array($results) === true && count($results) > 0) { + foreach ($results as &$result) { + $result = new Collection($result); + } + } else { + $results = []; + } + return $results; + } + + /** + * Don't worry about this guy. Converts stuff for IN statements + * + * Ex: $row = $db->fetchAll("SELECT * FROM table WHERE id = ? AND something IN(?), [ $id, [1,2,3] ]); + * Converts this to "SELECT * FROM table WHERE id = ? AND something IN(?,?,?)" + * + * @param string $sql the sql statement + * @param array $params the params for the sql statement + * + * @return array> + */ + protected function processInStatementSql(string $sql, array $params = []): array + { + // Replace "IN(?)" with "IN(?,?,?)" + $sql = preg_replace('/IN\s*\(\s*\?\s*\)/i', 'IN(?)', $sql); + + $current_index = 0; + while (($current_index = strpos($sql, 'IN(?)', $current_index)) !== false) { + $preceeding_count = substr_count($sql, '?', 0, $current_index - 1); + + $param = $params[$preceeding_count]; + $question_marks = '?'; + + if (is_string($param) || is_array($param)) { + $params_to_use = $param; + if (is_string($param)) { + $params_to_use = explode(',', $param); + } + + foreach ($params_to_use as $key => $value) { + if (is_string($value)) { + $params_to_use[$key] = trim($value); + } + } + + $question_marks = join(',', array_fill(0, count($params_to_use), '?')); + $sql = substr_replace($sql, $question_marks, $current_index + 3, 1); + + array_splice($params, $preceeding_count, 1, $params_to_use); + } + + $current_index += strlen($question_marks) + 4; + } + + return ['sql' => $sql, 'params' => $params]; + } +} diff --git a/api/flight/net/Request.php b/api/flight/net/Request.php new file mode 100644 index 0000000..fd9194b --- /dev/null +++ b/api/flight/net/Request.php @@ -0,0 +1,417 @@ + + * + * The default request properties are: + * + * - **url** - The URL being requested + * - **base** - The parent subdirectory of the URL + * - **method** - The request method (GET, POST, PUT, DELETE) + * - **referrer** - The referrer URL + * - **ip** - IP address of the client + * - **ajax** - Whether the request is an AJAX request + * - **scheme** - The server protocol (http, https) + * - **user_agent** - Browser information + * - **type** - The content type + * - **length** - The content length + * - **query** - Query string parameters + * - **data** - Post parameters + * - **cookies** - Cookie parameters + * - **files** - Uploaded files + * - **secure** - Connection is secure + * - **accept** - HTTP accept parameters + * - **proxy_ip** - Proxy IP address of the client + */ +class Request +{ + /** + * URL being requested + */ + public string $url; + + /** + * Parent subdirectory of the URL + */ + public string $base; + + /** + * Request method (GET, POST, PUT, DELETE) + */ + public string $method; + + /** + * Referrer URL + */ + public string $referrer; + + /** + * IP address of the client + */ + public string $ip; + + /** + * Whether the request is an AJAX request + */ + public bool $ajax; + + /** + * Server protocol (http, https) + */ + public string $scheme; + + /** + * Browser information + */ + public string $user_agent; + + /** + * Content type + */ + public string $type; + + /** + * Content length + */ + public int $length; + + /** + * Query string parameters + */ + public Collection $query; + + /** + * Post parameters + */ + public Collection $data; + + /** + * Cookie parameters + */ + public Collection $cookies; + + /** + * Uploaded files + */ + public Collection $files; + + /** + * Whether the connection is secure + */ + public bool $secure; + + /** + * HTTP accept parameters + */ + public string $accept; + + /** + * Proxy IP address of the client + */ + public string $proxy_ip; + + /** + * HTTP host name + */ + public string $host; + + /** + * Stream path for where to pull the request body from + */ + private string $stream_path = 'php://input'; + + /** + * Raw HTTP request body + */ + public string $body = ''; + + /** + * Constructor. + * + * @param array $config Request configuration + */ + public function __construct(array $config = []) + { + // Default properties + if (empty($config)) { + $config = [ + 'url' => str_replace('@', '%40', self::getVar('REQUEST_URI', '/')), + 'base' => str_replace(['\\', ' '], ['/', '%20'], \dirname(self::getVar('SCRIPT_NAME'))), + 'method' => self::getMethod(), + 'referrer' => self::getVar('HTTP_REFERER'), + 'ip' => self::getVar('REMOTE_ADDR'), + 'ajax' => self::getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest', + 'scheme' => self::getScheme(), + 'user_agent' => self::getVar('HTTP_USER_AGENT'), + 'type' => self::getVar('CONTENT_TYPE'), + 'length' => intval(self::getVar('CONTENT_LENGTH', 0)), + 'query' => new Collection($_GET), + 'data' => new Collection($_POST), + 'cookies' => new Collection($_COOKIE), + 'files' => new Collection($_FILES), + 'secure' => self::getScheme() === 'https', + 'accept' => self::getVar('HTTP_ACCEPT'), + 'proxy_ip' => self::getProxyIpAddress(), + 'host' => self::getVar('HTTP_HOST'), + ]; + } + + $this->init($config); + } + + /** + * Initialize request properties. + * + * @param array $properties Array of request properties + * + * @return self + */ + public function init(array $properties = []): self + { + // Set all the defined properties + foreach ($properties as $name => $value) { + $this->{$name} = $value; + } + + // Get the requested URL without the base directory + // This rewrites the url in case the public url and base directories match + // (such as installing on a subdirectory in a web server) + // @see testInitUrlSameAsBaseDirectory + if ($this->base !== '/' && $this->base !== '' && strpos($this->url, $this->base) === 0) { + $this->url = substr($this->url, \strlen($this->base)); + } + + // Default url + if (empty($this->url) === true) { + $this->url = '/'; + } else { + // Merge URL query parameters with $_GET + $_GET = array_merge($_GET, self::parseQuery($this->url)); + + $this->query->setData($_GET); + } + + // Check for JSON input + if (strpos($this->type, 'application/json') === 0) { + $body = $this->getBody(); + if ($body !== '') { + $data = json_decode($body, true); + if (is_array($data) === true) { + $this->data->setData($data); + } + } + } + + return $this; + } + + /** + * Gets the body of the request. + * + * @return string Raw HTTP request body + */ + public function getBody(): string + { + $body = $this->body; + + if ($body !== '') { + return $body; + } + + $method = $this->method ?? self::getMethod(); + + if ($method === 'POST' || $method === 'PUT' || $method === 'DELETE' || $method === 'PATCH') { + $body = file_get_contents($this->stream_path); + } + + $this->body = $body; + + return $body; + } + + /** + * Gets the request method. + */ + public static function getMethod(): string + { + $method = self::getVar('REQUEST_METHOD', 'GET'); + + if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']) === true) { + $method = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']; + } elseif (isset($_REQUEST['_method']) === true) { + $method = $_REQUEST['_method']; + } + + return strtoupper($method); + } + + /** + * Gets the real remote IP address. + * + * @return string IP address + */ + public static function getProxyIpAddress(): string + { + $forwarded = [ + 'HTTP_CLIENT_IP', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_FORWARDED', + 'HTTP_X_CLUSTER_CLIENT_IP', + 'HTTP_FORWARDED_FOR', + 'HTTP_FORWARDED', + ]; + + $flags = \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE; + + foreach ($forwarded as $key) { + if (\array_key_exists($key, $_SERVER) === true) { + sscanf($_SERVER[$key], '%[^,]', $ip); + if (filter_var($ip, \FILTER_VALIDATE_IP, $flags) !== false) { + return $ip; + } + } + } + + return ''; + } + + /** + * Gets a variable from $_SERVER using $default if not provided. + * + * @param string $var Variable name + * @param mixed $default Default value to substitute + * + * @return mixed Server variable value + */ + public static function getVar(string $var, $default = '') + { + return $_SERVER[$var] ?? $default; + } + + /** + * This will pull a header from the request. + * + * @param string $header Header name. Can be caps, lowercase, or mixed. + * @param string $default Default value if the header does not exist + * + * @return string + */ + public static function getHeader(string $header, $default = ''): string + { + $header = 'HTTP_' . strtoupper(str_replace('-', '_', $header)); + return self::getVar($header, $default); + } + + /** + * Gets all the request headers + * + * @return array + */ + public static function getHeaders(): array + { + $headers = []; + foreach ($_SERVER as $key => $value) { + if (strpos($key, 'HTTP_') === 0) { + // converts headers like HTTP_CUSTOM_HEADER to Custom-Header + $key = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5))))); + $headers[$key] = $value; + } + } + return $headers; + } + + /** + * Alias of Request->getHeader(). Gets a single header. + * + * @param string $header Header name. Can be caps, lowercase, or mixed. + * @param string $default Default value if the header does not exist + * + * @return string + */ + public static function header(string $header, $default = '') + { + return self::getHeader($header, $default); + } + + /** + * Alias of Request->getHeaders(). Gets all the request headers + * + * @return array + */ + public static function headers(): array + { + return self::getHeaders(); + } + + /** + * Gets the full request URL. + * + * @return string URL + */ + public function getFullUrl(): string + { + return $this->scheme . '://' . $this->host . $this->url; + } + + /** + * Grabs the scheme and host. Does not end with a / + * + * @return string + */ + public function getBaseUrl(): string + { + return $this->scheme . '://' . $this->host; + } + + /** + * Parse query parameters from a URL. + * + * @param string $url URL string + * + * @return array> + */ + public static function parseQuery(string $url): array + { + $params = []; + + $args = parse_url($url); + if (isset($args['query']) === true) { + parse_str($args['query'], $params); + } + + return $params; + } + + /** + * Gets the URL Scheme + * + * @return string 'http'|'https' + */ + public static function getScheme(): string + { + if ( + (isset($_SERVER['HTTPS']) === true && strtolower($_SERVER['HTTPS']) === 'on') + || + (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) === true && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') + || + (isset($_SERVER['HTTP_FRONT_END_HTTPS']) === true && $_SERVER['HTTP_FRONT_END_HTTPS'] === 'on') + || + (isset($_SERVER['REQUEST_SCHEME']) === true && $_SERVER['REQUEST_SCHEME'] === 'https') + ) { + return 'https'; + } + + return 'http'; + } +} diff --git a/api/flight/net/Response.php b/api/flight/net/Response.php new file mode 100644 index 0000000..1798de5 --- /dev/null +++ b/api/flight/net/Response.php @@ -0,0 +1,473 @@ + + */ +class Response +{ + /** + * Content-Length header. + */ + public bool $content_length = true; + + /** + * This is to maintain legacy handling of output buffering + * which causes a lot of problems. This will be removed + * in v4 + * + * @var boolean + */ + public bool $v2_output_buffering = false; + + /** + * HTTP status codes + * + * @var array $codes + */ + public static array $codes = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 208 => 'Already Reported', + + 226 => 'IM Used', + + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 306 => '(Unused)', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Payload Too Large', + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + + 426 => 'Upgrade Required', + + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + + 431 => 'Request Header Fields Too Large', + + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + + 510 => 'Not Extended', + 511 => 'Network Authentication Required', + ]; + + /** + * HTTP status + */ + protected int $status = 200; + + /** + * HTTP response headers + * + * @var array> $headers + */ + protected array $headers = []; + + /** + * HTTP response body + */ + protected string $body = ''; + + /** + * HTTP response sent + */ + protected bool $sent = false; + + /** + * These are callbacks that can process the response body before it's sent + * + * @var array $responseBodyCallbacks + */ + protected array $responseBodyCallbacks = []; + + /** + * Sets the HTTP status of the response. + * + * @param ?int $code HTTP status code. + * + * @throws Exception If invalid status code + * + * @return int|$this Self reference + */ + public function status(?int $code = null) + { + if ($code === null) { + return $this->status; + } + + if (\array_key_exists($code, self::$codes)) { + $this->status = $code; + } else { + throw new Exception('Invalid status code.'); + } + + return $this; + } + + /** + * Adds a header to the response. + * + * @param array|string $name Header name or array of names and values + * @param ?string $value Header value + * + * @return $this + */ + public function header($name, ?string $value = null): self + { + if (\is_array($name)) { + foreach ($name as $k => $v) { + $this->headers[$k] = $v; + } + } else { + $this->headers[$name] = $value; + } + + return $this; + } + + /** + * Gets a single header from the response. + * + * @param string $name the name of the header + * + * @return string|null + */ + public function getHeader(string $name): ?string + { + $headers = $this->headers; + // lowercase all the header keys + $headers = array_change_key_case($headers, CASE_LOWER); + return $headers[strtolower($name)] ?? null; + } + + /** + * Alias of Response->header(). Adds a header to the response. + * + * @param array|string $name Header name or array of names and values + * @param ?string $value Header value + * + * @return $this + */ + public function setHeader($name, ?string $value): self + { + return $this->header($name, $value); + } + + /** + * Returns the headers from the response. + * + * @return array> + */ + public function headers(): array + { + return $this->headers; + } + + /** + * Alias for Response->headers(). Returns the headers from the response. + * + * @return array> + */ + public function getHeaders(): array + { + return $this->headers(); + } + + /** + * Writes content to the response body. + * + * @param string $str Response content + * @param bool $overwrite Overwrite the response body + * + * @return $this Self reference + */ + public function write(string $str, bool $overwrite = false): self + { + if ($overwrite === true) { + $this->clearBody(); + } + + $this->body .= $str; + + return $this; + } + + /** + * Clears the response body. + * + * @return $this Self reference + */ + public function clearBody(): self + { + $this->body = ''; + return $this; + } + + /** + * Clears the response. + * + * @return $this Self reference + */ + public function clear(): self + { + $this->status = 200; + $this->headers = []; + $this->clearBody(); + + // This needs to clear the output buffer if it's on + if ($this->v2_output_buffering === false && ob_get_length() > 0) { + ob_clean(); + } + + return $this; + } + + /** + * Sets caching headers for the response. + * + * @param int|string|false $expires Expiration time as time() or as strtotime() string value + * + * @return $this Self reference + */ + public function cache($expires): self + { + if ($expires === false) { + $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; + + $this->headers['Cache-Control'] = [ + 'no-store, no-cache, must-revalidate', + 'post-check=0, pre-check=0', + 'max-age=0', + ]; + + $this->headers['Pragma'] = 'no-cache'; + } else { + $expires = \is_int($expires) ? $expires : strtotime($expires); + $this->headers['Expires'] = gmdate('D, d M Y H:i:s', $expires) . ' GMT'; + $this->headers['Cache-Control'] = 'max-age=' . ($expires - time()); + + if (isset($this->headers['Pragma']) && $this->headers['Pragma'] === 'no-cache') { + unset($this->headers['Pragma']); + } + } + + return $this; + } + + /** + * Sends HTTP headers. + * + * @return $this Self reference + */ + public function sendHeaders(): self + { + // Send status code header + if (strpos(\PHP_SAPI, 'cgi') !== false) { + // @codeCoverageIgnoreStart + $this->setRealHeader( + sprintf( + 'Status: %d %s', + $this->status, + self::$codes[$this->status] + ), + true + ); + // @codeCoverageIgnoreEnd + } else { + $this->setRealHeader( + sprintf( + '%s %d %s', + $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1', + $this->status, + self::$codes[$this->status] + ), + true, + $this->status + ); + } + + if ($this->content_length === true) { + // Send content length + $length = $this->getContentLength(); + + if ($length > 0) { + $this->setHeader('Content-Length', (string) $length); + } + } + + // Send other headers + foreach ($this->headers as $field => $value) { + if (\is_array($value)) { + foreach ($value as $v) { + $this->setRealHeader($field . ': ' . $v, false); + } + } else { + $this->setRealHeader($field . ': ' . $value); + } + } + + return $this; + } + + /** + * Sets a real header. Mostly used for test mocking. + * + * @param string $header_string The header string you would pass to header() + * @param bool $replace The optional replace parameter indicates whether the + * header should replace a previous similar header, or add a second header of + * the same type. By default it will replace, but if you pass in false as the + * second argument you can force multiple headers of the same type. + * @param int $response_code The response code to send + * + * @return self + * + * @codeCoverageIgnore + */ + public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): self + { + header($header_string, $replace, $response_code); + return $this; + } + + /** + * Gets the content length. + */ + public function getContentLength(): int + { + return \extension_loaded('mbstring') ? + mb_strlen($this->body, 'latin1') : + \strlen($this->body); + } + + /** + * Gets the response body + * + * @return string + */ + public function getBody(): string + { + return $this->body; + } + + /** + * Gets whether response body was sent. + */ + public function sent(): bool + { + return $this->sent; + } + + /** + * Marks the response as sent. + */ + public function markAsSent(): void + { + $this->sent = true; + } + + /** + * Sends a HTTP response. + */ + public function send(): void + { + // legacy way of handling this + if ($this->v2_output_buffering === true) { + if (ob_get_length() > 0) { + ob_end_clean(); // @codeCoverageIgnore + } + } + + // Only for the v3 output buffering. + if ($this->v2_output_buffering === false) { + $this->processResponseCallbacks(); + } + + if (headers_sent() === false) { + $this->sendHeaders(); // @codeCoverageIgnore + } + + echo $this->body; + + $this->sent = true; + } + + /** + * Adds a callback to process the response body before it's sent. These are processed in the order + * they are added + * + * @param callable $callback The callback to process the response body + * + * @return void + */ + public function addResponseBodyCallback(callable $callback): void + { + $this->responseBodyCallbacks[] = $callback; + } + + /** + * Cycles through the response body callbacks and processes them in order + * + * @return void + */ + protected function processResponseCallbacks(): void + { + foreach ($this->responseBodyCallbacks as $callback) { + $this->body = $callback($this->body); + } + } +} diff --git a/api/flight/net/Route.php b/api/flight/net/Route.php new file mode 100644 index 0000000..4e6e83c --- /dev/null +++ b/api/flight/net/Route.php @@ -0,0 +1,266 @@ + + */ +class Route +{ + /** + * URL pattern + */ + public string $pattern; + + /** + * Callback function + * + * @var mixed + */ + public $callback; + + /** + * HTTP methods + * + * @var array + */ + public array $methods = []; + + /** + * Route parameters + * + * @var array + */ + public array $params = []; + + /** + * Matching regular expression + */ + public ?string $regex = null; + + /** + * URL splat content + */ + public string $splat = ''; + + /** + * Pass self in callback parameters + */ + public bool $pass = false; + + /** + * The alias is a way to identify the route using a simple name ex: 'login' instead of /admin/login + */ + public string $alias = ''; + + /** + * The middleware to be applied to the route + * + * @var array + */ + public array $middleware = []; + + /** Whether the response for this route should be streamed. */ + public bool $is_streamed = false; + + /** + * If this route is streamed, the headers to be sent before the response. + * + * @var array + */ + public array $streamed_headers = []; + + /** + * Constructor. + * + * @param string $pattern URL pattern + * @param callable|string $callback Callback function + * @param array $methods HTTP methods + * @param bool $pass Pass self in callback parameters + */ + public function __construct(string $pattern, $callback, array $methods, bool $pass, string $alias = '') + { + $this->pattern = $pattern; + $this->callback = $callback; + $this->methods = $methods; + $this->pass = $pass; + $this->alias = $alias; + } + + /** + * Checks if a URL matches the route pattern. Also parses named parameters in the URL. + * + * @param string $url Requested URL (original format, not URL decoded) + * @param bool $case_sensitive Case sensitive matching + * + * @return bool Match status + */ + public function matchUrl(string $url, bool $case_sensitive = false): bool + { + // Wildcard or exact match + if ($this->pattern === '*' || $this->pattern === $url) { + return true; + } + + $ids = []; + $last_char = substr($this->pattern, -1); + + // Get splat + if ($last_char === '*') { + $n = 0; + $len = \strlen($url); + $count = substr_count($this->pattern, '/'); + + for ($i = 0; $i < $len; $i++) { + if ($url[$i] === '/') { + ++$n; + } + + if ($n === $count) { + break; + } + } + + $this->splat = urldecode(strval(substr($url, $i + 1))); + } + + // Build the regex for matching + $pattern_utf_chars_encoded = preg_replace_callback( + '#(\\p{L}+)#u', + static function ($matches) { + return urlencode($matches[0]); + }, + $this->pattern + ); + $regex = str_replace([')', '/*'], [')?', '(/?|/.*?)'], $pattern_utf_chars_encoded); + + $regex = preg_replace_callback( + '#@([\w]+)(:([^/\(\)]*))?#', + static function ($matches) use (&$ids) { + $ids[$matches[1]] = null; + if (isset($matches[3])) { + return '(?P<' . $matches[1] . '>' . $matches[3] . ')'; + } + + return '(?P<' . $matches[1] . '>[^/\?]+)'; + }, + $regex + ); + + $regex .= $last_char === '/' ? '?' : '/?'; + + // Attempt to match route and named parameters + if (!preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) { + return false; + } + + foreach (array_keys($ids) as $k) { + $this->params[$k] = (\array_key_exists($k, $matches)) ? urldecode($matches[$k]) : null; + } + + $this->regex = $regex; + + return true; + } + + /** + * Checks if an HTTP method matches the route methods. + * + * @param string $method HTTP method + * + * @return bool Match status + */ + public function matchMethod(string $method): bool + { + return \count(array_intersect([$method, '*'], $this->methods)) > 0; + } + + /** + * Checks if an alias matches the route alias. + */ + public function matchAlias(string $alias): bool + { + return $this->alias === $alias; + } + + /** + * Hydrates the route url with the given parameters + * + * @param array $params the parameters to pass to the route + */ + public function hydrateUrl(array $params = []): string + { + $url = preg_replace_callback("/(?:@([\w]+)(?:\:([^\/]+))?\)*)/i", function ($match) use ($params) { + if (isset($match[1]) && isset($params[$match[1]])) { + return $params[$match[1]]; + } + }, $this->pattern); + + // catches potential optional parameter + $url = str_replace('(/', '/', $url); + // trim any trailing slashes + if ($url !== '/') { + $url = rtrim($url, '/'); + } + return $url; + } + + /** + * Sets the route alias + * + * @return $this + */ + public function setAlias(string $alias): self + { + $this->alias = $alias; + return $this; + } + + /** + * Sets the route middleware + * + * @param array|callable|string $middleware + */ + public function addMiddleware($middleware): self + { + if (is_array($middleware) === true) { + $this->middleware = array_merge($this->middleware, $middleware); + } else { + $this->middleware[] = $middleware; + } + return $this; + } + + /** + * If the response should be streamed + * + * @return self + */ + public function stream(): self + { + $this->is_streamed = true; + return $this; + } + + /** + * This will allow the response for this route to be streamed. + * + * @param array $headers a key value of headers to set before the stream starts. + * + * @return $this + */ + public function streamWithHeaders(array $headers): self + { + $this->is_streamed = true; + $this->streamed_headers = $headers; + + return $this; + } +} diff --git a/api/flight/net/Router.php b/api/flight/net/Router.php new file mode 100644 index 0000000..a43b5ba --- /dev/null +++ b/api/flight/net/Router.php @@ -0,0 +1,330 @@ + + */ +class Router +{ + /** + * Case sensitive matching. + */ + public bool $case_sensitive = false; + + /** + * Mapped routes. + * + * @var array $routes + */ + protected array $routes = []; + + /** + * The current route that is has been found and executed. + */ + public ?Route $executedRoute = null; + + /** + * Pointer to current route. + */ + protected int $index = 0; + + /** + * When groups are used, this is mapped against all the routes + */ + protected string $groupPrefix = ''; + + /** + * Group Middleware + * + * @var array + */ + protected array $groupMiddlewares = []; + + /** + * Allowed HTTP methods + * + * @var array + */ + protected array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; + + /** + * Gets mapped routes. + * + * @return array Array of routes + */ + public function getRoutes(): array + { + return $this->routes; + } + + /** + * Clears all routes in the router. + */ + public function clear(): void + { + $this->routes = []; + } + + /** + * Maps a URL pattern to a callback function. + * + * @param string $pattern URL pattern to match. + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback. + * @param string $route_alias Alias for the route. + */ + public function map(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route + { + + // This means that the route ies defined in a group, but the defined route is the base + // url path. Note the '' in route() + // Ex: Flight::group('/api', function() { + // Flight::route('', function() {}); + // } + // Keep the space so that it can execute the below code normally + if ($this->groupPrefix !== '') { + $url = ltrim($pattern); + } else { + $url = trim($pattern); + } + + $methods = ['*']; + + if (strpos($url, ' ') !== false) { + [$method, $url] = explode(' ', $url, 2); + $url = trim($url); + $methods = explode('|', $method); + + // Add head requests to get methods, should they come in as a get request + if (in_array('GET', $methods, true) === true && in_array('HEAD', $methods, true) === false) { + $methods[] = 'HEAD'; + } + } + + // And this finishes it off. + if ($this->groupPrefix !== '') { + $url = rtrim($this->groupPrefix . $url); + } + + $route = new Route($url, $callback, $methods, $pass_route, $route_alias); + + // to handle group middleware + foreach ($this->groupMiddlewares as $gm) { + $route->addMiddleware($gm); + } + + $this->routes[] = $route; + + return $route; + } + + /** + * Creates a GET based route + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route + */ + public function get(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route + { + return $this->map('GET ' . $pattern, $callback, $pass_route, $alias); + } + + /** + * Creates a POST based route + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route + */ + public function post(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route + { + return $this->map('POST ' . $pattern, $callback, $pass_route, $alias); + } + + /** + * Creates a PUT based route + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route + */ + public function put(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route + { + return $this->map('PUT ' . $pattern, $callback, $pass_route, $alias); + } + + /** + * Creates a PATCH based route + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route + */ + public function patch(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route + { + return $this->map('PATCH ' . $pattern, $callback, $pass_route, $alias); + } + + /** + * Creates a DELETE based route + * + * @param string $pattern URL pattern to match + * @param callable|string $callback Callback function or string class->method + * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route + */ + public function delete(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route + { + return $this->map('DELETE ' . $pattern, $callback, $pass_route, $alias); + } + + /** + * Group together a set of routes + * + * @param string $groupPrefix group URL prefix (such as /api/v1) + * @param callable $callback The necessary calling that holds the Router class + * @param array $groupMiddlewares + * The middlewares to be applied to the group. Example: `[$middleware1, $middleware2]` + */ + public function group(string $groupPrefix, callable $callback, array $groupMiddlewares = []): void + { + $oldGroupPrefix = $this->groupPrefix; + $oldGroupMiddlewares = $this->groupMiddlewares; + $this->groupPrefix .= $groupPrefix; + $this->groupMiddlewares = array_merge($this->groupMiddlewares, $groupMiddlewares); + $callback($this); + $this->groupPrefix = $oldGroupPrefix; + $this->groupMiddlewares = $oldGroupMiddlewares; + } + + /** + * Routes the current request. + * + * @return false|Route Matching route or false if no match + */ + public function route(Request $request) + { + while ($route = $this->current()) { + $urlMatches = $route->matchUrl($request->url, $this->case_sensitive); + $methodMatches = $route->matchMethod($request->method); + if ($urlMatches === true && $methodMatches === true) { + $this->executedRoute = $route; + return $route; + // capture the route but don't execute it. We'll use this in Engine->start() to throw a 405 + } elseif ($urlMatches === true && $methodMatches === false) { + $this->executedRoute = $route; + } + $this->next(); + } + + return false; + } + + /** + * Gets the URL for a given route alias + * + * @param string $alias the alias to match + * @param array $params the parameters to pass to the route + */ + public function getUrlByAlias(string $alias, array $params = []): string + { + $potential_aliases = []; + foreach ($this->routes as $route) { + $potential_aliases[] = $route->alias; + if ($route->matchAlias($alias)) { + // This will make it so the params that already + // exist in the url will be passed in. + if (!empty($this->executedRoute->params)) { + $params = $params + $this->executedRoute->params; + } + return $route->hydrateUrl($params); + } + } + + // use a levenshtein to find the closest match and make a recommendation + $closest_match = ''; + $closest_match_distance = 0; + foreach ($potential_aliases as $potential_alias) { + $levenshtein_distance = levenshtein($alias, $potential_alias); + if ($levenshtein_distance > $closest_match_distance) { + $closest_match = $potential_alias; + $closest_match_distance = $levenshtein_distance; + } + } + + $exception_message = 'No route found with alias: \'' . $alias . '\'.'; + if ($closest_match !== '') { + $exception_message .= ' Did you mean \'' . $closest_match . '\'?'; + } + + throw new Exception($exception_message); + } + + /** + * Rewinds the current route index. + */ + public function rewind(): void + { + $this->index = 0; + } + + /** + * Checks if more routes can be iterated. + * + * @return bool More routes + */ + public function valid(): bool + { + return isset($this->routes[$this->index]); + } + + /** + * Gets the current route. + * + * @return false|Route + */ + public function current() + { + return $this->routes[$this->index] ?? false; + } + + /** + * Gets the previous route. + */ + public function previous(): void + { + --$this->index; + } + + /** + * Gets the next route. + */ + public function next(): void + { + ++$this->index; + } + + /** + * Reset to the first route. + */ + public function reset(): void + { + $this->rewind(); + } +} diff --git a/api/flight/template/View.php b/api/flight/template/View.php new file mode 100644 index 0000000..15e4fc8 --- /dev/null +++ b/api/flight/template/View.php @@ -0,0 +1,203 @@ + + */ +class View +{ + /** Location of view templates. */ + public string $path; + + /** File extension. */ + public string $extension = '.php'; + + public bool $preserveVars = true; + + /** + * View variables. + * + * @var array $vars + */ + protected array $vars = []; + + /** Template file. */ + private string $template; + + /** + * Constructor. + * + * @param string $path Path to templates directory + */ + public function __construct(string $path = '.') + { + $this->path = $path; + } + + /** + * Gets a template variable. + * + * @return mixed Variable value or `null` if doesn't exists + */ + public function get(string $key) + { + return $this->vars[$key] ?? null; + } + + /** + * Sets a template variable. + * + * @param string|iterable $key + * @param mixed $value Value + * + * @return self + */ + public function set($key, $value = null): self + { + if (\is_iterable($key)) { + foreach ($key as $k => $v) { + $this->vars[$k] = $v; + } + } else { + $this->vars[$key] = $value; + } + + return $this; + } + + /** + * Checks if a template variable is set. + * + * @return bool If key exists + */ + public function has(string $key): bool + { + return isset($this->vars[$key]); + } + + /** + * Unsets a template variable. If no key is passed in, clear all variables. + * + * @return $this + */ + public function clear(?string $key = null): self + { + if ($key === null) { + $this->vars = []; + } else { + unset($this->vars[$key]); + } + + return $this; + } + + /** + * Renders a template. + * + * @param string $file Template file + * @param ?array $data Template data + * + * @throws \Exception If template not found + */ + public function render(string $file, ?array $data = null): void + { + $this->template = $this->getTemplate($file); + + if (!\file_exists($this->template)) { + $normalized_path = self::normalizePath($this->template); + throw new \Exception("Template file not found: {$normalized_path}."); + } + + \extract($this->vars); + + if (\is_array($data) === true) { + \extract($data); + + if ($this->preserveVars === true) { + $this->vars = \array_merge($this->vars, $data); + } + } + + include $this->template; + } + + /** + * Gets the output of a template. + * + * @param string $file Template file + * @param ?array $data Template data + * + * @return string Output of template + */ + public function fetch(string $file, ?array $data = null): string + { + \ob_start(); + + $this->render($file, $data); + + return \ob_get_clean(); + } + + /** + * Checks if a template file exists. + * + * @param string $file Template file + * + * @return bool Template file exists + */ + public function exists(string $file): bool + { + return \file_exists($this->getTemplate($file)); + } + + /** + * Gets the full path to a template file. + * + * @param string $file Template file + * + * @return string Template file location + */ + public function getTemplate(string $file): string + { + $ext = $this->extension; + + if (!empty($ext) && (\substr($file, -1 * \strlen($ext)) != $ext)) { + $file .= $ext; + } + + $is_windows = \strtoupper(\substr(PHP_OS, 0, 3)) === 'WIN'; + + if ((\substr($file, 0, 1) === '/') || ($is_windows && \substr($file, 1, 1) === ':')) { + return $file; + } + + return $this->path . DIRECTORY_SEPARATOR . $file; + } + + /** + * Displays escaped output. + * + * @param string $str String to escape + * + * @return string Escaped string + */ + public function e(string $str): string + { + $value = \htmlentities($str); + echo $value; + return $value; + } + + protected static function normalizePath(string $path, string $separator = DIRECTORY_SEPARATOR): string + { + return \str_replace(['\\', '/'], $separator, $path); + } +} diff --git a/api/flight/util/Collection.php b/api/flight/util/Collection.php new file mode 100644 index 0000000..e17ed37 --- /dev/null +++ b/api/flight/util/Collection.php @@ -0,0 +1,223 @@ + + * @implements ArrayAccess + * @implements Iterator + */ +class Collection implements ArrayAccess, Iterator, Countable, JsonSerializable +{ + /** + * Collection data. + * + * @var array + */ + private array $data; + + /** + * Constructor. + * + * @param array $data Initial data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + /** + * Gets an item. + * + * @return mixed Value if `$key` exists in collection data, otherwise returns `NULL` + */ + public function __get(string $key) + { + return $this->data[$key] ?? null; + } + + /** + * Set an item. + * + * @param mixed $value Value + */ + public function __set(string $key, $value): void + { + $this->data[$key] = $value; + } + + /** + * Checks if an item exists. + */ + public function __isset(string $key): bool + { + return isset($this->data[$key]); + } + + /** + * Removes an item. + */ + public function __unset(string $key): void + { + unset($this->data[$key]); + } + + /** + * Gets an item at the offset. + * + * @param string $offset Offset + * + * @return mixed Value + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->data[$offset] ?? null; + } + + /** + * Sets an item at the offset. + * + * @param ?string $offset Offset + * @param mixed $value Value + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value): void + { + if ($offset === null) { + $this->data[] = $value; + } else { + $this->data[$offset] = $value; + } + } + + /** + * Checks if an item exists at the offset. + * + * @param string $offset + */ + public function offsetExists($offset): bool + { + return isset($this->data[$offset]); + } + + /** + * Removes an item at the offset. + * + * @param string $offset + */ + public function offsetUnset($offset): void + { + unset($this->data[$offset]); + } + + /** + * Resets the collection. + */ + public function rewind(): void + { + reset($this->data); + } + + /** + * Gets current collection item. + * + * @return mixed Value + */ + #[\ReturnTypeWillChange] + public function current() + { + return current($this->data); + } + + /** + * Gets current collection key. + * + * @return mixed Value + */ + #[\ReturnTypeWillChange] + public function key() + { + return key($this->data); + } + + /** + * Gets the next collection value. + */ + #[\ReturnTypeWillChange] + public function next(): void + { + next($this->data); + } + + /** + * Checks if the current collection key is valid. + */ + public function valid(): bool + { + return key($this->data) !== null; + } + + /** + * Gets the size of the collection. + */ + public function count(): int + { + return \count($this->data); + } + + /** + * Gets the item keys. + * + * @return array Collection keys + */ + public function keys(): array + { + return array_keys($this->data); + } + + /** + * Gets the collection data. + * + * @return array Collection data + */ + public function getData(): array + { + return $this->data; + } + + /** + * Sets the collection data. + * + * @param array $data New collection data + */ + public function setData(array $data): void + { + $this->data = $data; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->data; + } + + /** + * Removes all items from the collection. + */ + public function clear(): void + { + $this->data = []; + } +} diff --git a/api/flight/util/ReturnTypeWillChange.php b/api/flight/util/ReturnTypeWillChange.php new file mode 100644 index 0000000..1eba39e --- /dev/null +++ b/api/flight/util/ReturnTypeWillChange.php @@ -0,0 +1,8 @@ +query->title ?? false; + + if ($id === null) { + if($title) { + $res = MusicAPI::findAllSongByTitleContaining($title); + } else { + $res = MusicAPI::findAllSong(); + } + Flight::json(["results" => $res]); + } else { + $res = MusicAPI::findSongById($id); + if ($res) + { + Flight::json($res); + } else { + Flight::halt(404); + } + } +} + +function findAlbum($id = null) +{ + $name = Flight::request()->query->name ?? false; + + if ($id === null) { + if ($name){ + $res = MusicAPI::findAlbumByNameContaining($name); + } else { + $res = MusicAPI::findAllAlbum(); + } + Flight::json(["results" => $res]); + } else { + $res = MusicAPI::findAlbumById($id); + if ($res) { + Flight::json($res); + } else { + Flight::halt(404); + } + } +} + +function findArtist($id = null) +{ + $name = Flight::request()->query->name ?? false; + + if ($id === null) { + if ($name){ + $res = MusicAPI::findArtsistByNameContaining($name); + } else { + $res = MusicAPI::findAllArtist(); + } + Flight::json(["results" => $res]); + } + else { + $res = MusicAPI::findArtsistById($id); + if ($res) { + Flight::json($res); + } else { + Flight::halt(404); + } + } +} + +Flight::start(); + +?> diff --git a/api/model/model.php b/api/model/model.php new file mode 100644 index 0000000..200d1b6 --- /dev/null +++ b/api/model/model.php @@ -0,0 +1,131 @@ +host = $host; + $this->user = $user; + $this->pass = $pass; + $this->dbname = $dbname; + } + public function connect() { + $driver_options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + $dsn = "mysql:host=$this->host;dbname=$this->dbname"; + $this->pdo = new PDO($dsn, $this->user, $this->pass,$driver_options); + } + + public function query($sql, $params = []) { + $stmt = $this->pdo->prepare($sql); + if ($stmt->execute($params) === true) + return $stmt; + return false; + } + + public function lastInsertId(){ + return $this->pdo->lastInsertId(); + } +} + + +class MusicAPI +{ + static private $db = null; + static private $BASE_QUERY = "SELECT Song.id, Song.name, Album.name AS `album-name`, Album.year, Artist.name AS `artist-name`, Genre.name AS `genre` ". + "FROM Song ". + "JOIN Track ON Song.id=Track.songId ". + "JOIN Album ON Track.albumId=Album.id ". + "JOIN Artist ON Album.artistId=Artist.id ". + "JOIN Cover ON Album.coverId=Cover.id ". + "JOIN Genre ON Album.genreId=Genre.id "; + + public static function init() + { + self::$db = new Database("localhost", "fauvet", "toto", "fauvet"); + self::$db->connect(); + } + + public static function findSongById($id) + { + $sql = self::$BASE_QUERY."WHERE Song.id = ?"; + $stmt = self::$db->query($sql, [$id]); + return $stmt->fetchAll(); + } + + public static function findAllSongByTitleContaining($title) + { + $sql = self::$BASE_QUERY."WHERE Song.name LIKE ?;"; + $stmt = self::$db->query($sql, ['%' . $title . '%']); + return $stmt->fetchAll(); + } + + public static function findSongByAlbumName($a) + { + $sql = self::$BASE_QUERY."WHERE Album.name LIKE ?;"; + $stmt = self::$db->query($sql, ['%' . $a . '%']); + return $stmt->fetchAll(); + } + + public static function findAllSong() + { + $sql = self::$BASE_QUERY.";"; + $stmt = self::$db->query($sql); + return $stmt->fetchAll(); + } + + //Fonctions pour la recherche d'album + + public static function findAlbumById($id) + { + $sql = "SELECT Album.id, Album.name, Album.year FROM Album WHERE Album.id=?;"; + $stmt = self::$db->query($sql, [$id]); + return $stmt->fetchAll(); + } + + public static function findAlbumByNameContaining($n) + { + $sql = "SELECT Album.id, Album.name, Album.year FROM Album WHERE Album.name LIKE ?;"; + $stmt = self::$db->query($sql, ['%'.$n.'%']); + return $stmt->fetchAll(); + } + + public static function findAllAlbum() + { + $sql = "SELECT Album.id, Album.name, Album.year FROM Album;"; + $stmt = self::$db->query($sql); + return $stmt->fetchAll(); + } + + //Fonctions pour la recherche de titres + + public static function findArtsistById($id) + { + $sql = "SELECT Artist.id, Artist.name FROM Artist WHERE Artist.id=?;"; + $stmt = self::$db->query($sql, [$id]); + return $stmt->fetchAll(); + } + + public static function findArtsistByNameContaining($n) + { + $sql = "SELECT Artist.id, Artist.name FROM Artist WHERE Artist.name LIKE ?;"; + $stmt = self::$db->query($sql, ['%'.$n.'%']); + return $stmt->fetchAll(); + } + + public static function findAllArtist() + { + $sql = "SELECT * FROM Artist;"; + $stmt = self::$db->query($sql); + return $stmt->fetchAll(); + } + +} +?> diff --git a/api/phpcs.xml.dist b/api/phpcs.xml.dist new file mode 100644 index 0000000..79f3ff0 --- /dev/null +++ b/api/phpcs.xml.dist @@ -0,0 +1,53 @@ + + + + Created with the PHP Coding Standard Generator. + http://edorian.github.io/php-coding-standard-generator/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + flight/ + tests/ + tests/views/* +