├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── composer.json ├── phpunit.xml ├── public └── .gitkeep ├── src ├── Jarischaefer │ └── HalApi │ │ ├── Caching │ │ ├── CacheFactory.php │ │ ├── CacheFactoryImpl.php │ │ ├── HalApiCache.php │ │ └── HalApiCacheImpl.php │ │ ├── Controllers │ │ ├── HalApiController.php │ │ ├── HalApiControllerContract.php │ │ ├── HalApiControllerParameters.php │ │ ├── HalApiRequestParameters.php │ │ ├── HalApiResourceController.php │ │ └── HalApiResourceControllerContract.php │ │ ├── Exceptions │ │ ├── BadPostRequestException.php │ │ ├── BadPutRequestException.php │ │ ├── DatabaseConflictException.php │ │ ├── DatabaseSaveException.php │ │ ├── FieldNotSearchableException.php │ │ └── NotImplementedException.php │ │ ├── Helpers │ │ ├── CacheHelper.php │ │ ├── Checks.php │ │ ├── ResourceRoute.php │ │ ├── RouteHelper.php │ │ ├── RouteHelperConstants.php │ │ └── SafeIndexArray.php │ │ ├── Middleware │ │ ├── HalApiCacheMiddleware.php │ │ └── HalApiETagMiddleware.php │ │ ├── Providers │ │ └── HalApiServiceProvider.php │ │ ├── Repositories │ │ ├── HalApiEloquentRepository.php │ │ ├── HalApiEloquentSearchRepository.php │ │ ├── HalApiRepository.php │ │ └── HalApiSearchRepository.php │ │ ├── Representations │ │ ├── HalApiPaginatedRepresentation.php │ │ ├── HalApiPaginatedRepresentationImpl.php │ │ ├── HalApiRepresentation.php │ │ ├── HalApiRepresentationImpl.php │ │ ├── RepresentationFactory.php │ │ └── RepresentationFactoryImpl.php │ │ ├── Routing │ │ ├── HalApiLink.php │ │ ├── HalApiLinkImpl.php │ │ ├── HalApiUrlGenerator.php │ │ ├── LinkFactory.php │ │ └── LinkFactoryImpl.php │ │ └── Transformers │ │ ├── HalApiTransformer.php │ │ └── HalApiTransformerContract.php ├── config │ ├── .gitkeep │ └── pagination.php ├── controllers │ └── .gitkeep ├── lang │ └── .gitkeep ├── migrations │ └── .gitkeep └── views │ └── .gitkeep └── tests ├── .gitkeep ├── Helpers ├── ResourceRouteTest.php └── RouteHelperTest.php ├── Middleware ├── HalApiCacheMiddlerwareTest.php └── HalApiETagMiddlewareTest.php ├── Representations └── RepresentationFactoryImplTest.php ├── Routing ├── HalApiLinkImplTest.php └── LinkFactoryImplTest.php └── TestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /vendor 3 | composer.phar 4 | composer.lock 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.4 5 | - 5.5 6 | - 5.6 7 | - hhvm 8 | 9 | before_script: 10 | - travis_retry composer self-update 11 | - travis_retry composer install --prefer-source --no-interaction --dev 12 | 13 | script: phpunit 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Jari Schäfer 4 | 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HAL-API 2 | 3 | Enhances your HATEOAS experience by automating common tasks. 4 | 5 | # About 6 | 7 | This package is based on Laravel 5. 8 | It is designed to automate common tasks in RESTful API programming. 9 | These docs might not always be in sync with all the changes. 10 | 11 | # Installation 12 | 13 | ## Requirements 14 | 15 | Requires Laravel 5.4 and PHP 7.1. 16 | 17 | ## Composer 18 | 19 | Either require the package via Composer by issuing the following command 20 | 21 | > composer require jarischaefer/hal-api:dev-master 22 | 23 | or by including the following in your composer.json. 24 | 25 | ```json 26 | "require": { 27 | "jarischaefer/hal-api": "dev-master" 28 | } 29 | ``` 30 | Check the releases page for a list of available versions. 31 | 32 | 33 | ## Service Provider 34 | 35 | ### app.php 36 | 37 | Register the Service Provider in your config/app.php file. 38 | ```php 39 | 'providers' => [ 40 | Jarischaefer\HalApi\Providers\HalApiServiceProvider::class, 41 | ] 42 | ``` 43 | 44 | ### compile.php (optional step) 45 | 46 | Register the Service Provider in your config/compile.php file. 47 | ```php 48 | 'providers' => [ 49 | Jarischaefer\HalApi\Providers\HalApiServiceProvider::class, 50 | ] 51 | ``` 52 | 53 | Run `php artisan optimize --force` to compile an optimized classloader. 54 | 55 | 56 | # Usage 57 | 58 | ## Simple Controller 59 | 60 | This type of controller is not backed by a model and provides no CRUD operations. 61 | A typical use case is an entry point for the API. 62 | The following controller should be routed to the root of the API and 63 | lists all relationships. 64 | 65 | ```php 66 | class HomeController extends HalApiController 67 | { 68 | 69 | public function index(HalApiRequestParameters $parameters) 70 | { 71 | return $this->responseFactory->json($this->createResponse($parameters)->build()); 72 | } 73 | 74 | } 75 | ``` 76 | 77 | ## Resource Controller 78 | 79 | Resource controllers require three additional components: 80 | 81 | * Model: Resources' data is contained within models 82 | * Repository: Repositories retrieve and store models 83 | * Transformer: Transforms models into HAL representations 84 | 85 | ```php 86 | class UsersController extends HalApiResourceController 87 | { 88 | 89 | public static function getRelationName(): string 90 | { 91 | return 'users'; 92 | } 93 | 94 | public function __construct(HalApiControllerParameters $parameters, UserTransformer $transformer, UserRepository $repository) 95 | { 96 | parent::__construct($parameters, $transformer, $repository); 97 | } 98 | 99 | public function posts(HalApiRequestParameters $parameters, PostsController $postsController, User $user): Response 100 | { 101 | $posts = $user->posts()->paginate($parameters->getPerPage()); 102 | $response = $postsController->paginate($parameters, $posts)->build(); 103 | 104 | return $this->responseFactory->json($response); 105 | } 106 | 107 | } 108 | 109 | class PostsController extends HalApiResourceController 110 | { 111 | 112 | public static function getRelationName(): string 113 | { 114 | return 'posts'; 115 | } 116 | 117 | public function __construct(HalApiControllerParameters $parameters, PostTransformer $transformer, PostRepository $repository) 118 | { 119 | parent::__construct($parameters, $transformer, $repository); 120 | } 121 | 122 | } 123 | ``` 124 | 125 | ## Models 126 | 127 | The following is a simple relationship with two tables. 128 | User has a One-To-Many relationship with Post. 129 | 130 | ```php 131 | class User extends Model implements AuthenticatableContract, CanResetPasswordContract 132 | { 133 | 134 | use Authenticatable, CanResetPassword; 135 | 136 | /** 137 | * The attributes excluded from the model's JSON form. 138 | * 139 | * @var array 140 | */ 141 | protected $hidden = ['password', 'remember_token']; 142 | 143 | public function posts() 144 | { 145 | return $this->hasMany(Post::class); 146 | } 147 | 148 | } 149 | 150 | class Post extends Model 151 | { 152 | 153 | // ... 154 | 155 | public function user() 156 | { 157 | return $this->belongsTo(User::class); 158 | } 159 | 160 | } 161 | ``` 162 | 163 | ## Repository 164 | 165 | You may create an [Eloquent](https://laravel.com/docs/5.2/eloquent)-compatible 166 | repository by extending `HalApiEloquentRepository` and implementing its getModelClass() method. 167 | 168 | ```php 169 | class UserRepository extends HalApiEloquentRepository 170 | { 171 | 172 | public static function getModelClass(): string 173 | { 174 | return User::class; 175 | } 176 | 177 | } 178 | 179 | class PostRepository extends HalApiEloquentRepository 180 | { 181 | 182 | public static function getModelClass(): string 183 | { 184 | return Post::class; 185 | } 186 | 187 | } 188 | ``` 189 | 190 | ### Searchable repository 191 | 192 | Implementing HalApiSearchRepository enables searching/filtering by field. 193 | An [Eloquent](https://laravel.com/docs/5.2/eloquent)-compatible repository is available. 194 | Not restricting the searchable fields might result in information leakage. 195 | 196 | ```php 197 | class UserRepository extends HalApiEloquentSearchRepository 198 | { 199 | 200 | public static function getModelClass(): string 201 | { 202 | return User::class; 203 | } 204 | 205 | public static function searchableFields(): array 206 | { 207 | return [User::COLUMN_NAME]; 208 | } 209 | 210 | } 211 | 212 | class PostRepository extends HalApiEloquentSearchRepository 213 | { 214 | 215 | public static function getModelClass(): string 216 | { 217 | return Post::class; 218 | } 219 | 220 | public static function searchableFields(): array 221 | { 222 | return ['*']; 223 | } 224 | 225 | } 226 | ``` 227 | 228 | ## Transformer 229 | 230 | Transformers provide an additional layer between your models and the controller. 231 | They help you create a HAL response for either a single item or a collection of items. 232 | 233 | ```php 234 | class UserTransformer extends HalApiTransformer 235 | { 236 | 237 | public function transform(Model $model) 238 | { 239 | /** @var User $model */ 240 | 241 | return [ 242 | 'id' => (int)$model->id, 243 | 'username' => (string)$model->username, 244 | 'email' => (string)$model->email, 245 | 'firstname' => (string)$model->firstname, 246 | 'lastname' => (string)$model->lastname, 247 | 'disabled' => (bool)$model->disabled, 248 | ]; 249 | } 250 | 251 | } 252 | 253 | class PostTransformer extends HalApiTransformer 254 | { 255 | 256 | public function transform(Model $model) 257 | { 258 | /** @var Post $model */ 259 | 260 | return [ 261 | 'id' => (int)$model->id, 262 | 'title' => (string)$model->title, 263 | 'text' => (string)$model->text, 264 | 'user_id' => (int)$model->user_id, 265 | ]; 266 | } 267 | 268 | } 269 | ``` 270 | 271 | ### Linking relationships 272 | 273 | Overriding a transformer's getLinks method allows you to link to related resources. 274 | Linking a Post to its User: 275 | 276 | ```php 277 | class PostTransformer extends HalApiTransformer 278 | { 279 | 280 | private $userRoute; 281 | 282 | private $userRelation; 283 | 284 | public function __construct(LinkFactory $linkFactory, RepresentationFactory $representationFactory, RouteHelper $routeHelper, Route $self, Route $parent) 285 | { 286 | parent::__construct($linkFactory, $representationFactory, $routeHelper, $self, $parent); 287 | 288 | $this->userRoute = $routeHelper->byAction(UsersController::actionName(RouteHelper::SHOW)); 289 | $this->userRelation = UsersController::getRelation(RouteHelper::SHOW); 290 | } 291 | 292 | public function transform(Model $model) 293 | { 294 | /** @var Post $model */ 295 | 296 | return [ 297 | 'id' => (int)$model->id, 298 | 'title' => (string)$model->title, 299 | 'text' => (string)$model->text, 300 | 'user_id' => (int)$model->user_id, 301 | ]; 302 | } 303 | 304 | protected function getLinks(Model $model) 305 | { 306 | /** @var Post $model */ 307 | 308 | return [ 309 | $this->userRelation => $this->linkFactory->create($this->userRoute, $model->user_id), 310 | ]; 311 | } 312 | 313 | } 314 | ``` 315 | 316 | Notice the "users.show" relation among the links. 317 | 318 | ```json 319 | { 320 | "data": { 321 | "id": 123, 322 | "title": "Welcome!", 323 | "text": "Hello World", 324 | "user_id": 456 325 | }, 326 | "_links": { 327 | "self": { 328 | "href": "http://hal-api.development/posts/123", 329 | "templated": true 330 | }, 331 | "parent": { 332 | "href": "http://hal-api.development/posts", 333 | "templated": false 334 | }, 335 | "users.show": { 336 | "href": "http://hal-api.development/users/456", 337 | "templated": true 338 | }, 339 | "posts.update": { 340 | "href": "http://hal-api.development/posts/123", 341 | "templated": true 342 | }, 343 | "posts.destroy": { 344 | "href": "http://hal-api.development/posts/123", 345 | "templated": true 346 | } 347 | }, 348 | "_embedded": { 349 | } 350 | } 351 | ``` 352 | 353 | ### Embedded relationships 354 | 355 | Once data from two separate Models needs to be combined, the linking-approach 356 | doesn't quite cut it. Displaying Posts' authors (firstname and lastname in User model) 357 | becomes infeasible with more than a dozen items (N+1 GET requests to all "users.show" relationships). Embedding related data is basically the same as eager loading. 358 | 359 | ```php 360 | class PostTransformer extends HalApiTransformer 361 | { 362 | 363 | private $userTransformer; 364 | 365 | private $userRelation; 366 | 367 | public function __construct(LinkFactory $linkFactory, RepresentationFactory $representationFactory, RouteHelper $routeHelper, Route $self, Route $parent, UserTransformer $userTransformer) 368 | { 369 | parent::__construct($linkFactory, $representationFactory, $routeHelper, $self, $parent); 370 | 371 | $this->userTransformer = $userTransformer; 372 | $this->userRelation = UsersController::getRelation(RouteHelper::SHOW); 373 | } 374 | 375 | public function transform(Model $model) 376 | { 377 | /** @var Post $model */ 378 | 379 | return [ 380 | 'id' => (int)$model->id, 381 | 'title' => (string)$model->title, 382 | 'text' => (string)$model->text, 383 | 'user_id' => (int)$model->user_id, 384 | ]; 385 | } 386 | 387 | protected function getEmbedded(Model $model) 388 | { 389 | /** @var Post $model */ 390 | 391 | return [ 392 | $this->userRelation => $this->userTransformer->item($model->user), 393 | ]; 394 | } 395 | 396 | } 397 | ``` 398 | 399 | Notice the "users.show" relation in the `_embedded` field. 400 | 401 | ```json 402 | { 403 | "data": { 404 | "id": 123, 405 | "title": "Welcome!", 406 | "text": "Hello World", 407 | "user_id": 456 408 | }, 409 | "_links": { 410 | "self": { 411 | "href": "http://hal-api.development/posts/123", 412 | "templated": true 413 | }, 414 | "parent": { 415 | "href": "http://hal-api.development/posts", 416 | "templated": false 417 | }, 418 | "posts.update": { 419 | "href": "http://hal-api.development/posts/123", 420 | "templated": true 421 | }, 422 | "posts.destroy": { 423 | "href": "http://hal-api.development/posts/123", 424 | "templated": true 425 | } 426 | }, 427 | "_embedded": { 428 | "users.show": { 429 | "data": { 430 | "id": 456, 431 | "username": "foo-bar", 432 | "email": "foo.bar@example.com", 433 | "firstname": "foo", 434 | "lastname": "bar", 435 | "disabled": false 436 | }, 437 | "_links": { 438 | "self": { 439 | "href": "http://hal-api.development/users/456", 440 | "templated": true 441 | }, 442 | "parent": { 443 | "href": "http://hal-api.development/users", 444 | "templated": false 445 | }, 446 | "users.posts": { 447 | "href": "http://hal-api.development/users/456/posts", 448 | "templated": true 449 | }, 450 | "users.update": { 451 | "href": "http://hal-api.development/users/456", 452 | "templated": true 453 | }, 454 | "users.destroy": { 455 | "href": "http://hal-api.development/users/456", 456 | "templated": true 457 | } 458 | }, 459 | "_embedded": { 460 | } 461 | } 462 | } 463 | } 464 | ``` 465 | 466 | ## Dependency wiring 467 | 468 | It is recommended that you wire the transformers' dependencies in a Service Provider: 469 | 470 | ```php 471 | class MyServiceProvider extends ServiceProvider 472 | { 473 | 474 | public function boot(Router $router) 475 | { 476 | $this->app->singleton(UserTransformer::class, function (Illuminate\Contracts\Foundation\Application $application) { 477 | $linkFactory = $application->make(LinkFactory::class); 478 | $representationFactory = $application->make(RepresentationFactory::class); 479 | $routeHelper = $application->make(RouteHelper::class); 480 | $self = $routeHelper->byAction(UsersController::actionName(RouteHelper::SHOW)); 481 | $parent = $routeHelper->parent($self); 482 | 483 | return new UserTransformer($linkFactory, $representationFactory, $routeHelper, $self, $parent); 484 | }); 485 | 486 | $this->app->singleton(PostTransformer::class, function (Illuminate\Contracts\Foundation\Application $application) { 487 | $linkFactory = $application->make(LinkFactory::class); 488 | $representationFactory = $application->make(RepresentationFactory::class); 489 | $routeHelper = $application->make(RouteHelper::class); 490 | $self = $routeHelper->byAction(PostsController::actionName(RouteHelper::SHOW)); 491 | $parent = $routeHelper->parent($self); 492 | $userTransformer = $application->make(UserTransformer::class); 493 | 494 | return new PostTransformer($linkFactory, $representationFactory, $routeHelper, $self, $parent, $userTransformer); 495 | }); 496 | } 497 | 498 | } 499 | ``` 500 | 501 | ## routes.php 502 | 503 | The RouteHelper automatically creates routes for all CRUD operations. 504 | 505 | ```php 506 | RouteHelper::make($router) 507 | ->get('/', HomeController::class, 'index') // Link GET / to the index method in HomeController 508 | 509 | ->resource('users', UsersController::class) // Start a new resource block 510 | ->get('posts', 'posts') // Link GET /users/{users}/posts to the posts method in UsersController 511 | ->done() // Close the resource block 512 | 513 | ->resource('posts', PostsController::class) 514 | ->done(); 515 | ``` 516 | 517 | ### Disabling CRUD operations and pagination 518 | 519 | ```php 520 | RouteHelper::make($router) 521 | ->resource('users', UsersController::class, [RouteHelper::SHOW, RouteHelper::INDEX], false) 522 | ->done(); 523 | ``` 524 | 525 | ### Searching/filtering 526 | 527 | The controller's repository must implement HalApiSearchRepository. 528 | 529 | ```php 530 | RouteHelper::make($router) 531 | ->resource('users', UsersController::class) 532 | ->searchable() 533 | ->done(); 534 | ``` 535 | 536 | ## RouteServiceProvider 537 | 538 | Make sure you bind all route parameters in the RouteServiceProvider. 539 | The callback shown below handles missing parameters depending on the request method. 540 | For instance, a GET request for a nonexistent database record should yield a 404 response. 541 | The same is true for all other HTTP methods except for PUT. PUT simply creates the resource if it did not exist before. 542 | 543 | ```php 544 | public function boot(Router $router) 545 | { 546 | parent::boot($router); 547 | 548 | $callback = RouteHelper::getModelBindingCallback(); 549 | $router->model('users', User::class, $callback); 550 | $router->model('posts', Post::class, $callback); 551 | } 552 | ``` 553 | 554 | ## Exception handler 555 | 556 | The callback above throws NotFoundHttpException if no record was found. 557 | To create a proper response instead of an error page, the exception handler must be amended. 558 | As shown below, various HTTP status codes like 404 and 422 will be returned depending on the exception caught. 559 | 560 | ```php 561 | class Handler extends ExceptionHandler 562 | { 563 | 564 | public function report(Exception $e) 565 | { 566 | parent::report($e); 567 | } 568 | 569 | public function render($request, Exception $e) 570 | { 571 | switch (get_class($e)) { 572 | case ModelNotFoundException::class: 573 | return response('', Response::HTTP_NOT_FOUND); 574 | case NotFoundHttpException::class: 575 | return response('', Response::HTTP_NOT_FOUND); 576 | case BadPutRequestException::class: 577 | return response('', Response::HTTP_UNPROCESSABLE_ENTITY); 578 | case BadPostRequestException::class: 579 | return response('', Response::HTTP_UNPROCESSABLE_ENTITY); 580 | case TokenMismatchException::class: 581 | return response('', Response::HTTP_FORBIDDEN); 582 | case DatabaseConflictException::class: 583 | return response('', Response::HTTP_CONFLICT); 584 | case DatabaseSaveException::class: 585 | $this->report($e); 586 | return response('', Response::HTTP_UNPROCESSABLE_ENTITY); 587 | case FieldNotSearchableException::class: 588 | return response('', Response::HTTP_FORBIDDEN); 589 | default: 590 | $this->report($e); 591 | 592 | return Config::get('app.debug') ? parent::render($request, $e) : response('', Response::HTTP_INTERNAL_SERVER_ERROR); 593 | } 594 | } 595 | 596 | } 597 | ``` 598 | 599 | # Examples 600 | 601 | ## JSON for a specific model (show) 602 | 603 | ```json 604 | { 605 | "data": { 606 | "id": 123, 607 | "username": "FB", 608 | "email": "foo.bar@example.com", 609 | "firstname": "foo", 610 | "lastname": "bar", 611 | "disabled": false 612 | }, 613 | "_links": { 614 | "self": { 615 | "href": "http://hal-api.development/users/123", 616 | "templated": true 617 | }, 618 | "parent": { 619 | "href": "http://hal-api.development/users", 620 | "templated": false 621 | }, 622 | "users.posts": { 623 | "href": "http://hal-api.development/users/123/posts", 624 | "templated": true 625 | }, 626 | "users.update": { 627 | "href": "http://hal-api.development/users/123", 628 | "templated": true 629 | }, 630 | "users.destroy": { 631 | "href": "http://hal-api.development/users/123", 632 | "templated": true 633 | } 634 | }, 635 | "_embedded": { 636 | } 637 | } 638 | ``` 639 | 640 | ## JSON for a list of models (index) 641 | 642 | ```json 643 | { 644 | "_links": { 645 | "self": { 646 | "href": "http://hal-api.development/users", 647 | "templated": false 648 | }, 649 | "parent": { 650 | "href": "http://hal-api.development", 651 | "templated": false 652 | }, 653 | "users.posts": { 654 | "href": "http://hal-api.development/users/{users}/posts", 655 | "templated": true 656 | }, 657 | "users.show": { 658 | "href": "http://hal-api.development/users/{users}", 659 | "templated": true 660 | }, 661 | "users.store": { 662 | "href": "http://hal-api.development/users", 663 | "templated": false 664 | }, 665 | "users.update": { 666 | "href": "http://hal-api.development/users/{users}", 667 | "templated": true 668 | }, 669 | "users.destroy": { 670 | "href": "http://hal-api.development/users/{users}", 671 | "templated": true 672 | }, 673 | "users.posts": { 674 | "href": "http://hal-api.development/users/{users}/posts", 675 | "templated": true 676 | }, 677 | "first": { 678 | "href": "http://hal-api.development/users?current_page=1", 679 | "templated": false 680 | }, 681 | "next": { 682 | "href": "http://hal-api.development/users?current_page=2", 683 | "templated": false 684 | }, 685 | "last": { 686 | "href": "http://hal-api.development/users?current_page=10", 687 | "templated": false 688 | } 689 | }, 690 | "_embedded": { 691 | "users.show": [ 692 | { 693 | "data": { 694 | "id": 123, 695 | "username": "FB", 696 | "email": "foo.bar@example.com", 697 | "firstname": "Foo", 698 | "lastname": "Bar", 699 | "disabled": false 700 | }, 701 | "_links": { 702 | "self": { 703 | "href": "http://hal-api.development/users/123", 704 | "templated": true 705 | }, 706 | "parent": { 707 | "href": "http://hal-api.development/users", 708 | "templated": false 709 | }, 710 | "users.posts": { 711 | "href": "http://hal-api.development/users/123/posts", 712 | "templated": true 713 | }, 714 | "users.update": { 715 | "href": "http://hal-api.development/users/123", 716 | "templated": true 717 | }, 718 | "users.destroy": { 719 | "href": "http://hal-api.development/users/123", 720 | "templated": true 721 | }, 722 | "users.posts": { 723 | "href": "http://hal-api.development/users/123/posts", 724 | "templated": true 725 | } 726 | }, 727 | "_embedded": { 728 | } 729 | }, 730 | { 731 | "data": { 732 | "id": 456, 733 | "username": "JD", 734 | "email": "john.doe@example.com", 735 | "firstname": "John", 736 | "lastname": "Doe", 737 | "disabled": false 738 | }, 739 | "_links": { 740 | "self": { 741 | "href": "http://hal-api.development/users/456", 742 | "templated": true 743 | }, 744 | "parent": { 745 | "href": "http://hal-api.development/users", 746 | "templated": false 747 | }, 748 | "users.posts": { 749 | "href": "http://hal-api.development/users/456/posts", 750 | "templated": true 751 | }, 752 | "users.update": { 753 | "href": "http://hal-api.development/users/456", 754 | "templated": true 755 | }, 756 | "users.destroy": { 757 | "href": "http://hal-api.development/users/456", 758 | "templated": true 759 | }, 760 | "users.posts": { 761 | "href": "http://hal-api.development/users/456/posts", 762 | "templated": true 763 | } 764 | }, 765 | "_embedded": { 766 | } 767 | } 768 | ] 769 | } 770 | } 771 | ``` 772 | 773 | # Contributing 774 | 775 | Feel free to contribute anytime. 776 | Take a look at the [Laravel Docs](http://laravel.com/docs/master/packages) regarding package development first. 777 | Once you've made some changes, push them to a new branch and start a pull request. 778 | 779 | # License 780 | 781 | This project is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT). 782 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jarischaefer/hal-api", 3 | "description": "Enhances your HATEOAS experience by automating common tasks.", 4 | "type": "library", 5 | "keywords": ["HAL", "HATEOAS", "API", "REST", "Laravel", "CRUD", "PHP7"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Jari Schäfer", 10 | "email": "jari.schaefer@gmail.com", 11 | "role": "Owner" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=7.1.0" 16 | }, 17 | "require-dev": { 18 | "laravel/framework": "5.4.*", 19 | "fzaninotto/faker": "~1.4", 20 | "mockery/mockery": "dev-master", 21 | "phpunit/phpunit": "~5.7" 22 | }, 23 | "autoload": { 24 | "classmap": [ 25 | "src/migrations" 26 | ], 27 | "psr-0": { 28 | "Jarischaefer\\HalApi\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "classmap": [ 33 | "tests" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarischaefer/hal-api/fb56997ea47aaf5316db06983c883e398edf65cb/public/.gitkeep -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Caching/CacheFactory.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 23 | } 24 | 25 | /** 26 | * @inheritdoc 27 | */ 28 | public function create(string $cacheKey, int $cacheMinutes): HalApiCache 29 | { 30 | return new HalApiCacheImpl($this->repository, $cacheKey, $cacheMinutes); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Caching/HalApiCache.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 34 | $this->cacheKey = $cacheKey; 35 | $this->cacheMinutes = $cacheMinutes; 36 | } 37 | 38 | /** 39 | * @return Repository 40 | */ 41 | public function getRepository(): Repository 42 | { 43 | return $this->repository; 44 | } 45 | 46 | /** 47 | * @return string 48 | */ 49 | public function getCacheKey(): string 50 | { 51 | return $this->cacheKey; 52 | } 53 | 54 | /** 55 | * @return int 56 | */ 57 | public function getCacheMinutes(): int 58 | { 59 | return $this->cacheMinutes; 60 | } 61 | 62 | /** 63 | * @inheritdoc 64 | */ 65 | public function all(): array 66 | { 67 | return $this->repository->get($this->cacheKey, []); 68 | } 69 | 70 | /** 71 | * @inheritdoc 72 | */ 73 | public function has(string $key): bool 74 | { 75 | return array_key_exists($key, $this->all()); 76 | } 77 | 78 | /** 79 | * @inheritdoc 80 | */ 81 | public function fetch(string $key) 82 | { 83 | $cache = $this->all(); 84 | 85 | return array_key_exists($key, $cache) ? $cache[$key] : null; 86 | } 87 | 88 | /** 89 | * @inheritdoc 90 | */ 91 | public function put(string $key, $value): HalApiCache 92 | { 93 | $cache = $this->all(); 94 | $cache[$key] = $value; 95 | $this->replace($cache); 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * @inheritdoc 102 | */ 103 | public function evict(string $key): HalApiCache 104 | { 105 | $cache = $this->all(); 106 | 107 | if (array_key_exists($key, $cache)) { 108 | unset($cache[$key]); 109 | $this->replace($cache); 110 | } 111 | 112 | return $this; 113 | } 114 | 115 | /** 116 | * @inheritdoc 117 | */ 118 | public function purge(): HalApiCache 119 | { 120 | return $this->replace([]); 121 | } 122 | 123 | /** 124 | * @inheritdoc 125 | */ 126 | private function replace($value): HalApiCache 127 | { 128 | $this->repository->put($this->cacheKey, $value, $this->cacheMinutes); 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * @inheritdoc 135 | */ 136 | public function persist(string $key, Closure $closure) 137 | { 138 | $cached = $this->fetch($key); 139 | 140 | if ($cached === null) { 141 | $cached = $closure(); 142 | $this->put($key, $cached); 143 | } 144 | 145 | return $cached; 146 | } 147 | 148 | /** 149 | * @inheritdoc 150 | */ 151 | public function key(string ...$fragments): string 152 | { 153 | return join('_', $fragments); 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Controllers/HalApiController.php: -------------------------------------------------------------------------------- 1 | create(self::CACHE_GLOBAL_PREFIX . '_' . static::class, static::CACHE_MINUTES); 42 | } 43 | 44 | /** 45 | * @inheritdoc 46 | */ 47 | public static function getRelatedCaches(CacheFactory $cacheFactory): array 48 | { 49 | return []; 50 | } 51 | 52 | /** 53 | * @inheritdoc 54 | */ 55 | public static function getRelation(string $action = null): string 56 | { 57 | $relation = static::getRelationName(); 58 | 59 | return $action ? $relation . '.' . $action : $relation; 60 | } 61 | 62 | /** 63 | * @inheritdoc 64 | */ 65 | public static function actionName(string $methodName): string 66 | { 67 | return static::class . RouteHelper::ACTION_NAME_DELIMITER . $methodName; 68 | } 69 | 70 | /** 71 | * @inheritdoc 72 | */ 73 | public static function action(HalApiUrlGenerator $urlGenerator, string $methodName, $parameters = []): string 74 | { 75 | $parameters = is_array($parameters) ? $parameters : [$parameters]; 76 | 77 | return $urlGenerator->action(self::actionName($methodName), $parameters); 78 | } 79 | 80 | /** 81 | * @var LinkFactory 82 | */ 83 | protected $linkFactory; 84 | /** 85 | * @var RepresentationFactory 86 | */ 87 | protected $representationFactory; 88 | /** 89 | * @var RouteHelper 90 | */ 91 | protected $routeHelper; 92 | /** 93 | * @var ResponseFactory 94 | */ 95 | protected $responseFactory; 96 | /** 97 | * @var Gate 98 | */ 99 | protected $gate; 100 | /** 101 | * @var Guard 102 | */ 103 | protected $guard; 104 | 105 | /** 106 | * @param HalApiControllerParameters $parameters 107 | */ 108 | public function __construct(HalApiControllerParameters $parameters) 109 | { 110 | $this->linkFactory = $parameters->getLinkFactory(); 111 | $this->representationFactory = $parameters->getRepresentationFactory(); 112 | $this->routeHelper = $parameters->getRouteHelper(); 113 | $this->responseFactory = $parameters->getResponseFactory(); 114 | $this->gate = $parameters->getGate(); 115 | $this->guard = $parameters->getGuard(); 116 | } 117 | 118 | /** 119 | * @param HalApiRequestParameters $parameters 120 | * @return HalApiRepresentation 121 | */ 122 | protected function createResponse(HalApiRequestParameters $parameters): HalApiRepresentation 123 | { 124 | return $this->representationFactory->create($parameters->getSelf(), $parameters->getParent()); 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Controllers/HalApiControllerContract.php: -------------------------------------------------------------------------------- 1 | linkFactory = $linkFactory; 53 | $this->representationFactory = $representationFactory; 54 | $this->responseFactory = $responseFactory; 55 | $this->routeHelper = $routeHelper; 56 | $this->gate = $gate; 57 | $this->guard = $guard; 58 | } 59 | 60 | /** 61 | * @return LinkFactory 62 | */ 63 | public function getLinkFactory(): LinkFactory 64 | { 65 | return $this->linkFactory; 66 | } 67 | 68 | /** 69 | * @return RepresentationFactory 70 | */ 71 | public function getRepresentationFactory(): RepresentationFactory 72 | { 73 | return $this->representationFactory; 74 | } 75 | 76 | /** 77 | * @return ResponseFactory 78 | */ 79 | public function getResponseFactory(): ResponseFactory 80 | { 81 | return $this->responseFactory; 82 | } 83 | 84 | /** 85 | * @return RouteHelper 86 | */ 87 | public function getRouteHelper(): RouteHelper 88 | { 89 | return $this->routeHelper; 90 | } 91 | 92 | /** 93 | * @return Gate 94 | */ 95 | public function getGate(): Gate 96 | { 97 | return $this->gate; 98 | } 99 | 100 | /** 101 | * @return Guard 102 | */ 103 | public function getGuard(): Guard 104 | { 105 | return $this->guard; 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Controllers/HalApiRequestParameters.php: -------------------------------------------------------------------------------- 1 | get(RouteHelper::PARAM_PAGE, 1); 58 | 59 | if (!is_numeric($page) || $page <= 0) { 60 | $page = 1; 61 | } 62 | 63 | return $page; 64 | } 65 | 66 | /** 67 | * @param Request $request 68 | * @return int 69 | */ 70 | private static function getPerPageFromRequest(Request $request): int 71 | { 72 | $perPage = $request->get(RouteHelper::PARAM_PER_PAGE, self::PAGINATION_DEFAULT_ITEMS_PER_PAGE); 73 | 74 | if (!is_numeric($perPage) || $perPage < 1) { 75 | $perPage = self::PAGINATION_DEFAULT_ITEMS_PER_PAGE; 76 | } 77 | 78 | return $perPage; 79 | } 80 | 81 | /** 82 | * @param LinkFactory $linkFactory 83 | * @param RouteHelper $routeHelper 84 | * @param Request $request 85 | */ 86 | public function __construct(LinkFactory $linkFactory, RouteHelper $routeHelper, Request $request) 87 | { 88 | $this->request = $request; 89 | $route = $request->route(); 90 | $routeParameters = array_merge($route->parameters(), $request->input()); 91 | 92 | $this->self = $linkFactory->create($route, $routeParameters); 93 | $this->parent = $linkFactory->create($routeHelper->parent($route), $routeParameters); 94 | $this->parameters = new SafeIndexArray($request->input()); 95 | $this->body = new SafeIndexArray($request->json()->all()); 96 | $this->page = self::getPageFromRequest($request); 97 | $this->perPage = self::getPerPageFromRequest($request); 98 | } 99 | 100 | /** 101 | * @return Request 102 | */ 103 | public function getRequest(): Request 104 | { 105 | return $this->request; 106 | } 107 | 108 | /** 109 | * @return HalApiLink 110 | */ 111 | public function getSelf(): HalApiLink 112 | { 113 | return $this->self; 114 | } 115 | 116 | /** 117 | * @return HalApiLink 118 | */ 119 | public function getParent(): HalApiLink 120 | { 121 | return $this->parent; 122 | } 123 | 124 | /** 125 | * @return SafeIndexArray 126 | */ 127 | public function getParameters(): SafeIndexArray 128 | { 129 | return $this->parameters; 130 | } 131 | 132 | /** 133 | * @return SafeIndexArray 134 | */ 135 | public function getBody(): SafeIndexArray 136 | { 137 | return $this->body; 138 | } 139 | 140 | /** 141 | * @return int 142 | */ 143 | public function getPage(): int 144 | { 145 | return $this->page; 146 | } 147 | 148 | /** 149 | * @return int 150 | */ 151 | public function getPerPage(): int 152 | { 153 | return $this->perPage; 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Controllers/HalApiResourceController.php: -------------------------------------------------------------------------------- 1 | transformer = $transformer; 46 | $this->repository = $repository; 47 | 48 | $this->boot(); 49 | } 50 | 51 | /** 52 | * @inheritdoc 53 | */ 54 | public function getTransformer(): HalApiTransformerContract 55 | { 56 | return $this->transformer; 57 | } 58 | 59 | /** 60 | * @inheritdoc 61 | */ 62 | public function getRepository(): HalApiRepository 63 | { 64 | return $this->repository; 65 | } 66 | 67 | /** 68 | * Helps if one does not wish to override the constructor and consequently inherit all its default parameters. 69 | */ 70 | protected function boot() 71 | { 72 | // do not put anything here, children should not have to call this method 73 | } 74 | 75 | /** 76 | * Embeds data from a paginator instance inside the API response. Pagination metadata indicating number of pages, 77 | * totals, ... will be automatically added as well. Furthermore, links to the first, next, previous and last 78 | * pages will be added if applicable. 79 | * 80 | * @param HalApiRequestParameters $parameters 81 | * @param Paginator $paginator 82 | * @return HalApiPaginatedRepresentation 83 | */ 84 | protected function paginate(HalApiRequestParameters $parameters, Paginator $paginator): HalApiPaginatedRepresentation 85 | { 86 | $self = $parameters->getSelf(); 87 | $parent = $parameters->getParent(); 88 | $relation = static::getRelation(RouteHelper::SHOW); 89 | 90 | return $this->representationFactory->paginated($self, $parent, $paginator, $this->transformer, $relation); 91 | } 92 | 93 | /** 94 | * @inheritdoc 95 | */ 96 | public function index(HalApiRequestParameters $parameters): Response 97 | { 98 | $paginator = $this->repository->paginate($parameters->getPage(), $parameters->getPerPage()); 99 | 100 | return $this->responseFactory->json($this->paginate($parameters, $paginator)->build($this->guard->user())); 101 | } 102 | 103 | /** 104 | * @inheritdoc 105 | */ 106 | public function show(HalApiRequestParameters $parameters, Model $model): Response 107 | { 108 | return $this->responseFactory->json($this->transformer->item($model)->build($this->guard->user())); 109 | } 110 | 111 | /** 112 | * @inheritdoc 113 | */ 114 | public function store(HalApiRequestParameters $parameters): Response 115 | { 116 | $missingAttributes = $this->repository->getMissingFillableAttributes($parameters->getBody()->keys()); 117 | 118 | if (!empty($missingAttributes)) { 119 | throw new BadPostRequestException('POST requests must contain all attributes. Failed for: ' . join(',', $missingAttributes)); 120 | } 121 | 122 | $model = $this->repository->create($parameters->getBody()->getArray()); 123 | $this->repository->save($model); 124 | 125 | return $this->show($parameters, $model)->setStatusCode(Response::HTTP_CREATED); 126 | } 127 | 128 | /** 129 | * @inheritdoc 130 | */ 131 | public function update(HalApiRequestParameters $parameters, $model): Response 132 | { 133 | /** @var Model $model */ 134 | if (!($model instanceof Model)) { 135 | $id = $model; 136 | $model = $this->repository->create(); 137 | $model->{$model->getKeyName()} = $id; 138 | } 139 | 140 | switch ($parameters->getRequest()->getMethod()) { 141 | case Request::METHOD_PUT: 142 | $missingAttributes = $this->repository->getMissingFillableAttributes($parameters->getBody()->keys()); 143 | 144 | if (!empty($missingAttributes)) { 145 | throw new BadPutRequestException('PUT requests must contain all attributes. Failed for: ' . join(',', $missingAttributes)); 146 | } 147 | 148 | $existed = $model->exists; 149 | $model = $this->repository->save($model->fill($parameters->getBody()->getArray())); 150 | 151 | return $existed ? $this->show($parameters, $model) : $this->show($parameters, $model)->setStatusCode(Response::HTTP_CREATED); 152 | case Request::METHOD_PATCH: 153 | $this->repository->save($model->fill($parameters->getBody()->getArray())); 154 | 155 | return $this->show($parameters, $model); 156 | default: 157 | return $this->responseFactory->make('', Response::HTTP_METHOD_NOT_ALLOWED); 158 | } 159 | } 160 | 161 | /** 162 | * @inheritdoc 163 | */ 164 | public function destroy(HalApiRequestParameters $parameters, Model $model): Response 165 | { 166 | $this->repository->remove($model); 167 | 168 | return $this->responseFactory->make('', Response::HTTP_NO_CONTENT); 169 | } 170 | 171 | /** 172 | * @inheritdoc 173 | */ 174 | public function search(HalApiRequestParameters $parameters): Response 175 | { 176 | if (!is_subclass_of($this->repository, HalApiSearchRepository::class)) { 177 | throw new NotImplementedException('Cannot search unless ' 178 | . get_class($this->repository) . ' implements ' . HalApiSearchRepository::class); 179 | } 180 | 181 | /** @var HalApiSearchRepository $repository */ 182 | $repository = $this->repository; 183 | $searchAttributes = []; 184 | 185 | foreach ($parameters->getParameters()->getArray() as $key => $value) { 186 | if ($repository::isFieldSearchable($key)) { 187 | $searchAttributes[$key] = $value; 188 | } 189 | } 190 | 191 | $searchResult = $repository->searchMulti($searchAttributes, $parameters->getPage(), $parameters->getPerPage()); 192 | $response = $this->paginate($parameters, $searchResult)->build($this->guard->user()); 193 | 194 | return $this->responseFactory->json($response); 195 | } 196 | 197 | } 198 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Controllers/HalApiResourceControllerContract.php: -------------------------------------------------------------------------------- 1 | purge(); 35 | 36 | foreach ($controller::getRelatedCaches($cacheFactory) as $cache) { 37 | $cache->purge(); 38 | } 39 | }; 40 | 41 | $model::created($callback); 42 | $model::updated($callback); 43 | $model::deleted($callback); 44 | } 45 | 46 | /** 47 | * No instances 48 | */ 49 | private function __construct() 50 | { 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Helpers/Checks.php: -------------------------------------------------------------------------------- 1 | users) or the first part of its controller's name (UsersController -> users). 15 | * The URI always starts with this name (e.g. /users). 16 | * 17 | * @var string 18 | */ 19 | private $name; 20 | /** 21 | * The full path (e.g. App\Http\Controllers\MyController) to the controller which handles 22 | * all requests for this resource. 23 | * 24 | * @var string 25 | */ 26 | private $controller; 27 | /** 28 | * The "parent" RouteHelper where routes are actually registered. 29 | * 30 | * @var RouteHelper 31 | */ 32 | private $routeHelper; 33 | /** 34 | * @var bool 35 | */ 36 | private $pagination; 37 | /** 38 | * An array of methods (see INDEX, SHOW, ... consts in this class) for which routes shall be 39 | * generated automatically. By default, all CRUD methods are available. 40 | * 41 | * @var array 42 | */ 43 | private $methods; 44 | 45 | /** 46 | * @param string $name 47 | * @param string $controller 48 | * @param RouteHelper $routeHelper 49 | * @param array $methods 50 | * @param bool $pagination 51 | */ 52 | public function __construct(string $name, string $controller, RouteHelper $routeHelper, array $methods = [self::INDEX, self::SHOW, self::STORE, self::UPDATE, self::DESTROY], bool $pagination = true) 53 | { 54 | $this->name = $name; 55 | $this->controller = $controller; 56 | $this->routeHelper = $routeHelper; 57 | $this->pagination = $pagination; 58 | $this->methods = is_array($methods) ? $methods : [$methods]; 59 | } 60 | 61 | /** 62 | * Registers a GET route relative to the current resource. 63 | * 64 | * Example: 65 | * Resource name: users 66 | * Base URI: /users/{users} where {users} is the identifier (e.g. user ID) for a specific user 67 | * GET call to get a user's posts: /users/{users}/posts 68 | * 69 | * @param string $uri 70 | * @param string $method 71 | * @return ResourceRoute 72 | */ 73 | public function get(string $uri, string $method): ResourceRoute 74 | { 75 | $this->routeHelper->get($this->name . '/{' . $this->name . '}/' . $uri, $this->controller, $method); 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Registers a POST route relative to the current resource. Take a look at the docs for the get method for further information. 82 | * 83 | * @param $uri 84 | * @param $method 85 | * @return ResourceRoute 86 | */ 87 | public function post(string $uri, string $method): ResourceRoute 88 | { 89 | $this->routeHelper->post($this->name . '/{' . $this->name . '}/' . $uri, $this->controller, $method); 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * Registers a PUT route relative to the current resource. Take a look at the docs for the get method for further information. 96 | * 97 | * @param $uri 98 | * @param $method 99 | * @return ResourceRoute 100 | */ 101 | public function put(string $uri, string $method): ResourceRoute 102 | { 103 | $this->routeHelper->put($this->name . '/{' . $this->name . '}/' . $uri, $this->controller, $method); 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * Registers a PATCH route relative to the current resource. Take a look at the docs for the get method for further information. 110 | * 111 | * @param $uri 112 | * @param $method 113 | * @return ResourceRoute 114 | */ 115 | public function patch(string $uri, string $method): ResourceRoute 116 | { 117 | $this->routeHelper->patch($this->name . '/{' . $this->name . '}/' . $uri, $this->controller, $method); 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * Registers a DELETE route relative to the current resource. Take a look at the docs for the get method for further information. 124 | * 125 | * @param $uri 126 | * @param $method 127 | * @return ResourceRoute 128 | */ 129 | public function delete(string $uri, string $method): ResourceRoute 130 | { 131 | $this->routeHelper->delete($this->name . '/{' . $this->name . '}/' . $uri, $this->controller, $method); 132 | 133 | return $this; 134 | } 135 | 136 | /** 137 | * Registers a GET route that is not relative to the current resource. 138 | * 139 | * Example: 140 | * Resource name: users 141 | * Base URI: /users/{users} 142 | * Raw GET URI for all inactive users: /users/inactive 143 | * 144 | * @param $uri 145 | * @param $method 146 | * @return ResourceRoute 147 | */ 148 | public function rawGet(string $uri, string $method): ResourceRoute 149 | { 150 | $this->routeHelper->get($this->name . '/' . $uri, $this->controller, $method); 151 | 152 | return $this; 153 | } 154 | 155 | /** 156 | * Registers a POST route that is not relative to the current resource. Take a loot at the rawGet method for further information. 157 | * 158 | * @param $uri 159 | * @param $method 160 | * @return ResourceRoute 161 | */ 162 | public function rawPost(string $uri, string $method): ResourceRoute 163 | { 164 | $this->routeHelper->post($this->name . '/' . $uri, $this->controller, $method); 165 | 166 | return $this; 167 | } 168 | 169 | /** 170 | * Registers a PUT route that is not relative to the current resource. Take a loot at the rawGet method for further information. 171 | * 172 | * @param $uri 173 | * @param $method 174 | * @return ResourceRoute 175 | */ 176 | public function rawPut(string $uri, string $method): ResourceRoute 177 | { 178 | $this->routeHelper->put($this->name . '/' . $uri, $this->controller, $method); 179 | 180 | return $this; 181 | } 182 | 183 | /** 184 | * Registers a PATCH route that is not relative to the current resource. Take a loot at the rawGet method for further information. 185 | * 186 | * @param $uri 187 | * @param $method 188 | * @return ResourceRoute 189 | */ 190 | public function rawPatch(string $uri, string $method): ResourceRoute 191 | { 192 | $this->routeHelper->patch($this->name . '/' . $uri, $this->controller, $method); 193 | 194 | return $this; 195 | } 196 | 197 | /** 198 | * Registers a DELETE route that is not relative to the current resource. Take a loot at the rawGet method for further information. 199 | * 200 | * @param $uri 201 | * @param $method 202 | * @return ResourceRoute 203 | */ 204 | public function rawDelete(string $uri, string $method): ResourceRoute 205 | { 206 | $this->routeHelper->delete($this->name . '/' . $uri, $this->controller, $method); 207 | 208 | return $this; 209 | } 210 | 211 | /** 212 | * @param iterable $searchableFields 213 | * @return ResourceRoute 214 | */ 215 | public function searchable(iterable $searchableFields = null): ResourceRoute 216 | { 217 | $this->routeHelper 218 | ->get($this->name . '/search', $this->controller, 'search') 219 | ->get($this->name . '/search?' . self::PAGINATION_QUERY_STRING, $this->controller, 'search'); 220 | 221 | $searchableQueryString = $this->createSearchableQueryString($searchableFields); 222 | 223 | if (!empty($searchableQueryString)) { 224 | $uri = $this->name . '/search?' . self::PAGINATION_QUERY_STRING . '&' . $searchableQueryString; 225 | $this->routeHelper->get($uri, $this->controller, 'search'); 226 | } 227 | 228 | return $this; 229 | } 230 | 231 | /** 232 | * @param iterable $searchableFields 233 | * @return string 234 | */ 235 | private function createSearchableQueryString(iterable $searchableFields = null): string 236 | { 237 | if ($searchableFields === null) { 238 | return ''; 239 | } 240 | 241 | $string = ''; 242 | $delimiter = ''; 243 | 244 | foreach ($searchableFields as $field) { 245 | $string .= $delimiter . $field . '={' . $field . '}'; 246 | $delimiter = '&'; 247 | } 248 | 249 | return $string; 250 | } 251 | 252 | /** 253 | * Call this method once you're done registering additional routes to the current resource. 254 | * The original RouteHelper instance is returned, allowing infinite chaining of ordinary routes and resources. 255 | * 256 | * @return RouteHelper 257 | */ 258 | public function done(): RouteHelper 259 | { 260 | if (in_array(self::INDEX, $this->methods)) { 261 | $this->routeHelper->get($this->name, $this->controller, self::INDEX); 262 | 263 | if ($this->pagination) { 264 | $this->routeHelper->pagination($this->name, $this->controller, self::INDEX); 265 | } 266 | } 267 | if (in_array(self::SHOW, $this->methods)) { 268 | $this->routeHelper->get($this->name . '/{' . $this->name . '}', $this->controller, self::SHOW); 269 | } 270 | if (in_array(self::STORE, $this->methods)) { 271 | $this->routeHelper->post($this->name, $this->controller, self::STORE); 272 | } 273 | if (in_array(self::UPDATE, $this->methods)) { 274 | $this->routeHelper->put($this->name . '/{' . $this->name . '}', $this->controller, self::UPDATE); 275 | $this->routeHelper->patch($this->name . '/{' . $this->name . '}', $this->controller, self::UPDATE); 276 | } 277 | if (in_array(self::DESTROY, $this->methods)) { 278 | $this->routeHelper->delete($this->name . '/{' . $this->name . '}', $this->controller, self::DESTROY); 279 | } 280 | 281 | return $this->routeHelper; 282 | } 283 | 284 | } 285 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Helpers/RouteHelper.php: -------------------------------------------------------------------------------- 1 | getActionName()); 37 | } 38 | 39 | /** 40 | * @param string $actionName 41 | * @return bool 42 | */ 43 | public static function isValidActionName(string $actionName): bool 44 | { 45 | if (isset(self::$isValidActionNameCache[$actionName])) { 46 | return self::$isValidActionNameCache[$actionName]; 47 | } 48 | 49 | $split = explode(self::ACTION_NAME_DELIMITER, $actionName); 50 | $isValid = empty($split) ? false : is_subclass_of($split[0], HalApiControllerContract::class); 51 | 52 | return self::$isValidActionNameCache[$actionName] = $isValid; 53 | } 54 | 55 | /** 56 | * @param Route $route 57 | * @return string 58 | */ 59 | public static function relation(Route $route): string 60 | { 61 | $actionName = $route->getActionName(); 62 | 63 | if (isset(self::$relationCache[$actionName])) { 64 | return self::$relationCache[$actionName]; 65 | } 66 | 67 | /** @var HalApiControllerContract $class */ 68 | list($class, $method) = explode(self::ACTION_NAME_DELIMITER, $actionName); 69 | 70 | return self::$relationCache[$actionName] = $class::getRelation($method); 71 | } 72 | 73 | /** 74 | * @return callable 75 | */ 76 | public static function getModelBindingCallback(): callable 77 | { 78 | return function ($value) { 79 | switch (\Request::getMethod()) { 80 | case Request::METHOD_GET: 81 | throw new NotFoundHttpException; 82 | case Request::METHOD_POST: 83 | throw new NotFoundHttpException; 84 | case Request::METHOD_PUT: 85 | return $value; 86 | case Request::METHOD_PATCH: 87 | throw new NotFoundHttpException; 88 | case Request::METHOD_DELETE: 89 | throw new NotFoundHttpException; 90 | default: 91 | return null; 92 | } 93 | }; 94 | } 95 | 96 | /** 97 | * Holds routes belonging to an action name. 98 | * 99 | * @var array 100 | */ 101 | private $byActionRouteCache = []; 102 | /** 103 | * Holds parent routes. 104 | * 105 | * @var array 106 | */ 107 | private $parentRouteCache = []; 108 | /** 109 | * Holds child routes. 110 | * 111 | * @var array 112 | */ 113 | private $subordinateRouteCache = []; 114 | /** 115 | * The router where routes shall be registered. 116 | * 117 | * @var Router 118 | */ 119 | private $router; 120 | 121 | /** 122 | * @param Router $router 123 | * @return RouteHelper 124 | */ 125 | public static function make(Router $router): RouteHelper 126 | { 127 | return new static($router); 128 | } 129 | 130 | /** 131 | * @param Router $router 132 | */ 133 | public function __construct(Router $router) 134 | { 135 | $this->router = $router; 136 | } 137 | 138 | /** 139 | * @return Router 140 | */ 141 | public function getRouter(): Router 142 | { 143 | return $this->router; 144 | } 145 | 146 | /** 147 | * Creates a new REST resource. 148 | * 149 | * @param string $name The resource's name (e.g. users). 150 | * @param string $controller The path to the controller handling the resource (e.g. UsersController::class or App\Http\Controllers\UsersController). 151 | * @param array $methods Array of CRUD methods which should be registered automatically. 152 | * @param bool $pagination 153 | * @return ResourceRoute 154 | */ 155 | public function resource(string $name, string $controller, array $methods = self::ALL, bool $pagination = true): ResourceRoute 156 | { 157 | return new ResourceRoute($name, $controller, $this, $methods, $pagination); 158 | } 159 | 160 | /** 161 | * Registers a new GET route. 162 | * 163 | * @param string $uri The URI (e.g. / or /users or /users/{param}/friends). 164 | * @param string $controller The path to the controller handling the resource (e.g. UsersController::class or App\Http\Controllers\UsersController). 165 | * @param string $method Name of the method inside the controller that will handle the request. 166 | * @return RouteHelper 167 | */ 168 | public function get(string $uri, string $controller, string $method): RouteHelper 169 | { 170 | $this->router->get($uri, $controller . static::ACTION_NAME_DELIMITER . $method); 171 | 172 | return $this; 173 | } 174 | 175 | /** 176 | * Registers a new POST route. 177 | * 178 | * @param string $uri The URI (e.g. / or /users or /users/{param}/friends). 179 | * @param string $controller The path to the controller handling the resource (e.g. UsersController::class or App\Http\Controllers\UsersController). 180 | * @param string $method Name of the method inside the controller that will handle the request. 181 | * @return RouteHelper 182 | */ 183 | public function post(string $uri, string $controller, string $method): RouteHelper 184 | { 185 | $this->router->post($uri, $controller . static::ACTION_NAME_DELIMITER . $method); 186 | 187 | return $this; 188 | } 189 | 190 | /** 191 | * Registers a new PUT route. 192 | * 193 | * @param string $uri The URI (e.g. / or /users or /users/{param}/friends). 194 | * @param string $controller The path to the controller handling the resource (e.g. UsersController::class or App\Http\Controllers\UsersController). 195 | * @param string $method Name of the method inside the controller that will handle the request. 196 | * @return RouteHelper 197 | */ 198 | public function put(string $uri, string $controller, string $method): RouteHelper 199 | { 200 | $this->router->put($uri, $controller . static::ACTION_NAME_DELIMITER . $method); 201 | 202 | return $this; 203 | } 204 | 205 | /** 206 | * Registers a new PATCH route. 207 | * 208 | * @param string $uri The URI (e.g. / or /users or /users/{param}/friends). 209 | * @param string $controller The path to the controller handling the resource (e.g. UsersController::class or App\Http\Controllers\UsersController). 210 | * @param string $method Name of the method inside the controller that will handle the request. 211 | * @return RouteHelper 212 | */ 213 | public function patch(string $uri, string $controller, string $method): RouteHelper 214 | { 215 | $this->router->patch($uri, $controller . static::ACTION_NAME_DELIMITER . $method); 216 | 217 | return $this; 218 | } 219 | 220 | /** 221 | * Registers a new DELETE route. 222 | * 223 | * @param string $uri The URI (e.g. / or /users or /users/{param}/friends). 224 | * @param string $controller The path to the controller handling the resource (e.g. UsersController::class or App\Http\Controllers\UsersController). 225 | * @param string $method Name of the method inside the controller that will handle the request. 226 | * @return RouteHelper 227 | */ 228 | public function delete(string $uri, string $controller, string $method): RouteHelper 229 | { 230 | $this->router->delete($uri, $controller . static::ACTION_NAME_DELIMITER . $method); 231 | 232 | return $this; 233 | } 234 | 235 | /** 236 | * Registers a new GET route with pagination parameters. Pagination parameters are appended to the current 237 | * query string. An URI like /users/{userid}/friends?age=5 would result in/users/{userid}/friends?age=5&page={page}&per_page={per_page}. 238 | * 239 | * @param string $uri The URI (e.g. / or /users or /users/{param}/friends). 240 | * @param string $controller The path to the controller handling the resource (e.g. UsersController::class or App\Http\Controllers\UsersController). 241 | * @param string $method Name of the method inside the controller that will handle the request. 242 | * @return RouteHelper 243 | */ 244 | public function pagination(string $uri, string $controller, string $method): RouteHelper 245 | { 246 | $paginatedUri = $uri . (stripos($uri, '?') ? '&' : '?') . self::PAGINATION_QUERY_STRING; 247 | $this->router->get($paginatedUri, $controller . static::ACTION_NAME_DELIMITER . $method); 248 | 249 | return $this; 250 | } 251 | 252 | /** 253 | * Returns the route matching an action name. 254 | * 255 | * @param string $actionName The action name (e.g. App\Http\Controllers\UsersController@delete). 256 | * @return Route 257 | */ 258 | public function byAction(string $actionName): Route 259 | { 260 | if (isset($this->byActionRouteCache[$actionName])) { 261 | return $this->byActionRouteCache[$actionName]; 262 | } 263 | 264 | $route = $this->router->getRoutes()->getByAction($actionName); 265 | 266 | if ($route instanceof Route) { 267 | return $this->byActionRouteCache[$actionName] = $route; 268 | } 269 | 270 | throw new RuntimeException('Could not find route for action: ' . $actionName); 271 | } 272 | 273 | /** 274 | * Returns a given route's parent route. Given the routes /users/{userid} and /users, this method would 275 | * return the latter route if called with the former. If no parent is present (e.g. the / route), the uppermost 276 | * route will be returned (always /). 277 | * 278 | * @param Route $child 279 | * @return Route 280 | */ 281 | public function parent(Route $child): Route 282 | { 283 | if (isset($this->parentRouteCache[$child->uri])) { 284 | return $this->parentRouteCache[$child->uri]; 285 | } 286 | 287 | $lastSlash = strripos($child->uri, '/'); 288 | $guessedParentUri = $lastSlash === FALSE ? '/' : substr($child->uri, 0, $lastSlash); 289 | 290 | while (true) { 291 | /** @var Route $route */ 292 | foreach ($this->router->getRoutes() as $route) { 293 | if (strcmp($route->uri, $guessedParentUri) === 0) { 294 | return $this->parentRouteCache[$child->uri] = $route; 295 | } 296 | } 297 | 298 | if ($guessedParentUri === '/') { 299 | break; 300 | } 301 | 302 | // cut off another slash part (e.g. /users/{userid}/friends -> /users/{userid}) 303 | $guessedParentUri = substr($guessedParentUri, 0, strripos($guessedParentUri, '/')); 304 | 305 | if ($guessedParentUri === '') { 306 | $guessedParentUri = '/'; 307 | } 308 | } 309 | 310 | return $this->parentRouteCache[$child->uri] = $child; // return the same route if no parent exists 311 | } 312 | 313 | /** 314 | * Returns a given route's children as an array. A parent route like /users would return (if they existed) all routes 315 | * like /users/{userid}, /users/new. 316 | * 317 | * @param Route $parentRoute 318 | * @return Route[] 319 | */ 320 | public function subordinates(Route $parentRoute): array 321 | { 322 | if (isset($this->subordinateRouteCache[$parentRoute->uri])) { 323 | return $this->subordinateRouteCache[$parentRoute->uri]; 324 | } 325 | 326 | $parentActionName = $parentRoute->getActionName(); 327 | $children = []; 328 | 329 | /** @var Route $route */ 330 | foreach ($this->router->getRoutes() as $route) { 331 | // if the route does not start with the same uri as the current route -> skip 332 | if ($parentRoute->uri !== '/' && strpos($route->uri, $parentRoute->uri) !== 0) { 333 | continue; 334 | } 335 | 336 | $actionName = $route->getActionName(); 337 | 338 | if (!self::isValidActionName($actionName)) { 339 | continue; 340 | } 341 | 342 | if (strcmp($parentActionName, $actionName) !== 0) { 343 | $children[] = $route; 344 | } 345 | } 346 | 347 | return $this->subordinateRouteCache[$parentRoute->uri] = $children; 348 | } 349 | 350 | } 351 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Helpers/RouteHelperConstants.php: -------------------------------------------------------------------------------- 1 | array = $array; 28 | $this->default = $default; 29 | } 30 | 31 | /** 32 | * @return array 33 | */ 34 | public function getArray(): array 35 | { 36 | return $this->array; 37 | } 38 | 39 | /** 40 | * @return mixed|null 41 | */ 42 | public function getDefault() 43 | { 44 | return $this->default; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function keys(): array 51 | { 52 | return array_keys($this->array); 53 | } 54 | 55 | /** 56 | * @inheritdoc 57 | */ 58 | public function offsetExists($offset) 59 | { 60 | return array_key_exists($offset, $this->array); 61 | } 62 | 63 | /** 64 | * @inheritdoc 65 | */ 66 | public function offsetGet($offset) 67 | { 68 | return array_key_exists($offset, $this->array) ? $this->array[$offset] : $this->default; 69 | } 70 | 71 | /** 72 | * @inheritdoc 73 | */ 74 | public function offsetSet($offset, $value) 75 | { 76 | $this->array[$offset] = $value; 77 | } 78 | 79 | /** 80 | * @inheritdoc 81 | */ 82 | public function offsetUnset($offset) 83 | { 84 | unset($this->array[$offset]); 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Middleware/HalApiCacheMiddleware.php: -------------------------------------------------------------------------------- 1 | cacheFactory = $cacheFactory; 40 | $this->config = $config; 41 | } 42 | 43 | /** 44 | * Handle an incoming request. 45 | * 46 | * @param Request $request 47 | * @param Closure $next 48 | * @return mixed 49 | */ 50 | public function handle(Request $request, Closure $next) 51 | { 52 | if (!self::isCacheable($request)) { 53 | return $next($request); 54 | } 55 | 56 | $actionName = $request->route()->getActionName(); 57 | 58 | $class = explode(RouteHelper::ACTION_NAME_DELIMITER, $actionName)[0]; 59 | /** @var HalApiControllerContract $class */ 60 | $cache = $class::getCache($this->cacheFactory); 61 | $key = self::generateKey($cache, $request); 62 | 63 | return $cache->persist($key, function () use ($next, $request) { 64 | return $next($request); 65 | }); 66 | } 67 | 68 | /** 69 | * @param Request $request 70 | * @return bool 71 | */ 72 | private function isCacheable(Request $request): bool 73 | { 74 | if (!$request->isMethodSafe() || $this->config->get('app.debug', false)) { 75 | return false; 76 | } 77 | 78 | $route = $request->route(); 79 | 80 | return $route instanceof Route && RouteHelper::isValidActionName($route->getActionName()); 81 | } 82 | 83 | /** 84 | * @param HalApiCache $cache 85 | * @param Request $request 86 | * @return string 87 | */ 88 | private static function generateKey(HalApiCache $cache, Request $request): string 89 | { 90 | $method = $request->getMethod(); 91 | $uri = $request->getUri(); 92 | 93 | return $cache->key($method, $uri); 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Middleware/HalApiETagMiddleware.php: -------------------------------------------------------------------------------- 1 | isMethodSafe()) { 50 | $responseTag = md5($response->getContent()); 51 | $response->setMaxAge(0); 52 | $response->setEtag($responseTag); 53 | 54 | if (self::eTagsMatch($request, $responseTag)) { 55 | $response->setNotModified(); 56 | } 57 | } 58 | 59 | return $response; 60 | } 61 | 62 | /** 63 | * @param Request $request 64 | * @param string $responseTag 65 | * @return bool 66 | */ 67 | private static function eTagsMatch(Request $request, string $responseTag): bool 68 | { 69 | $requestTags = str_replace('"', '', $request->getETags()); 70 | 71 | if (in_array($responseTag, $requestTags)) { 72 | return true; 73 | } 74 | 75 | if (!self::$weakETagsAllowed) { 76 | return false; 77 | } 78 | 79 | $requestTags = str_replace('W/', '', $requestTags); 80 | 81 | return in_array($responseTag, $requestTags); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Providers/HalApiServiceProvider.php: -------------------------------------------------------------------------------- 1 | aliasMiddleware(HalApiETagMiddleware::NAME, HalApiETagMiddleware::class); 89 | $router->aliasMiddleware(HalApiCacheMiddleware::NAME, HalApiCacheMiddleware::class); 90 | 91 | $this->app->singleton(RouteHelper::class); 92 | $this->app->singleton(HalApiUrlGenerator::class); 93 | $this->app->singleton(LinkFactory::class, LinkFactoryImpl::class); 94 | } 95 | 96 | /** 97 | * Register the service provider. 98 | * 99 | * @return void 100 | */ 101 | public function register() 102 | { 103 | $this->app->singleton(CacheFactory::class, CacheFactoryImpl::class); 104 | $this->app->singleton(RepresentationFactory::class, RepresentationFactoryImpl::class); 105 | } 106 | 107 | /** 108 | * Get the services provided by the provider. 109 | * 110 | * @return array 111 | */ 112 | public function provides() 113 | { 114 | return []; 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Repositories/HalApiEloquentRepository.php: -------------------------------------------------------------------------------- 1 | databaseManager = $databaseManager; 35 | $this->class = static::getModelClass(); 36 | 37 | if (!is_subclass_of($this->class, Model::class)) { 38 | throw new RuntimeException('Model class must be subclass of ' . Model::class . ', but was ' . $this->class); 39 | } 40 | } 41 | 42 | /** 43 | * @param array $attributes 44 | * @return Model 45 | */ 46 | public function create(array $attributes = []): Model 47 | { 48 | return $this->newInstance()->fill($attributes); 49 | } 50 | 51 | /** 52 | * @inheritdoc 53 | */ 54 | public function all(): Collection 55 | { 56 | return $this->newInstance()->newQuery()->get(); 57 | } 58 | 59 | /** 60 | * @inheritdoc 61 | */ 62 | public function save(Model $model): Model 63 | { 64 | try { 65 | $model->save(); 66 | return $model; 67 | } catch (Exception $e) { 68 | throw new DatabaseSaveException('Model could not be saved.', 0, $e); 69 | } 70 | } 71 | 72 | /** 73 | * @inheritdoc 74 | */ 75 | public function saveMany(iterable $models): void 76 | { 77 | try { 78 | $this->databaseManager->connection()->transaction(function () use ($models) { 79 | /** @var Model $model */ 80 | foreach ($models as $model) { 81 | $model->save(); 82 | } 83 | }); 84 | } catch (Exception $e) { 85 | throw new DatabaseSaveException('Model could not be saved.', 0, $e); 86 | } 87 | } 88 | 89 | /** 90 | * @inheritdoc 91 | */ 92 | public function remove(Model $model) 93 | { 94 | try { 95 | $deleted = $model->delete(); 96 | } catch (Exception $e) { 97 | throw new DatabaseConflictException('Model could not be deleted: ' . $model->getKey(), $e); 98 | } 99 | 100 | if (!$deleted) { 101 | throw new DatabaseConflictException('Model could not be deleted: ' . $model->getKey()); 102 | } 103 | } 104 | 105 | /** 106 | * @inheritdoc 107 | */ 108 | public function paginate(int $page, int $perPage): LengthAwarePaginator 109 | { 110 | return $this->newInstance()->newQuery()->paginate($perPage, ['*'], 'page', $page); 111 | } 112 | 113 | /** 114 | * @inheritdoc 115 | */ 116 | public function simplePaginate(int $page, int $perPage): Paginator 117 | { 118 | return $this->newInstance()->newQuery()->simplePaginate($perPage, ['*'], 'page', $page); 119 | } 120 | 121 | /** 122 | * @inheritdoc 123 | */ 124 | public function getMissingFillableAttributes(array $attributes): array 125 | { 126 | $schemaBuilder = $this->databaseManager->connection()->getSchemaBuilder(); 127 | $model = $this->newInstance(); 128 | $columnNames = $schemaBuilder->getColumnListing($model->getTable()); 129 | $missing = []; 130 | 131 | foreach ($columnNames as $column) { 132 | if ($model->isFillable($column) && !in_array($column, $attributes)) { 133 | $missing[] = $column; 134 | } 135 | } 136 | 137 | return $missing; 138 | } 139 | 140 | /** 141 | * @return Model 142 | */ 143 | protected function newInstance(): Model 144 | { 145 | return new $this->class; 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Repositories/HalApiEloquentSearchRepository.php: -------------------------------------------------------------------------------- 1 | fieldExists($field)) { 30 | throw new FieldNotSearchableException($field); 31 | } 32 | 33 | $query = $this->newInstance()->newQuery(); 34 | 35 | static::appendSearchTerm($query, $field, $term); 36 | 37 | return static::execute($query, $page, $perPage); 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | public function searchMulti(array $searchAttributes, int $page, int $perPage): Paginator 44 | { 45 | $query = $this->newInstance()->newQuery(); 46 | 47 | foreach ($searchAttributes as $field => $term) { 48 | if ($term === null) { 49 | continue; 50 | } 51 | 52 | if (!self::isFieldSearchable($field)) { 53 | throw new FieldNotSearchableException($field); 54 | } 55 | 56 | if ($this->fieldExists($field)) { 57 | static::appendSearchTerm($query, $field, $term); 58 | } 59 | } 60 | 61 | return static::execute($query, $page, $perPage); 62 | } 63 | 64 | /** 65 | * @param Builder $builder 66 | * @param int $page 67 | * @param int $perPage 68 | * @return Paginator 69 | */ 70 | protected static function execute(Builder $builder, int $page, int $perPage): Paginator 71 | { 72 | return $builder->simplePaginate($perPage, ['*'], 'page', $page); 73 | } 74 | 75 | /** 76 | * This method should be overridden if the default search term is undesirable. 77 | * Very large tables might require native queries (e.g. full-text using MATCH AGAINST) 78 | * or no leading wildcard (e.g. TERM% instead of %TERM%). 79 | * 80 | * @param Builder $builder 81 | * @param string $field 82 | * @param string $term 83 | */ 84 | protected static function appendSearchTerm(Builder $builder, string $field, string $term) 85 | { 86 | $builder->where($field, 'LIKE', '%' . $term . '%'); 87 | } 88 | 89 | /** 90 | * @param string $field 91 | * @return bool 92 | */ 93 | private function fieldExists(string $field): bool 94 | { 95 | $schemaBuilder = $this->databaseManager->connection()->getSchemaBuilder(); 96 | $columnNames = $schemaBuilder->getColumnListing($this->newInstance()->getTable()); 97 | 98 | return in_array($field, $columnNames); 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Repositories/HalApiRepository.php: -------------------------------------------------------------------------------- 1 | getRoute(); 33 | $routeParameters = array_merge($self->getParameters(), [RouteHelper::PARAM_PAGE => $paginator->perPage()]); 34 | 35 | $this->embedFromArray([ 36 | $relation => $transformer->collection($paginator->items()) 37 | ])->meta('pagination', self::createPaginationMeta($paginator)) 38 | ->link('first', $linkFactory->create($route, array_merge($routeParameters, [RouteHelper::PARAM_PAGE => 1]))); 39 | 40 | $currentPage = $paginator->currentPage(); 41 | 42 | if ($currentPage > 1) { 43 | $prev = $linkFactory->create($route, array_merge($routeParameters, [RouteHelper::PARAM_PAGE => $currentPage - 1])); 44 | $this->link('prev', $prev); 45 | } 46 | 47 | if ($paginator->hasMorePages()) { 48 | $next = $linkFactory->create($route, array_merge($routeParameters, [RouteHelper::PARAM_PAGE => $currentPage + 1])); 49 | $this->link('next', $next); 50 | } 51 | 52 | if ($paginator instanceof LengthAwarePaginator) { 53 | $this->link('last', $linkFactory->create($route, array_merge($routeParameters, [RouteHelper::PARAM_PAGE => $paginator->lastPage() ?: 1]))); 54 | } 55 | } 56 | 57 | /** 58 | * @param Paginator $paginator 59 | * @return array 60 | */ 61 | private static function createPaginationMeta(Paginator $paginator): array 62 | { 63 | $meta = [ 64 | 'page' => $paginator->currentPage(), 65 | 'per_page' => $paginator->perPage(), 66 | 'count' => count($paginator->items()), 67 | ]; 68 | 69 | if ($paginator instanceof LengthAwarePaginator) { 70 | $meta['total'] = $paginator->total(); 71 | $meta['pages'] = $paginator->lastPage() ?: 1; 72 | } 73 | 74 | return $meta; 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Representations/HalApiRepresentation.php: -------------------------------------------------------------------------------- 1 | linkFactory = $linkFactory; 74 | $this->routeHelper = $routeHelper; 75 | $this->gate = $gate; 76 | 77 | $this->link(self::SELF, $self); 78 | $this->link(self::PARENT, $parent); 79 | } 80 | 81 | /** 82 | * @inheritdoc 83 | */ 84 | public function setAutoSubordinateRoutes(bool $flag) 85 | { 86 | $this->autoSubordinateRoutes = $flag; 87 | } 88 | 89 | /** 90 | * @inheritdoc 91 | */ 92 | public function add(string $key, $value): HalApiRepresentation 93 | { 94 | if (in_array($key, self::$reservedApiKeys)) { 95 | throw new InvalidArgumentException('key is restricted.'); 96 | } 97 | 98 | $this->root[$key] = $value; 99 | 100 | return $this; 101 | } 102 | 103 | /** 104 | * @inheritdoc 105 | */ 106 | public function meta(string $key, $value): HalApiRepresentation 107 | { 108 | $this->meta[$key] = $value; 109 | 110 | return $this; 111 | } 112 | 113 | /** 114 | * @inheritdoc 115 | */ 116 | public function metaFromArray(array $meta): HalApiRepresentation 117 | { 118 | $this->meta = array_merge_recursive($meta, $this->meta); 119 | 120 | return $this; 121 | } 122 | 123 | /** 124 | * @inheritdoc 125 | */ 126 | public function data(string $key, $value): HalApiRepresentation 127 | { 128 | $this->data[$key] = $value; 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * @inheritdoc 135 | */ 136 | public function dataFromArray(array $data): HalApiRepresentation 137 | { 138 | $this->data = array_merge_recursive($data, $this->data); 139 | 140 | return $this; 141 | } 142 | 143 | /** 144 | * @inheritdoc 145 | */ 146 | public function link(string $relation, HalApiLink $link): HalApiRepresentation 147 | { 148 | $this->links[$relation] = $link; 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * @inheritdoc 155 | */ 156 | public function links(array $links): HalApiRepresentation 157 | { 158 | if (empty($links)) { 159 | return $this; 160 | } 161 | 162 | foreach ($links as $relation => $link) { 163 | $this->link($relation, $link); 164 | } 165 | 166 | return $this; 167 | } 168 | 169 | /** 170 | * @inheritdoc 171 | */ 172 | public function embedSingle(string $relation, HalApiRepresentation $representation): HalApiRepresentation 173 | { 174 | $this->embedded[$relation] = $representation; 175 | 176 | return $this; 177 | } 178 | 179 | /** 180 | * @inheritdoc 181 | */ 182 | public function embedMulti(string $relation, HalApiRepresentation $representation): HalApiRepresentation 183 | { 184 | $this->embedded[$relation][] = $representation; 185 | 186 | return $this; 187 | } 188 | 189 | /** 190 | * @inheritdoc 191 | */ 192 | public function embedFromArray(array $embed): HalApiRepresentation 193 | { 194 | foreach ($embed as $relation => $item) { 195 | if (is_array($item)) { 196 | Checks::arrayType($item, HalApiRepresentation::class); 197 | 198 | foreach ($item as $representation) { 199 | $this->embedMulti($relation, $representation); 200 | } 201 | } else { 202 | $this->embedSingle($relation, $item); 203 | } 204 | } 205 | 206 | return $this; 207 | } 208 | 209 | /** 210 | * @param Authenticatable $authenticatable 211 | */ 212 | private function addSubordinateRoutes(Authenticatable $authenticatable = null) 213 | { 214 | $self = $this->links[self::SELF]; 215 | $parameters = $self->getParameters(); 216 | $subordinateRoutes = $this->routeHelper->subordinates($self->getRoute()); 217 | $gate = $authenticatable ? $this->gate->forUser($authenticatable) : null; 218 | 219 | foreach ($subordinateRoutes as $subRoute) { 220 | if ($gate) { 221 | $actionName = $subRoute->getActionName(); 222 | 223 | if ($gate->has($actionName) && $gate->denies($actionName)) { 224 | continue; 225 | } 226 | } 227 | 228 | $relation = RouteHelper::relation($subRoute); 229 | $link = $this->linkFactory->create($subRoute, $parameters); 230 | $this->link($relation, $link); 231 | } 232 | } 233 | 234 | /** 235 | * @inheritdoc 236 | */ 237 | public function build(Authenticatable $authenticatable = null): array 238 | { 239 | $build = $this->root; 240 | 241 | if ($this->autoSubordinateRoutes) { 242 | if (!isset($this->links[self::SELF])) { 243 | throw new RuntimeException('relation for self is not defined, cannot add subordinate routes'); 244 | } 245 | 246 | $this->addSubordinateRoutes($authenticatable); 247 | } 248 | 249 | if (!empty($this->meta)) { 250 | $build['meta'] = $this->meta; 251 | } 252 | if (!empty($this->data)) { 253 | $build['data'] = $this->data; 254 | } 255 | 256 | foreach ($this->links as $relation => $link) { 257 | $build['_links'][$relation] = $link->build(); 258 | } 259 | 260 | $build['_embedded'] = []; 261 | 262 | foreach ($this->embedded as $relation => $embedded) { 263 | if (is_array($embedded)) { 264 | /** @var HalApiRepresentation $item */ 265 | foreach ($embedded as $item) { 266 | $build['_embedded'][$relation][] = $item->build($authenticatable); 267 | } 268 | } else { 269 | /** @var HalApiRepresentation $embedded */ 270 | $build['_embedded'][$relation] = $embedded->build($authenticatable); 271 | } 272 | } 273 | 274 | return $build; 275 | } 276 | 277 | /** 278 | * @inheritdoc 279 | */ 280 | public function __toString(): string 281 | { 282 | return json_encode($this->build()); 283 | } 284 | 285 | } 286 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Representations/RepresentationFactory.php: -------------------------------------------------------------------------------- 1 | linkFactory = $linkFactory; 38 | $this->routeHelper = $routeHelper; 39 | $this->gate = $gate; 40 | } 41 | 42 | /** 43 | * @inheritdoc 44 | */ 45 | public function create(HalApiLink $self, HalApiLink $parent): HalApiRepresentation 46 | { 47 | return new HalApiRepresentationImpl($this->linkFactory, $this->routeHelper, $this->gate, $self, $parent); 48 | } 49 | 50 | /** 51 | * @inheritdoc 52 | */ 53 | public function paginated(HalApiLink $self, HalApiLink $parent, Paginator $paginator, HalApiTransformerContract $transformer, string $relation): HalApiPaginatedRepresentation 54 | { 55 | return new HalApiPaginatedRepresentationImpl($this->linkFactory, $this->routeHelper, $this->gate, $self, $parent, $paginator, $transformer, $relation); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Routing/HalApiLink.php: -------------------------------------------------------------------------------- 1 | route = $route; 42 | $this->parameters = self::extractFillableParameters($urlGenerator, $parameters); 43 | $this->queryString = self::createQueryString($queryString); 44 | $this->templated = self::evaluateTemplated($route, $urlGenerator, $this->queryString); 45 | $this->link = $urlGenerator->action($this->route->getActionName(), $this->templated ? $this->parameters : []); 46 | 47 | if (!empty($this->queryString)) { 48 | $this->link .= '?' . $this->queryString; 49 | } 50 | } 51 | 52 | /** 53 | * @param HalApiUrlGenerator $urlGenerator 54 | * @param $parameters 55 | * @return array 56 | */ 57 | private function extractFillableParameters(HalApiUrlGenerator $urlGenerator, $parameters): array 58 | { 59 | $parameters = is_array($parameters) ? $parameters : [$parameters]; 60 | $urlWithoutParameters = rawurldecode($urlGenerator->action($this->route->getActionName(), [], false)); 61 | preg_match_all('/\{(\w+)\}/', $urlWithoutParameters, $allParams); 62 | 63 | if (empty($allParams[1])) { 64 | return $parameters; 65 | } 66 | 67 | return array_filter($parameters, function ($key) use ($allParams) { 68 | return in_array($key, $allParams[1]); 69 | }, ARRAY_FILTER_USE_KEY); 70 | } 71 | 72 | /** 73 | * @return Route 74 | */ 75 | public function getRoute(): Route 76 | { 77 | return $this->route; 78 | } 79 | 80 | /** 81 | * @return array 82 | */ 83 | public function getParameters(): array 84 | { 85 | return $this->parameters; 86 | } 87 | 88 | /** 89 | * @param bool $encoded 90 | * @return string 91 | */ 92 | public function getLink($encoded = false): string 93 | { 94 | return $encoded ? $this->link : rawurldecode($this->link); 95 | } 96 | 97 | /** 98 | * @return bool 99 | */ 100 | public function isTemplated(): bool 101 | { 102 | return $this->templated; 103 | } 104 | 105 | /** 106 | * @return string 107 | */ 108 | public function getQueryString(): string 109 | { 110 | return $this->queryString; 111 | } 112 | 113 | /** 114 | * Returns the link's array representation. 115 | * 116 | * @return array 117 | */ 118 | public function build(): array 119 | { 120 | return [ 121 | 'href' => $this->getLink(), 122 | 'templated' => $this->templated, 123 | ]; 124 | } 125 | 126 | /** 127 | * @return string 128 | */ 129 | public function __toString(): string 130 | { 131 | return json_encode($this->build()); 132 | } 133 | 134 | /** 135 | * @param Route $route 136 | * @param HalApiUrlGenerator $urlGenerator 137 | * @param string $queryString 138 | * @return bool 139 | */ 140 | private static function evaluateTemplated(Route $route, HalApiUrlGenerator $urlGenerator, string $queryString): bool 141 | { 142 | // Does the route have named parameters? http://example.com/users/{users} 143 | if (count($route->parameterNames())) { 144 | return true; 145 | } 146 | 147 | // Does the query string contain any parameters? 148 | if (preg_match('/\?.*=\{.*?\}/', $queryString)) { 149 | return true; 150 | } 151 | 152 | $url = rawurldecode($urlGenerator->action($route->getActionName())); 153 | 154 | // Does the route's URI already contain a query string? http://example.com/users?page={page}&per_page={per_page} 155 | if (preg_match('/\?.*=\{.*?\}/', $url)) { 156 | return true; 157 | } 158 | 159 | return false; 160 | } 161 | 162 | /** 163 | * @param string $queryString 164 | * @return string 165 | */ 166 | private static function createQueryString(string $queryString): string 167 | { 168 | return empty($queryString) ? '' : ($queryString[0] === '?' ? substr($queryString, 1) : $queryString); 169 | } 170 | 171 | } 172 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Routing/HalApiUrlGenerator.php: -------------------------------------------------------------------------------- 1 | getRoutes(), $request); 27 | 28 | $this->urlGenerator = new class($this, $this->request) extends RouteUrlGenerator { 29 | 30 | /** 31 | * @param HalApiUrlGenerator $url 32 | * @param Request $request 33 | */ 34 | public function __construct(HalApiUrlGenerator $url, Request $request) 35 | { 36 | parent::__construct($url, $request); 37 | } 38 | 39 | /** 40 | * @return HalApiUrlGenerator 41 | */ 42 | private function urlGenerator(): HalApiUrlGenerator 43 | { 44 | return $this->url; 45 | } 46 | 47 | /** 48 | * @inheritdoc 49 | */ 50 | public function to($route, $parameters = [], $absolute = false) 51 | { 52 | $domain = $this->getRouteDomain($route, $parameters); 53 | $url = $this->urlGenerator()->format( 54 | $root = $this->replaceRootParameters($route, $domain, $parameters), 55 | $this->replaceRouteParameters($route->uri, $parameters) 56 | ); 57 | $uri = strtr(rawurlencode($this->addQueryString($url, $parameters)), $this->dontEncode); 58 | 59 | return $absolute ? $uri : '/' . ltrim(str_replace($root, '', $uri), '/'); 60 | } 61 | 62 | }; 63 | } 64 | 65 | /** 66 | * @inheritdoc 67 | */ 68 | protected function routeUrl() 69 | { 70 | return $this->urlGenerator; 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Routing/LinkFactory.php: -------------------------------------------------------------------------------- 1 | urlGenerator = $urlGenerator; 23 | } 24 | 25 | /** 26 | * @inheritdoc 27 | */ 28 | public function create(Route $route, $parameters = [], string $queryString = ''): HalApiLink 29 | { 30 | return new HalApiLinkImpl($this->urlGenerator, $route, $parameters, $queryString); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Transformers/HalApiTransformer.php: -------------------------------------------------------------------------------- 1 | linkFactory = $linkFactory; 50 | $this->representationFactory = $representationFactory; 51 | $this->routeHelper = $routeHelper; 52 | $this->self = $self; 53 | $this->parent = $parent; 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | */ 59 | public function item(Model $model): HalApiRepresentation 60 | { 61 | $self = $this->getSelf($model); 62 | $parent = $this->getParent($model); 63 | $data = $this->transform($model); 64 | $links = $this->getLinks($model); 65 | $embedded = $this->getEmbedded($model); 66 | 67 | return $this->representationFactory->create($self, $parent) 68 | ->dataFromArray($data) 69 | ->links($links) 70 | ->embedFromArray($embedded); 71 | } 72 | 73 | /** 74 | * @inheritdoc 75 | */ 76 | public function collection(array $collection): array 77 | { 78 | Checks::arrayType($collection, Model::class); 79 | 80 | $elements = []; 81 | 82 | foreach ($collection as $model) { 83 | $elements[] = $this->item($model); 84 | } 85 | 86 | return $elements; 87 | } 88 | 89 | /** 90 | * @param Model $model 91 | * @return HalApiLink 92 | */ 93 | protected function getSelf(Model $model): HalApiLink 94 | { 95 | return $this->linkFactory->create($this->self, $model->getKey()); 96 | } 97 | 98 | /** 99 | * @param Model $model 100 | * @return HalApiLink 101 | */ 102 | protected function getParent(Model $model): HalApiLink 103 | { 104 | return $this->linkFactory->create($this->parent); 105 | } 106 | 107 | /** 108 | * @param Model $model 109 | * @return HalApiLink[] 110 | */ 111 | protected function getLinks(Model $model): array 112 | { 113 | return []; 114 | } 115 | 116 | /** 117 | * @param Model $model 118 | * @return array 119 | */ 120 | protected function getEmbedded(Model $model): array 121 | { 122 | return []; 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /src/Jarischaefer/HalApi/Transformers/HalApiTransformerContract.php: -------------------------------------------------------------------------------- 1 | [ 6 | /* 7 | * Default number of items included in pagination. 8 | */ 9 | 'per_page' => 10, 10 | ], 11 | 12 | ]; -------------------------------------------------------------------------------- /src/controllers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarischaefer/hal-api/fb56997ea47aaf5316db06983c883e398edf65cb/src/controllers/.gitkeep -------------------------------------------------------------------------------- /src/lang/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarischaefer/hal-api/fb56997ea47aaf5316db06983c883e398edf65cb/src/lang/.gitkeep -------------------------------------------------------------------------------- /src/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarischaefer/hal-api/fb56997ea47aaf5316db06983c883e398edf65cb/src/migrations/.gitkeep -------------------------------------------------------------------------------- /src/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarischaefer/hal-api/fb56997ea47aaf5316db06983c883e398edf65cb/src/views/.gitkeep -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarischaefer/hal-api/fb56997ea47aaf5316db06983c883e398edf65cb/tests/.gitkeep -------------------------------------------------------------------------------- /tests/Helpers/ResourceRouteTest.php: -------------------------------------------------------------------------------- 1 | uri, $uri) === 0 && strcmp($route->getActionName(), $actionName) === 0 && in_array($httpMethod, $route->methods)) { 16 | return; 17 | } 18 | } 19 | 20 | $this->fail('Could not find route with uri [' . $uri . '], method [' . $httpMethod . '] and action name [' . $actionName . ']'); 21 | } 22 | 23 | public function testResourceRoute() 24 | { 25 | $routeHelper = $this->createRouteHelper(); 26 | 27 | $routeHelper->resource('test', 'TestController') 28 | ->get('get_test', 'get_test') 29 | ->post('post_test', 'post_test') 30 | ->put('put_test', 'put_test') 31 | ->patch('patch_test', 'patch_test') 32 | ->delete('delete_test', 'delete_test') 33 | ->rawGet('rawget', 'rawget') 34 | ->rawPost('rawpost', 'rawpost') 35 | ->rawPut('rawput', 'rawput') 36 | ->rawPatch('rawpatch', 'rawpatch') 37 | ->rawDelete('rawdelete', 'rawdelete') 38 | ->done(); 39 | 40 | $routes = $routeHelper->getRouter()->getRoutes(); 41 | 42 | $this->assertRoute($routes, 'test', 'GET', 'TestController@index'); 43 | $this->assertRoute($routes, 'test?' . RouteHelper::PAGINATION_QUERY_STRING, 'GET', 'TestController@index'); 44 | $this->assertRoute($routes, 'test', 'POST', 'TestController@store'); 45 | $this->assertRoute($routes, 'test/{test}', 'GET', 'TestController@show'); 46 | $this->assertRoute($routes, 'test/{test}', 'PUT', 'TestController@update'); 47 | $this->assertRoute($routes, 'test/{test}', 'PATCH', 'TestController@update'); 48 | $this->assertRoute($routes, 'test/{test}', 'DELETE', 'TestController@destroy'); 49 | $this->assertRoute($routes, 'test/{test}/get_test', 'GET', 'TestController@get_test'); 50 | $this->assertRoute($routes, 'test/{test}/post_test', 'POST', 'TestController@post_test'); 51 | $this->assertRoute($routes, 'test/{test}/put_test', 'PUT', 'TestController@put_test'); 52 | $this->assertRoute($routes, 'test/{test}/patch_test', 'PATCH', 'TestController@patch_test'); 53 | $this->assertRoute($routes, 'test/{test}/delete_test', 'DELETE', 'TestController@delete_test'); 54 | $this->assertRoute($routes, 'test/rawget', 'GET', 'TestController@rawget'); 55 | $this->assertRoute($routes, 'test/rawpost', 'POST', 'TestController@rawpost'); 56 | $this->assertRoute($routes, 'test/rawput', 'PUT', 'TestController@rawput'); 57 | $this->assertRoute($routes, 'test/rawpatch', 'PATCH', 'TestController@rawpatch'); 58 | $this->assertRoute($routes, 'test/rawdelete', 'DELETE', 'TestController@rawdelete'); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /tests/Helpers/RouteHelperTest.php: -------------------------------------------------------------------------------- 1 | createRouteHelper(); 28 | $helper->resource('test', TestController::class)->done(); 29 | $routes = $helper->getRouter()->getRoutes(); 30 | 31 | $found = false; 32 | /** @var Route $route */ 33 | foreach ($routes as $route) { 34 | if (strcmp($route->uri, 'test?' . RouteHelper::PAGINATION_QUERY_STRING) === 0) { 35 | $found = true; 36 | break; 37 | } 38 | } 39 | 40 | $this->assertTrue($found, 'Did not find pagination uri in routes list.'); 41 | 42 | $this->assertNotNull($routes->getByAction(TestController::actionName(RouteHelper::INDEX)), 'index route was not found.'); 43 | $this->assertNotNull($routes->getByAction(TestController::actionName(RouteHelper::SHOW)), 'show route was not found.'); 44 | $this->assertNotNull($routes->getByAction(TestController::actionName(RouteHelper::STORE)), 'store route was not found.'); 45 | $this->assertNotNull($routes->getByAction(TestController::actionName(RouteHelper::UPDATE)), 'update route was not found.'); 46 | $this->assertNotNull($routes->getByAction(TestController::actionName(RouteHelper::DESTROY)), 'destroy route was not found.'); 47 | } 48 | 49 | public function testGetRouteByAction() 50 | { 51 | $helper = $this->createRouteHelper(); 52 | $helper->getRouter()->get('/test', TestController::actionName('test')); 53 | 54 | $test = $helper->byAction(TestController::actionName('test')); 55 | $this->assertEquals($test->getActionName(), TestController::actionName('test')); 56 | 57 | try { 58 | $helper->byAction(''); 59 | $this->fail('Route should not have been found.'); 60 | } catch (Exception $e) { 61 | // expected 62 | } 63 | } 64 | 65 | public function testGetParentRoute() 66 | { 67 | $helper = $this->createRouteHelper(); 68 | $router = $helper->getRouter(); 69 | $router->get('/test', TestController::actionName('test')); 70 | $router->get('/test/sub1', TestController::actionName('sub1')); 71 | $router->get('/test/sub1/sub2', TestController::actionName('sub2')); 72 | 73 | $test = $helper->byAction(TestController::actionName('test')); 74 | $sub1 = $helper->byAction(TestController::actionName('sub1')); 75 | $sub2 = $helper->byAction(TestController::actionName('sub2')); 76 | 77 | $parentTest = $helper->parent($test); 78 | $parentSub1 = $helper->parent($sub1); 79 | $parentSub2 = $helper->parent($sub2); 80 | 81 | $this->assertEquals($test->getActionName(), $parentTest->getActionName()); 82 | $this->assertEquals($test->getActionName(), $parentSub1->getActionName()); 83 | $this->assertEquals($sub1->getActionName(), $parentSub2->getActionName()); 84 | } 85 | 86 | public function testGetSubordinateRoutes() 87 | { 88 | $helper = $this->createRouteHelper(); 89 | $router = $helper->getRouter(); 90 | 91 | $router->get('/test', TestController::actionName('test')); 92 | $router->get('/test/sub1', TestController::actionName('sub1')); 93 | $router->get('/test/sub1/sub2', TestController::actionName('sub2')); 94 | 95 | $test = $helper->byAction(TestController::actionName('test')); 96 | $sub1 = $helper->byAction(TestController::actionName('sub1')); 97 | $sub2 = $helper->byAction(TestController::actionName('sub2')); 98 | 99 | $subordinateTest = $helper->subordinates($test); 100 | $subordinateSub1 = $helper->subordinates($sub1); 101 | $subordinateSub2 = $helper->subordinates($sub2); 102 | 103 | $this->assertEquals(2, count($subordinateTest)); 104 | $this->assertEquals(TestController::actionName('sub1'), $subordinateTest[0]->getActionName()); 105 | $this->assertEquals(TestController::actionName('sub2'), $subordinateTest[1]->getActionName()); 106 | 107 | $this->assertEquals(1, count($subordinateSub1)); 108 | $this->assertEquals(TestController::actionName('sub2'), $subordinateSub1[0]->getActionName()); 109 | 110 | $this->assertEquals(0, count($subordinateSub2)); 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /tests/Middleware/HalApiCacheMiddlerwareTest.php: -------------------------------------------------------------------------------- 1 | cacheFactory = Mockery::mock(CacheFactory::class); 46 | $this->config = Mockery::mock(Repository::class); 47 | $this->config->shouldReceive('get')->with('app.debug', false)->andReturn(false); 48 | $this->request = Mockery::mock(Request::class); 49 | $this->route = Mockery::mock(Route::class); 50 | $this->middleware = new HalApiCacheMiddleware($this->cacheFactory, $this->config); 51 | } 52 | 53 | public function testGETRequest() 54 | { 55 | $cache = Mockery::mock(HalApiCache::class); 56 | $cache->shouldReceive('key')->once()->with(Request::METHOD_GET, '/')->andReturn('GET_/'); 57 | $cache->shouldReceive('persist')->once()->with('GET_/', Mockery::on(function (Closure $closure) { 58 | return $closure($this->request) === 'foo'; 59 | }))->andReturn('foo'); 60 | 61 | $controller = Mockery::mock(HalApiControllerContract::class); 62 | $controller->shouldReceive('getCache')->once()->with($this->cacheFactory)->andReturn($cache); 63 | $this->request->shouldReceive('route')->once()->andReturn($this->route); 64 | $this->request->shouldReceive('getMethod')->once()->andReturn(Request::METHOD_GET); 65 | $this->request->shouldReceive('getUri')->once()->andReturn('/'); 66 | $this->request->shouldReceive('isMethodSafe')->once()->andReturn(false); 67 | $this->route->shouldReceive('getActionName')->once()->andReturn(get_class($controller)); 68 | 69 | $response = $this->middleware->handle($this->request, function (Request $request) { 70 | if ($request !== $this->request) { 71 | $this->fail('Invalid request'); 72 | } 73 | return 'foo'; 74 | }); 75 | 76 | $this->assertEquals('foo', $response); 77 | } 78 | 79 | public function testPOSTRequest() 80 | { 81 | $this->request->shouldReceive('isMethodSafe')->once()->andReturn(false); 82 | 83 | $response = $this->middleware->handle($this->request, function (Request $request) { 84 | if ($request !== $this->request) { 85 | $this->fail('Invalid request'); 86 | } 87 | return 'foo'; 88 | }); 89 | 90 | $this->assertEquals('foo', $response); 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /tests/Middleware/HalApiETagMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | middleware = new HalApiETagMiddleware; 33 | $this->request = Mockery::mock(Request::class); 34 | $this->response = Mockery::mock(Response::class); 35 | } 36 | 37 | public function testETag() 38 | { 39 | $this->request->shouldReceive('isMethodSafe')->once()->andReturn(true); 40 | $this->request->shouldReceive('getETags')->once()->andReturn([]); 41 | 42 | $this->response->shouldReceive('getContent')->once()->andReturn('foo'); 43 | $this->response->shouldReceive('setMaxAge')->once()->with(0); 44 | $this->response->shouldReceive('setEtag')->once()->with(md5('foo')); 45 | 46 | $this->middleware->handle($this->request, function () { 47 | return $this->response; 48 | }); 49 | } 50 | 51 | public function testMatchingETag() 52 | { 53 | $this->request->shouldReceive('isMethodSafe')->once()->andReturn(true); 54 | $this->request->shouldReceive('getETags')->once()->andReturn([md5('foo')]); 55 | 56 | $this->response->shouldReceive('getContent')->once()->andReturn('foo'); 57 | $this->response->shouldReceive('setMaxAge')->once()->with(0); 58 | $this->response->shouldReceive('setEtag')->once()->with(md5('foo')); 59 | $this->response->shouldReceive('setNotModified')->once(); 60 | 61 | $this->middleware->handle($this->request, function () { 62 | return $this->response; 63 | }); 64 | } 65 | 66 | public function testETagIncompatibleResponse() 67 | { 68 | $response = $this->middleware->handle($this->request, function () { 69 | return 'foo bar baz'; 70 | }); 71 | 72 | $this->assertEquals('foo bar baz', $response); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /tests/Representations/RepresentationFactoryImplTest.php: -------------------------------------------------------------------------------- 1 | createRouteHelper(), $gate); 28 | $representation = $factory->create($self, $parent); 29 | 30 | $this->assertInstanceOf(HalApiRepresentation::class, $representation); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /tests/Routing/HalApiLinkImplTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('action') 16 | ->once() 17 | ->with('Foo\Bar\Controllers\TestController@doSomething', [], false) 18 | ->andReturn('/parameters/foo'); 19 | $urlGenerator->shouldReceive('action') 20 | ->once() 21 | ->with('Foo\Bar\Controllers\TestController@doSomething', ['foo']) 22 | ->andReturn('/parameters/foo'); 23 | 24 | /** @var HalApiUrlGenerator $urlGenerator */ 25 | $route = new Route(['GET'], '/parameters/{parameter}', ['controller' => 'Foo\Bar\Controllers\TestController@doSomething']); 26 | $link = new HalApiLinkImpl($urlGenerator, $route, ['foo'], '?bar=test'); 27 | 28 | $this->assertEquals('/parameters/foo?bar=test', $link->getLink()); 29 | } 30 | 31 | public function testGetRoute() 32 | { 33 | $urlGenerator = Mockery::mock(HalApiUrlGenerator::class); 34 | $urlGenerator->shouldReceive('action') 35 | ->once() 36 | ->with('Foo\Bar\Controllers\TestController@doSomething', [], false) 37 | ->andReturn('/parameters/foo'); 38 | $urlGenerator->shouldReceive('action') 39 | ->once() 40 | ->with('Foo\Bar\Controllers\TestController@doSomething', ['foo']) 41 | ->andReturn('/parameters/foo'); 42 | 43 | /** @var HalApiUrlGenerator $urlGenerator */ 44 | $route = new Route(['GET'], '/parameters/{parameter}', ['controller' => 'Foo\Bar\Controllers\TestController@doSomething']); 45 | $link = new HalApiLinkImpl($urlGenerator, $route, ['foo'], '?bar=test'); 46 | 47 | $this->assertEquals($route, $link->getRoute()); 48 | } 49 | 50 | public function testGetParameters() 51 | { 52 | $urlGenerator = Mockery::mock(HalApiUrlGenerator::class); 53 | $urlGenerator->shouldReceive('action') 54 | ->once() 55 | ->with('Foo\Bar\Controllers\TestController@doSomething', [], false) 56 | ->andReturn('/parameters/foo'); 57 | $urlGenerator->shouldReceive('action') 58 | ->once() 59 | ->with('Foo\Bar\Controllers\TestController@doSomething', ['foo']) 60 | ->andReturn('/parameters/foo'); 61 | 62 | /** @var HalApiUrlGenerator $urlGenerator */ 63 | $route = new Route(['GET'], '/parameters/{parameter}', ['controller' => 'Foo\Bar\Controllers\TestController@doSomething']); 64 | $link = new HalApiLinkImpl($urlGenerator, $route, ['foo'], '?bar=test'); 65 | 66 | $this->assertEquals(['foo'], $link->getParameters()); 67 | } 68 | 69 | public function testGetQueryString() 70 | { 71 | $urlGenerator = Mockery::mock(HalApiUrlGenerator::class); 72 | $urlGenerator->shouldReceive('action') 73 | ->twice() 74 | ->with('Foo\Bar\Controllers\TestController@doSomething', [], false) 75 | ->andReturn('/parameters/foo'); 76 | $urlGenerator->shouldReceive('action') 77 | ->twice() 78 | ->with('Foo\Bar\Controllers\TestController@doSomething', ['foo']) 79 | ->andReturn('/parameters/foo'); 80 | 81 | /** @var HalApiUrlGenerator $urlGenerator */ 82 | $route = new Route(['GET'], '/parameters/{parameter}', ['controller' => 'Foo\Bar\Controllers\TestController@doSomething']); 83 | $link = new HalApiLinkImpl($urlGenerator, $route, ['foo'], '?bar=test'); 84 | 85 | $this->assertEquals('bar=test', $link->getQueryString()); 86 | 87 | $link = new HalApiLinkImpl($urlGenerator, $route, ['foo'], 'bar=test'); 88 | $this->assertEquals('bar=test', $link->getQueryString()); 89 | } 90 | 91 | public function testIsTemplated() 92 | { 93 | $urlGenerator = Mockery::mock(HalApiUrlGenerator::class); 94 | $urlGenerator->shouldReceive('action') 95 | ->once() 96 | ->with('Foo\Bar\Controllers\TestController@doSomething', [], false) 97 | ->andReturn('/parameters/foo'); 98 | $urlGenerator->shouldReceive('action') 99 | ->once() 100 | ->with('Foo\Bar\Controllers\TestController@doSomething', ['foo']) 101 | ->andReturn('/parameters/foo'); 102 | 103 | /** @var HalApiUrlGenerator $urlGenerator */ 104 | $route = new Route(['GET'], '/parameters/{parameter}', ['controller' => 'Foo\Bar\Controllers\TestController@doSomething']); 105 | $link = new HalApiLinkImpl($urlGenerator, $route, ['foo'], '?bar=test'); 106 | 107 | $this->assertTrue($link->isTemplated()); 108 | } 109 | 110 | public function testIsTemplatedFalse() 111 | { 112 | $urlGenerator = Mockery::mock(HalApiUrlGenerator::class); 113 | $urlGenerator->shouldReceive('action') 114 | ->once() 115 | ->with('Foo\Bar\Controllers\TestController@doSomething', [], false) 116 | ->andReturn('/parameters'); 117 | $urlGenerator->shouldReceive('action') 118 | ->once() 119 | ->with('Foo\Bar\Controllers\TestController@doSomething') 120 | ->andReturn('/parameters'); 121 | $urlGenerator->shouldReceive('action') 122 | ->once() 123 | ->with('Foo\Bar\Controllers\TestController@doSomething', []) 124 | ->andReturn('/parameters'); 125 | 126 | /** @var HalApiUrlGenerator $urlGenerator */ 127 | $route = new Route(['GET'], '/parameters', ['controller' => 'Foo\Bar\Controllers\TestController@doSomething']); 128 | $link = new HalApiLinkImpl($urlGenerator, $route, [], '?bar=test'); 129 | 130 | $this->assertFalse($link->isTemplated()); 131 | } 132 | 133 | public function testIsTemplatedQueryString() 134 | { 135 | $urlGenerator = Mockery::mock(HalApiUrlGenerator::class); 136 | $urlGenerator->shouldReceive('action') 137 | ->once() 138 | ->with('Foo\Bar\Controllers\TestController@doSomething', [], false) 139 | ->andReturn('/parameters'); 140 | $urlGenerator->shouldReceive('action') 141 | ->once() 142 | ->with('Foo\Bar\Controllers\TestController@doSomething', ['page' => 10]) 143 | ->andReturn('/parameters'); 144 | 145 | /** @var HalApiUrlGenerator $urlGenerator */ 146 | $route = new Route(['GET'], '/parameters?page={page}', ['controller' => 'Foo\Bar\Controllers\TestController@doSomething']); 147 | $link = new HalApiLinkImpl($urlGenerator, $route, ['page' => 10], '?bar=test'); 148 | 149 | $this->assertTrue($link->isTemplated()); 150 | } 151 | 152 | public function testBuild() 153 | { 154 | $urlGenerator = Mockery::mock(HalApiUrlGenerator::class); 155 | $urlGenerator->shouldReceive('action') 156 | ->once() 157 | ->with('Foo\Bar\Controllers\TestController@doSomething', [], false) 158 | ->andReturn('/parameters'); 159 | $urlGenerator->shouldReceive('action') 160 | ->once() 161 | ->with('Foo\Bar\Controllers\TestController@doSomething', ['foo']) 162 | ->andReturn('/parameters/foo'); 163 | 164 | /** @var HalApiUrlGenerator $urlGenerator */ 165 | $route = new Route(['GET'], '/parameters/{parameter}', ['controller' => 'Foo\Bar\Controllers\TestController@doSomething']); 166 | $link = new HalApiLinkImpl($urlGenerator, $route, ['foo'], '?bar=test'); 167 | 168 | $this->assertEquals([ 169 | 'href' => '/parameters/foo?bar=test', 170 | 'templated' => true, 171 | ], $link->build()); 172 | } 173 | 174 | public function testToString() 175 | { 176 | $urlGenerator = Mockery::mock(HalApiUrlGenerator::class); 177 | $urlGenerator->shouldReceive('action') 178 | ->once() 179 | ->with('Foo\Bar\Controllers\TestController@doSomething', [], false) 180 | ->andReturn('/parameters'); 181 | $urlGenerator->shouldReceive('action') 182 | ->once() 183 | ->with('Foo\Bar\Controllers\TestController@doSomething', ['foo']) 184 | ->andReturn('/parameters/foo'); 185 | 186 | /** @var HalApiUrlGenerator $urlGenerator */ 187 | $route = new Route(['GET'], '/parameters/{parameter}', ['controller' => 'Foo\Bar\Controllers\TestController@doSomething']); 188 | $link = new HalApiLinkImpl($urlGenerator, $route, ['foo'], '?bar=test'); 189 | 190 | $build = $link->build(); 191 | $this->assertEquals([ 192 | 'href' => '/parameters/foo?bar=test', 193 | 'templated' => true, 194 | ], $build); 195 | $this->assertEquals(json_encode($build), (string)$link); 196 | } 197 | 198 | } 199 | -------------------------------------------------------------------------------- /tests/Routing/LinkFactoryImplTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('action') 16 | ->once() 17 | ->with('Jarischaefer\HalApi\Tests\TestController@doSomething', [], false) 18 | ->andReturn('/params/foo/bar'); 19 | $urlGenerator->shouldReceive('action') 20 | ->atLeast($this->once()) 21 | ->with('Jarischaefer\HalApi\Tests\TestController@doSomething', ['foo', 'bar']) 22 | ->andReturn('/params/foo/bar'); 23 | $route = new Route(['GET'], '/params/{paramonce}/{paramtwo}', ['controller' => 'Jarischaefer\HalApi\Tests\TestController@doSomething']); 24 | /** @var HalApiUrlGenerator $urlGenerator */ 25 | $factory = new LinkFactoryImpl($urlGenerator); 26 | $link = $factory->create($route, ['foo', 'bar'], '?foo=bar'); 27 | 28 | $this->assertEquals('/params/foo/bar?foo=bar', $link->getLink()); 29 | $this->assertEquals(['foo', 'bar'], $link->getParameters()); 30 | $this->assertEquals('foo=bar', $link->getQueryString()); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | createMock(Dispatcher::class); 18 | return new Router($dispatcher, null); 19 | } 20 | 21 | /** 22 | * @param Router|null $router 23 | * @return RouteHelper 24 | */ 25 | protected function createRouteHelper(Router $router = null): RouteHelper 26 | { 27 | return $router ? RouteHelper::make($router) : RouteHelper::make($this->createRouter()); 28 | } 29 | 30 | } 31 | --------------------------------------------------------------------------------