├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── artisan.php ├── phpunit.xsd ├── src ├── Adapter.php ├── ArtisanApiManager.php ├── ArtisanApiServiceProvider.php ├── Caller.php ├── Command.php ├── CommandsIterator.php ├── Console │ └── GenerateKeyCommand.php ├── Contracts │ ├── AdapterInterface.php │ ├── ControllerInterface.php │ ├── MiddlewareInterface.php │ └── RouterInterface.php ├── Controllers │ ├── GeneratorCommandController.php │ └── SingleCommandController.php ├── Facades │ └── ArtisanApi.php ├── Middleware │ ├── AbortForbiddenRoute.php │ ├── CheckEnvMode.php │ ├── KeyChecker.php │ └── TrustIp.php ├── Response.php ├── Router.php ├── Traits │ └── Singleton.php └── Util │ └── GenerateKey.php └── tests ├── AdapterTest.php ├── CallerTest.php ├── CommandTest.php ├── Middleware ├── AbortForbiddenRouteTest.php ├── CheckEnvModeTest.php └── TrustIpTest.php ├── ResponseTest.php ├── RouterTest.php └── TestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .phpunit.result.cache 3 | .idea 4 | .vscode 5 | composer.lock 6 | .todo -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022, Alireza Farhanian (aariow01@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

Artisan Api

3 |

Artisan commands with HTTP

4 |
5 | 6 | --- 7 | 8 | ![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/aariow/artisan-api) ![GitHub](https://img.shields.io/github/license/aariow/artisan-api) ![Packagist Version](https://img.shields.io/packagist/v/aariow/artisan-api?label=version) ![Packagist Downloads](https://img.shields.io/packagist/dm/aariow/artisan-api?label=Packagist%20downloads) ![GitHub all releases](https://img.shields.io/github/downloads/aariow/artisan-api/total?label=Github%20downloads) ![GitHub repo size](https://img.shields.io/github/repo-size/aariow/artisan-api?label=size) 9 | 10 | ![Linkedin URL](https://img.shields.io/badge/Linkedin-aariow-blue?style=social&logo=linkedin&url=linkedin.com/in/aariow) 11 | 12 | **There might be some times you wanted to execute an Artisan command, but you did not have access to shell or SSH. 13 | Here we brought REST API solution for you.** 14 | 15 | **You are able to run Artisan commands by REST APIs easily.** 16 | 17 | *'README.md need to be updated'* 18 | 19 | ### Table of contents 20 | - **[Get Started](#get-started)** 21 | - **[Endpoints](#endpoints)** 22 | - **[Routes](#routes)** 23 | - **[Responses](#responses)** 24 | - **[Successful](#successful)** 25 | - **[Not Found](#not-found)** 26 | - **[Invalid Arguments format](#invalid-arguments-format)** 27 | - **[Invalid Options format](#invalid-options-format)** 28 | - **[Forbidden Routes](#forbidden-routes)** 29 | - **[Authentication](#authentication)** 30 | - **[Configurations](#configurations)** 31 | - **[API Prefix and HTTP Method](#api-prefix-and-http-method)** 32 | - **[Auto Run](#auto-run)** 33 | - **[Security](#security)** 34 | - **[Middlewares](#middlewares)** 35 | - **[Useful tips](#useful-tips)** 36 | - **[Todo](#todo)** 37 | 38 | 39 | ### Get Started 40 | 41 | To use this package, you should install it alongside [Laravel v9.21](https://laravel.com/) and [PHP v8.0](https://php.net) or higher. 42 | 43 | you can install it via [Composer package manager](https://getcomposer.org/): 44 | ```shell 45 | composer require aariow/artisan-api --dev 46 | ``` 47 | 48 | Although, Artisan-Api has production decetor itself, it is possible to install it globally. 49 | 50 | ### Endpoints 51 | 52 | As its name explains, all commands are available via HTTP with _POST_ method. 53 | All commands will be generated as routes and integrated into Laravel Routing system. You only need to send a POST request and follow the signature, like other REST API endpoints. 54 | 55 | There are two kinds of commands; one is normal commands like `php artisan list` or `php artisan cache:clear`, and the second form is `GeneratorCommands` which tend to create files within your application like `make:model` and `make:migration`. These kind of commands have different purposes, you should follow diffenerent convention. 56 | 57 | > **`GeneratorCommand`** are instance of **`Illuminate\Console\GeneratorCommand`** that extends **`Illuminate\Console\Command`** class. 58 | 59 | All commands existed by default or created by you will be discovered automatically and you do not have to do anything manually. Thus, their endpoints will be generated dynamically to your application. So if you delete/add any command class, there is no reason to worry. 60 | 61 | #### Routes 62 | 63 | Let's dive into using endpoints: 64 | 65 | Routes are generated with the following format: 66 | ```shell 67 | https://domain.com/artisan/api/{command}/{subcommand}?args=key1:value1,key2:value2&options=opt1:value1,opt2 68 | ``` 69 | \ 70 | So the above endpoint will be translated to: 71 | ```shell 72 | php artisan command:subcommand value1 value2 --opt1=value1 --opt2 73 | ``` 74 | \ 75 | And for **Generator** commands the endpoint is: 76 | ```shell 77 | https://domain.com/artisan/api/{command}/{subcommand}/{name}?args=key1:value1,key2:value2&options=opt1:value1,opt2 78 | ``` 79 | 80 | **Pay attention that there is a `name` variable. As all Generator commands need an argument called `name`, this needs to be specified by what you desire.** 81 | 82 | \ 83 | Command Examples: 84 | 85 | ```shell 86 | php artisan list 87 | ``` 88 | will be translated to: 89 | ```shell 90 | https://domain.com/artisan/api/list 91 | ``` 92 | \ 93 | and this: 94 | ```shell 95 | php artisan cache:forget myCachedKeyName 96 | ``` 97 | will be translated to: 98 | ```shell 99 | https://domain.com/artisan/api/cache/forget?args=key:myCachedKeyName 100 | ``` 101 | \ 102 | Another one: 103 | ```shell 104 | php artisan optimize:clear -v 105 | ``` 106 | will be translated to: 107 | ```shell 108 | https://domain.com/artisan/api/optimize/clear?options=v 109 | ``` 110 | \ 111 | A **Generator** one: 112 | ```shell 113 | php artisan make:model MyModel --controller -f 114 | ``` 115 | will be translated to: 116 | ```shell 117 | https://domain.com/artisan/api/make/model/MyModel?options=controller,f 118 | ``` 119 |
120 | 121 | > Options with more than _one_ character will be translated to `--option`. 122 | 123 | #### Responses 124 | 125 | After calling an endpoint, you will receive a `` response. 126 | 127 | ##### Successful 128 | When everything works perfectly: `status : 200 OK` 129 | ```json 130 | { 131 | "ok": true, 132 | "status": 200, 133 | "output": "Output of the command, given by Artisan" 134 | } 135 | ``` 136 | 137 | ##### Not found 138 | When inputed command is not found by application: `status : 404 Not Found` 139 | ```json 140 | { 141 | "ok": false, 142 | "status": 404, 143 | "output": "Command 'command:subcommand' is not defined." 144 | } 145 | ``` 146 | 147 | ##### Invalid Arguments format 148 | When arguments are given by an invalid format: `status : 500 Server Error` 149 | ```json 150 | { 151 | "ok": false, 152 | "status": 500, 153 | "output": "Argument(s) 'key:value' given by an invalid format." 154 | } 155 | ``` 156 | 157 | ##### Invalid Options format: 158 | When options are given by an invalid format: `status : 500 Server Error` 159 | ```json 160 | { 161 | "ok": false, 162 | "status": 500, 163 | "output": "Options(s) 'key:value' given by an invalid format." 164 | } 165 | ``` 166 | 167 | #### Forbidden routes 168 | 169 | You might want to limit access to some critical commands like `db:seed`. **Artisan-Api** has thought about it and make those commands inaccessible by client. 170 | To specify forbidden commands, you are encouraged to add them within `config/artisan.php` file: 171 | ```php 172 | return [ 173 | ..., 174 | 175 | 'forbidden-routes' => [ 176 | 'clear-compiled', 177 | 'tinker', 178 | 'up', 179 | 'down', 180 | 'serve', 181 | 'completion', 182 | '_complete', 183 | 'db*', // all `db:seed` and `db:wipe` will be inaccessible 184 | '*publish' // like `vendor:publish` 185 | ] 186 | ]; 187 | ``` 188 | 189 | Whenever client wants to access these commands by endpoints, it will be given a `404 NOT_FOUND` HTTP response. 190 | 191 | #### Authentication 192 | All enpoints will be generated under the `api` middleware of Laravel and prevented by built-in authentication system, mostly with `Sanctum` and API tokens. 193 | 194 | ### Configurations 195 | 196 | As mentioned before, there is a configuration `config/artisan.php` file. 197 | You are free to modify specified values as you desire. 198 | 199 | #### API Prefix and HTTP Method 200 | Here, it is possible to change default API prefix and customize it as necessary. In addition you can access endpoints with any HTTP method as you set. 201 | 202 | ```php 203 | return [ 204 | ... 205 | 'api' => [ 206 | 'prefix' => "/artisan/api", 207 | 'method' => 'POST', // or ['POST', 'PUT'] 208 | ], 209 | ... 210 | ]; 211 | ``` 212 | 213 | #### Auto Run 214 | 215 | For some reason and mostly on production mode, you do not want to allow commands to be executed by HTTP request. To prevent this behavior, set that `auto-run` to `false`: 216 | ```php 217 | return [ 218 | ... 219 | 'auto-run' => false, 220 | ... 221 | ]; 222 | ``` 223 | 224 | **This prevents not to load package's service-provider (`ArtisanApiServiceProvider`) by default.** 225 | 226 | ### Security 227 | Artisan-Api has done its best to protect RCE vulnerability and other possible logical bugs. 228 | > Artisan-Api uses `Symfony/console` under the hood and all commands execution are filtered and recognized by it. There is no direct call to `shell_exec()` or `exec()` functions. 229 | 230 | #### IP restriction 231 | You can simply allow as many IP as you want to access to your commands. `'*'` means all IPs are trusted. 232 | 233 | #### Middlewares 234 | There are two middlewares in **Artisan-Api**. 235 | 236 | `CheckEnvMode` middleware exists to abort requests while in production environment. 237 | 238 | `AbortForbiddenRoute` middleware exists to throw `404 NOT_FOUND` status code while accessing to forbidden routes. 239 | 240 | ### Useful tips 241 | ;) 242 | 243 | ### Todo 244 | 1. It'd better be done to take `args` and `options` in query string, to be array. 245 | - Like: `?arg[key1]=value1&arg[key2]=value2` (it is a more standard way to deal with query string values) 246 | 2. Implement a way to deal with interactive commands like `tinker` (maybe can be implemented by socket) 247 | 3. Make response more readable for users, (remove "\n", ...) -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aariow/artisan-api", 3 | "description": "Execute Laravel Artisan commands via REST APIs and HTTP requests safely.", 4 | "keywords": ["laravel", "artisan", "api", "rest", "http", "php", "command", "console"], 5 | "type": "project", 6 | "license": "MIT", 7 | "minimum-stability": "beta", 8 | "authors": [ 9 | { 10 | "name": "Alireza Farhanian", 11 | "email": "aariow01@gmail.com", 12 | "role": "Creator" 13 | } 14 | ], 15 | "autoload": { 16 | "psr-4": { 17 | "Artisan\\Api\\": "src/" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Artisan\\Api\\Tests\\": "tests/" 23 | } 24 | }, 25 | "require": { 26 | "php": "^8.0.2", 27 | "illuminate/collections": "^9.21", 28 | "illuminate/console": "^9.21", 29 | "illuminate/routing": "^9.21", 30 | "illuminate/support": "^9.21", 31 | "illuminate/http": "^9.21" 32 | }, 33 | "require-dev": { 34 | "orchestra/testbench": "^7.6", 35 | "phpunit/phpunit": "^9.5" 36 | }, 37 | "extra": { 38 | "laravel": { 39 | "providers": [ 40 | "Artisan\\Api\\ArtisanApiServiceProvider" 41 | ], 42 | "aliases": { 43 | "ArtisanApi": "Artisan\\Api\\ArtisanApi" 44 | } 45 | }, 46 | "branch-alias": { 47 | "dev-master": "1.2.1-dev" 48 | } 49 | }, 50 | "scripts": { 51 | "test": [ 52 | "vendor/bin/phpunit tests/" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /config/artisan.php: -------------------------------------------------------------------------------- 1 | 'your generated key', // not implemented key 19 | 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | API and endpoints 24 | |-------------------------------------------------------------------------- 25 | | 26 | | You can modify the following as you desire. 27 | | 28 | */ 29 | 'api' => [ 30 | 'prefix' => "/artisan/api", 31 | 'method' => 'POST', // or ['POST', 'PUT', ...] 32 | 'signature' => '{command}/{subcommand}/{?name}?args={args}' // not implemented yet 33 | ], 34 | 35 | 36 | /* 37 | |-------------------------------------------------------------------------- 38 | | General running configurations 39 | |-------------------------------------------------------------------------- 40 | */ 41 | 'run' => [ 42 | 'only-dev' => false, 43 | 'auto' => true 44 | ], 45 | 46 | 47 | /* 48 | |-------------------------------------------------------------------------- 49 | | Trust who and what 50 | |-------------------------------------------------------------------------- 51 | | 52 | | Here you can allow trusted IPs to go through your commands. You will probably want to access 53 | | your resources only within local network, so it is a good approach to limit it. 54 | | 55 | */ 56 | 'trust' => [ 57 | 'ip' => [ 58 | '127.0.0.1', 59 | 60 | // To allow anyone connected to your local network 61 | '192.168.*.*' 62 | 63 | // To allow any IP 64 | // '*' 65 | ] 66 | ], 67 | 68 | 69 | /* 70 | |-------------------------------------------------------------------------- 71 | | Forbidden routes and commands 72 | |-------------------------------------------------------------------------- 73 | | 74 | | There are some commands which do sensitive actions. To make them inaccessible, 75 | | we can put them within the following array. They will not be added to Laravel 76 | | routing system and will throw '404 NOT_FOUD' HTTP response code. 77 | | Be aware of some commands like `tinker` and `down` can cause some unexpected behaviors 78 | | while accessing via APIs, to prevent your application crash, we recognize them as 79 | | forbidden commands by default. If you call command `down`, your application will 80 | | suspend to maintenance mode. Then you have to access SSH to run `up` command or consider 81 | | other tricks to put your application into production mode. 82 | | 83 | */ 84 | 'forbidden-routes' => [ 85 | 'clear-compiled', 86 | 'tinker', 87 | 'up', 88 | 'down', 89 | 'serve', 90 | 'completion', 91 | '_complete', 92 | 'migrate*', 93 | 'db*', 94 | '*publish', 95 | 'key:generate', 96 | 'artisan:key' 97 | ], 98 | 99 | 100 | 101 | /* 102 | |-------------------------------------------------------------------------- 103 | | Middlwares 104 | |-------------------------------------------------------------------------- 105 | | 106 | | You are extremely encouraged to implement your policy and access-control middleware 107 | | and inject it to Artisan-Api. As all projects have role-based limitation, it is possible 108 | | to add your own. 109 | | 110 | */ 111 | 'middlewares' => [ 112 | 'ip' => Artisan\Api\Middleware\TrustIp::class, 113 | 'key' => Artisan\Api\Middleware\KeyChecker::class, 114 | 'forbidden' => Artisan\Api\Middleware\AbortForbiddenRoute::class, 115 | 'env' => Artisan\Api\Middleware\CheckEnvMode::class, 116 | ] 117 | ]; 118 | -------------------------------------------------------------------------------- /phpunit.xsd: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | src/ 19 | 20 | 21 | 22 | 23 | ./tests 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Adapter.php: -------------------------------------------------------------------------------- 1 | commands = $collectionCommands; 32 | } 33 | 34 | /** 35 | * @inheritDoc 36 | */ 37 | public function getIterator(): IteratorAggregate 38 | { 39 | return $this->commands; 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public function getAll(): array 46 | { 47 | return $this->commands->all(); 48 | } 49 | 50 | /** 51 | * @inheritDoc 52 | */ 53 | public function toUri(Command $command, bool $withHiddens): string|false 54 | { 55 | $commandName = $command->getName(); 56 | 57 | $command = $this->commands->get($commandName); 58 | 59 | if ($withHiddens === false && $command->isHidden() === true) return false; 60 | 61 | 62 | // Replaces `:` with `/` for those commands' name with 'make:model' format 63 | $route = $this->getRoute($commandName); 64 | 65 | 66 | // Get command's arguments to route string 67 | $arguments = $this->getArguments($command); 68 | 69 | $uri = $route . "/" . $arguments; 70 | 71 | return $uri; 72 | } 73 | 74 | /** 75 | * Get route of the commands 76 | * 77 | * @param string $command 78 | * @return string 79 | */ 80 | protected function getRoute(string $command) 81 | { 82 | /** 83 | * If command's name follows 'make:model', then return '{command}/{subcommand} 84 | * If it follows 'help' or 'list', then return '{command} 85 | */ 86 | if (preg_match("/(.*):(.*)/", $command)) { 87 | return "{command}/{subcommand}"; 88 | } 89 | 90 | return "{command}"; 91 | } 92 | 93 | /** 94 | * Get arguments of the commands 95 | * 96 | * @param object $command 97 | * @return string 98 | */ 99 | protected function getArguments($command) 100 | { 101 | /** 102 | * If command is Generator, then an argument called 'name' is mandatory. 103 | * All Generator commands need 'name' argument. 104 | * Remained arguments would be gather from URI query. (?args=key:myId) 105 | * 106 | * Some commands like 'make:migration' has an argument called 'name', 107 | * these kind of commands actually create files, but not classes. So 108 | * they will NOT be considered as Generator commands. Although, they 109 | * need arguments to perform on. Consequently we search for 'name' key 110 | * in command's arguments. 111 | */ 112 | if ($this->isGenerator($command)) { 113 | return "{name}"; 114 | } 115 | 116 | return ""; 117 | } 118 | 119 | /** 120 | * @inheritDoc 121 | */ 122 | public function isGenerator($command): bool 123 | { 124 | return $command->isGenerator() || in_array("name", $command->getArguments()); 125 | } 126 | 127 | /** 128 | * @inheritDoc 129 | */ 130 | public function toCommand($command, $subcommand = null): string 131 | { 132 | if ($subcommand) { 133 | return "$command:$subcommand"; 134 | } 135 | 136 | return $command; 137 | } 138 | 139 | /** 140 | * @inheritDoc 141 | */ 142 | public function toArguments($arguments): array 143 | { 144 | $array = $this->separator($arguments); 145 | 146 | return $array; 147 | } 148 | 149 | /** 150 | * @inheritDoc 151 | */ 152 | public function toOptions($options): array 153 | { 154 | $array = $this->separator($options); 155 | 156 | $keys = array_keys($array); 157 | $values = array_values($array); 158 | 159 | foreach ($keys as &$key) { 160 | if (strlen($key) == 1) 161 | $key = "-$key"; 162 | else 163 | $key = "--$key"; 164 | } 165 | 166 | return array_combine($keys, $values); 167 | } 168 | 169 | /** 170 | * Seprate strings with the format of 'key:value,key2:value2' 171 | * into an associative array 172 | * 173 | * @param string $string 174 | * @return array 175 | */ 176 | protected function separator(string $string): array 177 | { 178 | $array = []; 179 | 180 | foreach (explode(",", $string) as $argv) { 181 | 182 | if (strpos($argv, ":")) { 183 | // If argv has ':', then extract it 184 | $argsArray = explode(":", $argv); 185 | } else { 186 | // If argv does not have ':', then return its value as true 187 | $argsArray = [$argv, true]; 188 | } 189 | 190 | $array[$argsArray[0]] = $argsArray[1]; 191 | 192 | } 193 | 194 | return $array; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/ArtisanApiManager.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | * 11 | * @link https://github/aariow/artisan-api 12 | */ 13 | 14 | namespace Artisan\Api; 15 | 16 | use Artisan\Api\Contracts\AdapterInterface; 17 | use Artisan\Api\Contracts\RouterInterface; 18 | use IteratorAggregate; 19 | 20 | class ArtisanApiManager 21 | { 22 | 23 | protected Router $router; 24 | 25 | public function __construct(AdapterInterface $adapter, IteratorAggregate $commands, RouterInterface $router) 26 | { 27 | $adapter->init($commands); 28 | $router->init($adapter); 29 | 30 | $this->adapter = $adapter; 31 | $this->router = $router; 32 | 33 | return $this; 34 | } 35 | 36 | /** 37 | * Get Router instance 38 | * 39 | * @return Router 40 | */ 41 | public function router(): RouterInterface 42 | { 43 | return $this->router; 44 | } 45 | 46 | /** 47 | * Get adapter instance 48 | * 49 | * @return AdapterInterface 50 | */ 51 | public function adapter(): AdapterInterface 52 | { 53 | return $this->adapter; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ArtisanApiServiceProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | * 11 | * @link https://github/aariow/artisan-api 12 | */ 13 | 14 | namespace Artisan\Api; 15 | 16 | use Artisan\Api\Console\GenerateKeyCommand; 17 | use Artisan\Api\Facades\ArtisanApi; 18 | use Illuminate\Support\ServiceProvider; 19 | 20 | class ArtisanApiServiceProvider extends ServiceProvider 21 | { 22 | 23 | /** 24 | * Package middlewares to be pushed 25 | * 26 | * @var array 27 | */ 28 | private array $middlewares = []; 29 | 30 | /** 31 | * Package commands 32 | * 33 | * @var array 34 | */ 35 | private array $commands = [ 36 | GenerateKeyCommand::class 37 | ]; 38 | 39 | /** 40 | * @inheritDoc 41 | */ 42 | public function register() 43 | { 44 | $this->publish(); 45 | 46 | if ($this->shouldBeLoaded()) { 47 | $this->bind(); 48 | } 49 | 50 | return; 51 | } 52 | 53 | /** 54 | * @inheritDoc 55 | */ 56 | public function boot() 57 | { 58 | if ($this->shouldBeLoaded()) { 59 | 60 | $this->commands($this->commands); 61 | 62 | $this->app->make('artisan.api') 63 | ->router()->generate(); 64 | 65 | $this->setMiddlewares(); 66 | } 67 | 68 | return; 69 | } 70 | 71 | /** 72 | * Bind service provider to the application container 73 | * 74 | * @return void 75 | */ 76 | private function bind() 77 | { 78 | $this->app->bind('artisan.api', function () { 79 | 80 | return new ArtisanApiManager( 81 | Adapter::getInstance(), 82 | new CommandsIterator, 83 | new Router 84 | ); 85 | }); 86 | 87 | $this->app->instance('path.artisan-api', __DIR__); 88 | 89 | // Registers Facade 90 | $loader = \Illuminate\Foundation\AliasLoader::getInstance(); 91 | $loader->alias('ArtisanApi', ArtisanApi::class); 92 | } 93 | 94 | /** 95 | * Publish all package related files 96 | * 97 | * @return void 98 | */ 99 | private function publish() 100 | { 101 | $this->mergeConfigFrom(__DIR__ . '/../config/artisan.php', 'artisan'); 102 | 103 | $this->publishes([ 104 | __DIR__ . "/../config/artisan.php" => config_path('artisan.php') 105 | ]); 106 | 107 | $this->middlewares = config('artisan.middlewares'); 108 | } 109 | 110 | /** 111 | * Evaluate necessary conditions to check whether the package should be loaded or not 112 | * 113 | * @return boolean 114 | */ 115 | private function shouldBeLoaded() 116 | { 117 | if (!config('artisan.run.auto')) return false; 118 | 119 | return true; 120 | } 121 | 122 | /** 123 | * Set route middlewares 124 | * 125 | * @return void 126 | */ 127 | private function setMiddlewares() 128 | { 129 | foreach ($this->middlewares as $key => $middleware) { 130 | $this->app->make('router') 131 | ->aliasMiddleware($key, $middleware) 132 | ->pushMiddlewareToGroup('artisan.api', $middleware); 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Caller.php: -------------------------------------------------------------------------------- 1 | toCommand($command["command"], $command["subcommand"]); 34 | else 35 | $command = $command["command"]; 36 | 37 | $arguments = $arguments ? Adapter::getInstance()->toArguments($arguments) : []; 38 | $options = $options ? Adapter::getInstance()->toOptions($options) : []; 39 | 40 | $parameters = array_merge_recursive($arguments, $options); 41 | 42 | $exitCode = Artisan::call($command, $parameters); 43 | 44 | // exit code `0` means everything works fine 45 | if ($exitCode != 0) 46 | throw new \Exception("Something went wrong while runnig command '$command'."); 47 | 48 | Response::getInstance()->setOutput(Artisan::output(), 200); 49 | 50 | } catch (CommandNotFoundException) { 51 | Response::getInstance()->error("Command '$command' called by API not found.", 404); 52 | } catch (InvalidArgumentException) { 53 | 54 | $argumentsKey = array_keys($arguments)[0]; 55 | 56 | $argumentsValue = array_values($arguments)[0]; 57 | 58 | Response::getInstance()->error("Argument(s) '$argumentsKey:$argumentsValue' given by an invalid format.", 500); 59 | 60 | } catch (InvalidOptionException) { 61 | 62 | $optionsKey = array_keys($options)[0]; 63 | 64 | $optionsValue = array_values($options)[0]; 65 | 66 | Response::getInstance()->error("Options(s) '$optionsKey:$optionsValue' given by an invalid format.", 500); 67 | 68 | } catch (RuntimeException $e) { 69 | Response::getInstance()->error($e->getMessage(), 500); 70 | } 71 | 72 | return Response::getInstance()->getOutput(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Command.php: -------------------------------------------------------------------------------- 1 | name = $command; 62 | $this->class = $class; 63 | 64 | $this->setArguments(); 65 | $this->setOptions(); 66 | $this->setIsGenerator(); 67 | $this->setIsHidden(); 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * Get command object as self class 74 | * 75 | * @return object 76 | */ 77 | public function getCommand() 78 | { 79 | return $this; 80 | } 81 | 82 | /** 83 | * Get command name as string 84 | * 85 | * @return string 86 | */ 87 | public function getName() 88 | { 89 | return $this->name; 90 | } 91 | 92 | /** 93 | * Get class as both object and string 94 | * Returned object is an instance of Laravel command 95 | * 96 | * @param boolean $toObject 97 | * @return object|string 98 | */ 99 | public function getClass(bool $toObject = false) 100 | { 101 | return $toObject ? $this->class 102 | : $this->class::class; 103 | } 104 | 105 | /** 106 | * Get arguments extracted from command object 107 | * 108 | * @return array 109 | */ 110 | public function getArguments() 111 | { 112 | return $this->arguments; 113 | } 114 | 115 | /** 116 | * Get options extracted from command object 117 | * 118 | * @return array 119 | */ 120 | public function getOptions() 121 | { 122 | return $this->options; 123 | } 124 | 125 | /** 126 | * Check if command is instance of GeneratorCommand 127 | * 128 | * @return bool 129 | */ 130 | public function isGenerator() 131 | { 132 | return $this->generator; 133 | } 134 | 135 | /** 136 | * Check if command is hidden 137 | * 138 | * @return boolean 139 | */ 140 | public function isHidden() 141 | { 142 | return $this->hidden; 143 | } 144 | 145 | /** 146 | * Set arguments if current command 147 | * 148 | * @return self 149 | */ 150 | protected function setArguments() 151 | { 152 | $this->arguments = array_keys($this->class->getDefinition()->getArguments()); 153 | 154 | return $this; 155 | } 156 | 157 | /** 158 | * Set options of current command 159 | * 160 | * @return self 161 | */ 162 | protected function setOptions() 163 | { 164 | $this->options = array_keys($this->class->getDefinition()->getOptions()); 165 | 166 | return $this; 167 | } 168 | 169 | /** 170 | * Set generator as whether command is instance of GeneratorCommand or not. 171 | * 172 | * @return self 173 | */ 174 | protected function setIsGenerator() 175 | { 176 | $this->generator = (bool)($this->getClass(true) instanceof GeneratorCommand); 177 | 178 | return $this; 179 | } 180 | 181 | /** 182 | * Set hidden whether command is hidden or not. 183 | * 184 | * @return self 185 | */ 186 | protected function setIsHidden() 187 | { 188 | $this->hidden = (bool)($this->getClass(true)->isHidden()); 189 | 190 | return $this; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/CommandsIterator.php: -------------------------------------------------------------------------------- 1 | setCommands($this->items); 31 | 32 | return $this; 33 | } 34 | 35 | /** 36 | * Add given Artisan commands into $items with expected format 37 | * 38 | * @param array $commands 39 | * @return self 40 | */ 41 | protected function setCommands(array $commands = []) 42 | { 43 | /** 44 | * Here we get all datas in $items with given variable - $commands, 45 | * we earse $items from Artisan commands within, then we put new 46 | * expected datas into it. 47 | */ 48 | $this->setEmpty(); 49 | 50 | foreach ($commands as $command => $class) { 51 | 52 | $commandObj = new Command($command, $class); 53 | 54 | $this->put($commandObj->getName(), $commandObj); 55 | } 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * Set $items empty 62 | * 63 | * @return self 64 | */ 65 | public function setEmpty() 66 | { 67 | $this->items = []; 68 | 69 | return $this; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Console/GenerateKeyCommand.php: -------------------------------------------------------------------------------- 1 | argument('algo') ?? 'base64'; 34 | 35 | $generatedKey = $generator->key($algo); 36 | 37 | if (!$generatedKey) { 38 | $this->error("Could not generate a key for Artisan-Api."); 39 | return 1; 40 | } 41 | 42 | $this->info("Artisan-Api key generated."); 43 | 44 | 45 | if ($this->option('show')) { 46 | $this->newLine(); 47 | $this->info($generatedKey); 48 | } 49 | 50 | return 0; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Contracts/AdapterInterface.php: -------------------------------------------------------------------------------- 1 | ArgumentValue] 41 | * 42 | * @param string $arguments 43 | * @return array 44 | */ 45 | public function toArguments($arguments): array; 46 | 47 | /** 48 | * Turn option's string into the following format 49 | * 50 | * ["--model" => OptionValue] 51 | * 52 | * @param string $options 53 | * @return array 54 | */ 55 | public function toOptions($options): array; 56 | 57 | /** 58 | * Check if command is generator 59 | * 60 | * @param object $command 61 | * @return boolean 62 | */ 63 | public function isGenerator($command): bool; 64 | 65 | /** 66 | * Get commands iterator instance 67 | * 68 | * @return IteratorAggregate 69 | */ 70 | public function getIterator(): IteratorAggregate; 71 | 72 | /** 73 | * Get all commands as an iterator array 74 | * 75 | * @return array 76 | */ 77 | public function getAll(): array; 78 | 79 | } -------------------------------------------------------------------------------- /src/Contracts/ControllerInterface.php: -------------------------------------------------------------------------------- 1 | command ?? null; 22 | $subcommand = $request->subcommand ?? null; 23 | 24 | $name = $request->name ? "name:" . $request->name : null; 25 | 26 | if ($arguments = $request->query('args')) { 27 | $arguments = $name . "," . $arguments; 28 | } else { 29 | $arguments = $name; 30 | } 31 | 32 | $options = $request->query('options'); 33 | 34 | Caller::call([ 35 | "command" => $command, 36 | "subcommand" => $subcommand 37 | ], $arguments, $options); 38 | 39 | return Response::getInstance()->json(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Controllers/SingleCommandController.php: -------------------------------------------------------------------------------- 1 | command ?? null; 22 | $subcommand = $request->subcommand ?? null; 23 | 24 | $arguments = $request->query('args'); 25 | $options = $request->query('options'); 26 | 27 | Caller::call([ 28 | "command" => $command, 29 | "subcommand" => $subcommand 30 | ], $arguments, $options); 31 | 32 | return Response::getInstance()->json(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Facades/ArtisanApi.php: -------------------------------------------------------------------------------- 1 | toCommand($request->command, $request->subcommand); 23 | 24 | foreach (config('artisan.forbidden-routes') as $route) { 25 | if (Str::is($route, $command)) { 26 | abort(404); 27 | } 28 | } 29 | 30 | return $next($request); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Middleware/CheckEnvMode.php: -------------------------------------------------------------------------------- 1 | ip())) { 23 | abort(404); 24 | } 25 | 26 | return $next($request); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | output = $output; 30 | $this->status = $status; 31 | } 32 | 33 | /** 34 | * Return the output that is set before. 35 | * 36 | * @return string 37 | */ 38 | public function getOutput() 39 | { 40 | return $this->output; 41 | } 42 | 43 | /** 44 | * Set the HTTP status code to be sent. 45 | * 46 | * @param integer $status 47 | * @return void 48 | */ 49 | public function setStatus(int $status) 50 | { 51 | $this->status = $status; 52 | } 53 | 54 | /** 55 | * Set the given output. This method is implemented for more readability. 56 | * 57 | * @param string $error 58 | * @param int $status 59 | * @return void 60 | */ 61 | public function error(string $error, int $status = 500) 62 | { 63 | $this->setOutput($error, $status); 64 | } 65 | 66 | /** 67 | * Return the output with the type of Json. 68 | * 69 | * @param array $data 70 | * @return Illuminate\Http\JsonResponse 71 | */ 72 | public function json(array $data = []) 73 | { 74 | $ok = ($this->status == 200) ? true : false; 75 | 76 | $data = $data ?: [ 77 | "ok" => $ok, 78 | "status" => $this->status, 79 | 'output' => $this->output 80 | ]; 81 | 82 | return response()->json($data, $this->status); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | adapter = $adapter; 55 | 56 | $this->method = config('artisan.api.method', ['POST']); 57 | $this->prefix = config('artisan.api.prefix', '/artisan/api'); 58 | 59 | $this->forbiddenRoutes = config('artisan.forbidden-routes'); 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * Generate routes by dynamic command's attributes; uses RouteAdapter to convert 66 | * command's attributes into readable string for Laravel routing system. 67 | * 68 | * @param boolean $withHiddens 69 | * @return void 70 | */ 71 | public function generate(bool $withHiddens = false): void 72 | { 73 | $routeConfig = [ 74 | 'prefix' => $this->prefix, 75 | 'middleware' => ['api', 'artisan.api'] 76 | ]; 77 | 78 | app('router') 79 | ->group($routeConfig, function ($router) use ($withHiddens) { 80 | 81 | // Add static routes 82 | foreach ($this->getStaticRoutes() as $route) { 83 | $router->addRoute($this->method, $route, $this->getAction()); 84 | } 85 | 86 | // Add dynamic routes for each command 87 | foreach ($this->adapter->getAll() as $command) { 88 | 89 | // Prevents empty routes to be added from hidden commands 90 | if (!$uri = $this->adapter->toUri($command, $withHiddens)) 91 | continue; 92 | 93 | $route = $router->addRoute($this->method, $uri, $this->getAction($command)); 94 | 95 | array_push($this->routes, $route->uri); 96 | } 97 | }); 98 | 99 | $this->routes = array_unique($this->routes); 100 | } 101 | 102 | /** 103 | * Get action to be run when route reached. 104 | * Here we return controller to do actions for cleaner code, 105 | * we can still use a Closure function to do actions. 106 | * 107 | * @param $command 108 | * @return array 109 | */ 110 | protected function getAction($command = null) 111 | { 112 | if ($command && $this->adapter->isGenerator($command)) 113 | return [GeneratorCommandController::class, 'run']; 114 | 115 | return [SingleCommandController::class, 'run']; 116 | } 117 | 118 | /** 119 | * @return array 120 | */ 121 | public function getRoutes(): array 122 | { 123 | return $this->routes; 124 | } 125 | 126 | /** 127 | * @return array 128 | */ 129 | protected function getStaticRoutes(): array 130 | { 131 | return $this->staticRoutes; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Traits/Singleton.php: -------------------------------------------------------------------------------- 1 | generateRandomKey($algorithm); 24 | } 25 | 26 | /** 27 | * Generate a random key. 28 | * 29 | * @param [type] $algorithm 30 | * @return string|false 31 | * @throws UnhandledMatchError 32 | */ 33 | protected function generateRandomKey($algorithm) 34 | { 35 | // Create the key 36 | 37 | try { 38 | $appKey = Encrypter::generateKey(app()['config']['app.cipher']); 39 | 40 | $key = match ($algorithm) { 41 | 'base64' => base64_encode($appKey), 42 | 'md5' => md5($appKey), 43 | 'sha1' => sha1($appKey), 44 | 'default'=> Crypt::encryptString($appKey) 45 | }; 46 | 47 | if (! $this->writeKeyInConfigFile($key)) { 48 | return false; 49 | } 50 | 51 | } catch (UnhandledMatchError) { 52 | return false; 53 | } 54 | 55 | return $key; 56 | } 57 | 58 | /** 59 | * Write given key into config file array. 60 | * 61 | * @param string $key 62 | * @return int|false 63 | */ 64 | protected function writeKeyInConfigFile($key) 65 | { 66 | return file_put_contents($this->getPackageConfigDir(), 67 | preg_replace( 68 | "/'key'\s=>\s(.*)/", 69 | "'key' => '$key',", 70 | file_get_contents($this->getPackageConfigDir()) 71 | ) 72 | ); 73 | } 74 | 75 | /** 76 | * Get package root directory 77 | * 78 | * @return string 79 | */ 80 | protected function getPackageConfigDir() 81 | { 82 | return app()["path.artisan-api"] . "/../config/artisan.php"; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/AdapterTest.php: -------------------------------------------------------------------------------- 1 | commandsIterator = CommandsIterator::getInstance(); 22 | 23 | $this->adapter = Adapter::getInstance(); 24 | 25 | $this->adapter->init($this->commandsIterator); 26 | } 27 | 28 | public function testGetCommandsUri() 29 | { 30 | $command = $this->command("cache:clear"); 31 | $routeSignature = "{command}/{subcommand}/"; 32 | 33 | $uri = $this->adapter->toUri($command, true); 34 | 35 | $this->assertIsString($uri); 36 | $this->assertEquals($routeSignature, $uri); 37 | } 38 | 39 | public function testGetCommandAsCommandName() 40 | { 41 | $name = $this->adapter->toCommand("make", "model"); 42 | 43 | $this->assertMatchesRegularExpression("/(.*):(.*)/", $name); 44 | } 45 | 46 | public function testStringArgumentsIntoArray() 47 | { 48 | $stringArgs = "arg1:something,key:value,key2:value2,nullValue,assoc:something"; 49 | 50 | $arrayArgs = $this->adapter->toArguments($stringArgs); 51 | 52 | $this->assertIsArray($arrayArgs); 53 | 54 | $this->assertArrayHasKey("arg1", $arrayArgs); 55 | $this->assertArrayHasKey("key", $arrayArgs); 56 | $this->assertArrayHasKey("nullValue", $arrayArgs); 57 | $this->assertArrayHasKey("assoc", $arrayArgs); 58 | 59 | $this->assertEquals("something", $arrayArgs["arg1"]); 60 | $this->assertEquals("value", $arrayArgs["key"]); 61 | $this->assertEquals("something", $arrayArgs["assoc"]); 62 | 63 | $this->assertTrue($arrayArgs["nullValue"]); 64 | 65 | $this->assertCount(5, $arrayArgs); 66 | } 67 | 68 | public function testStringOptionsIntoArray() 69 | { 70 | $stringArgs = "opt1:something,key:value,key2:value2,nullValue,assoc:something,v,c"; 71 | 72 | $arrayArgs = $this->adapter->toOptions($stringArgs); 73 | 74 | $this->assertIsArray($arrayArgs); 75 | 76 | $this->assertArrayHasKey("--opt1", $arrayArgs); 77 | $this->assertArrayHasKey("--key", $arrayArgs); 78 | $this->assertArrayHasKey("--nullValue", $arrayArgs); 79 | $this->assertArrayHasKey("--assoc", $arrayArgs); 80 | $this->assertArrayHasKey("-v", $arrayArgs); 81 | $this->assertArrayHasKey("-c", $arrayArgs); 82 | 83 | $this->assertEquals("something", $arrayArgs["--opt1"]); 84 | $this->assertEquals("value", $arrayArgs["--key"]); 85 | $this->assertEquals("something", $arrayArgs["--assoc"]); 86 | 87 | $this->assertTrue($arrayArgs["--nullValue"]); 88 | $this->assertTrue($arrayArgs["-v"]); 89 | $this->assertTrue($arrayArgs["-c"]); 90 | 91 | $this->assertCount(7, $arrayArgs); 92 | } 93 | 94 | public function testIfCommandIsInstanceOfGenerator() 95 | { 96 | $command = $this->command("make:model"); 97 | 98 | $isGenerator = $this->adapter->isGenerator($command); 99 | 100 | $this->assertTrue($isGenerator); 101 | } 102 | 103 | public function testIfCommandIsGeneratorEvenIfNotInstanceOfGeneratorCommand() 104 | { 105 | $command = $this->command("make:migration"); 106 | 107 | $isGenerator = $this->adapter->isGenerator($command); 108 | 109 | $this->assertTrue($isGenerator); 110 | } 111 | 112 | /** 113 | * Get inputed command object from CommandsIterator 114 | * 115 | * @param string $name 116 | * @return object 117 | */ 118 | protected function command($name) 119 | { 120 | return $this->commandsIterator->get($name); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/CallerTest.php: -------------------------------------------------------------------------------- 1 | command = "make"; 44 | $this->subcommand = "model"; 45 | $this->arguments = "name:TESTMODEL_" . uniqid(); 46 | $this->options = "f,controller:true"; // Create a factory and controller with model 47 | } 48 | 49 | public function testCommandWorksPerfectly() 50 | { 51 | $output = $this->callCommand(); 52 | 53 | $this->assertIsString($output); 54 | $this->assertNotNull($output); 55 | $this->assertStringContainsString("Model created successfully.", $output); 56 | $this->assertStringContainsString("Factory created successfully.", $output); 57 | $this->assertStringContainsString("Controller created successfully.", $output); 58 | } 59 | 60 | public function testForUnreadableArguments() 61 | { 62 | $this->arguments = "wrong_argument_name:value"; 63 | 64 | $output = $this->callCommand(); 65 | 66 | $this->assertIsString($output); 67 | $this->assertStringContainsString("Argument(s) '$this->arguments' given by an invalid format.", $output); 68 | } 69 | 70 | public function testForUnreadableOptions() 71 | { 72 | $this->options = "wrong_option_name:value"; 73 | 74 | $output = $this->callCommand(); 75 | 76 | $this->assertIsString($output); 77 | $this->assertStringContainsString("Options(s) '--$this->options' given by an invalid format.", $output); 78 | } 79 | 80 | public function testForMissingArguments() 81 | { 82 | $this->arguments = $this->options = ""; 83 | 84 | $output = $this->callCommand(); 85 | 86 | $this->assertIsString($output); 87 | $this->assertStringContainsString("Not enough arguments (missing: \"name\").", $output); 88 | } 89 | 90 | public function testCommandNotFound() 91 | { 92 | $this->command = "not_existed_command"; 93 | $this->subcommand = "something"; 94 | 95 | $output = $this->callCommand(); 96 | 97 | $this->assertIsString($output); 98 | $this->assertStringContainsString("Command '$this->command:$this->subcommand' called by API not found.", $output); 99 | } 100 | 101 | /** 102 | * Call inputed command by Caller class 103 | * 104 | * @return string 105 | */ 106 | protected function callCommand() 107 | { 108 | return Caller::call([ 109 | "command" => $this->command, 110 | "subcommand" => $this->subcommand 111 | ], $this->arguments, $this->options); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/CommandTest.php: -------------------------------------------------------------------------------- 1 | info("command:test run"); 22 | }); 23 | 24 | $this->command = new Command("command:test", $artisanCommand); 25 | } 26 | 27 | public function testGetCurrentCommand() 28 | { 29 | $this->assertInstanceOf(Command::class, $this->command); 30 | } 31 | 32 | public function testGetClassOfCurrentCommands() 33 | { 34 | $this->assertInstanceOf(SymfonyCommand::class, $this->command->getClass(true)); 35 | } 36 | 37 | public function testClosureCommandCanBeTreatedAsClassCommands() 38 | { 39 | $this->assertInstanceOf(ClosureCommand::class, $this->command->getClass(true)); 40 | } 41 | 42 | public function testGetAllArguments() 43 | { 44 | $this->assertIsArray($this->command->getArguments()); 45 | } 46 | 47 | public function testGetAllOptions() 48 | { 49 | $this->assertIsArray($this->command->getOptions()); 50 | } 51 | 52 | public function testCommandIfGenerator() 53 | { 54 | $this->assertIsBool($this->command->isGenerator()); 55 | } 56 | 57 | public function testCommandIfHidden() 58 | { 59 | $this->assertIsBool($this->command->isHidden()); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Middleware/AbortForbiddenRouteTest.php: -------------------------------------------------------------------------------- 1 | apiPrefix . "/down"; 12 | 13 | $response = $this->post($forbiddenRoute); 14 | 15 | $response->assertNotFound(); 16 | } 17 | 18 | public function testValidCommandIsCalled() 19 | { 20 | $uri = $this->apiPrefix . "/help"; 21 | 22 | $response = $this->post($uri); 23 | 24 | $response->assertOk(); 25 | $this->assertNotEmpty($response->getContent()); 26 | $this->assertJson($response->getContent()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Middleware/CheckEnvModeTest.php: -------------------------------------------------------------------------------- 1 | false]); 15 | 16 | $uri = $this->apiPrefix . "/help"; 17 | 18 | $response = $this->post($uri); 19 | 20 | $response->assertOk(); 21 | } 22 | 23 | public function testMiddlewareAllowsInDevelopmentMode() 24 | { 25 | $uri = $this->apiPrefix . "/help"; 26 | 27 | $response = $this->post($uri); 28 | 29 | $response->assertOk(); 30 | } 31 | 32 | public function testIfOnlyDevIsTrueAndEnvIsProduction() 33 | { 34 | app()['env'] = "production"; 35 | 36 | config(['artisan.run.only-dev' => true]); 37 | 38 | $uri = $this->apiPrefix . "/help"; 39 | 40 | $response = $this->post($uri); 41 | 42 | $response->assertNotFound(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Middleware/TrustIpTest.php: -------------------------------------------------------------------------------- 1 | route = $this->apiPrefix . "/list"; 17 | } 18 | 19 | public function testAnUntrustedIpIsAborted() 20 | { 21 | // Impossible ! 22 | $untrustedIp = '256.0.0.0'; 23 | 24 | config(['artisan.trust.ip' => [$untrustedIp]]); 25 | 26 | $response = $this->post($this->route); 27 | 28 | $response->assertNotFound(); 29 | } 30 | 31 | public function testATrustedIpIsAllowed() 32 | { 33 | config(['artisan.trust.ip' => ['127.0.0.1']]); 34 | 35 | $response = $this->post($this->route); 36 | 37 | $response->assertOk(); 38 | } 39 | 40 | public function testAnyIpIsAllowed() 41 | { 42 | config(['artisan.trust.ip' => ['*']]); 43 | 44 | $response = $this->post($this->route); 45 | 46 | $response->assertOk(); 47 | } 48 | 49 | public function testNoIpIsAllowed() 50 | { 51 | config(['artisan.trust.ip' => []]); 52 | 53 | $response = $this->post($this->route); 54 | 55 | $response->assertNotFound(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/ResponseTest.php: -------------------------------------------------------------------------------- 1 | message = "Output is set"; 19 | 20 | $this->data = [ 21 | "ok" => true, 22 | "status" => 200, 23 | "output" => $this->message 24 | ]; 25 | 26 | Response::getInstance()->setOutput($this->message, 200); 27 | } 28 | 29 | public function testOutputIsSet() 30 | { 31 | $output = Response::getInstance()->getOutput(); 32 | 33 | $this->assertIsString($output); 34 | $this->assertStringContainsString($output, $this->message); 35 | } 36 | 37 | public function testForResponseFormat() 38 | { 39 | $data = Response::getInstance()->json()->getData(assoc: true); 40 | 41 | $this->assertIsArray($data); 42 | $this->assertEquals($this->data, $data); 43 | } 44 | 45 | public function testResponseIsJson() 46 | { 47 | $data = Response::getInstance()->json()->getContent(); 48 | 49 | $this->assertJson($data); 50 | } 51 | 52 | public function testStatusCodeIsSent() 53 | { 54 | $jsonObj = Response::getInstance()->json(); 55 | 56 | $data = $jsonObj->getData(assoc: true); 57 | 58 | $status = $jsonObj->getStatusCode(); 59 | 60 | $this->assertIsInt($status); 61 | $this->assertNotNull($status); 62 | $this->assertEquals($status, $data["status"]); 63 | } 64 | 65 | public function testResponseIsInstanceOfJsonResponse() 66 | { 67 | $data = Response::getInstance()->json(); 68 | 69 | $this->assertInstanceOf(\Illuminate\Http\JsonResponse::class, $data); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/RouterTest.php: -------------------------------------------------------------------------------- 1 | apiPrefix . '/help'; 13 | 14 | $response = $this->post($uri); 15 | 16 | $response->assertOk() 17 | ->assertJson(["ok" => true]) 18 | ->assertJsonCount(3); 19 | 20 | // route 'prefix/{command}/{subcommand}' 21 | $uri = $this->apiPrefix . '/optimize/clear'; 22 | 23 | $response = $this->post($uri); 24 | 25 | $response->assertOk() 26 | ->assertJson(["ok" => true]) 27 | ->assertJsonCount(3); 28 | 29 | // route 'prefix/{command}/{subcommand}/{name}' 30 | $uri = $this->apiPrefix . '/make/model/TEST_MODEL_SHOULD_BE_REMOVED'; 31 | 32 | $response = $this->post($uri); 33 | 34 | $response->assertOk() 35 | ->assertJson(["ok" => true]) 36 | ->assertJsonCount(3); 37 | } 38 | 39 | public function testIfRouteNotFound() 40 | { 41 | $uri = $this->apiPrefix . '/not-existed-command'; 42 | 43 | $response = $this->post($uri); 44 | 45 | $response->assertNotFound(); 46 | } 47 | 48 | public function testIfRouteIsWrong() 49 | { 50 | $uri = $this->apiPrefix . '/optimizer/clear'; 51 | 52 | $response = $this->post($uri); 53 | 54 | $response->assertNotFound(); 55 | } 56 | 57 | public function testThereIsNoRouteForHiddenCommand() 58 | { 59 | $uri = $this->apiPrefix . '/down'; 60 | 61 | $response = $this->post($uri); 62 | 63 | $response->assertNotFound(); 64 | } 65 | 66 | public function testRouteWithArgument() 67 | { 68 | $keyName = "value"; 69 | $uri = $this->apiPrefix . "/cache/forget?args=key:$keyName"; 70 | 71 | $response = $this->post($uri); 72 | 73 | $response->assertOk() 74 | ->assertJsonCount(3) 75 | ->assertJson(["ok" => true]); 76 | 77 | $this->assertStringContainsString("The [$keyName] key has been removed from the cache.", $response->getData(true)["output"]); 78 | } 79 | 80 | public function testRouteWithOption() 81 | { 82 | $uri = $this->apiPrefix . "/optimize/clear?options=v"; 83 | 84 | $response = $this->post($uri); 85 | 86 | $response->assertOk() 87 | ->assertJsonCount(3) 88 | ->assertJson(["ok" => true]); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | apiPrefix = config("artisan.api.prefix"); 19 | } 20 | 21 | protected function getPackageProviders($app) 22 | { 23 | return [ 24 | ArtisanApiServiceProvider::class, 25 | ]; 26 | } 27 | 28 | protected function getEnvironmentSetUp($app) 29 | { 30 | // perform environment setup 31 | } 32 | } 33 | --------------------------------------------------------------------------------