├── .gitignore ├── .scrutinizer.yml ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── docs └── README.md ├── examples ├── Controllers │ └── GoodbyeController.php ├── Modules │ └── SimpleModule.php ├── build_in_server_daemonized_example.php ├── build_in_server_example.php ├── example_error_handling.php └── modular_application_example.php ├── phpunit.xml ├── src └── Application │ ├── Application.php │ ├── Config.php │ ├── Controller.php │ ├── ControllerAggregator.php │ ├── Exception │ ├── ApplicationException.php │ ├── ConfigException.php │ └── ControllerException.php │ ├── Http │ ├── Controller.php │ ├── GenericRouter.php │ └── MiddlewareAggregator.php │ ├── HttpApplication.php │ ├── Listeners │ ├── OnBootListener.php │ ├── OnErrorListener.php │ ├── OnRunListener.php │ └── OnShutDownListener.php │ └── Providers │ ├── ConfigProvider.php │ ├── ControllerProvider.php │ ├── MiddlewareProvider.php │ └── ServiceProvider.php └── tests ├── Fixtures ├── Boo.php ├── CustomHttpException.php ├── HttpController.php ├── Modules │ ├── ExampleModuleA.php │ └── ExampleModuleB.php ├── NullApplication.php ├── SampleController.php ├── config │ ├── a.ini │ ├── auth.routes.autoload.ini │ ├── autoload │ │ ├── b.ini │ │ └── c.ini │ ├── test.autoload.ini │ └── user.routes.autoload.ini └── http.ini └── Functional └── Application ├── ApplicationTest.php ├── ConfigTest.php ├── Http └── GenericRouterTest.php └── HttpApplicationTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /composer.lock 3 | /.idea 4 | /vendor 5 | /tests/cache/* 6 | /coverage.clover 7 | /coverage 8 | /tests/.phpunit.result.cache 9 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | checks: 2 | php: 3 | code_rating: true 4 | 5 | build: 6 | tests: 7 | override: 8 | command: "php -v" 9 | 10 | tools: 11 | external_code_coverage: true 12 | php_analyzer: true 13 | php_changetracking: true 14 | php_code_sniffer: 15 | config: 16 | standard: "PSR2" 17 | php_mess_detector: true 18 | 19 | filter: 20 | excluded_paths: 21 | - docs/* 22 | - examples/* 23 | - tests/* 24 | - src/Exception/* 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | sudo: false 4 | 5 | php: 6 | - 7.1 7 | - 7.2 8 | 9 | before_install: 10 | - yes Y | pecl install swoole 11 | - composer self-update 12 | 13 | install: 14 | - composer update 15 | 16 | script: 17 | - vendor/bin/phpunit 18 | 19 | after_script: 20 | - wget https://scrutinizer-ci.com/ocular.phar 21 | - php ocular.phar code-coverage:upload --format=php-clover coverage.clover 22 | 23 | cache: 24 | directories: 25 | - $HOME/.composer/cache 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Release notes for 2.x 2 | 3 | ### 2.0.0 4 | 5 | #### Fixed 6 | - Server listeners now are refactored to use own pipes, this fixes issue where some listeners were overriding others 7 | 8 | #### Changed 9 | - Error handling improvements: 10 | - `Igni\Network\Exception\HttpException` interface now provides one `toResponse` method instead `getHttpCode` and `getHttpBody` 11 | - `Igni\Application\Listeners\OnErrorListener` allows to swap the exception 12 | - Removed not found middleware, now error middleware keeps track of not found routes as well 13 | - `Igni\Http\Application` becomes `Igni\Application\HttpApplication` 14 | - `Igni\Http\Application` becomes `Igni\Application\HttpApplication` 15 | - `Igni\Application\Controller\ControllerAggregate` interface becomes `Igni\Application\ControllerAggregator` 16 | - `Igni\Application\Controller\ControllerAggregate::add` method was rename to `Igni\Application\ControllerAggregator::register` 17 | - `Igni\Http\Controller\ControllerAggregate` gets removed and responsibility is passed to `Igni\Application\HttpApplication` 18 | - `Igni\Http\MiddlewareProvider` becomes `Igni\Application\Providers\MiddlewareProvider` 19 | - `Igni\Http\Server` becomes `Igni\Network\Server\HttpServer` 20 | - `Igni\Http\Server\HttpConfiguration` becomes `Igni\Network\Server\Configuration` 21 | - `Igni\Http\Response` becomes `Igni\Network\Http\Response` 22 | - `Igni\Http\Route` becomes `Igni\Network\Http\Route` 23 | - `Igni\Http\Router` becomes `Igni\Application\Http\GenericRouter` 24 | - `Igni\Http\Route::from*` methods were rename to `Igni\Network\Http\Route::as*` methods 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Dawid Kraczkowski 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR 15 | A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 16 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 17 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH 18 | THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Igni logo](https://github.com/igniphp/common/blob/master/logo/full.svg) 2 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) 3 | [![Build Status](https://travis-ci.org/igniphp/framework.svg?branch=master)](https://travis-ci.org/igniphp/framework) 4 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/igniphp/framework/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/igniphp/framework/?branch=master) 5 | [![Code Coverage](https://scrutinizer-ci.com/g/igniphp/framework/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/igniphp/framework/?branch=master) 6 | 7 | Igni is a php7 anti-framework with built-in [swoole server](https://www.swoole.co.uk) and modular architecture support to 8 | help you quickly write scalable PSR-7 and PSR-15 compilant REST services. 9 | 10 | Its main objective it to be as much transparent and as less visible for your application as possible. 11 | 12 | ```php 13 | get('/hello/{name}', function (Request $request) : Response { 26 | return Response::asText("Hello {$request->getAttribute('name')}."); 27 | }); 28 | 29 | // Middleware - no interfaces no binding with framework code is required in order things to work. 30 | $application->use(function($request, /** callable|RequestHandlerInterface */$next) { 31 | $response = $next($request); 32 | return $response->withAddedHeader('Version', $this->getConfig()->get('version')); 33 | }); 34 | 35 | // Extending application is a brief just create and implement methods for your needs. 36 | $application->extend(new class implements ConfigProvider { 37 | public function provideConfig(Config $config): void { 38 | $config->set('version', '1.0'); 39 | } 40 | }); 41 | 42 | $application->run(); 43 | ``` 44 | 45 | ## Installation and requirements 46 | 47 | Recommended installation way of the Igni Framework is with composer: 48 | 49 | ``` 50 | composer install igniphp/framework 51 | ``` 52 | 53 | Requirements: 54 | - php 7.1 or better 55 | - [swoole](https://github.com/swoole/swoole-src) extension for build-in http server support 56 | 57 | ### What's new 58 | 59 | With version 2.0 network package was extracted from the framework code, error handling was 60 | overall improved as well as Server's listeners. More details can be found in changelog file. 61 | 62 | ### Quick start 63 | Alternatively you can start using framework with [quick start](https://github.com/igniphp/framework-quick-start) which contains bootstrap application. 64 | 65 | ## Features 66 | 67 | ### Routing 68 | 69 | Igni router is based on very fast symfony routing library. 70 | 71 | ### PSR-7, PSR-15 Support 72 | 73 | Igni fully supports PSR message standards for both manipulating http response, request and http middlwares. 74 | 75 | ### Dependency Injection and Autoresolving 76 | 77 | Igni autoresolves dependencies for you and provides intuitive dependency container. 78 | It also allows you to use any PSR compatible container of your choice. 79 | 80 | ### Modular architecture 81 | 82 | Modular and scalable solution is one of the most important aspects why this framework was born. 83 | Simply create a module class, implement required interfaces and extend application by your module. 84 | 85 | ### Performant, production ready http server 86 | 87 | No nginx nor apache is required when `swoole` is installed, application can be run the same manner as in node.js world: 88 | ``` 89 | php examples/build_in_server_example.php 90 | ``` 91 | 92 | Igni's http server is as fast as express.js application with almost 0 configuration. 93 | 94 | ### Detailed documentation 95 | 96 | Detailed documentation and more examples can be [found here](docs/README.md) and in examples directory. 97 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "igniphp/framework", 3 | "description": "Swoole, PSR-7, PSR-15 modular micro anti-framework.", 4 | "keywords": [ 5 | "swoole", 6 | "http", 7 | "psr-7", 8 | "psr-15", 9 | "middleware", 10 | "micro", 11 | "framework" 12 | ], 13 | "support": { 14 | "issues": "https://github.com/igniphp/framework/issues" 15 | }, 16 | "license": "MIT", 17 | "authors": [ 18 | { 19 | "name": "Dawid Kraczkowski", 20 | "email": "dawid.kraczkowski@gmail.com" 21 | } 22 | ], 23 | "scripts": { 24 | "phpstan": "vendor/bin/phpstan analyse src --level=0", 25 | "coverage": "vendor/bin/phpunit --coverage-html ../coverage" 26 | }, 27 | "require": { 28 | "php": ">=7.1.0", 29 | "igniphp/container": ">=1.1.0", 30 | "igniphp/exception": ">=1.0", 31 | "igniphp/network": ">=0.3.2", 32 | "psr/container": ">=1.0", 33 | "psr/http-message": ">=1.0", 34 | "psr/http-server-middleware": ">=1.0", 35 | "psr/log": ">=1.0", 36 | "psr/simple-cache": ">=1.0", 37 | "symfony/routing": ">=4.1", 38 | "zendframework/zend-httphandlerrunner": "^1.0" 39 | }, 40 | "suggest": { 41 | "ext-swoole": "for build in http server support." 42 | }, 43 | "require-dev": { 44 | "mockery/mockery": ">=1.0.0", 45 | "league/container": ">=1.0.0", 46 | "phpstan/phpstan": ">=0.9.2", 47 | "phpunit/phpunit": ">=7.0.0" 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "Igni\\": "src/" 52 | } 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "Igni\\Tests\\": "tests/" 57 | } 58 | }, 59 | "config": { 60 | "sort-packages": true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # ![Igni logo](https://github.com/igniphp/common/blob/master/logo/full.svg)![Build Status](https://travis-ci.org/igniphp/framework.svg?branch=master) 2 | 3 | ## Igni framework 4 | Licensed under MIT License. 5 | 6 | ## Table of Contents 7 | * [Overview](#overview) 8 | + [Introduction](#introduction) 9 | + [Installation](#installation) 10 | + [Usage](#usage) 11 | * [Routing](#routing) 12 | * [Middleware](#middleware) 13 | * [Modules](#modules) 14 | + [Listeners](#listeners) 15 | + [Providers](#providers) 16 | * [The Request](#the-request) 17 | * [The Response](#the-response) 18 | * [Error handling](#error-handling) 19 | * [Testing](#testing) 20 | * [Working with containers](#working-with-containers) 21 | * [Igni's server](#ignis-server) 22 | + [Installation](#installation-1) 23 | + [Basic Usage](#basic-usage) 24 | + [Listeners](#listeners-1) 25 | + [Configuration](#configuration) 26 | * [Webserver configuration](#external-webserver) 27 | + [Apache](#apache) 28 | + [nginx + php-fpm](#nginx--php-fpm) 29 | + [PHP built-in webserver](#php-built-in-webserver) 30 | 31 | ## Overview 32 | 33 | ### Introduction 34 | **Igni** anti-framework allows you to write extensible and middleware based REST applications which are [PSR-7](https://www.php-fig.org/psr/psr-7/) and [PSR-15](https://www.php-fig.org/psr/psr-15/) compliant. 35 | 36 | Igni aims to be: 37 | - Lightweight: Igni's source code is around 60KB. 38 | - Extensible: Igni offers extension system that allows to use `PSR-15` middleware (with `zend-stratigility`) and modular architecture. 39 | - Testable: Igni extends `zend-diactoros` (`PSR-7` implementation) and allows to manually dispatch routes to perform end-to-end tests. 40 | - Easy to use: Igni exposes an intuitive and concise API. All you need to do to use it is to include composer autoloader. 41 | - Transparent: dont bind your application to the framework. 42 | 43 | ### Installation 44 | 45 | ``` 46 | composer install igniphp/framework 47 | ``` 48 | 49 | ### Usage 50 | 51 | In a nutshell, you can define controllers and map them to routes in one step. When the route matches, the function is executed and the response is dispatched to the client. 52 | 53 | Igni can be used with already configured web-server or with shipped server (if `swoole` extension is installed). 54 | 55 | Following example shows how you can use Igni without build in webserver: 56 | ```php 57 | get('/hello/{name}', function (ServerRequestInterface $request): ResponseInterface { 70 | return Response::asText("Hello {$request->getAttribute('name')}"); 71 | }); 72 | 73 | // Run the application 74 | $application->run(); 75 | ``` 76 | First composer's autoloader is included in the application. Next instantiation of application happens. 77 | In line `64` callable controller is attached to the `GET /hello/{name}` route. 78 | Finally application is being run. 79 | 80 | Similar approach is taken when build-in server comes in place: 81 | 82 | ```php 83 | get('/hello/{name}', function (ServerRequestInterface $request): ResponseInterface { 95 | return Response::asText("Hello {$request->getAttribute('name')}"); 96 | }); 97 | 98 | // Run with the server 99 | $application->run(new HttpServer()); 100 | ``` 101 | 102 | Server instance is created and passed to application's `run` method. 103 | While using default settings it listens on incoming localhost connections on port `8080`. 104 | 105 | ## Routing 106 | 107 | The route is a pattern representation of expected URI requested by clients. 108 | Route pattern can use a syntax where `{var}` specifies a placeholder with name `var` and 109 | it matches a regex `[^/]+.`. 110 | 111 | ```php 112 | get('/users/{id}', function() {...}); 114 | $application->get('/books/{isbn}/page/{page}', function() {...}); 115 | ``` 116 | 117 | ### Custom pattern 118 | 119 | You can specify a custom pattern after the parameter name, the pattern should be enclosed between `<` and `>`. 120 | Here are some examples: 121 | 122 | ```php 123 | get('/users/{id<\d+>}', function() {...}); 128 | 129 | // Matches following get requests: /users/42, /users/me 130 | $application->get('/users/{name<\w+>}', function() {...}); 131 | 132 | // Bind controller to custom route instance 133 | $application->on(new Route('/hello/{name<\w+>}', ['GET', 'DELETE', 'POST']), function() {...}); 134 | ``` 135 | 136 | ### Default value 137 | 138 | Default value can be specified after parameter name and should follow `?` sign, example: 139 | ```php 140 | get('/users/{id<\d+>?2}', function() {...}); 144 | 145 | // Matches following get requests with default id(me): /users/42, /users/me 146 | $application->get('/users/{name<\w+>?me}', function() {...}); 147 | ``` 148 | 149 | ### Optional parameter 150 | 151 | In order to make parameter optional just add `?` add the end of the name 152 | ```php 153 | get('/users/{id?}', function() {...}); 157 | ``` 158 | 159 | 160 | Full example: 161 | ```php 162 | get('/hello/{name}', function (ServerRequestInterface $request) { 172 | return Response::asText("Hello: {$request->getAttribute('name')}"); 173 | }); 174 | $application->run(); 175 | ``` 176 | 177 | #### `HttpApplication::on(Route $route, callable $controller)` 178 | 179 | Makes `$controller` to listen on instance of the `$route`. 180 | 181 | #### `HttpApplication::get(string $route, callable $controller)` 182 | 183 | Makes `$controller` to listen on `$route` pattern on http `GET` request. 184 | 185 | Should be used to **read** or **retrieve** resource. On success response should return `200` (OK) status code. 186 | In case of error most often `404` (Not found) or `400` (Bad request) should be returned in the status code. 187 | 188 | #### `HttpApplication::post(string $route, callable $controller)` 189 | 190 | Makes `$controller` to listen on `$route` pattern on http `POST` request. 191 | 192 | Should be used to **create** new resources (can be also used as a wild card verb for operations that don't fit elsewhere). 193 | On successful creation, response should return `201` (created) or `202` (accepted) status code. 194 | In case of error most often `406` (not acceptable), `409` (conflict) `413` (request entity too large) 195 | 196 | #### `HttpApplication::put(string $route, callable $controller)` 197 | 198 | Makes `$controller` to listen on `$route` pattern on http `PUT` request. 199 | 200 | Should be used to **update** a specific resource (by an identifier) or a collection of resources. 201 | Can also be used to create a specific resource if the resource identifier is known before-hand. 202 | Response code scenario is same as `post` method with additional `404` if resource to update was not found. 203 | 204 | #### `HttpApplication::patch(string $route, callable $controller)` 205 | 206 | Makes `$controller` to listen on `$route` pattern on http `PATCH` request. 207 | 208 | As `patch` should be used for **modify** resource. The difference is that `patch` request 209 | can contain only the changes to the resource, not the complete resource as `put` or `post`. 210 | 211 | #### `HttpApplication::delete(string $route, callable $controller)` 212 | 213 | Makes `$controller` to listen on `$route` pattern on http `DELETE` request. 214 | 215 | Should be used to **delete** resource. 216 | 217 | #### `HttpApplication::options(string $route, callable $controller)` 218 | 219 | Makes `$controller` to listen on `$route` pattern on http `OPTIONS` request. 220 | 221 | #### `HttpApplication::head(string $route, callable $controller)` 222 | 223 | Makes `$controller` to listen on `$route` pattern on http `HEAD` request. 224 | 225 | ## Middleware 226 | 227 | Middleware is an individual component participating in processing incoming request and the creation 228 | of resulting response. 229 | 230 | Middleware can be used to: 231 | - Handle authentication details 232 | - Perform content negotiation 233 | - Error handling 234 | 235 | In Igni middleware can be any callable that accepts `\Psr\Http\Message\ServerRequestInterface` and `\Psr\Http\Server\RequestHandlerInterface` or `callable` as parameters 236 | and returns valid instance of `\Psr\Http\Message\ResponseInterface` or any class/object that implements `\Psr\Http\Server\MiddlewareInterface` interface. 237 | 238 | You can add as many middleware as you want, and they are triggered in the same order as you add them. 239 | In fact even `Igni\Http\HttpApplication` is a middleware itself which is automatically added at the end of the pipe. 240 | 241 | #### Example 242 | ```php 243 | handle($request); 260 | $renderTime = microtime(true) - $time; 261 | 262 | return $response->withHeader('render-time', $renderTime); 263 | } 264 | } 265 | 266 | $application = new HttpApplication(); 267 | 268 | // Attach custom middleware instance. 269 | $application->use(new BenchmarkMiddleware()); 270 | 271 | // Attach callable middleware. 272 | $application->use(function($request, callable $next) { 273 | $response = $next($request); 274 | return $response->withHeader('foo', 'bar'); 275 | }); 276 | 277 | // Run the application. 278 | $application->run(); 279 | ``` 280 | 281 | ## Modules 282 | 283 | Module is a reusable part of application or business logic. It can listen on application state or/and extend application 284 | by providing additional middleware, services, models, libraries etc... 285 | 286 | In Igni module is any class implementing any listener or provider interface. 287 | 288 | The following list contains all possible interfaces that module can implement in order to provide additional 289 | features for the application: 290 | 291 | - Listeners: 292 | - `Igni\Application\Listeners\OnBootListener` 293 | - `Igni\Application\Listeners\OnErrorListener` 294 | - `Igni\Application\Listeners\OnRunListener` 295 | - `Igni\Application\Listeners\OnShutdownListener` 296 | - Providers: 297 | - `Igni\Application\Providers\ConfigProvider` 298 | - `Igni\Application\Providers\ControllerProvider` 299 | - `Igni\Application\Providers\ServiceProvider` 300 | - `Igni\Application\Providers\MiddlewareProvider` 301 | 302 | Example: 303 | ```php 304 | register(function ($request) { 322 | return Response::asText("Hello {$request->getAttribute('name')}!"); 323 | }, Route::get('/hello/{name}')); 324 | } 325 | } 326 | 327 | $application = new HttpApplication(); 328 | 329 | // Extend application with the module. 330 | $application->extend(SimpleModule::class); 331 | 332 | // Run the application. 333 | $application->run(); 334 | ``` 335 | 336 | ### Listeners 337 | 338 | #### Boot Listener 339 | OnBootListener can be implemented to perform tasks on application in boot state. 340 | 341 | #### Run Listener 342 | OnRunListener can be implemented to perform tasks which are dependent on various services 343 | provided by extensions. 344 | 345 | #### Shutdown Listener 346 | Can be used for cleaning-up tasks. 347 | 348 | ### Providers 349 | 350 | #### Config Provider 351 | Config provider is used to provide additional configuration settings to config service `\Igni\Application\Config`. 352 | 353 | #### Controller Provider 354 | Controller provider is used to register controllers within the application. 355 | 356 | #### Service Provider 357 | Makes usage of PSR compatible DI of your choice (If none is passed to application igniphp/container 358 | implementation will be used as default) to register additional services. 359 | 360 | ## Controllers 361 | 362 | Igni uses invokable controllers (single action controllers). There few reasons for that: 363 | 364 | - Controller can effectively wrap simple functionality into well defined namespace 365 | - Less dependencies are required to perform the action 366 | - Can be easy replaced with functions 367 | - Does not break SRP 368 | - Makes testing easier 369 | 370 | In order to define controller one must implement `Igni\Http\Controller` interface. 371 | The following code contains an example controller which contains welcome message in the response. 372 | 373 | ```php 374 | getAttribute('name')}!"); 389 | } 390 | 391 | public static function getRoute(): Route 392 | { 393 | return Route::get('hi/{name}'); 394 | } 395 | } 396 | ``` 397 | 398 | Controller can be registered either in your [module file](../examples/Modules/SimpleModule.php) 399 | or simply by calling `register` method on application's controller aggregate: 400 | 401 | ```php 402 | $application->getControllerAggregate()->add(WelcomeUserController::class); 403 | ``` 404 | 405 | ## The Request 406 | Igni's controllers and middleware are given a PSR-7 server request object that represents http request send 407 | by the client. Request contains route's params, body, request method, request uri and so on. 408 | 409 | For information how to work with PSR-7 [read this](https://www.php-fig.org/psr/psr-7/). 410 | 411 | ## The Response 412 | Igni's controllers and middleware must return valid PSR-7 response object. 413 | Igni's `Igni\Network\Http\Response` class provides factories methods to simplify response creation. 414 | 415 | #### `Response::empty(int $status = 200, array $headers = [])` 416 | 417 | Creates empty PSR-7 response object. 418 | 419 | #### `Response::asText(string $text, int $status = 200, array $headers = [])` 420 | 421 | Creates PSR-7 request with content type set to `text/plain` and body containing passed `$text` 422 | 423 | #### `Response::asJson($data, int $status = 200, array $headers = [])` 424 | 425 | Creates PSR-7 request with content type set to `application/json` and body containing json data. 426 | `$data` can be array or `\JsonSerializable` instance. 427 | 428 | #### `Response::asHtml(string $html, int $status = 200, array $headers = [])` 429 | 430 | Creates PSR-7 request with content type set to `text/html` and body containing passed html. 431 | 432 | #### `Response::asXml($data, int $status = 200, array $headers = [])` 433 | 434 | Creates PSR-7 request with content type set to `application/xml` and body containing xml string. 435 | `$data` can be `\SimpleXMLElement`, `\DOMDocument` or just plain string. 436 | 437 | ## Error handling 438 | Igni provides default error handler (`\Igni\Network\Http\Middleware\ErrorMiddleware`) so if anything goes 439 | wrong in your application the error will not be directly propagated to the client layer unless it 440 | is a fatal error (fatals cannot be catched nor handled). 441 | 442 | If you intend to propagate custom error for your clients, you have two options: 443 | - Custom exception classes implementing `\Igni\Network\Exception\HttpException` 444 | - Provide custom error handling middleware 445 | 446 | ### Custom Exceptions 447 | All exceptions that implement `\Igni\Network\Exception\HttpException` are catch by default error handler and used 448 | to generate response for your clients: 449 | 450 | ```php 451 | $this->getMessage(), 465 | ], 404); 466 | } 467 | 468 | } 469 | 470 | $application = new HttpApplication(); 471 | $application->get('/article/{id}', function() { 472 | throw new NotFoundException('Article with given id does not exists'); 473 | }); 474 | 475 | // Run the application. 476 | $application->run(); 477 | ``` 478 | 479 | 480 | ### Custom error handling middleware 481 | 482 | In any case you would like to provide custom error handler, it can be done by simply creating middleware with try/catch 483 | statement inside `process` method. 484 | The following code returns custom response in case any error occurs in the application: 485 | 486 | ```php 487 | handle($request); 504 | } catch (Throwable $throwable) { 505 | $response = Response::asText('Custom error message', $status = 500); 506 | } 507 | 508 | return $response; 509 | } 510 | } 511 | 512 | $application = new HttpApplication(); 513 | $application->use(new CustomErrorHandler()); 514 | 515 | // Run the application. 516 | $application->run(); 517 | ``` 518 | 519 | ## Testing 520 | Igni is build to be testable and maintainable in fact most of the crucial framework's layers are covered 521 | with reasonable amount of tests. 522 | 523 | Testing your code can be simply performed by executing your controller with mocked ServerRequest object, 524 | consider following example: 525 | ```php 526 | getAttribute('name')}!"); 541 | } 542 | 543 | public static function getRoute(): Route 544 | { 545 | return Route::get('hi/{name}'); 546 | } 547 | } 548 | 549 | final class WelcomeUserControllerTest extends TestCase 550 | { 551 | public function testWelcome(): void 552 | { 553 | $controller = new WelcomeUserController(); 554 | $response = $controller(new ServerRequest('/hi/Tom')); 555 | 556 | self::assertSame('Hi Tom!', (string) $response->getBody()); 557 | self::assertSame(200, $response->getStatusCode()); 558 | } 559 | } 560 | ``` 561 | 562 | 563 | ## Working with containers 564 | 565 | ### Using default container 566 | By default Igni is using its own [dependency injection container](https://github.com/igniphp/container), which provides: 567 | 568 | - easy to use interface 569 | - autowiring support 570 | - contextual injection 571 | - free of any configuration or complex building process 572 | - small footprint 573 | 574 | So if you are fan of small and easy-to-use solutions there are no steps required in order to use it 575 | within your application. 576 | 577 | ### Using custom container 578 | Igni can work with any dependency injection container that is PSR-11 compatible service. 579 | In order to use your favourite DI library just pass it as parameter to application's constructor. 580 | If you container requires building process and you would like to use `ServiceProvider` interface, 581 | it is recommended to provide services as you would do this usually with your modules and attach `OnRunListener` 582 | to any of your modules and build your container in the provided method: 583 | 584 | ```php 585 | getContainer(); 601 | $container->compile(); 602 | } 603 | 604 | /** 605 | * @param ContainerBuilder $container 606 | */ 607 | public function provideServices(ContainerInterface $container): void 608 | { 609 | $container->register('mailer', 'Mailer'); 610 | } 611 | } 612 | 613 | $containerBuilder = new ContainerBuilder(); 614 | $application = new Igni\Application\HttpApplication($containerBuilder); 615 | $application->use(new SymfonyDependencyInjectionModule()); 616 | 617 | // Run the application. 618 | $application->run(); 619 | ``` 620 | 621 | ## Igni server based on swoole 622 | 623 | ```php 624 | get('/hello/{name}', function (ServerRequestInterface $request) : ResponseInterface { 639 | return Response::asText("Hello {$request->getAttribute('name')}"); 640 | }); 641 | 642 | // Run the server, it should listen on localhost:80 643 | $application->run($server); 644 | 645 | ``` 646 | 647 | ## External webserver 648 | 649 | ### Apache 650 | 651 | If you are using Apache, make sure mod_rewrite is enabled and use the following .htaccess file: 652 | ```xml 653 | 654 | Options -MultiViews 655 | 656 | RewriteEngine On 657 | #RewriteBase /path/to/app 658 | RewriteCond %{REQUEST_FILENAME} !-d 659 | RewriteCond %{REQUEST_FILENAME} !-f 660 | RewriteRule ^ index.php [QSA,L] 661 | 662 | ``` 663 | 664 | ### nginx + php-fpm 665 | 666 | If you are using nginx + php-fpm, the following is minimal configuration to get the things done: 667 | 668 | ```nginx 669 | server { 670 | server_name domain.tld www.domain.tld; 671 | root /var/www/project/web; 672 | 673 | location / { 674 | # try to serve file directly, fallback to front controller 675 | try_files $uri /index.php$is_args$args; 676 | } 677 | 678 | # 679 | location ~ ^/index\.php(/|$) { 680 | 681 | fastcgi_split_path_info ^(.+\.php)(/.*)$; 682 | if (!-f $document_root$fastcgi_script_name) { 683 | return 404; 684 | } 685 | 686 | # For socket connection 687 | fastcgi_pass unix:/var/run/php-fpm.sock; 688 | 689 | # Uncomment following line to use tcp connection instead socket 690 | # fastcgi_pass 127.0.0.1:9000; 691 | 692 | include fastcgi_params; 693 | 694 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 695 | fastcgi_param HTTPS off; 696 | 697 | # Prevents URIs that include the front controller. This will 404: 698 | # http://domain.tld/index.php/some-path 699 | # Enable the internal directive to disable URIs like this 700 | # internal; 701 | } 702 | 703 | #return 404 for all php files as we do have a front controller 704 | location ~ \.php$ { 705 | return 404; 706 | } 707 | 708 | error_log /var/log/nginx/project_error.log; 709 | access_log /var/log/nginx/project_access.log; 710 | } 711 | ``` 712 | 713 | ### PHP built-in webserver 714 | 715 | PHP ships with a built-in webserver for development. This server allows you to run Igni without any configuration. 716 | 717 | ```php 718 | get('/hello/{name}', function (ServerRequestInterface $request): ResponseInterface { 729 | return Response::asText("Hello {$request->getAttribute('name')}"); 730 | }); 731 | 732 | // Run with the server 733 | $application->run(); 734 | 735 | ``` 736 | 737 | Assuming your front controller is at ./index.php, you can start the server using the following command: 738 | 739 | ``` 740 | php -S localhost:8080 index.php 741 | ``` 742 | 743 | > Note: This should be used only for development. 744 | -------------------------------------------------------------------------------- /examples/Controllers/GoodbyeController.php: -------------------------------------------------------------------------------- 1 | register(function (ServerRequestInterface $request) { 23 | return Response::asText("Hello {$request->getAttribute('name')}!"); 24 | }, Route::get('/hello/{name}')); 25 | 26 | $controllers->register(GoodbyeController::class); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/build_in_server_daemonized_example.php: -------------------------------------------------------------------------------- 1 | enableDaemon(__DIR__ . '/igni.pid'); 18 | 19 | // Setup server 20 | $server = new HttpServer($configuration); 21 | 22 | // Setup application and routes 23 | $application = new HttpApplication(); 24 | $application->get('/hello/{name}', function (ServerRequestInterface $request) : ResponseInterface { 25 | return Response::asText("Hello {$request->getAttribute('name')}"); 26 | }); 27 | 28 | // Run the server 29 | $application->run($server); 30 | -------------------------------------------------------------------------------- /examples/build_in_server_example.php: -------------------------------------------------------------------------------- 1 | get('/hello/{name}', function (ServerRequestInterface $request) : ResponseInterface { 17 | return Response::asText("Hello {$request->getAttribute('name')}"); 18 | }); 19 | 20 | // Run the server, it should listen on localhost:8080 21 | $application->run($server); 22 | -------------------------------------------------------------------------------- /examples/example_error_handling.php: -------------------------------------------------------------------------------- 1 | get('/hello', new Controller()); 23 | 24 | $application->run($server); 25 | -------------------------------------------------------------------------------- /examples/modular_application_example.php: -------------------------------------------------------------------------------- 1 | addPsr4('Examples\\Controllers\\', __DIR__ . '/Controllers'); 13 | $classLoader->addPsr4('Examples\\Modules\\', __DIR__ . '/Modules'); 14 | $classLoader->register(); 15 | 16 | // Create application instance. 17 | $application = new HttpApplication(); 18 | 19 | // Attach modules. 20 | $application->extend(SimpleModule::class); 21 | 22 | // Run application. 23 | if (php_sapi_name() == 'cli-server') { 24 | $application->run(); 25 | } else { 26 | $application->run(new HttpServer(new Configuration(8080, '0.0.0.0'))); 27 | } 28 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests 15 | 16 | 17 | 18 | 19 | ./src/ 20 | 21 | ./tests 22 | ./examples 23 | ./src/Http/Exception 24 | ./src/Application/Exception 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Application/Application.php: -------------------------------------------------------------------------------- 1 | container = $container; 63 | $this->resolver = new DependencyResolver($this->container); 64 | 65 | if ($this->container->has(Config::class)) { 66 | $this->config = $this->container->get(Config::class); 67 | } else { 68 | $this->config = new Config([]); 69 | } 70 | 71 | $this->modules = []; 72 | } 73 | 74 | /** 75 | * Allows for application extension by modules. 76 | * Module can be any valid object or class name. 77 | * 78 | * @param $module 79 | */ 80 | public function extend($module): void 81 | { 82 | if (is_object($module) || class_exists($module)) { 83 | $this->modules[] = $module; 84 | } else { 85 | throw ApplicationException::forInvalidModule($module); 86 | } 87 | } 88 | 89 | /** 90 | * Starts the application. 91 | * Initialize modules. Performs tasks to generate response for the client. 92 | * 93 | * @return mixed 94 | */ 95 | abstract public function run(); 96 | 97 | /** 98 | * Controller aggregator is used to register application's controllers. 99 | * @return ControllerAggregator 100 | */ 101 | abstract public function getControllerAggregator(): ControllerAggregator; 102 | 103 | /** 104 | * Middleware aggregator is used to register application's middlewares. 105 | * @return MiddlewareAggregator 106 | */ 107 | abstract public function getMiddlewareAggregator(): MiddlewareAggregator; 108 | 109 | /** 110 | * @return Config 111 | */ 112 | public function getConfig(): Config 113 | { 114 | return $this->config; 115 | } 116 | 117 | public function getContainer(): ContainerInterface 118 | { 119 | return $this->container; 120 | } 121 | 122 | protected function handleOnBootListeners(): void 123 | { 124 | foreach ($this->modules as $module) { 125 | if ($module instanceof OnBootListener) { 126 | $module->onBoot($this); 127 | } 128 | } 129 | } 130 | 131 | protected function handleOnShutDownListeners(): void 132 | { 133 | foreach ($this->modules as $module) { 134 | if ($module instanceof OnShutDownListener) { 135 | $module->onShutDown($this); 136 | } 137 | } 138 | } 139 | 140 | protected function handleOnErrorListeners(Throwable $exception): Throwable 141 | { 142 | foreach ($this->modules as $module) { 143 | if ($module instanceof OnErrorListener) { 144 | $exception = $module->onError($this, $exception); 145 | } 146 | } 147 | 148 | return $exception; 149 | } 150 | 151 | protected function handleOnRunListeners(): void 152 | { 153 | foreach ($this->modules as $module) { 154 | if ($module instanceof OnRunListener) { 155 | $module->onRun($this); 156 | } 157 | } 158 | } 159 | 160 | protected function initialize(): void 161 | { 162 | if ($this->initialized) { 163 | return; 164 | } 165 | 166 | foreach ($this->modules as &$module) { 167 | $this->initializeModule($module); 168 | } 169 | 170 | $this->initialized = true; 171 | } 172 | 173 | protected function initializeModule(&$module): void 174 | { 175 | if (is_string($module)) { 176 | $module = $this->resolver->resolve($module); 177 | } 178 | 179 | if ($module instanceof ConfigProvider) { 180 | $module->provideConfig($this->getConfig()); 181 | } 182 | 183 | if ($module instanceof ControllerProvider) { 184 | $module->provideControllers($this->getControllerAggregator()); 185 | } 186 | 187 | if ($module instanceof ServiceProvider) { 188 | $module->provideServices($this->getContainer()); 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/Application/Config.php: -------------------------------------------------------------------------------- 1 | set('some.key', true); 17 | * $some = $config->get('some'); // returns ['key' => true] 18 | * 19 | * @package Igni\Application 20 | */ 21 | class Config 22 | { 23 | /** 24 | * @var array 25 | */ 26 | private $config; 27 | 28 | /** 29 | * Config constructor. 30 | * 31 | * @param array $config 32 | */ 33 | public function __construct(array $config = []) 34 | { 35 | $this->config = $config; 36 | } 37 | 38 | /** 39 | * Checks if config key exists. 40 | * 41 | * @param string $key 42 | * @return bool 43 | */ 44 | public function has(string $key): bool 45 | { 46 | return $this->lookup($key) !== null; 47 | } 48 | 49 | private function lookup(string $key) 50 | { 51 | $result = $this->config; 52 | $key = explode('.', $key); 53 | foreach($key as $part) { 54 | if (!is_array($result) || !isset($result[$part])) { 55 | return null; 56 | } 57 | $result = $result[$part]; 58 | } 59 | 60 | return $result; 61 | } 62 | 63 | /** 64 | * Gets value behind the key, or returns $default value if path does not exists. 65 | * 66 | * @param string $key 67 | * @param null $default 68 | * @return null|string|string[] 69 | */ 70 | public function get(string $key, $default = null) 71 | { 72 | $result = $this->lookup($key); 73 | return $result === null ? $default : $this->fetchConstants($result); 74 | } 75 | 76 | /** 77 | * Merges one instance of Config class into current one and 78 | * returns current instance. 79 | * 80 | * @param Config $config 81 | * @return Config 82 | */ 83 | public function merge(Config $config): Config 84 | { 85 | $this->config = array_merge_recursive($this->config, $config->config); 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Returns new instance of the config containing only values from the 92 | * given namespace. 93 | * 94 | * @param string $namespace 95 | * @return Config 96 | */ 97 | public function extract(string $namespace): Config 98 | { 99 | $extracted = $this->get($namespace); 100 | if (!is_array($extracted)) { 101 | throw ConfigException::forExtractionFailure($namespace); 102 | } 103 | 104 | return new self($extracted); 105 | } 106 | 107 | /** 108 | * Sets new value. 109 | * 110 | * @param string $key 111 | * @param $value 112 | */ 113 | public function set(string $key, $value): void 114 | { 115 | $key = explode('.', $key); 116 | $last = array_pop($key); 117 | $result = &$this->config; 118 | 119 | foreach ($key as $part) { 120 | if (!isset($result[$part]) || !is_array($result[$part])) { 121 | $result[$part] = []; 122 | } 123 | $result = &$result[$part]; 124 | } 125 | $result[$last] = $value; 126 | } 127 | 128 | /** 129 | * Returns array representation of the config. 130 | * 131 | * @return array 132 | */ 133 | public function toArray(): array 134 | { 135 | return $this->config; 136 | } 137 | 138 | /** 139 | * Returns flat array representation of the config, all nested values are stored 140 | * in keys containing path separated by dot. 141 | * 142 | * @return array 143 | */ 144 | public function toFlatArray(): array 145 | { 146 | return self::flatten($this->config); 147 | } 148 | 149 | private static function flatten(array &$array, string $prefix = ''): array 150 | { 151 | $values = []; 152 | foreach ($array as $key => &$value) { 153 | if (is_array($value) && !empty($value)) { 154 | $values = array_merge($values, self::flatten($value, $prefix . $key . '.')); 155 | } else { 156 | $values[$prefix . $key] = $value; 157 | } 158 | } 159 | 160 | return $values; 161 | } 162 | 163 | private function fetchConstants($value) 164 | { 165 | if (!is_string($value)) { 166 | return $value; 167 | } 168 | return preg_replace_callback( 169 | '#\$\{([^{}]*)\}#', 170 | function($matches) { 171 | if (defined($matches[1])) { 172 | return constant($matches[1]); 173 | } 174 | return $matches[0]; 175 | }, 176 | $value 177 | ); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Application/Controller.php: -------------------------------------------------------------------------------- 1 | routeCollection = new RouteCollection(); 31 | } 32 | 33 | /** 34 | * Registers new route. 35 | * 36 | * @param Route $route 37 | */ 38 | public function add(Route $route): void 39 | { 40 | if ($route instanceof Route) { 41 | $name = $route->getName(); 42 | } else { 43 | $name = Route::generateNameFromPath($route->getPath()); 44 | } 45 | 46 | $baseRoute = new SymfonyRoute($route->getPath()); 47 | $baseRoute->setMethods($route->getMethods()); 48 | 49 | $this->routeCollection->add($name, $baseRoute); 50 | $this->routes[$name] = $route; 51 | } 52 | 53 | /** 54 | * Finds route matching clients request. 55 | * 56 | * @param string $method request method. 57 | * @param string $path request path. 58 | * @return Route 59 | */ 60 | public function find(string $method, string $path): Route 61 | { 62 | $matcher = new UrlMatcher($this->routeCollection, new RequestContext('/', $method)); 63 | try { 64 | $route = $matcher->match($path); 65 | } catch (ResourceNotFoundException $exception) { 66 | throw RouterException::noRouteMatchesRequestedUri($path, $method); 67 | } catch (SymfonyMethodNotAllowedException $exception) { 68 | throw RouterException::methodNotAllowed($path, $exception->getAllowedMethods()); 69 | } 70 | 71 | $routeName = $route['_route']; 72 | unset($route['_route']); 73 | 74 | return $this->routes[$routeName]->withAttributes($route); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Application/Http/MiddlewareAggregator.php: -------------------------------------------------------------------------------- 1 | getContainer()->has(Router::class)) { 70 | $this->router = $this->getContainer()->get(Router::class); 71 | } else { 72 | $this->router = new GenericRouter(); 73 | } 74 | 75 | if ($this->getContainer()->has(EmitterInterface::class)) { 76 | $this->emitter = $this->getContainer()->get(EmitterInterface::class); 77 | } else { 78 | $this->emitter = new SapiEmitter(); 79 | } 80 | } 81 | 82 | /** 83 | * While testing call this method before handle method. 84 | */ 85 | public function startup(): void 86 | { 87 | $this->handleOnBootListeners(); 88 | $this->initialize(); 89 | $this->handleOnRunListeners(); 90 | } 91 | 92 | /** 93 | * While testing, call this method after handle method. 94 | */ 95 | public function shutdown(): void 96 | { 97 | $this->handleOnShutDownListeners(); 98 | } 99 | 100 | /** 101 | * Startups and run application with/or without dedicated server. 102 | * Once application is run it will listen to incoming http requests, 103 | * and takes care of the entire request flow process. 104 | * 105 | * @param HttpServer|null $server 106 | */ 107 | public function run(HttpServer $server = null): void 108 | { 109 | $this->startup(); 110 | if ($server) { 111 | $server->addListener($this); 112 | $server->start(); 113 | } else { 114 | $response = $this->handle(ServerRequest::fromGlobals()); 115 | $this->emitter->emit($response); 116 | if ($response instanceof Response) { 117 | $response->end(); 118 | } 119 | } 120 | 121 | $this->shutdown(); 122 | } 123 | 124 | /** 125 | * Registers PSR-15 compatible middelware. 126 | * Middleware can be either callable object which accepts PSR-7 server request interface and returns 127 | * response interface, or just class name that implements psr-15 middleware or its instance. 128 | * 129 | * @param MiddlewareInterface|callable $middleware 130 | */ 131 | public function use($middleware): void 132 | { 133 | if (!is_subclass_of($middleware, MiddlewareInterface::class)) { 134 | if (!is_callable($middleware)) { 135 | throw new ApplicationException(sprintf( 136 | 'Middleware must be either class or object that implements `%s`', 137 | MiddlewareInterface::class 138 | )); 139 | } 140 | 141 | $middleware = new CallableMiddleware($middleware); 142 | } 143 | 144 | $this->middleware[] = $middleware; 145 | } 146 | 147 | public function register($controller, Route $route = null): void 148 | { 149 | if (is_callable($controller) && $route !== null) { 150 | $route = $route->withController($controller); 151 | $this->router->add($route); 152 | return; 153 | } 154 | 155 | if ($controller instanceof Controller) { 156 | /** @var Route $route */ 157 | $route = $controller::getRoute(); 158 | $route = $route->withController($controller); 159 | $this->router->add($route); 160 | return; 161 | } 162 | 163 | if (is_string($controller) && is_subclass_of($controller, Controller::class)) { 164 | /** @var Route $route */ 165 | $route = $controller::getRoute(); 166 | $route = $route->withController($controller); 167 | $this->router->add($route); 168 | return; 169 | } 170 | 171 | throw ApplicationException::forInvalidController($controller); 172 | } 173 | 174 | /** 175 | * Handles request flow process. 176 | * 177 | * @see MiddlewareInterface::process() 178 | * 179 | * @param ServerRequestInterface $request 180 | * @param RequestHandlerInterface $next 181 | * @return ResponseInterface 182 | */ 183 | public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface 184 | { 185 | /** @var Route $route */ 186 | $route = $this->router->find( 187 | $request->getMethod(), 188 | $request->getUri()->getPath() 189 | ); 190 | 191 | $controller = $route->getController(); 192 | 193 | if ($request instanceof ServerRequest) { 194 | $request = $request->withAttributes($route->getAttributes()); 195 | } 196 | 197 | if (is_string($controller) && 198 | class_exists($controller) && 199 | is_subclass_of($controller, Controller::class) 200 | ) { 201 | /** @var Controller $instance */ 202 | $instance = $this->resolver->resolve($controller); 203 | return $instance($request); 204 | } 205 | 206 | if (is_callable($controller)) { 207 | $response = $controller($request); 208 | if (!$response instanceof ResponseInterface) { 209 | throw ControllerException::forInvalidReturnValue(); 210 | } 211 | 212 | return $response; 213 | } 214 | 215 | throw ControllerException::forMissingController($route->getPath()); 216 | } 217 | 218 | /** 219 | * Runs application listeners and handles request flow process. 220 | * 221 | * @param ServerRequestInterface $request 222 | * @return ResponseInterface 223 | */ 224 | public function handle(ServerRequestInterface $request): ResponseInterface 225 | { 226 | $response = $this->getMiddlewarePipe()->handle($request); 227 | 228 | return $response; 229 | } 230 | 231 | /** 232 | * Decorator for handle method, used by server instance. 233 | * @see Application::handle() 234 | * @see Server::addListener() 235 | * 236 | * @param ResponseInterface $response 237 | * @param Client $client 238 | * @param ServerRequestInterface $request 239 | * @return ResponseInterface 240 | */ 241 | public function onRequest(Client $client, ServerRequestInterface $request, ResponseInterface $response): ResponseInterface 242 | { 243 | return $this->handle($request); 244 | } 245 | 246 | /** 247 | * Registers new controller that accepts get request 248 | * when request uri matches passed route pattern. 249 | * 250 | * @param string $route 251 | * @param callable $controller 252 | */ 253 | public function get(string $route, callable $controller): void 254 | { 255 | $this->register($controller, Route::get($route)); 256 | } 257 | 258 | /** 259 | * Registers new controller that accepts post request 260 | * when request uri matches passed route pattern. 261 | * 262 | * @param string $route 263 | * @param callable $controller 264 | */ 265 | public function post(string $route, callable $controller): void 266 | { 267 | $this->register($controller, Route::post($route)); 268 | } 269 | 270 | /** 271 | * Registers new controller that accepts put request 272 | * when request uri matches passed route pattern. 273 | * 274 | * @param string $route 275 | * @param callable $controller 276 | */ 277 | public function put(string $route, callable $controller): void 278 | { 279 | $this->register($controller, Route::put($route)); 280 | } 281 | 282 | /** 283 | * Registers new controller that accepts patch request 284 | * when request uri matches passed route pattern. 285 | * 286 | * @param string $route 287 | * @param callable $controller 288 | */ 289 | public function patch(string $route, callable $controller): void 290 | { 291 | $this->register($controller, Route::patch($route)); 292 | } 293 | 294 | /** 295 | * Registers new controller that accepts delete request 296 | * when request uri matches passed route pattern. 297 | * 298 | * @param string $route 299 | * @param callable $controller 300 | */ 301 | public function delete(string $route, callable $controller): void 302 | { 303 | $this->register($controller, Route::delete($route)); 304 | } 305 | 306 | /** 307 | * Registers new controller that accepts options request 308 | * when request uri matches passed route pattern. 309 | * 310 | * @param string $route 311 | * @param callable $controller 312 | */ 313 | public function options(string $route, callable $controller): void 314 | { 315 | $this->register($controller, Route::options($route)); 316 | } 317 | 318 | /** 319 | * Registers new controller that accepts head request 320 | * when request uri matches passed route pattern. 321 | * 322 | * @param string $route 323 | * @param callable $controller 324 | */ 325 | public function head(string $route, callable $controller): void 326 | { 327 | $this->register($controller, Route::head($route)); 328 | } 329 | 330 | /** 331 | * Registers new controller that listens on the passed route. 332 | * 333 | * @param Route $route 334 | * @param callable $controller 335 | */ 336 | public function on(Route $route, callable $controller): void 337 | { 338 | $this->register($controller, $route); 339 | } 340 | 341 | /** 342 | * Returns application's controller aggregate. 343 | * 344 | * @return ControllerAggregator 345 | */ 346 | public function getControllerAggregator(): ControllerAggregator 347 | { 348 | return $this; 349 | } 350 | 351 | /** 352 | * Middleware aggregator is used to register application's middlewares. 353 | * 354 | * @return MiddlewareAggregator 355 | */ 356 | public function getMiddlewareAggregator(): MiddlewareAggregator 357 | { 358 | return $this; 359 | } 360 | 361 | protected function getMiddlewarePipe(): MiddlewarePipe 362 | { 363 | if ($this->pipeline) { 364 | return $this->pipeline; 365 | } 366 | 367 | return $this->pipeline = $this->composeMiddlewarePipe(); 368 | } 369 | 370 | protected function initializeModule(&$module): void 371 | { 372 | parent::initializeModule($module); 373 | 374 | if ($module instanceof MiddlewareProvider) { 375 | $module->provideMiddleware($this->getMiddlewareAggregator()); 376 | } 377 | } 378 | 379 | private function composeMiddlewarePipe(): MiddlewarePipe 380 | { 381 | $pipe = new MiddlewarePipe(); 382 | $pipe->add(new ErrorMiddleware(function(Throwable $exception) { 383 | return $this->handleOnErrorListeners($exception); 384 | })); 385 | foreach ($this->middleware as $middleware) { 386 | if (is_string($middleware)) { 387 | $middleware = $this->resolver->resolve($middleware); 388 | } 389 | $pipe->add($middleware); 390 | } 391 | $pipe->add($this); 392 | 393 | return $pipe; 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /src/Application/Listeners/OnBootListener.php: -------------------------------------------------------------------------------- 1 | $this->getCode(), 22 | 'error_message' => $this->getMessage(), 23 | ], $this->getCode()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Fixtures/HttpController.php: -------------------------------------------------------------------------------- 1 | handleOnBootListeners(); 22 | $this->initialize(); 23 | $this->handleOnRunListeners(); 24 | $this->handleOnShutDownListeners(); 25 | } 26 | 27 | public function getControllerAggregator(): ControllerAggregator 28 | { 29 | static $aggregate; 30 | 31 | if (null !== $aggregate) { 32 | return $aggregate; 33 | } 34 | $locator = $this->getContainer(); 35 | 36 | return $aggregate = new class($locator) implements ControllerAggregator { 37 | 38 | /** 39 | * @var ServiceLocator 40 | */ 41 | private $locator; 42 | 43 | public function __construct(ServiceLocator $locator) 44 | { 45 | $this->locator = $locator; 46 | } 47 | 48 | public function register($controller, string $name = null): void 49 | { 50 | if ($controller) { 51 | $this->locator->set($name, $controller); 52 | } else { 53 | $this->locator->share($name); 54 | } 55 | } 56 | 57 | public function has(string $controller): bool 58 | { 59 | return $this->locator->has($controller); 60 | } 61 | 62 | public function get(string $controller): Controller 63 | { 64 | return $this->locator->get($controller); 65 | } 66 | }; 67 | } 68 | 69 | /** 70 | * Middleware aggregator is used to register application's middlewares. 71 | * 72 | * @return MiddlewareAggregator 73 | */ 74 | public function getMiddlewareAggregator(): MiddlewareAggregator 75 | { 76 | static $aggregate; 77 | 78 | if (null !== $aggregate) { 79 | return $aggregate; 80 | } 81 | 82 | return $aggregate = new class implements MiddlewareAggregator { 83 | 84 | public $middleware = []; 85 | 86 | /** 87 | * @param string|MiddlewareInterface|callable $middleware 88 | */ 89 | public function use($middleware): void 90 | { 91 | $this->middleware[] = $middleware; 92 | } 93 | }; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/Fixtures/SampleController.php: -------------------------------------------------------------------------------- 1 | boo = $boo; 15 | } 16 | 17 | public function __invoke() 18 | { 19 | return $this->boo->a->getA(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Fixtures/config/a.ini: -------------------------------------------------------------------------------- 1 | [a] 2 | a = "a" 3 | b = "b" 4 | -------------------------------------------------------------------------------- /tests/Fixtures/config/auth.routes.autoload.ini: -------------------------------------------------------------------------------- 1 | ; Router configuration 2 | [router] 3 | post[token] = "AuthorizeUser" 4 | delete[token/:token] = "UnauthorizeUser" 5 | -------------------------------------------------------------------------------- /tests/Fixtures/config/autoload/b.ini: -------------------------------------------------------------------------------- 1 | ; C Section 2 | [b] 3 | a = "a" 4 | b = "b" 5 | -------------------------------------------------------------------------------- /tests/Fixtures/config/autoload/c.ini: -------------------------------------------------------------------------------- 1 | ; C Section 2 | [c] 3 | a = 1 4 | b = 2 5 | -------------------------------------------------------------------------------- /tests/Fixtures/config/test.autoload.ini: -------------------------------------------------------------------------------- 1 | ; extend(new class implements OnBootListener { 29 | public function onBoot(Application $application): void 30 | { 31 | $application->onBoot = true; 32 | } 33 | }); 34 | $application->extend(ApplicationModule::class); 35 | $application->extend(MiddlewareModule::class); 36 | $application->run(); 37 | 38 | $middleware = &$application->getMiddlewareAggregator()->middleware; 39 | 40 | self::assertTrue(isset($middleware[0])); 41 | self::assertSame('called', $middleware[0]()); 42 | self::assertTrue($application->onBoot); 43 | self::assertFalse($application->onRun); 44 | self::assertFalse($application->onShutDown); 45 | } 46 | 47 | public function testExtendWithInvalidModule(): void 48 | { 49 | $this->expectException(ApplicationException::class); 50 | $application = new NullApplication(); 51 | $application->extend('t1'); 52 | } 53 | 54 | public function testGetDefaultConfig(): void 55 | { 56 | $application = new NullApplication(); 57 | self::assertInstanceOf(Config::class, $application->getConfig()); 58 | } 59 | } 60 | 61 | 62 | class ApplicationModule implements ControllerProvider 63 | { 64 | public function provideControllers(ControllerAggregator $controllers): void 65 | { 66 | $controllers->register(function() {}, 'test_controller'); 67 | } 68 | } 69 | 70 | class MiddlewareModule implements MiddlewareProvider 71 | { 72 | public function provideMiddleware(MiddlewareAggregator $aggregate): void 73 | { 74 | $aggregate->use(function () { 75 | return 'called'; 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/Functional/Application/ConfigTest.php: -------------------------------------------------------------------------------- 1 | '${TEST_1}', 24 | ]); 25 | 26 | self::assertSame($value, $config->get('test')); 27 | } 28 | 29 | public function testNestingValues(): void 30 | { 31 | $config = new Config(); 32 | 33 | $config->set('test.a', 1); 34 | self::assertEquals(['a' => 1], $config->get('test')); 35 | self::assertSame(1, $config->get('test.a')); 36 | } 37 | 38 | public function testConfigMerge(): void 39 | { 40 | $a = new Config([ 41 | 'testA' => [ 42 | 'a' => 1, 43 | ], 44 | 'testB' => 'b' 45 | ]); 46 | 47 | $b = new Config([ 48 | 'testA' => [ 49 | 'b' => 2 50 | ], 51 | 'testB' => 'c', 52 | 'testC' => 2 53 | ]); 54 | 55 | $a->merge($b); 56 | 57 | self::assertSame( 58 | [ 59 | 'testA' => [ 60 | 'a' => 1, 61 | 'b' => 2 62 | ], 63 | 'testB' => ['b', 'c'], 64 | 'testC' => 2, 65 | ], 66 | $a->toArray() 67 | ); 68 | } 69 | 70 | public function testToArray(): void 71 | { 72 | $config = new Config(); 73 | $config->set('a.b.c' , 1); 74 | $config->set('b.c', 2); 75 | $config->set('c', 3); 76 | 77 | self::assertSame([ 78 | 'a' => [ 79 | 'b' => [ 80 | 'c' => 1, 81 | ], 82 | ], 83 | 'b' => [ 84 | 'c' => 2, 85 | ], 86 | 'c' => 3, 87 | ], $config->toArray()); 88 | } 89 | 90 | public function testExtract(): void 91 | { 92 | $config = new Config(); 93 | $config->set('a.b.c' , 123); 94 | $config->set('a.a.b', 112); 95 | $config->set('a.a.c', 113); 96 | $config->set('a.c.a', 131); 97 | $config->set('b.c', 23); 98 | $config->set('c', 3); 99 | 100 | self::assertSame( 101 | [ 102 | 'b.c' => 123, 103 | 'a.b' => 112, 104 | 'a.c' => 113, 105 | 'c.a' => 131, 106 | ], 107 | $config->extract('a')->toFlatArray() 108 | ); 109 | 110 | self::assertSame( 111 | [ 112 | 'b' => 112, 113 | 'c' => 113, 114 | ], 115 | $config->extract('a.a')->toFlatArray() 116 | ); 117 | 118 | self::assertSame( 119 | [ 120 | 'c' => 23, 121 | ], 122 | $config->extract('b')->toFlatArray() 123 | ); 124 | } 125 | 126 | public function testFailOnExtractingNonArrayKey(): void 127 | { 128 | $this->expectException(ConfigException::class); 129 | $config = new Config(); 130 | $config->set('c', 3); 131 | self::assertSame( 132 | [ 133 | 3, 134 | ], 135 | $config->extract('c')->toFlatArray() 136 | ); 137 | } 138 | 139 | public function testToFlatArray(): void 140 | { 141 | $config = new Config(); 142 | $config->set('a.b.c' , 1); 143 | $config->set('b.c', 2); 144 | $config->set('b.d', 2.1); 145 | $config->set('c', 3); 146 | 147 | self::assertSame( 148 | [ 149 | 'a.b.c' => 1, 150 | 'b.c' => 2, 151 | 'b.d' => 2.1, 152 | 'c' => 3, 153 | ], 154 | $config->toFlatArray() 155 | ); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /tests/Functional/Application/Http/GenericRouterTest.php: -------------------------------------------------------------------------------- 1 | add($route); 26 | 27 | self::assertInstanceOf(Router::class, $router); 28 | } 29 | 30 | public function testFindRoute(): void 31 | { 32 | $test = Route::get('/test'); 33 | $test1 = Route::get('/test/{id}'); 34 | $test2 = Route::post('/a/{b?b}/{c?c}'); 35 | $router = new GenericRouter(); 36 | $router->add($test); 37 | $router->add($test2); 38 | $router->add($test1); 39 | 40 | $result = $router->find('POST', '/a/1'); 41 | self::assertInstanceOf(Route::class, $result); 42 | self::assertSame(['b' => '1', 'c' => 'c'], $result->getAttributes()); 43 | $result = $router->find('POST', '/a/1/1'); 44 | self::assertInstanceOf(Route::class, $result); 45 | self::assertSame(['b' => '1', 'c' => '1'], $result->getAttributes()); 46 | $result = $router->find('GET', '/test'); 47 | self::assertInstanceOf(Route::class, $result); 48 | self::assertSame([], $result->getAttributes()); 49 | } 50 | 51 | public function testNotFound(): void 52 | { 53 | $test = Route::get('/test'); 54 | $router = new GenericRouter(); 55 | $router->add($test); 56 | 57 | $this->expectException(RouterException::class); 58 | $router->find('GET', '/a/b'); 59 | } 60 | 61 | public function testMethodNotAllowed(): void 62 | { 63 | $test = Route::get('/test'); 64 | $router = new GenericRouter(); 65 | $router->add($test); 66 | 67 | $this->expectException(RouterException::class); 68 | $router->find('POST', '/test'); 69 | } 70 | 71 | public function testMatchOptionals(): void 72 | { 73 | $test = Route::delete('/users/{name<\d+>?2}'); 74 | $router = new GenericRouter(); 75 | $router->add($test); 76 | 77 | $route = $router->find('DELETE', '/users'); 78 | self::assertSame('2', $route->getAttribute('name')); 79 | 80 | $route = $router->find('DELETE', '/users/1'); 81 | self::assertSame('1', $route->getAttribute('name')); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Functional/Application/HttpApplicationTest.php: -------------------------------------------------------------------------------- 1 | handle($request); 43 | 44 | self::assertInstanceOf(ResponseInterface::class, $response); 45 | self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); 46 | self::assertContains('No route matches requested uri', (string) $response->getBody()); 47 | } 48 | 49 | public function testProcessRequestWithClosures(): void 50 | { 51 | $body = ['test' => 1]; 52 | $application = new HttpApplication(); 53 | $application->get('/test/{id}', function(ServerRequest $request): ResponseInterface { 54 | return Response::asJson(['test' => (int) $request->getAttribute('id')]); 55 | }); 56 | $request = new ServerRequest('/test/1'); 57 | $response = $application->handle($request); 58 | 59 | self::assertEquals($body, json_decode((string)$response->getBody(), true)); 60 | self::assertSame(Response::HTTP_OK, $response->getStatusCode()); 61 | } 62 | 63 | public function testErrorListener(): void 64 | { 65 | $exceptionMock = new class extends Exception implements HttpException { 66 | public function toResponse(): ResponseInterface 67 | { 68 | $responseMock = Mockery::mock(ResponseInterface::class); 69 | $responseMock->shouldReceive('getStatusCode') 70 | ->andReturn(Response::HTTP_I_AM_A_TEAPOT); 71 | 72 | $responseMock->shouldReceive('getBody') 73 | ->andReturn(Stream::fromString('Override exception')); 74 | 75 | return $responseMock; 76 | } 77 | }; 78 | 79 | $application = new HttpApplication(); 80 | $application->extend(new class($exceptionMock) implements OnErrorListener { 81 | private $exceptionMock; 82 | 83 | public function __construct(HttpException $exception) 84 | { 85 | $this->exceptionMock = $exception; 86 | } 87 | 88 | public function onError(Application $application, Throwable $exception): Throwable 89 | { 90 | return $this->exceptionMock; 91 | } 92 | }); 93 | $request = new ServerRequest('/test/1'); 94 | $response = $application->handle($request); 95 | 96 | self::assertInstanceOf(ResponseInterface::class, $response); 97 | self::assertSame(Response::HTTP_I_AM_A_TEAPOT, $response->getStatusCode()); 98 | self::assertContains('Override exception', (string) $response->getBody()); 99 | 100 | } 101 | 102 | public function testProcessWithMiddleware(): void 103 | { 104 | $body = 'No content available.'; 105 | $application = new HttpApplication(); 106 | $application->get('/test/{id}', function(ServerRequest $request): ResponseInterface { 107 | return Response::asJson(['test' => (int) $request->getAttribute('id')]); 108 | }); 109 | 110 | $application->use(function(ServerRequestInterface $request, RequestHandlerInterface $next) use ($body) { 111 | $response = $next->handle($request); 112 | 113 | return $response->withBody(Stream::fromString($body)); 114 | }); 115 | 116 | $request = new ServerRequest('/test/1'); 117 | $response = $application->handle($request); 118 | 119 | self::assertEquals($body, (string) $response->getBody()); 120 | self::assertSame(Response::HTTP_OK, $response->getStatusCode()); 121 | } 122 | 123 | public function testRegisterCallableController(): void 124 | { 125 | $controller = function() {}; 126 | $route = Mockery::mock(Route::class); 127 | $route->shouldReceive('withController') 128 | ->withArgs([$controller]); 129 | $route->shouldReceive('getPath') 130 | ->andReturn('test/path'); 131 | 132 | $router = Mockery::mock(Router::class); 133 | $router->shouldReceive('add') 134 | ->withArgs(function($route) { 135 | self::assertInstanceOf(Route::class, $route); 136 | return true; 137 | }); 138 | $container = new ServiceLocator(); 139 | $container->share(Router::class, function() use ($router) { 140 | return $router; 141 | }); 142 | $aggregate = new HttpApplication($container); 143 | 144 | self::assertNull($aggregate->register($controller, $route)); 145 | } 146 | 147 | public function testRegisterControllerClass(): void 148 | { 149 | $controller = HttpController::class; 150 | 151 | $router = Mockery::mock(Router::class); 152 | $router->shouldReceive('add') 153 | ->withArgs(function(Route $route) { 154 | self::assertSame(HttpController::URI, $route->getPath()); 155 | return true; 156 | }); 157 | $container = new ServiceLocator(); 158 | $container->share(Router::class, function() use ($router) { 159 | return $router; 160 | }); 161 | $aggregate = new HttpApplication($container); 162 | 163 | self::assertNull($aggregate->register($controller)); 164 | } 165 | 166 | public function testRegisterControllerObject(): void 167 | { 168 | $controller = new HttpController(); 169 | 170 | $router = Mockery::mock(Router::class); 171 | $router->shouldReceive('add') 172 | ->withArgs(function(Route $route) { 173 | self::assertInstanceOf(Route::class, $route); 174 | self::assertSame(HttpController::URI, $route->getPath()); 175 | return true; 176 | }); 177 | $container = new ServiceLocator(); 178 | $container->share(Router::class, function() use ($router) { 179 | return $router; 180 | }); 181 | $aggregate = new HttpApplication($container); 182 | 183 | self::assertNull($aggregate->register($controller)); 184 | } 185 | 186 | public function testRegisterInvalidController(): void 187 | { 188 | $this->expectException(ApplicationException::class); 189 | $aggregate = new HttpApplication(); 190 | $aggregate->register(1); 191 | } 192 | 193 | public function testProcessWithCallableMiddlewareHandler(): void 194 | { 195 | $body = 'No content available.'; 196 | $application = new HttpApplication(); 197 | $application->get('/test/{id}', function(ServerRequest $request): ResponseInterface { 198 | return Response::asJson(['test' => (int) $request->getAttribute('id')]); 199 | }); 200 | 201 | $application->use(function(ServerRequestInterface $request, $next) use ($body) { 202 | $response = $next($request); 203 | 204 | return $response->withBody(Stream::fromString($body)); 205 | }); 206 | 207 | $request = new ServerRequest('/test/1'); 208 | $response = $application->handle($request); 209 | 210 | self::assertEquals($body, (string) $response->getBody()); 211 | self::assertSame(Response::HTTP_OK, $response->getStatusCode()); 212 | } 213 | 214 | public function testListeners(): void 215 | { 216 | $onBoot = new class implements OnBootListener { 217 | public $dispatched = false; 218 | public function onBoot(Application $application): void 219 | { 220 | $this->dispatched = true; 221 | } 222 | }; 223 | $onRun = new class implements OnRunListener { 224 | public $dispatched = false; 225 | public function onRun(Application $application): void 226 | { 227 | $this->dispatched = true; 228 | } 229 | }; 230 | $onShutDown = new class implements OnShutDownListener { 231 | public $dispatched = false; 232 | public function onShutDown(Application $application): void 233 | { 234 | $this->dispatched = true; 235 | } 236 | }; 237 | $application = new HttpApplication(); 238 | $application->extend($onRun); 239 | $application->extend($onShutDown); 240 | $application->extend($onBoot); 241 | $application->startup(); 242 | $application->handle(new ServerRequest('/test')); 243 | $application->shutdown(); 244 | self::assertTrue($onRun->dispatched); 245 | self::assertTrue($onShutDown->dispatched); 246 | self::assertTrue($onBoot->dispatched); 247 | } 248 | 249 | /** 250 | * @dataProvider provideTestRoutes 251 | */ 252 | public function testRoutes(string $type, string $requestMethod): void 253 | { 254 | 255 | $application = new HttpApplication(); 256 | $application->$type('/test/{name}', function(ServerRequest $request): Response { 257 | return Response::asText("Test passes: {$request->getAttribute('name')}"); 258 | }); 259 | $application->startup(); 260 | $response = $application->handle(new ServerRequest('/test/OK', $requestMethod)); 261 | 262 | self::assertSame(Response::HTTP_OK, $response->getStatusCode()); 263 | self::assertSame('Test passes: OK', (string) $response->getBody()); 264 | } 265 | 266 | public function testGetControllerAggregator(): void 267 | { 268 | $application = new HttpApplication(); 269 | 270 | self::assertInstanceOf(ControllerAggregator::class, $application->getControllerAggregator()); 271 | } 272 | 273 | public function provideTestRoutes(): array 274 | { 275 | return [ 276 | ['post', Request::METHOD_POST], 277 | ['get', Request::METHOD_GET], 278 | ['delete', Request::METHOD_DELETE], 279 | ['put', Request::METHOD_PUT], 280 | ['patch', Request::METHOD_PATCH], 281 | ['options', Request::METHOD_OPTIONS], 282 | ['head', Request::METHOD_HEAD], 283 | ]; 284 | } 285 | } 286 | --------------------------------------------------------------------------------