├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── SECURITY.md ├── composer.json ├── phpunit.xml ├── setup.sh ├── src ├── ApiController.php ├── ApiModel.php ├── ApiResponse.php ├── Exceptions │ ├── ApiException.php │ ├── ErrorCodes.php │ ├── Parse │ │ ├── FilterNotFoundException.php │ │ ├── InvalidFilterDefinitionException.php │ │ ├── InvalidLimitException.php │ │ ├── InvalidOrderingDefinitionException.php │ │ ├── MaxLimitException.php │ │ ├── NotAllowedToFilterOnThisFieldException.php │ │ └── UnknownFieldException.php │ ├── RelatedResourceNotFoundException.php │ ├── ResourceNotFoundException.php │ ├── UnauthenticationException.php │ ├── UnauthorizedException.php │ └── ValidationException.php ├── ExtendedRelations │ └── BelongsToMany.php ├── Facades │ └── ApiRoute.php ├── Handlers │ └── ApiExceptionHandler.php ├── Middleware │ └── ApiMiddleware.php ├── Providers │ └── ApiServiceProvider.php ├── RequestParser.php ├── Routing │ ├── ApiResourceRegistrar.php │ ├── ApiRouter.php │ └── ApiUrlGenerator.php └── api.php └── tests ├── Controllers ├── CommentController.php ├── PostController.php └── UserController.php ├── DummyUserTest.php ├── Factories └── ModelFactory.php ├── Models ├── DummyComment.php ├── DummyPhone.php ├── DummyPost.php └── DummyUser.php ├── PaginationTest.php └── TestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | laravel/ 2 | .idea/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.6 5 | - 7.0 6 | 7 | sudo: false 8 | 9 | cache: 10 | directories: 11 | - laravel 12 | install: 13 | # specify the laravel service providers to insert 14 | - export PACKAGE_PROVIDER=" 15 | Froiden\\\\RestAPI\\\\Providers\\\\ApiServiceProvider::class" 16 | - export FACADES=" 17 | 'ApiRoute' => Froiden\\\\RestAPI\\\\Facades\\\\ApiRoute::class" 18 | - export DB_HOST="localhost" 19 | - export DB_DATABASE="homestead_test" 20 | - export DB_USERNAME="root" 21 | - export DB_PASSWORD="" 22 | #specify the package to test 23 | - export PACKAGE_NAME=froiden/laravel-rest-api 24 | #run the setup script 25 | - curl -s https://raw.githubusercontent.com/froiden/laravel-rest-api/master/setup.sh | bash 26 | - mysql -e 'create database homestead_test;' 27 | 28 | services: mysql 29 | 30 | script: 31 | - cd laravel/laravel-rest-api 32 | - ../vendor/bin/phpunit -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Froiden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Rest API 2 | 3 | [![Packagist License](https://poser.pugx.org/froiden/laravel-rest-api/license)]() 4 | [![Total Downloads](https://poser.pugx.org/froiden/laravel-rest-api/d/total)](https://packagist.org/packages/froiden/laravel-rest-api) 5 | 6 | This package provides a powerful Rest API functionality for your Laravel project, with minimum code required for you to write. 7 | 8 | ## Documentation 9 | For full documentation please refer to [plugin's wiki](https://github.com/Froiden/laravel-rest-api/wiki). 10 | 11 | ## Contribution 12 | This package is in its very early phase. We would love your feedback, issue reports and contributions. Many things are missing, like, tests, many bugs are there and many new features need to be implemented. So, if you find this useful, please contribute. 13 | 14 | ## 💰 Sponsor 15 | I fell in love with open-source in 2012 and there has been no looking back since! You can read more about me [Froiden][https://froiden.com]. 16 | 17 | - ☕ How about we get to know each other over coffee? Buy me a cup for just [**$5**][buymeacoffee] 18 | 19 | 20 | 21 | [paypal]: https://paypal.com/froiden 22 | [buymeacoffee]: https://www.buymeacoffee.com/froiden 23 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "froiden/laravel-rest-api", 3 | "description": "Powerful RestAPI plugin for Laravel framework", 4 | "type": "library", 5 | "require": { 6 | "laravel/framework": "5.6.*|5.7.*|5.8.*|6.*|7.*|8.*|9.*|10.*|^11.0|^12.0" 7 | }, 8 | "license": "MIT", 9 | "autoload": { 10 | "psr-4": { 11 | "Froiden\\RestAPI\\": "src", 12 | "Froiden\\RestAPI\\Tests\\": "tests" 13 | } 14 | }, 15 | "authors": [ 16 | { 17 | "name": "Shashank Jain", 18 | "email": "shashank@froiden.com" 19 | }, 20 | { 21 | "name": "Ajay Kumar Choudhary", 22 | "email": "ajay@froiden.com" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests 15 | 16 | 17 | 18 | 19 | ./app 20 | 21 | ./app/Http/routes.php 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ ! -f "laravel/composer.json" ] ; then 4 | rm -rf laravel 5 | git clone https://github.com/laravel/laravel 6 | cd laravel || exit 7 | # git checkout 5.2 8 | composer install --no-interaction 9 | cp .env.example .env 10 | php artisan key:generate 11 | 12 | if [[ -v PACKAGE_PROVIDER ]]; then 13 | echo "$(awk '/'\''providers'\''[^\n]*?\[/ { print; print "'$(sed -e 's/\s*//g' <<<${PACKAGE_PROVIDER})',"; next }1' config/app.php)" > config/app.php 14 | fi 15 | 16 | if [[ -v FACADES ]]; then 17 | echo "$(awk '/'\''aliases'\''[^\n]*?\[/ { print; print "'$(sed -e 's/\s*//g' <<<${FACADES})',"; next }1' config/app.php)" > config/app.php 18 | fi 19 | 20 | sed -i "s|'strict' => true|'strict' => false|g" ./config/database.php 21 | 22 | php -r " 23 | \$arr = json_decode(file_get_contents(\"composer.json\"), true); 24 | \$arr[\"autoload\"][\"psr-4\"][\"Froiden\\\\RestAPI\\\\\"] = \"laravel-rest-api/src\"; 25 | \$arr[\"autoload\"][\"psr-4\"][\"Froiden\\\\RestAPI\\\\Tests\\\\\"] = \"laravel-rest-api/tests\"; 26 | file_put_contents(\"composer.json\", json_encode(\$arr)); 27 | " 28 | else 29 | cd laravel || exit 30 | fi 31 | 32 | rm -rf laravel-rest-api 33 | git clone https://github.com/Froiden/laravel-rest-api 34 | git checkout master 35 | composer du 36 | cd .. || exit -------------------------------------------------------------------------------- /src/ApiController.php: -------------------------------------------------------------------------------- 1 | processingStartTime = microtime(true); 127 | 128 | if ($this->model) { 129 | // Only if model is defined. Otherwise, this is a normal controller 130 | $this->primaryKey = call_user_func([new $this->model(), "getKeyName"]); 131 | $this->table = call_user_func([new $this->model(), "getTable"]); 132 | } 133 | 134 | if (config('app.debug') == true) { 135 | \DB::enableQueryLog(); 136 | } 137 | } 138 | 139 | /** 140 | * Process index page request 141 | * 142 | * @return mixed 143 | */ 144 | public function index() 145 | { 146 | $this->validate(); 147 | 148 | $results = $this->parseRequest() 149 | ->addIncludes() 150 | ->addFilters() 151 | ->addOrdering() 152 | ->addPaging() 153 | ->modify() 154 | ->getResults() 155 | ->toArray(); 156 | 157 | $meta = $this->getMetaData(); 158 | 159 | return ApiResponse::make(null, $results, $meta); 160 | } 161 | 162 | /** 163 | * Process the show request 164 | * 165 | * @return mixed 166 | */ 167 | public function show(...$args) 168 | { 169 | // We need to do this in order to support multiple parameter resource routes. For example, 170 | // if we map route /user/{user}/comments/{comment} to a controller, Laravel will pass `user` 171 | // as first argument and `comment` as last argument. So, id object that we want to fetch 172 | // is the last argument. 173 | $id = last(func_get_args()); 174 | 175 | $this->validate(); 176 | 177 | $results = $this->parseRequest() 178 | ->addIncludes() 179 | ->addKeyConstraint($id) 180 | ->modify() 181 | ->getResults(true) 182 | ->first() 183 | ->toArray(); 184 | 185 | $meta = $this->getMetaData(true); 186 | 187 | return ApiResponse::make(null, $results, $meta); 188 | } 189 | 190 | public function store() 191 | { 192 | \DB::beginTransaction(); 193 | 194 | $this->validate(); 195 | 196 | // Create new object 197 | /** @var ApiModel $object */ 198 | $object = new $this->model(); 199 | $object->fill(request()->all()); 200 | 201 | // Run hook if exists 202 | if(method_exists($this, 'storing')) { 203 | $object = call_user_func([$this, 'storing'], $object); 204 | } 205 | 206 | $object->save(); 207 | 208 | $meta = $this->getMetaData(true); 209 | 210 | \DB::commit(); 211 | 212 | if(method_exists($this, 'stored')) { 213 | call_user_func([$this, 'stored'], $object); 214 | } 215 | 216 | return ApiResponse::make("Resource created successfully", [ "id" => $object->id ], $meta); 217 | } 218 | 219 | public function update(...$args) 220 | { 221 | \DB::beginTransaction(); 222 | 223 | $id = last(func_get_args()); 224 | 225 | $this->validate(); 226 | 227 | // Get object for update 228 | $this->query = call_user_func($this->model . "::query"); 229 | $this->modify(); 230 | 231 | /** @var ApiModel $object */ 232 | $object = $this->query->find($id); 233 | 234 | if (!$object) { 235 | throw new ResourceNotFoundException(); 236 | } 237 | 238 | $object->fill(request()->all()); 239 | 240 | if(method_exists($this, 'updating')) { 241 | $object = call_user_func([$this, 'updating'], $object); 242 | } 243 | 244 | $object->save(); 245 | 246 | $meta = $this->getMetaData(true); 247 | 248 | \DB::commit(); 249 | 250 | if(method_exists($this, 'updated')) { 251 | call_user_func([$this, 'updated'], $object); 252 | } 253 | 254 | return ApiResponse::make("Resource updated successfully", [ "id" => $object->id ], $meta); 255 | } 256 | 257 | public function destroy(...$args) 258 | { 259 | \DB::beginTransaction(); 260 | 261 | $id = last(func_get_args()); 262 | 263 | $this->validate(); 264 | 265 | // Get object for update 266 | $this->query = call_user_func($this->model . "::query"); 267 | $this->modify(); 268 | 269 | /** @var Model $object */ 270 | $object = $this->query->find($id); 271 | 272 | if (!$object) { 273 | throw new ResourceNotFoundException(); 274 | } 275 | 276 | if(method_exists($this, 'destroying')) { 277 | $object = call_user_func([$this, 'destroying'], $object); 278 | } 279 | 280 | $object->delete(); 281 | 282 | $meta = $this->getMetaData(true); 283 | 284 | \DB::commit(); 285 | 286 | if(method_exists($this, 'destroyed')) { 287 | call_user_func([$this, 'destroyed'], $object); 288 | } 289 | 290 | return ApiResponse::make("Resource deleted successfully", null, $meta); 291 | } 292 | 293 | public function relation($id, $relation) 294 | { 295 | $this->validate(); 296 | 297 | // To show relations, we just make a new fields parameter, which requests 298 | // only object id, and the relation and get the results like normal index request 299 | 300 | $fields = "id," . $relation . ".limit(" . ((request()->limit) ? request()->limit : $this->defaultLimit) . 301 | ")" . ((request()->offset) ? ".offset(" . request()->offset . ")": "" ) 302 | . ((request()->fields) ? "{" .request()->fields . "}" : ""); 303 | 304 | request()->fields = $fields; 305 | 306 | $results = $this->parseRequest() 307 | ->addIncludes() 308 | ->addKeyConstraint($id) 309 | ->modify() 310 | ->getResults(true) 311 | ->first() 312 | ->toArray(); 313 | 314 | $data = $results[$relation]; 315 | 316 | $meta = $this->getMetaData(true); 317 | 318 | return ApiResponse::make(null, $data, $meta); 319 | 320 | } 321 | 322 | protected function parseRequest() 323 | { 324 | $this->parser = new RequestParser($this->model); 325 | 326 | return $this; 327 | } 328 | 329 | protected function validate() 330 | { 331 | 332 | if ($this->isIndex()) { 333 | $requestClass = $this->indexRequest; 334 | } 335 | else if ($this->isShow()) { 336 | $requestClass = $this->showRequest; 337 | } 338 | else if ($this->isUpdate()) { 339 | $requestClass = $this->updateRequest; 340 | } 341 | else if ($this->isDelete()) { 342 | $requestClass = $this->deleteRequest; 343 | } 344 | else if ($this->isStore()) { 345 | $requestClass = $this->storeRequest; 346 | } 347 | else if ($this->isRelation()) { 348 | $requestClass = $this->indexRequest; 349 | } 350 | else { 351 | $requestClass = null; 352 | } 353 | 354 | if ($requestClass) { 355 | // We just make the class, its validation is called automatically 356 | app()->make($requestClass); 357 | } 358 | } 359 | 360 | /** 361 | * Looks for relations in the requested fields and adds with query for them 362 | * 363 | * @return $this current controller object for chain method calling 364 | */ 365 | protected function addIncludes() 366 | { 367 | 368 | $relations = $this->parser->getRelations(); 369 | 370 | if (!empty($relations)) { 371 | $includes = []; 372 | 373 | foreach ($relations as $key => $relation) { 374 | $includes[$key] = function (Relation $q) use ($relation, $key) { 375 | 376 | $relations = $this->parser->getRelations(); 377 | 378 | $tableName = $q->getRelated()->getTable(); 379 | $primaryKey = $q->getRelated()->getKeyName(); 380 | 381 | if ($relation["userSpecifiedFields"]) { 382 | // Prefix table name so that we do not get ambiguous column errors 383 | $fields = $relation["fields"]; 384 | } 385 | else { 386 | // Add default fields, if no fields specified 387 | $related = $q->getRelated(); 388 | 389 | $fields = call_user_func(get_class($related) . "::getDefaultFields"); 390 | $fields = array_merge($fields, $relation["fields"]); 391 | 392 | $relations[$key]["fields"] = $fields; 393 | } 394 | 395 | // Remove appends from select 396 | $appends = call_user_func(get_class($q->getRelated()) . "::getAppendFields"); 397 | $relations[$key]["appends"] = $appends; 398 | 399 | if (!in_array($primaryKey, $fields)) { 400 | $fields[] = $primaryKey; 401 | } 402 | 403 | $fields = array_map(function($name) use($tableName) { 404 | return $tableName . "." . $name; 405 | }, array_diff($fields, $appends)); 406 | 407 | if ($q instanceof BelongsToMany) { 408 | // Because laravel loads all the related models of relations in many-to-many 409 | // together, limit and offset do not work. So, we have to complicate things 410 | // to make them work 411 | $innerQuery = $q->getQuery(); 412 | $innerQuery->select($fields); 413 | $innerQuery->selectRaw("@currcount := IF(@currvalue = " . $q->getQualifiedForeignPivotKeyName() . ", @currcount + 1, 1) AS rank"); 414 | $innerQuery->selectRaw("@currvalue := " . $q->getQualifiedForeignPivotKeyName() . " AS whatever"); 415 | $innerQuery->orderBy($q->getQualifiedForeignPivotKeyName(), ($relation["order"] == "chronological") ? "ASC" : "DESC"); 416 | 417 | // Inner Join causes issues when a relation for parent does not exist. 418 | // So, we change it to right join for this query 419 | $innerQuery->getQuery()->joins[0]->type = "right"; 420 | 421 | $outerQuery = $q->newPivotStatement(); 422 | $outerQuery->from(\DB::raw("(". $innerQuery->toSql() . ") as `$tableName`")) 423 | ->mergeBindings($innerQuery->getQuery()); 424 | 425 | $q->select($fields) 426 | ->join(\DB::raw("(" . $outerQuery->toSql() . ") as `outer_query`"), function ($join) use($q) { 427 | $join->on("outer_query." . $q->getRelatedKeyName(), "=", $q->getQualifiedRelatedPivotKeyName ()); 428 | $join->on("outer_query.whatever", "=", $q->getQualifiedForeignPivotKeyName()); 429 | }) 430 | ->setBindings(array_merge($q->getQuery()->getBindings(), $outerQuery->getBindings())) 431 | ->where("rank", "<=", $relation["limit"] + $relation["offset"]) 432 | ->where("rank", ">", $relation["offset"]); 433 | } 434 | else { 435 | // We need to select foreign key so that Laravel can match to which records these 436 | // need to be attached 437 | if ($q instanceof BelongsTo) { 438 | $fields[] = $q->getOwnerKeyName(); 439 | 440 | if (strpos($key, ".") !== false) { 441 | $parts = explode(".", $key); 442 | array_pop($parts); 443 | 444 | $relation["limit"] = $relations[implode(".", $parts)]["limit"]; 445 | } 446 | } 447 | else if ($q instanceof HasOne) { 448 | $fields[] = $q->getQualifiedForeignKeyName(); 449 | 450 | // This will be used to hide this foreign key field 451 | // in the processAppends function later 452 | $relations[$key]["foreign"] = $q->getQualifiedForeignKeyName(); 453 | } 454 | else if ($q instanceof HasMany) { 455 | $fields[] = $q->getQualifiedForeignKeyName(); 456 | $relations[$key]["foreign"] = $q->getQualifiedForeignKeyName(); 457 | 458 | $q->orderBy($primaryKey, ($relation["order"] == "chronological") ? "ASC" : "DESC"); 459 | } 460 | 461 | $q->select($fields); 462 | 463 | $q->take($relation["limit"]); 464 | 465 | if ($relation["offset"] !== 0) { 466 | $q->skip($relation["offset"]); 467 | } 468 | } 469 | 470 | $this->parser->setRelations($relations); 471 | }; 472 | } 473 | 474 | $this->query = call_user_func($this->model."::with", $includes); 475 | } 476 | else { 477 | $this->query = call_user_func($this->model."::query"); 478 | } 479 | 480 | return $this; 481 | } 482 | 483 | /** 484 | * Add requested filters. Filters are defined similar to normal SQL queries like 485 | * (name eq "Milk" or name eq "Eggs") and price lt 2.55 486 | * The string should be enclosed in double quotes 487 | * @return $this 488 | * @throws NotAllowedToFilterOnThisFieldException 489 | */ 490 | protected function addFilters() 491 | { 492 | if ($this->parser->getFilters()) { 493 | 494 | $this->query->whereRaw($this->parser->getFilters()); 495 | } 496 | 497 | return $this; 498 | } 499 | 500 | /** 501 | * Add sorting to the query. Sorting is similar to SQL queries 502 | * 503 | * @return $this 504 | */ 505 | protected function addOrdering() 506 | { 507 | if ($this->parser->getOrder()) { 508 | $this->query->orderByRaw($this->parser->getOrder()); 509 | } 510 | 511 | return $this; 512 | } 513 | 514 | /** 515 | * Adds paging limit and offset to SQL query 516 | * 517 | * @return $this 518 | */ 519 | protected function addPaging() 520 | { 521 | $limit = $this->parser->getLimit(); 522 | $offset = $this->parser->getOffset(); 523 | 524 | if ($offset <= 0) { 525 | $skip = 0; 526 | } 527 | else { 528 | $skip = $offset; 529 | } 530 | 531 | $this->query->skip($skip); 532 | 533 | $this->query->take($limit); 534 | 535 | return $this; 536 | } 537 | 538 | protected function addKeyConstraint($id) 539 | { 540 | // Add equality constraint 541 | $this->query->where($this->table . "." . ($this->primaryKey), "=", $id); 542 | 543 | return $this; 544 | } 545 | 546 | /** 547 | * Runs query and fetches results 548 | * 549 | * @param bool $single 550 | * @return Collection 551 | * @throws ResourceNotFoundException 552 | */ 553 | protected function getResults($single = false) 554 | { 555 | $customAttributes = call_user_func($this->model."::getAppendFields"); 556 | 557 | // Laravel's $appends adds attributes always to the output. With this method, 558 | // we can specify which attributes are to be included 559 | $appends = []; 560 | 561 | $fields = $this->parser->getFields(); 562 | 563 | foreach ($fields as $key => $field) { 564 | if (in_array($field, $customAttributes)) { 565 | $appends[] = $field; 566 | unset($fields[$key]); 567 | } 568 | else { 569 | // Add table name to fields to prevent ambiguous column issues 570 | $fields[$key] = $this->table . "." . $field; 571 | } 572 | } 573 | 574 | $this->parser->setFields($fields); 575 | 576 | if (!$single) { 577 | /** @var Collection $results */ 578 | $results = $this->query->select($fields)->get(); 579 | } 580 | else { 581 | /** @var Collection $results */ 582 | $results = $this->query->select($fields)->skip(0)->take(1)->get(); 583 | 584 | if ($results->count() == 0) { 585 | throw new ResourceNotFoundException(); 586 | } 587 | } 588 | 589 | foreach($results as $result) { 590 | $result->setAppends($appends); 591 | } 592 | 593 | $this->processAppends($results); 594 | 595 | $this->results = $results; 596 | 597 | return $results; 598 | } 599 | 600 | private function processAppends($models, $parent = null) 601 | { 602 | if (! ($models instanceof Collection)) { 603 | return $models; 604 | } 605 | else if ($models->count() == 0) { 606 | return $models; 607 | } 608 | 609 | // Attribute at $key is a relation 610 | $first = $models->first(); 611 | $attributeKeys = array_keys($first->getRelations()); 612 | $relations = $this->parser->getRelations(); 613 | 614 | foreach ($attributeKeys as $key) { 615 | $relationName = ($parent === null) ? $key : $parent . "." . $key; 616 | 617 | if (isset($relations[$relationName])) { 618 | 619 | $appends = $relations[$relationName]["appends"]; 620 | $appends = array_intersect($appends, $relations[$relationName]["fields"]); 621 | 622 | if (isset($relations[$relationName]["foreign"])) { 623 | $foreign = explode(".", $relations[$relationName]["foreign"])[1]; 624 | } 625 | else { 626 | $foreign = null; 627 | } 628 | 629 | foreach ($models as $model) { 630 | if ($model->$key instanceof Collection) { 631 | $model->{$key}->each(function ($item, $key) use($appends, $foreign) { 632 | $item->setAppends($appends); 633 | 634 | // Hide the foreign key fields 635 | if (!empty($foreign)) { 636 | $item->makeHidden($foreign); 637 | } 638 | }); 639 | 640 | $this->processAppends($model->$key, $key); 641 | } 642 | else if (!empty($model->$key)) { 643 | $model->$key->setAppends($appends); 644 | 645 | if (!empty($foreign)) { 646 | $model->$key->makeHidden($foreign); 647 | } 648 | 649 | $this->processAppends(collect($model->$key), $key); 650 | } 651 | } 652 | } 653 | } 654 | } 655 | 656 | /** 657 | * Builds metadata - paging, links, time to complete request, etc 658 | * 659 | * @return array 660 | */ 661 | protected function getMetaData($single = false) 662 | { 663 | if (!$single) { 664 | $meta = [ 665 | "paging" => [ 666 | "links" => [ 667 | 668 | ] 669 | ] 670 | ]; 671 | $limit = $this->parser->getLimit(); 672 | $pageOffset = $this->parser->getOffset(); 673 | 674 | $current = $pageOffset; 675 | 676 | // Remove offset because setting offset does not return 677 | // result. As, there is single result in count query, 678 | // and setting offset will not return that record 679 | $offset = $this->query->getQuery()->offset; 680 | 681 | $this->query->offset(0); 682 | 683 | $totalRecords = $this->query->count($this->table . "." . $this->primaryKey); 684 | 685 | $this->query->offset($offset); 686 | 687 | $meta["paging"]["total"] = $totalRecords; 688 | 689 | if (($current + $limit) < $meta["paging"]["total"]) { 690 | $meta["paging"]["links"]["next"] = $this->getNextLink(); 691 | } 692 | 693 | if ($current >= $limit) { 694 | $meta["paging"]["links"]["previous"] = $this->getPreviousLink(); 695 | } 696 | } 697 | 698 | $meta["time"] = round(microtime(true) - $this->processingStartTime, 3); 699 | 700 | if (config('app.debug') == true) { 701 | $log = \DB::getQueryLog(); 702 | \DB::disableQueryLog(); 703 | 704 | $meta["queries"] = count($log); 705 | $meta["queries_list"] = $log; 706 | } 707 | 708 | return $meta; 709 | } 710 | 711 | protected function getPreviousLink() 712 | { 713 | $offset = $this->parser->getOffset(); 714 | $limit = $this->parser->getLimit(); 715 | 716 | $queryString = ((request()->fields) ? "&fields=" . urlencode(request()->fields) : "") . 717 | ((request()->filters) ? "&filters=" . urlencode(request()->filters) : "") . 718 | ((request()->order) ? "&fields=" . urlencode(request()->order) : ""); 719 | 720 | $queryString .= "&offset=" . ($offset - $limit); 721 | 722 | return request()->url() . "?" . trim($queryString, "&"); 723 | } 724 | 725 | protected function getNextLink() 726 | { 727 | $offset = $this->parser->getOffset(); 728 | $limit = $this->parser->getLimit(); 729 | 730 | $queryString = ((request()->fields) ? "&fields=" . urlencode(request()->fields) : "") . 731 | ((request()->filters) ? "&filters=" . urlencode(request()->filters) : "") . 732 | ((request()->order) ? "&fields=" . urlencode(request()->order) : ""); 733 | 734 | $queryString .= "&offset=" . ($offset + $limit); 735 | 736 | return request()->url() . "?" . trim($queryString, "&"); 737 | } 738 | 739 | /** 740 | * Checks if current request is index request 741 | * @return bool 742 | */ 743 | protected function isIndex() 744 | { 745 | return in_array("index", explode(".", request()->route()->getName())); 746 | } 747 | 748 | /** 749 | * Checks if current request is create request 750 | * @return bool 751 | */ 752 | protected function isCreate() 753 | { 754 | return in_array("create", explode(".", request()->route()->getName())); 755 | } 756 | 757 | /** 758 | * Checks if current request is show request 759 | * @return bool 760 | */ 761 | protected function isShow() 762 | { 763 | return in_array("show", explode(".", request()->route()->getName())); 764 | } 765 | 766 | /** 767 | * Checks if current request is update request 768 | * @return bool 769 | */ 770 | protected function isUpdate() 771 | { 772 | return in_array("update", explode(".", request()->route()->getName())); 773 | } 774 | 775 | /** 776 | * Checks if current request is delete request 777 | * @return bool 778 | */ 779 | protected function isDelete() 780 | { 781 | return in_array("destroy", explode(".", request()->route()->getName())); 782 | } 783 | 784 | /** 785 | * Checks if current request is store request 786 | * @return bool 787 | */ 788 | protected function isStore() 789 | { 790 | return in_array("store", explode(".", request()->route()->getName())); 791 | } 792 | 793 | /** 794 | * Checks if current request is relation request 795 | * @return bool 796 | */ 797 | protected function isRelation() 798 | { 799 | return in_array("relation", explode(".", request()->route()->getName())); 800 | } 801 | 802 | /** 803 | * Calls the modifyRequestType methods to modify query just before execution 804 | * @return $this 805 | */ 806 | private function modify() 807 | { 808 | if ($this->isIndex()) { 809 | $this->query = $this->modifyIndex($this->query); 810 | } 811 | else if ($this->isShow()) { 812 | $this->query = $this->modifyShow($this->query); 813 | } 814 | else if ($this->isDelete()) { 815 | $this->query = $this->modifyDelete($this->query); 816 | } 817 | else if ($this->isUpdate()) { 818 | $this->query = $this->modifyUpdate($this->query); 819 | } 820 | 821 | return $this; 822 | } 823 | 824 | /** 825 | * Modify the query for show request 826 | * @param $query 827 | * @return mixed 828 | */ 829 | protected function modifyShow($query) 830 | { 831 | return $query; 832 | } 833 | 834 | /** 835 | * Modify the query for update request 836 | * @param $query 837 | * @return mixed 838 | */ 839 | protected function modifyUpdate($query) 840 | { 841 | return $query; 842 | } 843 | 844 | /** 845 | * Modify the query for delete request 846 | * @param $query 847 | * @return mixed 848 | */ 849 | protected function modifyDelete($query) 850 | { 851 | return $query; 852 | } 853 | 854 | /** 855 | * Modify the query for index request 856 | * @param $query 857 | * @return mixed 858 | */ 859 | protected function modifyIndex($query) 860 | { 861 | return $query; 862 | } 863 | 864 | protected function getQuery() { 865 | return $this->query; 866 | } 867 | 868 | protected function setQuery($query) { 869 | $this->query = $query; 870 | } 871 | 872 | //endregion 873 | } 874 | -------------------------------------------------------------------------------- /src/ApiModel.php: -------------------------------------------------------------------------------- 1 | table; 68 | } 69 | 70 | /** 71 | * Date fields in this model 72 | * 73 | * @return array 74 | */ 75 | public static function getDateFields() 76 | { 77 | return (new static)->dates; 78 | } 79 | 80 | /** 81 | * List of custom fields (attributes) that are appended by default 82 | * ($appends array) 83 | * 84 | * @return array 85 | */ 86 | public static function getAppendFields() 87 | { 88 | return (new static)->appends; 89 | } 90 | 91 | /** 92 | * List of fields to display by default ($defaults array) 93 | * 94 | * @return array 95 | */ 96 | public static function getDefaultFields() 97 | { 98 | return (new static)->default; 99 | } 100 | 101 | /** 102 | * Return the $relationKeys array 103 | * 104 | * @return mixed 105 | */ 106 | public static function getRelationKeyFields() 107 | { 108 | return (new static)->relationKeys; 109 | } 110 | 111 | /** 112 | * Returns list of fields on which filter is allowed to be applied 113 | * 114 | * @return array 115 | */ 116 | public static function getFilterableFields() 117 | { 118 | return (new static)->filterable; 119 | } 120 | 121 | /** 122 | * Checks if given relation exists on the model 123 | * 124 | * @param $relation 125 | * @return bool 126 | */ 127 | public static function relationExists($relation) 128 | { 129 | // Check if relation name in modal is in camel case or not 130 | if (config("api.relation_case", 'snakecase') === 'camelcase') { 131 | return (method_exists(new static(), $relation) ?? false) || (method_exists(new static(), Str::camel($relation)) ?? false); 132 | } 133 | return method_exists(new static(), $relation); 134 | } 135 | 136 | //endregion 137 | 138 | /** 139 | * Prepare a date for array / JSON serialization. Override base method in Model to suite our needs 140 | * 141 | * @param \DateTime $date 142 | * @return string 143 | */ 144 | protected function serializeDate(\DateTimeInterface $date) 145 | { 146 | return $date->format("c"); 147 | } 148 | 149 | /** 150 | * Return a timestamp as DateTime object. 151 | * 152 | * @param mixed $value 153 | * @return \Carbon\Carbon 154 | */ 155 | protected function asDateTime($value) 156 | { 157 | // If this value is already a Carbon instance, we shall just return it as is. 158 | // This prevents us having to re-instantiate a Carbon instance when we know 159 | // it already is one, which wouldn't be fulfilled by the DateTime check. 160 | if ($value instanceof Carbon) { 161 | return $value; 162 | } 163 | 164 | // If the value is already a DateTime instance, we will just skip the rest of 165 | // these checks since they will be a waste of time, and hinder performance 166 | // when checking the field. We will just return the DateTime right away. 167 | if ($value instanceof DateTimeInterface) { 168 | return new Carbon( 169 | $value->format('Y-m-d H:i:s.u'), $value->getTimeZone() 170 | ); 171 | } 172 | 173 | // If this value is an integer, we will assume it is a UNIX timestamp's value 174 | // and format a Carbon object from this timestamp. This allows flexibility 175 | // when defining your date fields as they might be UNIX timestamps here. 176 | if (is_numeric($value)) { 177 | return Carbon::createFromTimestamp($value); 178 | } 179 | 180 | // If the value is in simply year, month, day format, we will instantiate the 181 | // Carbon instances from that format. Again, this provides for simple date 182 | // fields on the database, while still supporting Carbonized conversion. 183 | if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value)) { 184 | return Carbon::createFromFormat('Y-m-d', $value)->startOfDay(); 185 | } 186 | 187 | // Parse ISO 8061 date 188 | if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\\+(\d{2}):(\d{2})$/', $value)) { 189 | return Carbon::createFromFormat('Y-m-d\TH:i:s+P', $value); 190 | } 191 | elseif (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2}T(\d{2}):(\d{2}):(\d{2})\\.(\d{1,3})Z)$/', $value)) { 192 | return Carbon::createFromFormat('Y-m-d\TH:i:s.uZ', $value); 193 | } 194 | 195 | // Finally, we will just assume this date is in the format used by default on 196 | // the database connection and use that format to create the Carbon object 197 | // that is returned back out to the developers after we convert it here. 198 | return Carbon::createFromFormat($this->getDateFormat(), $value); 199 | } 200 | 201 | /** 202 | * Eagerly load the relationship on a set of models. 203 | * 204 | * @param array $models 205 | * @param string $name 206 | * @param \Closure $constraints 207 | * @return array 208 | */ 209 | protected function loadRelation(array $models, $name, Closure $constraints) 210 | { 211 | // First we will "back up" the existing where conditions on the query so we can 212 | // add our eager constraints. Then we will merge the wheres that were on the 213 | // query back to it in order that any where conditions might be specified. 214 | $relation = $this->getRelation($name); 215 | 216 | $relation->addEagerConstraints($models); 217 | 218 | call_user_func($constraints, $relation); 219 | 220 | $models = $relation->initRelation($models, $name); 221 | 222 | // Once we have the results, we just match those back up to their parent models 223 | // using the relationship instance. Then we just return the finished arrays 224 | // of models which have been eagerly hydrated and are readied for return. 225 | $results = $relation->getEager(); 226 | 227 | return $relation->match($models, $results, $name); 228 | } 229 | 230 | /** 231 | * Fill the model with an array of attributes. 232 | * 233 | * @param array $attributes 234 | * @param bool $relations If the attributes also contain relations 235 | * @return Model 236 | */ 237 | public function fill(array $attributes = []) 238 | { 239 | $this->raw = $attributes; 240 | 241 | $excludes = config("api.excludes"); 242 | 243 | foreach ($attributes as $key => $attribute) { 244 | // Guarded attributes should be removed 245 | if (in_array($key, $excludes)) { 246 | unset($attributes[$key]); 247 | } 248 | else if (method_exists($this, $key) && ((is_array($attribute) || is_null($attribute)))) { 249 | // Its a relation 250 | $this->relationAttributes[$key] = $attribute; 251 | 252 | // For belongs to relation, while filling, we need to set relation key. 253 | $relation = call_user_func([$this, $key]); 254 | 255 | if ($relation instanceof BelongsTo) { 256 | $primaryKey = $relation->getRelated()->getKeyName(); 257 | 258 | if ($attribute !== null) { 259 | // If key value is not set in request, we create new object 260 | if (!isset($attribute[$primaryKey])) { 261 | throw new RelatedResourceNotFoundException('Resource for relation "' . $key . '" not found'); 262 | } 263 | else { 264 | $model = $relation->getRelated()->find($attribute[$primaryKey]); 265 | 266 | if (!$model) { 267 | // Resource not found 268 | throw new ResourceNotFoundException(); 269 | } 270 | } 271 | } 272 | 273 | $relationKey = $relation->getForeignKeyName(); 274 | 275 | $this->setAttribute($relationKey, ($attribute === null) ? null : $model->getKey()); 276 | } 277 | 278 | unset($attributes[$key]); 279 | } 280 | } 281 | 282 | return parent::fill($attributes); 283 | } 284 | 285 | public function save(array $options = []) 286 | { 287 | // Belongs to relation needs to be set before, because we need the parent's Id 288 | foreach ($this->relationAttributes as $key => $relationAttribute) { 289 | /** @var Relation $relation */ 290 | $relation = call_user_func([$this, $key]); 291 | 292 | if ($relation instanceof BelongsTo) { 293 | $primaryKey = $relation->getRelated()->getKeyName(); 294 | 295 | if ($relationAttribute !== null) { 296 | // If key value is not set in request, we create new object 297 | if (!isset($relationAttribute[$primaryKey])) { 298 | throw new RelatedResourceNotFoundException('Resource for relation "' . $key . '" not found'); 299 | } 300 | else { 301 | $model = $relation->getRelated()->find($relationAttribute[$primaryKey]); 302 | 303 | if (!$model) { 304 | // Resource not found 305 | throw new RelatedResourceNotFoundException('Resource for relation "' . $key . '" not found'); 306 | } 307 | } 308 | } 309 | 310 | $relationKey = $relation->getForeignKeyName(); 311 | 312 | $this->setAttribute($relationKey, ($relationAttribute === null) ? null : $model->getKey()); 313 | 314 | unset($this->relationAttributes[$key]); 315 | } 316 | } 317 | 318 | parent::save($options); 319 | 320 | // Fill all other relations 321 | foreach ($this->relationAttributes as $key => $relationAttribute) { 322 | /** @var Relation $relation */ 323 | $relation = call_user_func([$this, $key]); 324 | $primaryKey = $relation->getRelated()->getKeyName(); 325 | 326 | if ($relation instanceof HasOne || $relation instanceof HasMany) { 327 | 328 | if ($relation instanceof HasOne) { 329 | $relationAttribute = [$relationAttribute]; 330 | } 331 | 332 | $relationKey = explode(".", $relation->getQualifiedParentKeyName())[1]; 333 | 334 | foreach ($relationAttribute as $val) { 335 | if ($val !== null) { 336 | if (!isset($val[$primaryKey])) { 337 | throw new RelatedResourceNotFoundException('Resource for relation "' . $key . '" not found'); 338 | } 339 | else { 340 | /** @var Model $model */ 341 | $model = $relation->getRelated()->find($val[$primaryKey]); 342 | 343 | if (!$model) { 344 | // Resource not found 345 | throw new RelatedResourceNotFoundException('Resource for relation "' . $key . '" not found'); 346 | } 347 | 348 | // Only update relation key to attach $model to $this object 349 | $model->{$relationKey} = $this->getKey(); 350 | $model->save(); 351 | } 352 | } 353 | } 354 | } 355 | 356 | else if ($relation instanceof BelongsToMany) { 357 | $relatedIds = []; 358 | 359 | // Value is an array of related models 360 | foreach ($relationAttribute as $val) { 361 | if ($val !== null) { 362 | if (!isset($val[$primaryKey])) { 363 | throw new RelatedResourceNotFoundException('Resource for relation "' . $key . '" not found'); 364 | } 365 | else { 366 | /** @var Model $model */ 367 | $model = $relation->getRelated()->find($val[$primaryKey]); 368 | 369 | if (!$model) { 370 | // Resource not found 371 | throw new RelatedResourceNotFoundException('Resource for relation "' . $key . '" not found'); 372 | } 373 | } 374 | } 375 | 376 | if ($val !== null) { 377 | if(isset($val['pivot'])) { 378 | // We have additional fields other than primary key 379 | // that need to be saved to pivot table 380 | /* 381 | [ 382 | { 383 | "id": 12, // Primary key 384 | "pivot": { 385 | "count": 8 // Pivot table column 386 | } 387 | } 388 | ] 389 | */ 390 | $relatedIds[$model->getKey()] = $val['pivot']; 391 | } 392 | else { 393 | // We just have ids 394 | $relatedIds[] = $model->getKey(); 395 | } 396 | } 397 | } 398 | 399 | $relation->sync($relatedIds); 400 | } 401 | } 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /src/ApiResponse.php: -------------------------------------------------------------------------------- 1 | message; 32 | } 33 | 34 | /** 35 | * Set response message 36 | * 37 | * @param string $message 38 | */ 39 | public function setMessage($message) 40 | { 41 | $this->message = $message; 42 | } 43 | 44 | /** 45 | * Get response data 46 | * 47 | * @return array 48 | */ 49 | public function getData() 50 | { 51 | return $this->data; 52 | } 53 | 54 | /** 55 | * Set response data 56 | * 57 | * @param array $data 58 | */ 59 | public function setData($data) 60 | { 61 | $this->data = $data; 62 | } 63 | 64 | /** 65 | * Make new success response 66 | * @param string $message 67 | * @param array $data 68 | * @return \Response 69 | */ 70 | public static function make($message = null, $data = null, $meta = null) 71 | { 72 | $response = []; 73 | 74 | if (!empty($message)) { 75 | $response["message"] = $message; 76 | } 77 | 78 | if ($data !== null && is_array($data)){ 79 | $response["data"] = $data; 80 | } 81 | 82 | if ($meta !== null && is_array($meta)){ 83 | $response["meta"] = $meta; 84 | } 85 | 86 | $returnResponse = \Response::make($response); 87 | 88 | return $returnResponse; 89 | } 90 | 91 | /** 92 | * Handle api exception an return proper error response 93 | * @param ApiException $exception 94 | * @return \Illuminate\Http\Response 95 | * @throws ApiException 96 | */ 97 | public static function exception(ApiException $exception) 98 | { 99 | $returnResponse = \Response::make($exception->jsonSerialize()); 100 | 101 | $returnResponse->setStatusCode($exception->getStatusCode()); 102 | 103 | return $returnResponse; 104 | } 105 | } -------------------------------------------------------------------------------- /src/Exceptions/ApiException.php: -------------------------------------------------------------------------------- 1 | statusCode = $statusCode; 34 | } 35 | 36 | if ($code !== null) { 37 | $this->code = $code; 38 | } 39 | 40 | if ($innerError !== null) { 41 | $this->innerError = $innerError; 42 | } 43 | 44 | if (!empty($details)) { 45 | $this->details = $details; 46 | } 47 | 48 | if ($message == null) { 49 | parent::__construct($this->message, $this->code, $previous); 50 | } 51 | else { 52 | parent::__construct($message, $this->code, $previous); 53 | } 54 | } 55 | 56 | public function __toString() 57 | { 58 | return "ApiException (#{$this->getCode()}): {$this->getMessage()}"; 59 | } 60 | 61 | /** 62 | * Return the status code the response should be sent with 63 | * 64 | * @return int 65 | */ 66 | public function getStatusCode() 67 | { 68 | return $this->statusCode; 69 | } 70 | 71 | /** 72 | * Convert the exception to its JSON representation. 73 | * 74 | * @param int $options 75 | * @return string 76 | */ 77 | public function toJson($options = 0) 78 | { 79 | return json_encode($this->jsonSerialize(), $options); 80 | } 81 | 82 | /** 83 | * Specify data which should be serialized to JSON 84 | * @link http://php.net/manual/en/jsonserializable.jsonserialize.php 85 | * @return mixed data which can be serialized by json_encode, 86 | * which is a value of any type other than a resource. 87 | */ 88 | public function jsonSerialize() 89 | { 90 | $jsonArray = [ 91 | "message" => $this->getMessage(), 92 | "error" => [ 93 | "message" => $this->getMessage(), 94 | "code" => $this->getCode() 95 | ] 96 | ]; 97 | 98 | if (isset($this->details)) { 99 | $jsonArray["error"]["details"] = $this->details; 100 | } 101 | 102 | if (isset($this->innerError)) { 103 | $jsonArray["error"]["innererror"] = [ 104 | "code" => $this->innerError 105 | ]; 106 | } 107 | 108 | return $jsonArray; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Exceptions/ErrorCodes.php: -------------------------------------------------------------------------------- 1 | details = $errors; 26 | } 27 | } -------------------------------------------------------------------------------- /src/ExtendedRelations/BelongsToMany.php: -------------------------------------------------------------------------------- 1 | relatedKey ?: 'id'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Facades/ApiRoute.php: -------------------------------------------------------------------------------- 1 | is($prefix . '/*')) { 33 | 34 | // When the user is not authenticated or logged show this message with status code 401 35 | if ($e instanceof AuthenticationException) { 36 | return ApiResponse::exception(new UnauthenticationException()); 37 | } 38 | 39 | if ($e instanceof HttpResponseException || $e instanceof \Illuminate\Validation\ValidationException) { 40 | if ($e->status == 403) { 41 | return ApiResponse::exception(new UnauthorizedException()); 42 | } 43 | return ApiResponse::exception(new ValidationException($e->errors())); 44 | } 45 | 46 | if ($e instanceof NotFoundHttpException) { 47 | return ApiResponse::exception(new ApiException('This api endpoint does not exist', null, 404, 404, 2005, [ 48 | 'url' => request()->url() 49 | ])); 50 | } 51 | 52 | if ($e instanceof ModelNotFoundException) { 53 | return ApiResponse::exception(new ApiException('Requested resource not found', null, 404, 404, null, [ 54 | 'url' => request()->url() 55 | ])); 56 | } 57 | 58 | if ($e instanceof ApiException) { 59 | return ApiResponse::exception($e); 60 | } 61 | 62 | if ($e instanceof QueryException) { 63 | if ($e->getCode() == "422") { 64 | preg_match("/Unknown column \\'([^']+)\\'/", $e->getMessage(), $result); 65 | 66 | if (!isset($result[1])) { 67 | return ApiResponse::exception(new UnknownFieldException(null, $e)); 68 | } 69 | 70 | $parts = explode(".", $result[1]); 71 | 72 | $field = count($parts) > 1 ? $parts[1] : $result; 73 | 74 | return ApiResponse::exception(new UnknownFieldException("Field '" . $field . "' does not exist", $e)); 75 | 76 | } 77 | 78 | } 79 | // When Debug is on move show error here 80 | $message = null; 81 | 82 | if($debug){ 83 | $response['trace'] = $e->getTrace(); 84 | $response['code'] = $e->getCode(); 85 | $message = $e->getMessage(); 86 | } 87 | 88 | return ApiResponse::exception(new ApiException($message, null, 500, 500, null, $response)); 89 | } 90 | 91 | return parent::render($request, $e); 92 | } 93 | 94 | } 95 | 96 | 97 | -------------------------------------------------------------------------------- /src/Middleware/ApiMiddleware.php: -------------------------------------------------------------------------------- 1 | getStatusCode() == 403 && ($response->getContent() == "Forbidden" || Str::contains($response->getContent(), ['HttpException', 'authorized']))) { 20 | $response = ApiResponse::exception(new UnauthorizedException()); 21 | } 22 | 23 | if (config("api.cors") && !$response instanceof StreamedResponse) { 24 | $response->header('Access-Control-Allow-Origin', '*') 25 | ->header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') 26 | ->header('Access-Control-Allow-Headers', implode(',', config('api.cors_headers'))); 27 | } 28 | 29 | 30 | return $response; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Providers/ApiServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 21 | __DIR__.'/../api.php' => config_path("api.php"), 22 | ]); 23 | } 24 | 25 | /** 26 | * Register the service provider. 27 | * 28 | * @return void 29 | */ 30 | public function register() 31 | { 32 | $this->registerRouter(); 33 | $this->registerExceptionHandler(); 34 | 35 | $this->mergeConfigFrom( 36 | __DIR__.'/../api.php', 'api' 37 | ); 38 | } 39 | 40 | public function registerRouter() 41 | { 42 | $this->app->singleton( 43 | ApiRouter::class, 44 | function ($app) { 45 | return new ApiRouter($app->make(Dispatcher::class), $app->make(Container::class)); 46 | } 47 | ); 48 | 49 | $this->app->singleton( 50 | ApiResourceRegistrar::class, 51 | function ($app) { 52 | return new ApiResourceRegistrar($app->make(ApiRouter::class)); 53 | } 54 | ); 55 | } 56 | 57 | public function registerExceptionHandler() 58 | { 59 | $this->app->singleton( 60 | \Illuminate\Contracts\Debug\ExceptionHandler::class, 61 | ApiExceptionHandler::class 62 | ); 63 | } 64 | } -------------------------------------------------------------------------------- /src/RequestParser.php: -------------------------------------------------------------------------------- 1 | [^{}]+)|(?&curly))*\\})?+)/"; 21 | 22 | /** 23 | * Extracts fields parts 24 | */ 25 | const FIELD_PARTS_REGEX = "/([^{.]+)(.limit\\(([0-9]+)\\)|.offset\\(([0-9]+)\\)|.order\\(([A-Za-z_]+)\\))*(\\{((?>[^{}]+)|(?R))*\\})?/i"; 26 | 27 | /** 28 | * Checks if filters are correctly specified 29 | */ 30 | const FILTER_REGEX = "/(\\((?:[\\s]*(?:and|or)?[\\s]*[\\w\\.]+[\\s]+(?:eq|ne|gt|ge|lt|le|lk)[\\s]+(?:\\\"(?:[^\\\"\\\\]|\\\\.)*\\\"|\\d+(,\\d+)*(\\.\\d+(e\\d+)?)?|null)[\\s]*|(?R))*\\))/i"; 31 | 32 | /** 33 | * Extracts filter parts 34 | */ 35 | const FILTER_PARTS_REGEX = "/([\\w\\.]+)[\\s]+(?:eq|ne|gt|ge|lt|le|lk)[\\s]+(?:\"(?:[^\"\\\\]|\\\\.)*\"|\\d+(?:,\\d+)*(?:\\.\\d+(?:e\\d+)?)?|null)/i"; 36 | 37 | /** 38 | * Checks if ordering is specified correctly 39 | */ 40 | const ORDER_FILTER = "/[\\s]*([\\w\\.]+)(?:[\\s](?!,))*(asc|desc|)/i"; 41 | 42 | /** 43 | * Full class reference to model this controller represents 44 | * 45 | * @var string 46 | */ 47 | protected $model = null; 48 | 49 | /** 50 | * Table name corresponding to the model this controller is handling 51 | * 52 | * @var string 53 | */ 54 | private $table = null; 55 | 56 | /** 57 | * Primary key of the model 58 | * 59 | * @var string 60 | */ 61 | private $primaryKey = null; 62 | 63 | /** 64 | * Fields to be returned in response. This does not include relations 65 | * 66 | * @var array 67 | */ 68 | private $fields = []; 69 | 70 | /** 71 | * Relations to be included in the response 72 | * 73 | * @var array 74 | */ 75 | private $relations = []; 76 | 77 | /** 78 | * Number of results requested per page 79 | * 80 | * @var int 81 | */ 82 | private $limit = 10; 83 | 84 | /** 85 | * Offset from where fetching should start 86 | * 87 | * @var int 88 | */ 89 | private $offset = 0; 90 | 91 | /** 92 | * Ordering string 93 | * 94 | * @var int 95 | */ 96 | private $order = null; 97 | 98 | /** 99 | * Filters to be applied 100 | * 101 | * @var string 102 | */ 103 | private $filters = null; 104 | 105 | /** 106 | * Attributes passed in request 107 | * 108 | * @var array 109 | */ 110 | private $attributes = []; 111 | 112 | public function __construct($model) 113 | { 114 | $this->model = $model; 115 | $this->primaryKey = call_user_func([new $this->model(), "getKeyName"]); 116 | 117 | $this->parseRequest(); 118 | } 119 | 120 | /** 121 | * @return array 122 | */ 123 | public function getFields() 124 | { 125 | return $this->fields; 126 | } 127 | 128 | /** 129 | * @param array $fields 130 | */ 131 | public function setFields($fields) 132 | { 133 | $this->fields = $fields; 134 | } 135 | 136 | /** 137 | * @return array 138 | */ 139 | public function getRelations() 140 | { 141 | return $this->relations; 142 | } 143 | 144 | /** 145 | * @param array $relations 146 | */ 147 | public function setRelations($relations) 148 | { 149 | $this->relations = $relations; 150 | } 151 | 152 | /** 153 | * @return int 154 | */ 155 | public function getLimit() 156 | { 157 | return $this->limit; 158 | } 159 | 160 | /** 161 | * @return int 162 | */ 163 | public function getOffset() 164 | { 165 | return $this->offset; 166 | } 167 | 168 | /** 169 | * @return int 170 | */ 171 | public function getOrder() 172 | { 173 | return $this->order; 174 | } 175 | 176 | /** 177 | * @return string 178 | */ 179 | public function getFilters() 180 | { 181 | return $this->filters; 182 | } 183 | 184 | /** 185 | * @return array 186 | */ 187 | public function getAttributes() 188 | { 189 | return $this->attributes; 190 | } 191 | 192 | /** 193 | * Parse request and fill the parameters 194 | * @return $this current controller object for chain method calling 195 | * @throws InvalidFilterDefinitionException 196 | * @throws InvalidOrderingDefinitionException 197 | * @throws MaxLimitException 198 | */ 199 | protected function parseRequest() 200 | { 201 | if (request()->limit) { 202 | if (request()->limit <= 0) { 203 | throw new InvalidLimitException(); 204 | } 205 | else if (request()->limit > config("api.maxLimit")) { 206 | throw new MaxLimitException(); 207 | } 208 | else { 209 | $this->limit = request()->limit; 210 | } 211 | } 212 | else { 213 | $this->limit = config("api.defaultLimit"); 214 | } 215 | 216 | if (request()->offset) { 217 | $this->offset = request()->offset; 218 | } 219 | else { 220 | $this->offset = 0; 221 | } 222 | 223 | $this->extractFields(); 224 | $this->extractFilters(); 225 | $this->extractOrdering(); 226 | $this->loadTableName(); 227 | 228 | $this->attributes = request()->all(); 229 | 230 | return $this; 231 | } 232 | 233 | protected function extractFields() 234 | { 235 | if (request()->fields) { 236 | $this->parseFields(request()->fields); 237 | } 238 | else { 239 | // Else, by default, we only return default set of visible fields 240 | $fields = call_user_func($this->model."::getDefaultFields"); 241 | 242 | // We parse the default fields in same way as above so that, if 243 | // relations are included in default fields, they also get included 244 | $this->parseFields(implode(",", $fields)); 245 | } 246 | 247 | if (!in_array($this->primaryKey, $this->fields)) { 248 | $this->fields[] = $this->primaryKey; 249 | } 250 | } 251 | 252 | protected function extractFilters() 253 | { 254 | if (request()->filters) { 255 | $filters = "(" . request()->filters . ")"; 256 | 257 | if (preg_match(RequestParser::FILTER_REGEX, $filters) === 1) { 258 | 259 | preg_match_all(RequestParser::FILTER_PARTS_REGEX, $filters, $parts); 260 | 261 | $filterable = call_user_func($this->model . "::getFilterableFields"); 262 | 263 | foreach ($parts[1] as $column) { 264 | if (!in_array($column, $filterable)) { 265 | throw new NotAllowedToFilterOnThisFieldException("Applying filter on field \"" . $column . "\" is not allowed"); 266 | } 267 | } 268 | 269 | // Convert filter name to sql `column` format 270 | $where = preg_replace( 271 | [ 272 | "/([\\w]+)\\.([\\w]+)[\\s]+(eq|ne|gt|ge|lt|le|lk)/i", 273 | "/([\\w]+)[\\s]+(eq|ne|gt|ge|lt|le|lk)/i", 274 | ], 275 | [ 276 | "`$1`.`$2` $3", 277 | "`$1` $2", 278 | ], 279 | $filters 280 | ); 281 | 282 | // convert eq null to is null and ne null to is not null 283 | $where = preg_replace( 284 | [ 285 | "/ne[\\s]+null/i", 286 | "/eq[\\s]+null/i" 287 | ], 288 | [ 289 | "is not null", 290 | "is null" 291 | ], 292 | $where 293 | ); 294 | 295 | // Replace operators 296 | $where = preg_replace( 297 | [ 298 | "/[\\s]+eq[\\s]+/i", 299 | "/[\\s]+ne[\\s]+/i", 300 | "/[\\s]+gt[\\s]+/i", 301 | "/[\\s]+ge[\\s]+/i", 302 | "/[\\s]+lt[\\s]+/i", 303 | "/[\\s]+le[\\s]+/i", 304 | "/[\\s]+lk[\\s]+/i" 305 | ], 306 | [ 307 | " = ", 308 | " != ", 309 | " > ", 310 | " >= ", 311 | " < ", 312 | " <= ", 313 | " LIKE " 314 | ], 315 | $where 316 | ); 317 | 318 | $this->filters = $where; 319 | } 320 | else { 321 | throw new InvalidFilterDefinitionException(); 322 | } 323 | } 324 | } 325 | 326 | protected function extractOrdering() 327 | { 328 | if (request()->order) { 329 | if (preg_match(RequestParser::ORDER_FILTER, request()->order) === 1) { 330 | $order = request()->order; 331 | 332 | 333 | // eg : user.name asc, year desc, age,month 334 | $order = preg_replace( 335 | [ 336 | "/[\\s]*([\\w]+)\\.([\\w]+)(?:[\\s](?!,))*(asc|desc|)/", 337 | "/[\\s]*([\\w`\\.]+)(?:[\\s](?!,))*(asc|desc|)/", 338 | ], 339 | [ 340 | "$1`.`$2 $3", // Result: user`.`name asc, year desc, age,month 341 | "`$1` $2", // Result: `user`.`name` asc, `year` desc, `age`,`month` 342 | ], 343 | $order 344 | ); 345 | 346 | $this->order = $order; 347 | } 348 | else { 349 | throw new InvalidOrderingDefinitionException(); 350 | } 351 | } 352 | } 353 | 354 | /** 355 | * Recursively parses fields to extract limit, ordering and their own fields 356 | * and adds width relations 357 | * 358 | * @param $fields 359 | */ 360 | private function parseFields($fields) 361 | { 362 | // If fields parameter is set, parse it using regex 363 | preg_match_all(static::FIELDS_REGEX, $fields, $matches); 364 | 365 | if (!empty($matches[0])) { 366 | foreach ($matches[0] as $match) { 367 | 368 | preg_match_all(static::FIELD_PARTS_REGEX, $match, $parts); 369 | 370 | $fieldName = $parts[1][0]; 371 | 372 | if (Str::contains($fieldName, ":") || call_user_func($this->model . "::relationExists", $fieldName)) { 373 | // If field name has a colon, we assume its a relations 374 | // OR 375 | // If method with field name exists in the class, we assume its a relation 376 | // This is default laravel behavior 377 | 378 | $limit = ($parts[3][0] == "") ? config("api.defaultLimit") : $parts[3][0]; 379 | $offset = ($parts[4][0] == "") ? 0 : $parts[4][0]; 380 | $order = ($parts[5][0] == "chronological") ? "chronological" : "reverse_chronological"; 381 | 382 | if (!empty($parts[7][0])) { 383 | $subFields = explode(",", $parts[7][0]); 384 | // This indicates if user specified fields for relation or not 385 | $userSpecifiedFields = true; 386 | } 387 | else { 388 | $subFields = []; 389 | $userSpecifiedFields = false; 390 | } 391 | 392 | $fieldName = str_replace(":", ".", $fieldName); 393 | 394 | // Check if relation name in modal is in camel case then convert relation name in camel case 395 | if(config("api.relation_case", 'snakecase') === 'camelcase'){ 396 | $fieldName = Str::camel($fieldName); 397 | } 398 | 399 | if (!isset($this->relations[$fieldName])) { 400 | $this->relations[$fieldName] = [ 401 | "limit" => $limit, 402 | "offset" => $offset, 403 | "order" => $order, 404 | "fields" => $subFields, 405 | "userSpecifiedFields" => $userSpecifiedFields 406 | ]; 407 | } 408 | else { 409 | $this->relations[$fieldName]["limit"] = $limit; 410 | $this->relations[$fieldName]["offset"] = $offset; 411 | $this->relations[$fieldName]["order"] = $order; 412 | $this->relations[$fieldName]["fields"] = array_merge($this->relations[$fieldName]["fields"], $subFields); 413 | } 414 | 415 | // We also need to add the relation's foreign key field to select. If we don't, 416 | // relations always return null 417 | 418 | if (Str::contains($fieldName, ".")) { 419 | 420 | $relationNameParts = explode('.', $fieldName); 421 | $model = $this->model; 422 | 423 | $relation = null; 424 | 425 | foreach ($relationNameParts as $rp) { 426 | $relation = call_user_func([ new $model(), $rp]); 427 | $model = $relation->getRelated(); 428 | } 429 | 430 | // Its a multi level relations 431 | $fieldParts = explode(".", $fieldName); 432 | 433 | if ($relation instanceof BelongsTo) { 434 | $singular = $relation->getForeignKeyName(); 435 | } 436 | else if ($relation instanceof HasOne || $relation instanceof HasMany) { 437 | $singular = $relation->getForeignKeyName(); 438 | } 439 | 440 | // Unset last element of array 441 | unset($fieldParts[count($fieldParts) - 1]); 442 | 443 | $parent = implode(".", $fieldParts); 444 | 445 | if ($relation instanceof HasOne || $relation instanceof HasMany) { 446 | // For hasMany and HasOne, the foreign key is in current relation table, not in parent 447 | $this->relations[$fieldName]["fields"][] = $singular; 448 | } 449 | else { 450 | // The parent might already been set because we cannot rely on order 451 | // in which user sends relations in request 452 | if (!isset($this->relations[$parent])) { 453 | $this->relations[$parent] = [ 454 | "limit" => config("api.defaultLimit"), 455 | "offset" => 0, 456 | "order" => "chronological", 457 | "fields" => isset($singular) ? [$singular] : [], 458 | "userSpecifiedFields" => true 459 | ]; 460 | } 461 | else { 462 | if (isset($singular)) { 463 | $this->relations[$parent]["fields"][] = $singular; 464 | } 465 | } 466 | } 467 | 468 | if ($relation instanceof BelongsTo) { 469 | $this->relations[$fieldName]["limit"] = max($this->relations[$fieldName]["limit"], $this->relations[$parent]["limit"]); 470 | } 471 | else if ($relation instanceof HasMany) { 472 | $this->relations[$fieldName]["limit"] = $this->relations[$fieldName]["limit"] * $this->relations[$parent]["limit"]; 473 | } 474 | } 475 | else { 476 | 477 | $relation = call_user_func([new $this->model(), $fieldName]); 478 | 479 | if ($relation instanceof HasOne) { 480 | $keyField = explode(".", $relation->getQualifiedParentKeyName())[1]; 481 | } 482 | else if ($relation instanceof BelongsTo) { 483 | $keyField = explode(".", $relation->getQualifiedForeignKeyName())[1]; 484 | } 485 | 486 | if (isset($keyField) && !in_array($keyField, $this->fields)) { 487 | $this->fields[] = $keyField; 488 | } 489 | 490 | if ($relation instanceof BelongsTo) { 491 | $this->relations[$fieldName]["limit"] = max($this->relations[$fieldName]["limit"], $this->limit); 492 | } 493 | else if ($relation instanceof HasMany) { 494 | // Commented out for third level hasmany limit 495 | // $this->relations[$fieldName]["limit"] = $this->relations[$fieldName]["limit"] * $this->limit; 496 | } 497 | } 498 | 499 | } 500 | else { 501 | // Else, its a normal field 502 | $this->fields[] = $fieldName; 503 | } 504 | } 505 | } 506 | } 507 | 508 | /** 509 | * Load table name into the $table property 510 | */ 511 | private function loadTableName() 512 | { 513 | $this->table = call_user_func($this->model."::getTableName"); 514 | } 515 | 516 | } 517 | -------------------------------------------------------------------------------- /src/Routing/ApiResourceRegistrar.php: -------------------------------------------------------------------------------- 1 | router = $router; 25 | } 26 | 27 | /** 28 | * Route a resource to a controller. 29 | * 30 | * @param string $name 31 | * @param string $controller 32 | * @param array $options 33 | * @return void 34 | */ 35 | public function register($name, $controller, array $options = []) 36 | { 37 | if (isset($options['parameters']) && ! isset($this->parameters)) { 38 | $this->parameters = $options['parameters']; 39 | } 40 | 41 | // If the resource name contains a slash, we will assume the developer wishes to 42 | // register these resource routes with a prefix so we will set that up out of 43 | // the box so they don't have to mess with it. Otherwise, we will continue. 44 | if (Str::contains($name, '/')) { 45 | $this->prefixedResource($name, $controller, $options); 46 | 47 | return; 48 | } 49 | 50 | // We need to extract the base resource from the resource name. Nested resources 51 | // are supported in the framework, but we need to know what name to use for a 52 | // place-holder on the route parameters, which should be the base resources. 53 | $base = $this->getResourceWildcard(last(explode('.', $name))); 54 | 55 | $defaults = $this->resourceDefaults; 56 | 57 | foreach ($this->getResourceMethods($defaults, $options) as $m) { 58 | $this->{'addResource'.ucfirst($m)}($name, $base, $controller, $options); 59 | } 60 | } 61 | 62 | /** 63 | * Add the relation get method for a resourceful route. 64 | * 65 | * @param string $name 66 | * @param string $base 67 | * @param string $controller 68 | * @param array $options 69 | * @return \Illuminate\Routing\Route 70 | */ 71 | protected function addResourceRelation($name, $base, $controller, $options) 72 | { 73 | $uri = $this->getResourceUri($name).'/{'.$base.'}'."/{relation}"; 74 | 75 | $action = $this->getResourceAction($name, $controller, 'relation', $options); 76 | 77 | return $this->router->get($uri, $action); 78 | } 79 | } -------------------------------------------------------------------------------- /src/Routing/ApiRouter.php: -------------------------------------------------------------------------------- 1 | container && $this->container->bound('Froiden\RestAPI\Routing\ApiResourceRegistrar')) { 29 | $registrar = $this->container->make('Froiden\RestAPI\Routing\ApiResourceRegistrar'); 30 | } 31 | else { 32 | $registrar = new ResourceRegistrar($this); 33 | } 34 | 35 | $registrar->register($name, $controller, $options); 36 | } 37 | 38 | public function version($versions, Closure $callback) 39 | { 40 | if (is_string($versions)) 41 | { 42 | $versions = [$versions]; 43 | } 44 | 45 | $this->versions = $versions; 46 | 47 | call_user_func($callback, $this); 48 | } 49 | 50 | /** 51 | * Add a route to the underlying route collection. 52 | * 53 | * @param array|string $methods 54 | * @param string $uri 55 | * @param \Closure|array|string|null $action 56 | * @return \Illuminate\Routing\Route 57 | */ 58 | public function addRoute($methods, $uri, $action) 59 | { 60 | // We do not keep routes in ApiRouter. Whenever a route is added, 61 | // we add it to Laravel's primary route collection 62 | $routes = app("router")->getRoutes(); 63 | $prefix = config("api.prefix"); 64 | 65 | if (empty($this->versions)) { 66 | if (($default = config("api.default_version")) !== null) { 67 | $versions = [$default]; 68 | } 69 | else { 70 | $versions = [null]; 71 | } 72 | 73 | } 74 | else { 75 | $versions = $this->versions; 76 | } 77 | 78 | 79 | // Add version prefix 80 | foreach ($versions as $version) { 81 | // Add ApiMiddleware to all routes 82 | $route = $this->createRoute($methods, $uri, $action); 83 | $route->middleware(ApiMiddleware::class); 84 | 85 | if ($version !== null) { 86 | $route->prefix($version); 87 | $route->name("." . $version); 88 | } 89 | 90 | if (!empty($prefix)) { 91 | $route->prefix($prefix); 92 | } 93 | 94 | // $routes->add($route); 95 | 96 | // Options route 97 | // $route = $this->createRoute(['OPTIONS'], $uri, ['uses' => '\Froiden\RestAPI\Routing\ApiRouter@returnRoute']); 98 | 99 | // $route->middleware(ApiMiddleware::class); 100 | 101 | // if ($version !== null) { 102 | // $route->prefix($version); 103 | // $route->name("." . $version); 104 | // } 105 | 106 | // if (!empty($prefix)) { 107 | // $route->prefix($prefix); 108 | // } 109 | 110 | $routes->add($route); 111 | } 112 | 113 | app("router")->setRoutes($routes); 114 | } 115 | public function returnRoute() 116 | { 117 | return []; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Routing/ApiUrlGenerator.php: -------------------------------------------------------------------------------- 1 | 10, 9 | 10 | /** 11 | * Maximum number of records to return in single request. This limit is used 12 | * when user enters large number in limit parameter of the request 13 | */ 14 | 'maxLimit' => 1000, 15 | 16 | /* 17 | * Add allow cross origin headers. It is recommended by APIs to allow cross origin 18 | * requests. But, you can disable it. 19 | */ 20 | 'cors' => true, 21 | 22 | /** 23 | * Which headers are allowed in CORS requests 24 | */ 25 | 'cors_headers' => ['Authorization', 'Content-Type'], 26 | 27 | /** 28 | * List of fields that should not be considered while saving a model 29 | */ 30 | 'excludes' => ['_token'], 31 | 32 | /** 33 | * Prefix for all the routes 34 | */ 35 | 'prefix' => 'api', 36 | 37 | /** 38 | * Default version for the API. Set null to disable versions 39 | */ 40 | 'default_version' => 'v1', 41 | 42 | /** 43 | * Relation method name case snakecase|camelcase default it is snakecase 44 | */ 45 | 'relation_case' => 'snakecase' 46 | ]; 47 | -------------------------------------------------------------------------------- /tests/Controllers/CommentController.php: -------------------------------------------------------------------------------- 1 | call('GET', '/dummyUser'); 21 | 22 | $this->assertEquals(200, $response->status()); 23 | } 24 | 25 | public function testUserIndexWithFields() 26 | { 27 | $response = $this->call('GET', '/dummyUser', 28 | [ 29 | 'fields' => "id,name,email,age", 30 | ]); 31 | 32 | $this->assertEquals(200, $response->status()); 33 | } 34 | 35 | public function testOneToOneRelationWithFieldsParameter() 36 | { 37 | 38 | $response = $this->call('GET', '/dummyUser', 39 | [ 40 | 'fields' => "id,name,email,phone", 41 | ]); 42 | $responseContent = json_decode($response->getContent(), true); 43 | $this->assertNotNull($responseContent["data"]["0"]["phone"]); 44 | $this->assertEquals(200, $response->status()); 45 | } 46 | 47 | public function testOneToManyRelationWithFieldsParameter() 48 | { 49 | // Get Data With Related Post 50 | $response = $this->call('GET', '/dummyUser', 51 | [ 52 | 'fields' => "id,name,email,posts", 53 | ]); 54 | $responseContent = json_decode($response->getContent(), true); 55 | $this->assertNotEmpty($responseContent["data"]["0"]["posts"]); 56 | $this->assertEquals(200, $response->status()); 57 | 58 | // Get Data With User Comments on Post 59 | $response = $this->call('GET', '/dummyUser', 60 | [ 61 | 'fields' => "id,name,email,comments", 62 | ]); 63 | $responseContent = json_decode($response->getContent(), true); 64 | $this->assertNotEmpty($responseContent["data"]["0"]["comments"]); 65 | $this->assertEquals(200, $response->status()); 66 | 67 | } 68 | 69 | public function testUserIndexWithFilters() 70 | { 71 | $createFactory = \Illuminate\Database\Eloquent\Factory::construct(\Faker\Factory::create(), 72 | base_path() . '/laravel-rest-api/tests/Factories'); 73 | 74 | $userId = $createFactory->of(\Froiden\RestAPI\Tests\Models\DummyUser::class)->create(); 75 | 76 | // Use "filters" to modify The result 77 | $response = $this->call('GET', '/dummyUser', 78 | [ 79 | 'filters' => 'age lt 7', 80 | ]); 81 | $this->assertEquals(200, $response->status()); 82 | 83 | // With 'lk' operator 84 | $response = $this->call('GET', '/dummyUser', 85 | [ 86 | 'fields' => "id,name", 87 | 'filters' => 'name lk "%'.$userId->name.'%"', 88 | ]); 89 | $this->assertEquals(200, $response->status()); 90 | } 91 | 92 | public function testUserIndexWithLimit() 93 | { 94 | // Use "Limit" to get required number of result 95 | $response = $this->call('GET', '/dummyUser', 96 | [ 97 | 'limit' => '5', 98 | ]); 99 | 100 | $this->assertEquals(200, $response->status()); 101 | } 102 | 103 | public function testUserIndexWithsOrderParameter() 104 | { 105 | // Define order of result 106 | $response = $this->call('GET', '/dummyUser', 107 | [ 108 | 'order' => "id desc", 109 | ]); 110 | 111 | $this->assertEquals(200, $response->status()); 112 | 113 | $response = $this->call('GET', '/dummyUser', 114 | [ 115 | 'order' => "id asc", 116 | ]); 117 | 118 | $this->assertEquals(200, $response->status()); 119 | 120 | } 121 | 122 | public function testUserShowFunction() 123 | { 124 | $user = \Froiden\RestAPI\Tests\Models\DummyUser::all()->random(); 125 | $response = $this->call('GET', '/dummyUser/'.$user->id); 126 | 127 | $this->assertEquals(200, $response->status()); 128 | } 129 | 130 | public function testShowCommentsByUserRelationsEndpoint() 131 | { 132 | $user = \Froiden\RestAPI\Tests\Models\DummyUser::all()->random(); 133 | 134 | $post = \Froiden\RestAPI\Tests\Models\DummyPost::all()->random(); 135 | 136 | $createFactory = \Illuminate\Database\Eloquent\Factory::construct(\Faker\Factory::create(), 137 | base_path() . '/laravel-rest-api/tests/Factories'); 138 | 139 | $comment = $createFactory->of(\Froiden\RestAPI\Tests\Models\DummyComment::class)->create([ 140 | 'comment' => "Dummy Comments", 141 | 'user_id' => $user->id, 142 | 'post_id' => $post->id 143 | ]); 144 | $response = $this->call('GET', '/dummyUser/'.$user->id.'/comments'); 145 | 146 | $responseContent = json_decode($response->getContent(), true); 147 | 148 | $this->assertNotEmpty($responseContent["data"]); 149 | 150 | $this->assertEquals(200, $response->status()); 151 | } 152 | 153 | public function testShowPostsByUserRelationsEndpoint() 154 | { 155 | //region Insert Dummy Data 156 | $user = \Froiden\RestAPI\Tests\Models\DummyUser::all()->random(); 157 | 158 | $createFactory = \Illuminate\Database\Eloquent\Factory::construct(\Faker\Factory::create(), 159 | base_path() . '/laravel-rest-api/tests/Factories'); 160 | 161 | $createFactory->of(\Froiden\RestAPI\Tests\Models\DummyPost::class)->create([ 162 | 'post' => "dummy POst", 163 | 'user_id' => $user->id, 164 | ]); 165 | 166 | //endregion 167 | 168 | $response = $this->call('GET', '/dummyUser/'.$user->id.'/posts'); 169 | 170 | $responseContent = json_decode($response->getContent(), true); 171 | 172 | $this->assertNotEmpty($responseContent["data"]); 173 | 174 | $this->assertEquals(200, $response->status()); 175 | } 176 | 177 | public function testUserStore() 178 | { 179 | $response = $this->call('POST', '/dummyUser', 180 | [ 181 | 'name' => "Dummy User", 182 | 'email' => "dummy@test.com", 183 | 'age' => 25 184 | ]); 185 | $this->assertEquals(200, $response->status()); 186 | 187 | } 188 | 189 | public function testUserUpdate() 190 | { 191 | $user = \Froiden\RestAPI\Tests\Models\DummyUser::all()->random(); 192 | 193 | $response = $this->call('PUT', '/dummyUser/'.$user->id, 194 | [ 195 | 'name' => "Dummy1 User", 196 | 'email' => "dummy2@test.com", 197 | 'age' => 25, 198 | ]); 199 | $this->assertEquals(200, $response->status()); 200 | } 201 | 202 | /** 203 | * Test User Delete Function. 204 | * 205 | * @return void 206 | */ 207 | public function testUserDelete() 208 | { 209 | $user = \Froiden\RestAPI\Tests\Models\DummyUser::all()->random(); 210 | 211 | $response = $this->call('DELETE', '/dummyUser/'.$user->id); 212 | 213 | $this->assertEquals(200, $response->status()); 214 | } 215 | 216 | } 217 | -------------------------------------------------------------------------------- /tests/Factories/ModelFactory.php: -------------------------------------------------------------------------------- 1 | define( 4 | \Froiden\RestAPI\Tests\Models\DummyUser::class, 5 | function(Faker\Generator $faker){ 6 | return [ 7 | 'name' => $faker->name, 8 | 'email' => $faker->email, 9 | 'age' => $faker->randomDigitNotNull, 10 | 11 | ]; 12 | } 13 | ); 14 | 15 | $factory->define( 16 | \Froiden\RestAPI\Tests\Models\DummyPhone::class, 17 | function(Faker\Generator $faker){ 18 | 19 | return [ 20 | 'name' => $faker->name, 21 | 'modal_no' => $faker->swiftBicNumber, 22 | 'user_id' => \Froiden\RestAPI\Tests\Models\DummyUser::all()->random()->id, 23 | ]; 24 | } 25 | ); 26 | 27 | $factory->define(\Froiden\RestAPI\Tests\Models\DummyPost::class, 28 | function(Faker\Generator $faker) 29 | { 30 | $createFactory = \Illuminate\Database\Eloquent\Factory::construct(\Faker\Factory::create(), 31 | base_path() . '/laravel-rest-api/tests/Factories'); 32 | return [ 33 | 'post' => $faker->company, 34 | 'user_id' => \Froiden\RestAPI\Tests\Models\DummyUser::all()->random()->id, 35 | ]; 36 | } 37 | ); 38 | 39 | $factory->define(\Froiden\RestAPI\Tests\Models\DummyComment::class, 40 | function(Faker\Generator $faker) 41 | { 42 | $createFactory = \Illuminate\Database\Eloquent\Factory::construct(\Faker\Factory::create(), 43 | base_path() . '/laravel-rest-api/tests/Factories'); 44 | return [ 45 | 'comment' => $faker->text, 46 | 'user_id' => \Froiden\RestAPI\Tests\Models\DummyUser::all()->random()->id, 47 | 'post_id' => \Froiden\RestAPI\Tests\Models\DummyPost::all()->random()->id, 48 | ]; 49 | } 50 | ); 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /tests/Models/DummyComment.php: -------------------------------------------------------------------------------- 1 | belongsTo('Froiden\RestAPI\Tests\Models\DummyPost'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Models/DummyPhone.php: -------------------------------------------------------------------------------- 1 | hasMany('Froiden\RestAPI\Tests\Models\DummyComment'); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /tests/Models/DummyUser.php: -------------------------------------------------------------------------------- 1 | hasOne('Froiden\RestAPI\Tests\Models\DummyPhone', 'user_id', 'id'); 39 | } 40 | 41 | /** 42 | * The posts that belong to the user. 43 | */ 44 | public function posts() 45 | { 46 | return $this->hasMany('Froiden\RestAPI\Tests\Models\DummyPost', 'user_id', 'id'); 47 | } 48 | 49 | /** 50 | * The comments that belong to the user. 51 | */ 52 | public function comments() 53 | { 54 | return $this->hasMany('Froiden\RestAPI\Tests\Models\DummyComment', 'user_id', 'id'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/PaginationTest.php: -------------------------------------------------------------------------------- 1 | 2 | call('GET', '/dummyUser', 21 | [ 22 | 'order' => 'id asc', 23 | 'offset' => '5', 24 | 'limit' => '2' 25 | ]); 26 | $this->assertEquals(200, $response->status()); 27 | 28 | // Pagination set offset = "1" or limit ="1" 29 | $response = $this->call('GET', '/dummyUser', 30 | [ 31 | 'order' => 'id asc', 32 | 'offset' => '1', 33 | 'limit' => '1' 34 | ]); 35 | $this->assertEquals(200, $response->status()); 36 | 37 | // Pagination set offset = "5" or limit ="3" 38 | $response = $this->call('GET', '/dummyUser', 39 | [ 40 | 'order' => 'id asc', 41 | 'offset' => '5', 42 | 'limit' => '-2' 43 | ]); 44 | $this->assertNotEquals(200, $response->status()); 45 | } 46 | } -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | createTables(); 41 | $this->seedDummyData(); 42 | 43 | $this->app[ApiRouter::class]->resource('/dummyUser', UserController::class); 44 | $this->app[ApiRouter::class]->resource('/dummyPost', PostController::class); 45 | $this->app[ApiRouter::class]->resource('/dummyComment', CommentController::class); 46 | } 47 | 48 | /** 49 | * Creates the application. 50 | * 51 | * @return \Illuminate\Foundation\Application 52 | */ 53 | 54 | public function createApplication() 55 | { 56 | $app = require __DIR__.'/../../bootstrap/app.php'; 57 | 58 | $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); 59 | 60 | return $app; 61 | } 62 | 63 | /** 64 | * This is the description for the function below. 65 | * 66 | * Insert dummy data into tables 67 | * 68 | * @return void 69 | */ 70 | public function seedDummyData() 71 | { 72 | $factory = \Illuminate\Database\Eloquent\Factory::construct(\Faker\Factory::create(), 73 | base_path() . '/laravel-rest-api/tests/Factories'); 74 | \DB::beginTransaction(); 75 | 76 | for($i = 0; $i < 10; $i++) 77 | { 78 | $user = $factory->of(DummyUser::class)->create(); 79 | $factory->of(DummyPhone::class)->create( 80 | [ 81 | 'user_id' => $user->id 82 | ] 83 | ); 84 | 85 | $post = $factory->of(DummyPost::class)->create( 86 | [ 87 | 'user_id' => $user->id, 88 | ] 89 | ); 90 | 91 | $factory->of(DummyComment::class)->create( 92 | [ 93 | 'post_id' => $post->id, 94 | 'user_id' => $user->id, 95 | ] 96 | ); 97 | 98 | } 99 | 100 | \DB::commit(); 101 | 102 | } 103 | 104 | /** 105 | * This is the description for the function below. 106 | * 107 | * Create a tables 108 | * 109 | * @return void 110 | */ 111 | public function createTables() 112 | { 113 | Schema::dropIfExists('dummy_comments'); 114 | Schema::dropIfExists('dummy_posts'); 115 | Schema::dropIfExists('dummy_phones'); 116 | Schema::dropIfExists('dummy_users'); 117 | 118 | Schema::create('dummy_users', function (Blueprint $table) { 119 | $table->increments('id'); 120 | $table->string('name'); 121 | $table->string('email', 100)->unique(); 122 | $table->integer('age'); 123 | $table->timestamps(); 124 | }); 125 | 126 | Schema::create('dummy_phones', function (Blueprint $table) { 127 | $table->increments('id'); 128 | $table->string('name'); 129 | $table->string('modal_no'); 130 | $table->unsignedInteger('user_id'); 131 | $table->foreign('user_id')->references('id')->on('dummy_users') 132 | ->onUpdate('CASCADE') 133 | ->onDelete('CASCADE'); 134 | $table->timestamps(); 135 | }); 136 | 137 | Schema::create('dummy_posts', function (Blueprint $table) { 138 | $table->increments('id'); 139 | $table->string('post'); 140 | $table->unsignedInteger('user_id'); 141 | $table->foreign('user_id')->references('id')->on('dummy_users') 142 | ->onUpdate('CASCADE') 143 | ->onDelete('CASCADE'); 144 | $table->timestamps(); 145 | }); 146 | 147 | Schema::create('dummy_comments', function (Blueprint $table) { 148 | $table->increments('id'); 149 | $table->string('comment'); 150 | $table->unsignedInteger('user_id'); 151 | $table->foreign('user_id')->references('id')->on('dummy_users') 152 | ->onUpdate('CASCADE') 153 | ->onDelete('CASCADE'); 154 | $table->unsignedInteger('post_id'); 155 | $table->foreign('post_id')->references('id')->on('dummy_posts') 156 | ->onUpdate('CASCADE') 157 | ->onDelete('CASCADE'); 158 | $table->timestamps(); 159 | }); 160 | } 161 | 162 | } 163 | 164 | --------------------------------------------------------------------------------