├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── ConfigProvider.php ├── Exception ├── ExceptionInterface.php ├── InvalidObjectException.php ├── InvalidResourceValueException.php ├── InvalidStrategyException.php └── UnknownMetadataTypeException.php ├── HalResource.php ├── HalResponseFactory.php ├── HalResponseFactoryFactory.php ├── Link.php ├── LinkCollection.php ├── LinkGenerator.php ├── LinkGenerator ├── ExpressiveUrlGenerator.php ├── ExpressiveUrlGeneratorFactory.php └── UrlGeneratorInterface.php ├── LinkGeneratorFactory.php ├── Metadata ├── AbstractCollectionMetadata.php ├── AbstractMetadata.php ├── AbstractResourceMetadata.php ├── Exception │ ├── DuplicateMetadataException.php │ ├── ExceptionInterface.php │ ├── InvalidConfigException.php │ ├── UndefinedClassException.php │ └── UndefinedMetadataException.php ├── MetadataFactoryInterface.php ├── MetadataMap.php ├── MetadataMapFactory.php ├── RouteBasedCollectionMetadata.php ├── RouteBasedCollectionMetadataFactory.php ├── RouteBasedResourceMetadata.php ├── RouteBasedResourceMetadataFactory.php ├── UrlBasedCollectionMetadata.php ├── UrlBasedCollectionMetadataFactory.php ├── UrlBasedResourceMetadata.php └── UrlBasedResourceMetadataFactory.php ├── Renderer ├── JsonRenderer.php ├── RendererInterface.php └── XmlRenderer.php ├── ResourceGenerator.php ├── ResourceGenerator ├── Exception │ ├── ExceptionInterface.php │ ├── InvalidCollectionException.php │ ├── InvalidConfigException.php │ ├── InvalidExtractorException.php │ ├── OutOfBoundsException.php │ └── UnexpectedMetadataTypeException.php ├── ExtractCollectionTrait.php ├── ExtractInstanceTrait.php ├── RouteBasedCollectionStrategy.php ├── RouteBasedResourceStrategy.php ├── StrategyInterface.php ├── UrlBasedCollectionStrategy.php └── UrlBasedResourceStrategy.php └── ResourceGeneratorFactory.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file, in reverse chronological order by release. 4 | 5 | Versions prior to 0.4.0 were released as the package "weierophinney/hal". 6 | 7 | ## 1.3.2 - TBD 8 | 9 | ### Added 10 | 11 | - Nothing. 12 | 13 | ### Changed 14 | 15 | - Nothing. 16 | 17 | ### Deprecated 18 | 19 | - Nothing. 20 | 21 | ### Removed 22 | 23 | - Nothing. 24 | 25 | ### Fixed 26 | 27 | - Nothing. 28 | 29 | ## 1.3.1 - 2019-02-11 30 | 31 | ### Added 32 | 33 | - Nothing. 34 | 35 | ### Changed 36 | 37 | - Nothing. 38 | 39 | ### Deprecated 40 | 41 | - Nothing. 42 | 43 | ### Removed 44 | 45 | - Nothing. 46 | 47 | ### Fixed 48 | 49 | - [#56](https://github.com/zendframework/zend-expressive-hal/pull/56) fixes an issue calculating the offset when generating a paginated Doctrine collection. 50 | 51 | ## 1.3.0 - 2019-02-06 52 | 53 | ### Added 54 | 55 | - [#55](https://github.com/zendframework/zend-expressive-hal/pull/55) adds the ability to generate paginated HAL collections from 56 | `Doctrine\ORM\Tools\Pagination\Paginator` instances. 57 | 58 | ### Changed 59 | 60 | - Nothing. 61 | 62 | ### Deprecated 63 | 64 | - Nothing. 65 | 66 | ### Removed 67 | 68 | - Nothing. 69 | 70 | ### Fixed 71 | 72 | - Nothing. 73 | 74 | ## 1.2.0 - 2018-12-11 75 | 76 | ### Added 77 | 78 | - [#51](https://github.com/zendframework/zend-expressive-hal/pull/51) adds support for zend-hydrator version 3 releases. You may continue to use 79 | version 2 releases as well. 80 | 81 | ### Changed 82 | 83 | - Nothing. 84 | 85 | ### Deprecated 86 | 87 | - Nothing. 88 | 89 | ### Removed 90 | 91 | - Nothing. 92 | 93 | ### Fixed 94 | 95 | - Nothing. 96 | 97 | ## 1.1.1 - 2018-12-11 98 | 99 | ### Added 100 | 101 | - Nothing. 102 | 103 | ### Changed 104 | 105 | - Nothing. 106 | 107 | ### Deprecated 108 | 109 | - Nothing. 110 | 111 | ### Removed 112 | 113 | - Nothing. 114 | 115 | ### Fixed 116 | 117 | - [#50](https://github.com/zendframework/zend-expressive-hal/pull/50) fixes the `Halresource` constructor documentation of the `$embedded` 118 | argument to correctly be an array of `HalResource` arrays (and not just an 119 | array of `HalResource` instances). 120 | 121 | - [#41](https://github.com/zendframework/zend-expressive-hal/pull/41) fixes how `null` values in resources are handled when rendering as XML. 122 | Previously, these would lead to an `InvalidResourceValueException`; now they 123 | are rendered as content-less tags. 124 | 125 | ## 1.1.0 - 2018-06-05 126 | 127 | ### Added 128 | 129 | - [#39](https://github.com/zendframework/zend-expressive-hal/pull/39) adds a cookbook recipe detailing how to create a fully contained, path-segregated 130 | module, complete with its own router, capable of generating HAL resources. 131 | 132 | ### Changed 133 | 134 | - [#39](https://github.com/zendframework/zend-expressive-hal/pull/39) updates `LinkGeneratorFactory` to allow passing an alternate service name to use when 135 | retrieving the `LinkGenerator\UriGeneratorInterface` dependency. 136 | 137 | - [#39](https://github.com/zendframework/zend-expressive-hal/pull/39) updates `ResourceGeneratorFactory` to allow passing an alternate service name to use when 138 | retrieving the `LinkGenerator` dependency. 139 | 140 | ### Deprecated 141 | 142 | - Nothing. 143 | 144 | ### Removed 145 | 146 | - Nothing. 147 | 148 | ### Fixed 149 | 150 | - Nothing. 151 | 152 | ## 1.0.3 - TBD 153 | 154 | ### Added 155 | 156 | - Nothing. 157 | 158 | ### Changed 159 | 160 | - Nothing. 161 | 162 | ### Deprecated 163 | 164 | - Nothing. 165 | 166 | ### Removed 167 | 168 | - Nothing. 169 | 170 | ### Fixed 171 | 172 | - Nothing. 173 | 174 | ## 1.0.2 - 2018-04-04 175 | 176 | ### Added 177 | 178 | - Nothing. 179 | 180 | ### Changed 181 | 182 | - Nothing. 183 | 184 | ### Deprecated 185 | 186 | - Nothing. 187 | 188 | ### Removed 189 | 190 | - Nothing. 191 | 192 | ### Fixed 193 | 194 | - [#37](https://github.com/zendframework/zend-expressive-hal/pull/37) modifies 195 | `HalResource` to no longer treat empty arrays as embedded collections when 196 | passed via the constructor or `withElement()`. If an empty embedded collection 197 | is required, use `embed()` with a boolean third argument to force 198 | representation as an array of resources. 199 | 200 | ## 1.0.1 - 2018-03-28 201 | 202 | ### Added 203 | 204 | - Nothing. 205 | 206 | ### Changed 207 | 208 | - Nothing. 209 | 210 | ### Deprecated 211 | 212 | - Nothing. 213 | 214 | ### Removed 215 | 216 | - Nothing. 217 | 218 | ### Fixed 219 | 220 | - [#36](https://github.com/zendframework/zend-expressive-hal/pull/36) 221 | fixes an issue whereby query string arguments were not being added to 222 | links generated for a resource. It now correctly merges those specified in 223 | metadata with those from the request when generating links. 224 | 225 | ## 1.0.0 - 2018-03-15 226 | 227 | ### Added 228 | 229 | - Nothing. 230 | 231 | ### Changed 232 | 233 | - [#31](https://github.com/zendframework/zend-expressive-hal/pull/31) changes 234 | the constructor signature of `Zend\Expressive\Hal\HalResponseFactory` to read: 235 | 236 | ```php 237 | public function __construct( 238 | callable $responseFactory, 239 | Renderer\JsonRenderer $jsonRenderer = null, 240 | Renderer\XmlRenderer $xmlRenderer = null 241 | ) 242 | ``` 243 | 244 | Previously, the `$responseFactory` argument was a 245 | `Psr\Http\Message\ResponseInterface $responsePrototype`; it is now a PHP 246 | callable capable of producing a new, empty instance of that type. 247 | 248 | Additionally, the signature previously included a callable `$streamFactory`; 249 | this has been removed. 250 | 251 | - [#31](https://github.com/zendframework/zend-expressive-hal/pull/31) updates 252 | the `HalResponseFactoryFactory` to follow the changes made to the 253 | `HalResponseFactory` constructor. It now **requires** that a 254 | `Psr\Http\Message\ResponseInterface` service be registered, and that the 255 | service resolve to a `callable` capable of producing a `ResponseInterface` 256 | instance. 257 | 258 | ### Deprecated 259 | 260 | - Nothing. 261 | 262 | ### Removed 263 | 264 | - Nothing. 265 | 266 | ### Fixed 267 | 268 | - Nothing. 269 | 270 | ## 0.6.3 - 2018-03-12 271 | 272 | ### Added 273 | 274 | - Nothing. 275 | 276 | ### Changed 277 | 278 | - [#32](https://github.com/zendframework/zend-expressive-hal/pull/32) modifies 279 | `HalResponseFactoryFactory` to test if a `ResponseInterface` service instance 280 | is `callable` before returning it; if it is, it calls it first. This allows 281 | the `ResponseInterface` service to return a response _factory_ instead of an 282 | instance. 283 | 284 | ### Deprecated 285 | 286 | - Nothing. 287 | 288 | ### Removed 289 | 290 | - Nothing. 291 | 292 | ### Fixed 293 | 294 | - Nothing. 295 | 296 | ## 0.6.2 - 2018-01-03 297 | 298 | ### Added 299 | 300 | - Nothing. 301 | 302 | ### Changed 303 | 304 | - [#27](https://github.com/zendframework/zend-expressive-hal/pull/27) modifies 305 | the `XmlRenderer` to raise an exception when attempting to render objects that 306 | are not serializable to strings. 307 | 308 | ### Deprecated 309 | 310 | - Nothing. 311 | 312 | ### Removed 313 | 314 | - Nothing. 315 | 316 | ### Fixed 317 | 318 | - [#27](https://github.com/zendframework/zend-expressive-hal/pull/27) adds 319 | handling for `DateTime` and string serializable objects to the `XmlRenderer`, 320 | allowing them to be rendered. 321 | 322 | ## 0.6.1 - 2017-12-12 323 | 324 | ### Added 325 | 326 | - [#26](https://github.com/zendframework/zend-expressive-hal/pull/26) adds 327 | support for the zend-expressive-helpers 5.0 series of releases. 328 | 329 | ### Changed 330 | 331 | - Nothing. 332 | 333 | ### Deprecated 334 | 335 | - Nothing. 336 | 337 | ### Removed 338 | 339 | - Nothing. 340 | 341 | ### Fixed 342 | 343 | - Nothing. 344 | 345 | ## 0.6.0 - 2017-11-07 346 | 347 | ### Added 348 | 349 | - Nothing. 350 | 351 | ### Changed 352 | 353 | - [#23](https://github.com/zendframework/zend-expressive-hal/pull/23) modifies 354 | how the resource generator factory adds strategies and maps metadata to 355 | strategies. It now adds the following factories under the 356 | `Zend\Expressive\Hal\Metadata` namespace: 357 | 358 | - `RouteBasedCollectionMetadataFactory` 359 | - `RouteBasedResourceMetadataFactory` 360 | - `UrlBasedCollectionMetadataFactory` 361 | - `UrlBasedResourceMetadataFactory` 362 | 363 | Each implements a new `MetadataFactoryInterface` under that same namespace 364 | that accepts the requested metadata type name and associated metadata in order 365 | to create an `AbstractMetadata` instance. Metadata types are mapped to their 366 | factories under the `zend-expressive-hal.metadata-factories` key. 367 | 368 | Strategies are now configured as metadata => strategy class pairings under the 369 | `zend-expressive-hal.resource-generator.strategies` key. 370 | 371 | In both cases, defaults that mimic previous behavior are provided via the 372 | `ConfigProvider`. 373 | 374 | ### Deprecated 375 | 376 | - Nothing. 377 | 378 | ### Removed 379 | 380 | - Nothing. 381 | 382 | ### Fixed 383 | 384 | - Nothing. 385 | 386 | ## 0.5.1 - 2017-11-07 387 | 388 | ### Added 389 | 390 | - Nothing. 391 | 392 | ### Changed 393 | 394 | - Nothing. 395 | 396 | ### Deprecated 397 | 398 | - Nothing. 399 | 400 | ### Removed 401 | 402 | - Nothing. 403 | 404 | ### Fixed 405 | 406 | - [#21](https://github.com/zendframework/zend-expressive-hal/pull/21) fixes the 407 | `LinkGeneratorFactory` to properly use the 408 | `Zend\Expressive\Hal\LinkGenerator\UrlGeneratorInterface` service when 409 | creating and returning the `LinkGenerator` instance. (0.5.0 was incorrectly 410 | attempting to use the `UrlGenerator` service, which does not exist.) 411 | 412 | ## 0.5.0 - 2017-10-30 413 | 414 | ### Added 415 | 416 | - Nothing. 417 | 418 | ### Changed 419 | 420 | - [#20](https://github.com/zendframework/zend-expressive-hal/pull/20) renames 421 | the following interfaces and traits to have `Interface` and `Trait` suffixes, 422 | respectively; this was done for consistency with existing ZF packages. (Values 423 | after the `:` retain the namespace, which is omitted for brevity.) 424 | 425 | - `Zend\Expressive\Hal\LinkGenerator\UrlGenerator`: `UrlGeneratorInterface` 426 | - `Zend\Expressive\Hal\Renderer\Renderer`: `RendererInterface` 427 | - `Zend\Expressive\Hal\ResourceGenerator\Strategy`: `StrategyInterface` 428 | - `Zend\Expressive\Hal\ResourceGenerator\ExtractCollection`: `ExtractCollectionTrait` 429 | - `Zend\Expressive\Hal\ResourceGenerator\ExtractInstance`: `ExtractInstanceTrait` 430 | 431 | - [#16](https://github.com/zendframework/zend-expressive-hal/pull/16) renames 432 | the various `Exception` interfaces to `ExceptionInterface`, in order to be 433 | consistent with other ZF packages. 434 | 435 | ### Deprecated 436 | 437 | - Nothing. 438 | 439 | ### Removed 440 | 441 | - Nothing. 442 | 443 | ### Fixed 444 | 445 | - Nothing. 446 | 447 | ## 0.4.3 - 2017-10-30 448 | 449 | ### Added 450 | 451 | - Nothing. 452 | 453 | ### Changed 454 | 455 | - Nothing. 456 | 457 | ### Deprecated 458 | 459 | - Nothing. 460 | 461 | ### Removed 462 | 463 | - Nothing. 464 | 465 | ### Fixed 466 | 467 | - [#19](https://github.com/zendframework/zend-expressive-hal/pull/19) fixes the 468 | behavior of `ResourceGenerator` when nesting a collection inside another 469 | resource to properly nest it as an array of items, rather than a collection 470 | resource. 471 | 472 | - [#18](https://github.com/zendframework/zend-expressive-hal/pull/18) fixes the 473 | return type hint of `RouteBasedResourceMetadata::setRouteParams()` to correctly 474 | be `void`. 475 | 476 | - [#13](https://github.com/zendframework/zend-expressive-hal/pull/13) updates 477 | `ExtractCollection::extractPaginator()` to validate that the pagination 478 | parameter is within the range of pages represented by the paginator instance; 479 | if not, an `OutOfBoundsException` is raised. 480 | 481 | - [#12](https://github.com/zendframework/zend-expressive-hal/pull/12) fixes how pagination 482 | metadata (`_page`, `_page_count`, `_total_items`) is represented in generated 483 | resources, ensuring values are cast to integers. 484 | 485 | ## 0.4.2 - 2017-09-20 486 | 487 | ### Added 488 | 489 | - Nothing. 490 | 491 | ### Changed 492 | 493 | - Nothing. 494 | 495 | ### Deprecated 496 | 497 | - Nothing. 498 | 499 | ### Removed 500 | 501 | - Nothing. 502 | 503 | ### Fixed 504 | 505 | - [#7](https://github.com/zendframework/zend-expressive-hal/pull/7) fixes a number of issues in 506 | the various exception implementations due to failure to import classes 507 | referenced in typehints. 508 | 509 | - [#6](https://github.com/zendframework/zend-expressive-hal/pull/6) fixes a number of docblock 510 | annotations to reference `HalResource` vs `Resource` (which is a reserved 511 | word). 512 | 513 | ## 0.4.1 - 2017-08-08 514 | 515 | ### Added 516 | 517 | - Nothing. 518 | 519 | ### Changed 520 | 521 | - Nothing. 522 | 523 | ### Deprecated 524 | 525 | - Nothing. 526 | 527 | ### Removed 528 | 529 | - Nothing. 530 | 531 | ### Fixed 532 | 533 | - [#6](https://github.com/zendframework/zend-expressive-hal/pull/6) fixes an issue with the XML 534 | renderer when creating resource elements that represent an array. 535 | 536 | ## 0.4.0 - 2017-08-08 537 | 538 | ### Added 539 | 540 | - Nothing. 541 | 542 | ### Changed 543 | 544 | - The package name was changed to "zendframework/zend-expressive-hal". 545 | - The namespace was changed from `Hal` to `Zend\Expressive\Hal`. 546 | 547 | ### Deprecated 548 | 549 | - Nothing. 550 | 551 | ### Removed 552 | 553 | - Nothing. 554 | 555 | ### Fixed 556 | 557 | - Nothing. 558 | 559 | ## 0.3.0 - 2017-08-07 560 | 561 | ### Added 562 | 563 | - [#4](https://github.com/weierophinney/hal/pull/4) adds the ability to force 564 | both links and embedded resources to be rendered as collections, even if the 565 | given relation only contains one item. 566 | 567 | To force a link to be rendered as a collection, pass the attribute 568 | `__FORCE__COLLECTION__` with a boolean value of `true` (or use the constant 569 | `Link::AS_COLLECTION` to refer to the attribute name). 570 | 571 | To force an embedded resource to be rendered as a collection, pass a boolean 572 | `true` as the third argument to `embed()`. Alternately, pass an array 573 | containing the single resource to any of the constructor, `withElement()`, or 574 | `embed()`. 575 | 576 | ### Changed 577 | 578 | - Nothing. 579 | 580 | ### Deprecated 581 | 582 | - Nothing. 583 | 584 | ### Removed 585 | 586 | - Nothing. 587 | 588 | ### Fixed 589 | 590 | - Nothing. 591 | 592 | ## 0.2.0 - 2017-07-13 593 | 594 | ### Added 595 | 596 | - [#1](https://github.com/weierophinney/hal/pull/1) adds a `Hal\Renderer` 597 | subcomponent with the following: 598 | - `Renderer` interface 599 | - `JsonRenderer`, for creating JSON representations of `HalResource` instances. 600 | - `XmlRenderer`, for creating XML representations of `HalResource` instances. 601 | 602 | ### Changed 603 | 604 | - [#1](https://github.com/weierophinney/hal/pull/1) changes `Hal\HalResponseFactory` 605 | to compose a `JsonRenderer` and `XmlRenderer`, instead of composing 606 | `$jsonFlags` and creating representations itself. 607 | 608 | It also makes the response prototype and the stream factory the first 609 | arguments, as those will be the values most often injected. 610 | 611 | The constructor signature is 612 | now: 613 | 614 | ```php 615 | public function __construct( 616 | Psr\Http\Message\ResponseInterface $responsePrototype = null, 617 | callable $streamFactory = null, 618 | Hal\Renderer\JsonRenderer $jsonRenderer = null, 619 | Hal\Renderer\XmlRenderer $xmlRenderer = null 620 | ) { 621 | ``` 622 | 623 | - [#1](https://github.com/weierophinney/hal/pull/1) changes `Hal\HalResponseFactoryFactory` 624 | to comply with the new constructor signature of `Hal\HalResponseFactory`. It 625 | also updates to check for `Psr\Http\Message\ResponseInterface` and 626 | `Psr\Http\Message\StreamInterface` services before attempting to use 627 | zend-diactoros classes. 628 | 629 | ### Deprecated 630 | 631 | - Nothing. 632 | 633 | ### Removed 634 | 635 | - Nothing. 636 | 637 | ### Fixed 638 | 639 | - Nothing. 640 | 641 | ## 0.1.6 - 2017-07-12 642 | 643 | ### Added 644 | 645 | - Adds keywords to the `composer.json` 646 | - Adds a "provides" section to the `composer.json` (provides PSR-13 implementation) 647 | - Adds `composer.json` suggestions for: 648 | - PSR-11 implementation 649 | - zend-paginator 650 | 651 | ### Deprecated 652 | 653 | - Nothing. 654 | 655 | ### Removed 656 | 657 | - Nothing. 658 | 659 | ### Fixed 660 | 661 | - Nothing. 662 | 663 | ## 0.1.5 - 2017-07-12 664 | 665 | ### Added 666 | 667 | - Adds documentation; see the [doc/book/](doc/book/) tree, or browse at 668 | https://weierophinney.github.io/hal/ 669 | 670 | ### Deprecated 671 | 672 | - Nothing. 673 | 674 | ### Removed 675 | 676 | - Nothing. 677 | 678 | ### Fixed 679 | 680 | - Nothing. 681 | 682 | ## 0.1.4 - 2017-07-12 683 | 684 | ### Added 685 | 686 | - Adds the method `templatedFromRoute()` to the `LinkGenerator` class. Acts 687 | exactly like `fromRoute()`, but the generated `Link` instance will have the 688 | `isTemplated` property toggled `true`. 689 | 690 | ### Deprecated 691 | 692 | - Nothing. 693 | 694 | ### Removed 695 | 696 | - Nothing. 697 | 698 | ### Fixed 699 | 700 | - Nothing. 701 | 702 | ## 0.1.3 - 2017-07-11 703 | 704 | ### Added 705 | 706 | - Nothing. 707 | 708 | ### Deprecated 709 | 710 | - Nothing. 711 | 712 | ### Removed 713 | 714 | - Nothing. 715 | 716 | ### Fixed 717 | 718 | - Fixes registration of the `MetadataMap` in the `ConfigProvider`; it was 719 | previously using an incorrect namespace. 720 | 721 | ## 0.1.2 - 2017-07-11 722 | 723 | ### Added 724 | 725 | - Adds `HalResponseFactoryFactory`, a factory for generating a 726 | `HalResponseFactory` instance. 727 | 728 | ### Deprecated 729 | 730 | - Nothing. 731 | 732 | ### Removed 733 | 734 | - Nothing. 735 | 736 | ### Fixed 737 | 738 | - Nothing. 739 | 740 | ## 0.1.1 - 2017-07-11 741 | 742 | ### Added 743 | 744 | - Adds the ability to inject route params and query string arguments at run-time 745 | to the route-based metadata instances. 746 | 747 | When dealing with route-based metadata, we may be dealing with 748 | sub-resources; in such cases, the route parameters may be derived from 749 | the request, and we will want to inject them at run-time. 750 | 751 | When dealing with collections, the query string arguments may indicate 752 | things such as searches, sort directions, sort columns, filters, limits, 753 | etc.; these will be derived from the request, and need to be injected at 754 | run-time. 755 | 756 | ### Deprecated 757 | 758 | - Nothing. 759 | 760 | ### Removed 761 | 762 | - Nothing. 763 | 764 | ### Fixed 765 | 766 | - Nothing. 767 | 768 | ## 0.1.0 - 2017-07-10 769 | 770 | Initial Release. 771 | 772 | ### Added 773 | 774 | - Everything. 775 | 776 | ### Deprecated 777 | 778 | - Nothing. 779 | 780 | ### Removed 781 | 782 | - Nothing. 783 | 784 | ### Fixed 785 | 786 | - Nothing. 787 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Zend Technologies USA, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | - Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | - Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | - Neither the name of Zend Technologies USA, Inc. nor the names of its 15 | contributors may be used to endorse or promote products derived from this 16 | software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hypertext Application Language (HAL) for PSR-7 Applications 2 | 3 | > ## Repository abandoned 2019-12-31 4 | > 5 | > This repository has moved to [mezzio/mezzio-hal](https://github.com/mezzio/mezzio-hal). 6 | 7 | [![Build Status](https://secure.travis-ci.org/zendframework/zend-expressive-hal.svg?branch=master)](https://secure.travis-ci.org/zendframework/zend-expressive-hal) 8 | [![Coverage Status](https://coveralls.io/repos/github/zendframework/zend-expressive-hal/badge.svg?branch=master)](https://coveralls.io/github/zendframework/zend-expressive-hal?branch=master) 9 | 10 | This library provides utilities for modeling HAL resources with links and 11 | generating [PSR-7](http://www.php-fig.org/psr/psr-7/) responses representing 12 | both JSON and XML serializations of them. 13 | 14 | ## Installation 15 | 16 | Run the following to install this library: 17 | 18 | ```bash 19 | $ composer require zendframework/zend-expressive-hal 20 | ``` 21 | 22 | ## Documentation 23 | 24 | Documentation is [in the doc tree](docs/book/), and can be compiled using [mkdocs](http://www.mkdocs.org): 25 | 26 | ```bash 27 | $ mkdocs build 28 | ``` 29 | 30 | You may also [browse the documentation online](https://docs.zendframework.com/zend-expressive-hal/). 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zendframework/zend-expressive-hal", 3 | "description": "Hypertext Application Language implementation for PHP and PSR-7", 4 | "license": "BSD-3-Clause", 5 | "keywords": [ 6 | "expressive", 7 | "hal", 8 | "http", 9 | "psr", 10 | "psr-7", 11 | "psr-11", 12 | "psr-13", 13 | "rest", 14 | "zf", 15 | "zendframework", 16 | "zend-expressive" 17 | ], 18 | "support": { 19 | "docs": "https://docs.zendframework.com/zend-expressive-hal/", 20 | "issues": "https://github.com/zendframework/zend-expressive-hal/issues", 21 | "source": "https://github.com/zendframework/zend-expressive-hal", 22 | "rss": "https://github.com/zendframework/zend-expressive-hal/releases.atom", 23 | "slack": "https://zendframework-slack.herokuapp.com", 24 | "forum": "https://discourse.zendframework.com/c/questions/expressive" 25 | }, 26 | "minimum-stability": "alpha", 27 | "require": { 28 | "php": "^7.1", 29 | "psr/http-message": "^1.0.1", 30 | "psr/link": "^1.0", 31 | "willdurand/negotiation": "^2.3.1" 32 | }, 33 | "require-dev": { 34 | "doctrine/orm": "^2.6", 35 | "phpunit/phpunit": "^7.0.1", 36 | "zendframework/zend-coding-standard": "~1.0.0", 37 | "zendframework/zend-expressive-helpers": "^5.0.0alpha3", 38 | "zendframework/zend-hydrator": "^2.3.1 || ^3.0", 39 | "zendframework/zend-paginator": "^2.7" 40 | }, 41 | "provide": { 42 | "psr/link-implementation": "1.0" 43 | }, 44 | "suggest": { 45 | "psr/container-implementation": "^1.0 in order to use the provided PSR-11 factories", 46 | "zendframework/zend-expressive-helpers": "^5.0 in order to use UrlHelper/ServerUrlHelper-based ExpressiveUrlGenerator with the LinkGenerator", 47 | "zendframework/zend-hydrator": "^2.3.1 in order to use the ResourceGenerator to create Resource instances from objects", 48 | "zendframework/zend-paginator": "^2.7 in order to provide paginated collections" 49 | }, 50 | "autoload": { 51 | "psr-4": { 52 | "Zend\\Expressive\\Hal\\": "src/" 53 | } 54 | }, 55 | "autoload-dev": { 56 | "psr-4": { 57 | "ZendTest\\Expressive\\Hal\\": "test/" 58 | } 59 | }, 60 | "config": { 61 | "sort-packages": true 62 | }, 63 | "extra": { 64 | "branch-alias": { 65 | "dev-master": "1.3.x-dev", 66 | "dev-develop": "1.4.x-dev" 67 | }, 68 | "zf": { 69 | "config-provider": "Zend\\Expressive\\Hal\\ConfigProvider" 70 | } 71 | }, 72 | "scripts": { 73 | "check": [ 74 | "@cs-check", 75 | "@test" 76 | ], 77 | "cs-check": "phpcs", 78 | "cs-fix": "phpcbf", 79 | "test": "phpunit --colors=always", 80 | "test-coverage": "phpunit --colors=always --coverage-clover clover.xml" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | $this->getDependencies(), 32 | 'zend-expressive-hal' => $this->getHalConfig(), 33 | ]; 34 | } 35 | 36 | public function getDependencies() : array 37 | { 38 | return [ 39 | 'aliases' => [ 40 | UrlGeneratorInterface::class => LinkGenerator\ExpressiveUrlGenerator::class, 41 | ], 42 | 'factories' => [ 43 | HalResponseFactory::class => HalResponseFactoryFactory::class, 44 | LinkGenerator::class => LinkGeneratorFactory::class, 45 | ExpressiveUrlGenerator::class => LinkGenerator\ExpressiveUrlGeneratorFactory::class, 46 | MetadataMap::class => Metadata\MetadataMapFactory::class, 47 | ResourceGenerator::class => ResourceGeneratorFactory::class, 48 | ], 49 | 'invokables' => [ 50 | RouteBasedCollectionStrategy::class => RouteBasedCollectionStrategy::class, 51 | RouteBasedResourceStrategy::class => RouteBasedResourceStrategy::class, 52 | 53 | UrlBasedCollectionStrategy::class => UrlBasedCollectionStrategy::class, 54 | UrlBasedResourceStrategy::class => UrlBasedResourceStrategy::class 55 | ], 56 | ]; 57 | } 58 | 59 | public function getHalConfig() : array 60 | { 61 | return [ 62 | 'resource-generator' => [ 63 | 'strategies' => [ // The registered strategies and their metadata types 64 | RouteBasedCollectionMetadata::class => RouteBasedCollectionStrategy::class, 65 | RouteBasedResourceMetadata::class => RouteBasedResourceStrategy::class, 66 | 67 | UrlBasedCollectionMetadata::class => UrlBasedCollectionStrategy::class, 68 | UrlBasedResourceMetadata::class => UrlBasedResourceStrategy::class, 69 | ], 70 | ], 71 | 'metadata-factories' => [ // The factories for the metadata types 72 | RouteBasedCollectionMetadata::class => RouteBasedCollectionMetadataFactory::class, 73 | RouteBasedResourceMetadata::class => RouteBasedResourceMetadataFactory::class, 74 | 75 | UrlBasedCollectionMetadata::class => UrlBasedCollectionMetadataFactory::class, 76 | UrlBasedResourceMetadata::class => UrlBasedResourceMetadataFactory::class, 77 | ], 78 | ]; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | validateElementName($name, $context); 64 | if (! empty($value) 65 | && ($value instanceof self || $this->isResourceCollection($value, $name, $context)) 66 | ) { 67 | $this->embedded[$name] = $value; 68 | return; 69 | } 70 | $this->data[$name] = $value; 71 | }); 72 | 73 | array_walk($embedded, function ($resource, $name) use ($context) { 74 | $this->validateElementName($name, $context); 75 | $this->detectCollisionWithData($name, $context); 76 | if (! ($resource instanceof self || $this->isResourceCollection($resource, $name, $context))) { 77 | throw new InvalidArgumentException(sprintf( 78 | 'Invalid embedded resource provided to %s constructor with name "%s"', 79 | $context, 80 | $name 81 | )); 82 | } 83 | $this->embedded[$name] = $resource; 84 | }); 85 | 86 | if (array_reduce($links, function ($containsNonLinkItem, $link) { 87 | return $containsNonLinkItem || ! $link instanceof LinkInterface; 88 | }, false)) { 89 | throw new InvalidArgumentException('Non-Link item provided in $links array'); 90 | } 91 | $this->links = $links; 92 | } 93 | 94 | /** 95 | * Retrieve a named element from the resource. 96 | * 97 | * If the element does not exist, but a corresponding embedded resource 98 | * is present, the embedded resource will be returned. 99 | * 100 | * If the element does not exist at all, a null value is returned. 101 | * 102 | * @param string $name 103 | * @return mixed 104 | * @throws InvalidArgumentException if $name is empty 105 | * @throws InvalidArgumentException if $name is a reserved keyword 106 | */ 107 | public function getElement(string $name) 108 | { 109 | $this->validateElementName($name, __METHOD__); 110 | 111 | if (! isset($this->data[$name]) && ! isset($this->embedded[$name])) { 112 | return null; 113 | } 114 | 115 | if (isset($this->embedded[$name])) { 116 | return $this->embedded[$name]; 117 | } 118 | 119 | return $this->data[$name]; 120 | } 121 | 122 | /** 123 | * Retrieve all elements of the resource. 124 | * 125 | * Returned as a set of key/value pairs. Embedded resources are mixed 126 | * in as `HalResource` instances under the associated key. 127 | */ 128 | public function getElements() : array 129 | { 130 | return array_merge($this->data, $this->embedded); 131 | } 132 | 133 | /** 134 | * Return an instance including the named element. 135 | * 136 | * If the value is another resource, proxies to embed(). 137 | * 138 | * If the $name existed in the original instance, it will be overwritten 139 | * by $value in the returned instance. 140 | * 141 | * @param string $name 142 | * @param mixed $value 143 | * @return HalResource 144 | * @throws InvalidArgumentException if $name is empty 145 | * @throws InvalidArgumentException if $name is a reserved keyword 146 | * @throws RuntimeException if $name is already in use for an embedded 147 | * resource. 148 | */ 149 | public function withElement(string $name, $value) : HalResource 150 | { 151 | $this->validateElementName($name, __METHOD__); 152 | 153 | if (! empty($value) 154 | && ($value instanceof self || $this->isResourceCollection($value, $name, __METHOD__)) 155 | ) { 156 | return $this->embed($name, $value); 157 | } 158 | 159 | $this->detectCollisionWithEmbeddedResource($name, __METHOD__); 160 | 161 | $new = clone $this; 162 | $new->data[$name] = $value; 163 | return $new; 164 | } 165 | 166 | /** 167 | * Return an instance removing the named element or embedded resource. 168 | * 169 | * @param string $name 170 | * @return HalResource 171 | * @throws InvalidArgumentException if $name is empty 172 | * @throws InvalidArgumentException if $name is a reserved keyword 173 | */ 174 | public function withoutElement(string $name) : HalResource 175 | { 176 | $this->validateElementName($name, __METHOD__); 177 | 178 | if (isset($this->data[$name])) { 179 | $new = clone $this; 180 | unset($new->data[$name]); 181 | return $new; 182 | } 183 | 184 | if (isset($this->embedded[$name])) { 185 | $new = clone $this; 186 | unset($new->embedded[$name]); 187 | return $new; 188 | } 189 | 190 | return $this; 191 | } 192 | 193 | /** 194 | * Return an instance containing the provided elements. 195 | * 196 | * If any given element exists, either as top-level data or as an embedded 197 | * resource, it will be replaced. Otherwise, the new elements are added to 198 | * the resource returned. 199 | */ 200 | public function withElements(array $elements) : HalResource 201 | { 202 | $resource = $this; 203 | foreach ($elements as $name => $value) { 204 | $resource = $resource->withElement($name, $value); 205 | } 206 | 207 | return $resource; 208 | } 209 | 210 | /** 211 | * @param string $name 212 | * @param HalResource|HalResource[] $resource 213 | * @param bool $forceCollection Whether or not a single resource or an 214 | * array containing a single resource should be represented as an array of 215 | * resources during representation. 216 | * @return HalResource 217 | */ 218 | public function embed(string $name, $resource, bool $forceCollection = false) : HalResource 219 | { 220 | $this->validateElementName($name, __METHOD__); 221 | $this->detectCollisionWithData($name, __METHOD__); 222 | if (! $resource instanceof self && ! $this->isResourceCollection($resource, $name, __METHOD__)) { 223 | throw new InvalidArgumentException(sprintf( 224 | '%s expects a %s instance or array of %s instances; received %s', 225 | __METHOD__, 226 | __CLASS__, 227 | __CLASS__, 228 | is_object($resource) ? get_class($resource) : gettype($resource) 229 | )); 230 | } 231 | $new = clone $this; 232 | $new->embedded[$name] = $this->aggregateEmbeddedResource($name, $resource, __METHOD__, $forceCollection); 233 | return $new; 234 | } 235 | 236 | public function toArray() : array 237 | { 238 | $resource = $this->data; 239 | 240 | $links = $this->serializeLinks(); 241 | if (! empty($links)) { 242 | $resource['_links'] = $links; 243 | } 244 | 245 | $embedded = $this->serializeEmbeddedResources(); 246 | if (! empty($embedded)) { 247 | $resource['_embedded'] = $embedded; 248 | } 249 | 250 | return $resource; 251 | } 252 | 253 | public function jsonSerialize() 254 | { 255 | return $this->toArray(); 256 | } 257 | 258 | /** 259 | * @throws InvalidArgumentException if $name is empty 260 | * @throws InvalidArgumentException if $name is a reserved keyword 261 | */ 262 | private function validateElementName(string $name, string $context) : void 263 | { 264 | if (empty($name)) { 265 | throw new InvalidArgumentException(sprintf( 266 | '$name provided to %s cannot be empty', 267 | $context 268 | )); 269 | } 270 | if (in_array($name, ['_links', '_embedded'], true)) { 271 | throw new InvalidArgumentException(sprintf( 272 | 'Error calling %s: %s is not a reserved element $name and cannot be retrieved', 273 | $context, 274 | $name 275 | )); 276 | } 277 | } 278 | 279 | private function detectCollisionWithData(string $name, string $context) : void 280 | { 281 | if (isset($this->data[$name])) { 282 | throw new RuntimeException(sprintf( 283 | 'Collision detected in %s; attempt to embed resource matching element name "%s"', 284 | $context, 285 | $name 286 | )); 287 | } 288 | } 289 | 290 | private function detectCollisionWithEmbeddedResource(string $name, string $context) : void 291 | { 292 | if (isset($this->embedded[$name])) { 293 | throw new RuntimeException(sprintf( 294 | 'Collision detected in %s; attempt to add element matching resource name "%s"', 295 | $context, 296 | $name 297 | )); 298 | } 299 | } 300 | 301 | /** 302 | * Determine how to aggregate an embedded resource. 303 | * 304 | * If no embedded resource exists with the given name, returns it verbatim. 305 | * 306 | * If another does, it compares the new resource with the old, raising an 307 | * exception if they differ in structure, and returning an array containing 308 | * both if they do not. 309 | * 310 | * If another does as an array, it compares the new resource with the 311 | * structure of the first element; if they are comparable, then it appends 312 | * the new one to the list. 313 | * 314 | * @return HalResource|HalResource[] 315 | */ 316 | private function aggregateEmbeddedResource(string $name, $resource, string $context, bool $forceCollection) 317 | { 318 | if (! isset($this->embedded[$name])) { 319 | return $forceCollection ? [$resource] : $resource; 320 | } 321 | 322 | // $resource is an collection; existing individual or collection resource exists 323 | if (is_array($resource)) { 324 | return $this->aggregateEmbeddedCollection($name, $resource, $context); 325 | } 326 | 327 | // $resource is a HalResource; existing resource is also a HalResource 328 | if ($this->embedded[$name] instanceof self) { 329 | $this->compareResources( 330 | $this->embedded[$name], 331 | $resource, 332 | $name, 333 | $context 334 | ); 335 | return [$this->embedded[$name], $resource]; 336 | } 337 | 338 | // $resource is a HalResource; existing collection present 339 | $this->compareResources( 340 | $this->firstResource($this->embedded[$name]), 341 | $resource, 342 | $name, 343 | $context 344 | ); 345 | $collection = $this->embedded[$name]; 346 | array_push($collection, $resource); 347 | return $collection; 348 | } 349 | 350 | private function aggregateEmbeddedCollection(string $name, array $collection, string $context) : array 351 | { 352 | $original = $this->embedded[$name] instanceof self ? [$this->embedded[$name]] : $this->embedded[$name]; 353 | $this->compareResources( 354 | $this->firstResource($original), 355 | $this->firstResource($collection), 356 | $name, 357 | $context 358 | ); 359 | return $original + $collection; 360 | } 361 | 362 | /** 363 | * Return the first resource in a list. 364 | * 365 | * Exists as array_shift is destructive, and we cannot necessarily know the 366 | * index of the first element. 367 | */ 368 | private function firstResource(array $resources) 369 | { 370 | foreach ($resources as $resource) { 371 | return $resource; 372 | } 373 | } 374 | 375 | private function isResourceCollection($value, string $name, string $context) : bool 376 | { 377 | if (! is_array($value)) { 378 | return false; 379 | } 380 | 381 | if (! array_reduce($value, function ($isResource, $item) { 382 | return $isResource && $item instanceof self; 383 | }, true)) { 384 | return false; 385 | } 386 | 387 | $resource = $this->firstResource($value); 388 | return array_reduce($value, function ($matchesCollection, $item) use ($name, $resource, $context) { 389 | return $matchesCollection && $this->compareResources($resource, $item, $name, $context); 390 | }, true); 391 | } 392 | 393 | private function serializeLinks() 394 | { 395 | $relations = array_reduce($this->links, function (array $byRelation, LinkInterface $link) { 396 | $representation = array_merge($link->getAttributes(), [ 397 | 'href' => $link->getHref(), 398 | ]); 399 | if ($link->isTemplated()) { 400 | $representation['templated'] = true; 401 | } 402 | 403 | $linkRels = $link->getRels(); 404 | array_walk($linkRels, function ($rel) use (&$byRelation, $representation) { 405 | $forceCollection = array_key_exists(Link::AS_COLLECTION, $representation) 406 | ? (bool) $representation[Link::AS_COLLECTION] 407 | : false; 408 | unset($representation[Link::AS_COLLECTION]); 409 | 410 | if (isset($byRelation[$rel])) { 411 | $byRelation[$rel][] = $representation; 412 | } else { 413 | $byRelation[$rel] = [$representation]; 414 | } 415 | 416 | // If we're forcing a collection, and the current relation only 417 | // has one item, mark the relation to force a collection 418 | if (1 === count($byRelation[$rel]) && $forceCollection) { 419 | $byRelation[$rel][Link::AS_COLLECTION] = true; 420 | } 421 | 422 | // If we have more than one link for the relation, and the 423 | // marker for forcing a collection is present, remove the 424 | // marker; it's redundant. Check for a count greater than 2, 425 | // as the marker itself will affect the count! 426 | if (2 < count($byRelation[$rel]) && isset($byRelation[$rel][Link::AS_COLLECTION])) { 427 | unset($byRelation[$rel][Link::AS_COLLECTION]); 428 | } 429 | }); 430 | 431 | return $byRelation; 432 | }, []); 433 | 434 | array_walk($relations, function ($links, $key) use (&$relations) { 435 | if (isset($relations[$key][Link::AS_COLLECTION])) { 436 | // If forcing a collection, do nothing to the links, but DO 437 | // remove the marker indicating a collection should be 438 | // returned. 439 | unset($relations[$key][Link::AS_COLLECTION]); 440 | return; 441 | } 442 | 443 | $relations[$key] = 1 === count($links) ? array_shift($links) : $links; 444 | }); 445 | 446 | return $relations; 447 | } 448 | 449 | private function serializeEmbeddedResources() 450 | { 451 | $embedded = []; 452 | array_walk($this->embedded, function ($resource, $name) use (&$embedded) { 453 | $embedded[$name] = $resource instanceof self 454 | ? $resource->toArray() 455 | : array_map(function ($item) { 456 | return $item->toArray(); 457 | }, $resource); 458 | }); 459 | 460 | return $embedded; 461 | } 462 | 463 | /** 464 | * @throws InvalidArgumentException if $a and $b are not structurally equivalent. 465 | */ 466 | private function compareResources(self $a, self $b, string $name, string $context) : bool 467 | { 468 | $structureA = array_keys($a->getElements()); 469 | $structureB = array_keys($b->getElements()); 470 | sort($structureA); 471 | sort($structureB); 472 | if ($structureA !== $structureB) { 473 | throw new InvalidArgumentException(sprintf( 474 | '%s detected structurally inequivalent resources for element %s', 475 | $context, 476 | $name 477 | )); 478 | } 479 | return true; 480 | } 481 | } 482 | -------------------------------------------------------------------------------- /src/HalResponseFactory.php: -------------------------------------------------------------------------------- 1 | responseFactory = function () use ($responseFactory) : ResponseInterface { 50 | return $responseFactory(); 51 | }; 52 | $this->jsonRenderer = $jsonRenderer ?: new Renderer\JsonRenderer(); 53 | $this->xmlRenderer = $xmlRenderer ?: new Renderer\XmlRenderer(); 54 | } 55 | 56 | public function createResponse( 57 | ServerRequestInterface $request, 58 | HalResource $resource, 59 | string $mediaType = self::DEFAULT_CONTENT_TYPE 60 | ) : ResponseInterface { 61 | $accept = $request->getHeaderLine('Accept') ?: '*/*'; 62 | $matchedType = (new Negotiator())->getBest($accept, self::NEGOTIATION_PRIORITIES); 63 | 64 | switch (true) { 65 | case ($matchedType && strstr($matchedType->getValue(), 'json')): 66 | $renderer = $this->jsonRenderer; 67 | $mediaType = $mediaType . '+json'; 68 | break; 69 | case (! $matchedType): 70 | // fall-through 71 | default: 72 | $renderer = $this->xmlRenderer; 73 | $mediaType = $mediaType . '+xml'; 74 | break; 75 | } 76 | 77 | $response = ($this->responseFactory)(); 78 | $response->getBody()->write($renderer->render($resource)); 79 | return $response->withHeader('Content-Type', $mediaType); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/HalResponseFactoryFactory.php: -------------------------------------------------------------------------------- 1 | has(Renderer\JsonRenderer::class) 32 | ? $container->get(Renderer\JsonRenderer::class) 33 | : new Renderer\JsonRenderer(); 34 | 35 | $xmlRenderer = $container->has(Renderer\XmlRenderer::class) 36 | ? $container->get(Renderer\XmlRenderer::class) 37 | : new Renderer\XmlRenderer(); 38 | 39 | return new HalResponseFactory( 40 | $container->get(ResponseInterface::class), 41 | $jsonRenderer, 42 | $xmlRenderer 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Link.php: -------------------------------------------------------------------------------- 1 | relations = $this->validateRelation($relation); 61 | $this->uri = is_string($uri) ? $uri : (string) $uri; 62 | $this->isTemplated = $isTemplated; 63 | $this->attributes = $this->validateAttributes($attributes); 64 | } 65 | 66 | /** 67 | * {@inheritDoc} 68 | */ 69 | public function getHref() 70 | { 71 | return $this->uri; 72 | } 73 | 74 | /** 75 | * {@inheritDoc} 76 | */ 77 | public function isTemplated() 78 | { 79 | return $this->isTemplated; 80 | } 81 | 82 | /** 83 | * {@inheritDoc} 84 | */ 85 | public function getRels() 86 | { 87 | return $this->relations; 88 | } 89 | 90 | /** 91 | * {@inheritDoc} 92 | */ 93 | public function getAttributes() 94 | { 95 | return $this->attributes; 96 | } 97 | 98 | /** 99 | * {@inheritDoc} 100 | * @throws InvalidArgumentException if $href is not a string, and not an 101 | * object implementing __toString. 102 | */ 103 | public function withHref($href) 104 | { 105 | if (! is_string($href) 106 | && ! (is_object($href) && method_exists($href, '__toString')) 107 | ) { 108 | throw new InvalidArgumentException(sprintf( 109 | '%s expects a string URI or an object implementing __toString; received %s', 110 | __METHOD__, 111 | is_object($href) ? get_class($href) : gettype($href) 112 | )); 113 | } 114 | $new = clone $this; 115 | $new->uri = (string) $href; 116 | return $new; 117 | } 118 | 119 | /** 120 | * {@inheritDoc} 121 | * @throws InvalidArgumentException if $rel is not a string. 122 | */ 123 | public function withRel($rel) 124 | { 125 | if (! is_string($rel) || empty($rel)) { 126 | throw new InvalidArgumentException(sprintf( 127 | '%s expects a non-empty string relation type; received %s', 128 | __METHOD__, 129 | is_object($rel) ? get_class($rel) : gettype($rel) 130 | )); 131 | } 132 | 133 | if (in_array($rel, $this->relations, true)) { 134 | return $this; 135 | } 136 | 137 | $new = clone $this; 138 | $new->relations[] = $rel; 139 | return $new; 140 | } 141 | 142 | /** 143 | * {@inheritDoc} 144 | */ 145 | public function withoutRel($rel) 146 | { 147 | if (! is_string($rel) || empty($rel)) { 148 | return $this; 149 | } 150 | 151 | if (! in_array($rel, $this->relations, true)) { 152 | return $this; 153 | } 154 | 155 | $new = clone $this; 156 | $new->relations = array_filter($this->relations, function ($value) use ($rel) { 157 | return $rel !== $value; 158 | }); 159 | return $new; 160 | } 161 | 162 | /** 163 | * {@inheritDoc} 164 | * @throws InvalidArgumentException if $attribute is not a string or is empty. 165 | * @throws InvalidArgumentException if $value is neither a scalar nor an array. 166 | * @throws InvalidArgumentException if $value is an array, but one or more values 167 | * is not a string. 168 | */ 169 | public function withAttribute($attribute, $value) 170 | { 171 | $this->validateAttributeName($attribute, __METHOD__); 172 | $this->validateAttributeValue($value, __METHOD__); 173 | 174 | $new = clone $this; 175 | $new->attributes[$attribute] = $value; 176 | return $new; 177 | } 178 | 179 | /** 180 | * {@inheritDoc} 181 | */ 182 | public function withoutAttribute($attribute) 183 | { 184 | if (! is_string($attribute) || empty($attribute)) { 185 | return $this; 186 | } 187 | 188 | if (! isset($this->attributes[$attribute])) { 189 | return $this; 190 | } 191 | 192 | $new = clone $this; 193 | unset($new->attributes[$attribute]); 194 | return $new; 195 | } 196 | 197 | /** 198 | * @param mixed $name 199 | * @param string $context 200 | * @throws InvalidArgumentException if $attribute is not a string or is empty. 201 | */ 202 | private function validateAttributeName($name, string $context) 203 | { 204 | if (! is_string($name) || empty($name)) { 205 | throw new InvalidArgumentException(sprintf( 206 | '%s expects the $name argument to be a non-empty string; received %s', 207 | $context, 208 | is_object($name) ? get_class($name) : gettype($name) 209 | )); 210 | } 211 | } 212 | 213 | /** 214 | * @param mixed $value 215 | * @param string $context 216 | * @throws InvalidArgumentException if $value is neither a scalar nor an array. 217 | * @throws InvalidArgumentException if $value is an array, but one or more values 218 | * is not a string. 219 | */ 220 | private function validateAttributeValue($value, string $context) 221 | { 222 | if (! is_scalar($value) && ! is_array($value)) { 223 | throw new InvalidArgumentException(sprintf( 224 | '%s expects the $value to be a PHP primitive or array of strings; received %s', 225 | $context, 226 | is_object($value) ? get_class($value) : gettype($value) 227 | )); 228 | } 229 | 230 | if (is_array($value) && array_reduce($value, function ($isInvalid, $value) { 231 | return $isInvalid || ! is_string($value); 232 | }, false)) { 233 | throw new InvalidArgumentException(sprintf( 234 | '%s expects $value to contain an array of strings; one or more values was not a string', 235 | $context 236 | )); 237 | } 238 | } 239 | 240 | private function validateAttributes(array $attributes) : array 241 | { 242 | foreach ($attributes as $name => $value) { 243 | $this->validateAttributeName($name, __CLASS__); 244 | $this->validateAttributeValue($value, __CLASS__); 245 | } 246 | return $attributes; 247 | } 248 | 249 | /** 250 | * @param mixed $relation 251 | * @throws InvalidArgumentException if $relation is neither a string nor an array 252 | * @throws InvalidArgumentException if $relation is an array, but any given value in it is not a string 253 | */ 254 | private function validateRelation($relation) 255 | { 256 | if (! is_array($relation) && (! is_string($relation) || empty($relation))) { 257 | throw new InvalidArgumentException(sprintf( 258 | '$relation argument must be a string or array of strings; received %s', 259 | is_object($relation) ? get_class($relation) : gettype($relation) 260 | )); 261 | } 262 | 263 | if (is_array($relation) && false === array_reduce($relation, function ($isString, $value) { 264 | return $isString === false || is_string($value) || empty($value); 265 | }, true)) { 266 | throw new InvalidArgumentException( 267 | 'When passing an array for $relation, each value must be a non-empty string; ' 268 | . 'one or more non-string or empty values were present' 269 | ); 270 | } 271 | 272 | return is_string($relation) ? [$relation] : $relation; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/LinkCollection.php: -------------------------------------------------------------------------------- 1 | links; 33 | } 34 | 35 | /** 36 | * {@inheritDoc} 37 | */ 38 | public function getLinksByRel($rel) 39 | { 40 | return array_filter($this->links, function (LinkInterface $link) use ($rel) { 41 | $rels = $link->getRels(); 42 | return in_array($rel, $rels, true); 43 | }); 44 | } 45 | 46 | /** 47 | * {@inheritDoc} 48 | */ 49 | public function withLink(LinkInterface $link) 50 | { 51 | if (in_array($link, $this->links, true)) { 52 | return $this; 53 | } 54 | 55 | $new = clone $this; 56 | $new->links[] = $link; 57 | return $new; 58 | } 59 | 60 | /** 61 | * {@inheritDoc} 62 | */ 63 | public function withoutLink(LinkInterface $link) 64 | { 65 | if (! in_array($link, $this->links, true)) { 66 | return $this; 67 | } 68 | 69 | $new = clone $this; 70 | $new->links = array_filter($this->links, function (LinkInterface $compare) use ($link) { 71 | return $link !== $compare; 72 | }); 73 | return $new; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/LinkGenerator.php: -------------------------------------------------------------------------------- 1 | urlGenerator = $urlGenerator; 22 | } 23 | 24 | public function fromRoute( 25 | string $relation, 26 | ServerRequestInterface $request, 27 | string $routeName, 28 | array $routeParams = [], 29 | array $queryParams = [], 30 | array $attributes = [] 31 | ) : Link { 32 | return new Link($relation, $this->urlGenerator->generate( 33 | $request, 34 | $routeName, 35 | $routeParams, 36 | $queryParams 37 | ), false, $attributes); 38 | } 39 | 40 | /** 41 | * Creates a templated link 42 | */ 43 | public function templatedFromRoute( 44 | string $relation, 45 | ServerRequestInterface $request, 46 | string $routeName, 47 | array $routeParams = [], 48 | array $queryParams = [], 49 | array $attributes = [] 50 | ) : Link { 51 | return new Link($relation, $this->urlGenerator->generate( 52 | $request, 53 | $routeName, 54 | $routeParams, 55 | $queryParams 56 | ), true, $attributes); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/LinkGenerator/ExpressiveUrlGenerator.php: -------------------------------------------------------------------------------- 1 | urlHelper = $urlHelper; 29 | $this->serverUrlHelper = $serverUrlHelper; 30 | } 31 | 32 | public function generate( 33 | ServerRequestInterface $request, 34 | string $routeName, 35 | array $routeParams = [], 36 | array $queryParams = [] 37 | ) : string { 38 | $path = $this->urlHelper->generate($routeName, $routeParams, $queryParams); 39 | 40 | if (! $this->serverUrlHelper) { 41 | return $path; 42 | } 43 | 44 | $serverUrlHelper = clone $this->serverUrlHelper; 45 | $serverUrlHelper->setUri($request->getUri()); 46 | return $serverUrlHelper($path); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/LinkGenerator/ExpressiveUrlGeneratorFactory.php: -------------------------------------------------------------------------------- 1 | urlHelperServiceName = $urlHelperServiceName; 38 | } 39 | 40 | public function __invoke(ContainerInterface $container) : ExpressiveUrlGenerator 41 | { 42 | if (! $container->has($this->urlHelperServiceName)) { 43 | throw new RuntimeException(sprintf( 44 | '%s requires a %s in order to generate a %s instance; none found', 45 | __CLASS__, 46 | $this->urlHelperServiceName, 47 | ExpressiveUrlGenerator::class 48 | )); 49 | } 50 | 51 | return new ExpressiveUrlGenerator( 52 | $container->get($this->urlHelperServiceName), 53 | $container->has(ServerUrlHelper::class) ? $container->get(ServerUrlHelper::class) : null 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/LinkGenerator/UrlGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | urlGeneratorServiceName = $urlGeneratorServiceName; 33 | } 34 | 35 | public function __invoke(ContainerInterface $container) : LinkGenerator 36 | { 37 | return new LinkGenerator( 38 | $container->get($this->urlGeneratorServiceName) 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Metadata/AbstractCollectionMetadata.php: -------------------------------------------------------------------------------- 1 | collectionRelation; 27 | } 28 | 29 | public function getPaginationParam() : string 30 | { 31 | return $this->paginationParam; 32 | } 33 | 34 | public function getPaginationParamType() : string 35 | { 36 | return $this->paginationParamType; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Metadata/AbstractMetadata.php: -------------------------------------------------------------------------------- 1 | class; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Metadata/AbstractResourceMetadata.php: -------------------------------------------------------------------------------- 1 | extractor; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Metadata/Exception/DuplicateMetadataException.php: -------------------------------------------------------------------------------- 1 | 21 | * [ 22 | * '__class__' => 'Fully qualified class name of an AbstractMetadata type', 23 | * // additional key/value pairs as required by the metadata type. 24 | * ] 25 | * 26 | * 27 | * The '__class__' key decides which AbstractMetadata should be used 28 | * (and which corresponding factory will be called to create it). 29 | * @return AbstractMetadata 30 | */ 31 | public function createMetadata(string $requestedName, array $metadata) : AbstractMetadata; 32 | } 33 | -------------------------------------------------------------------------------- /src/Metadata/MetadataMap.php: -------------------------------------------------------------------------------- 1 | getClass(); 25 | if (isset($this->map[$class])) { 26 | throw Exception\DuplicateMetadataException::create($class); 27 | } 28 | 29 | if (! class_exists($class)) { 30 | throw Exception\UndefinedClassException::create($class); 31 | } 32 | 33 | $this->map[$class] = $metadata; 34 | } 35 | 36 | public function has(string $class) : bool 37 | { 38 | return isset($this->map[$class]); 39 | } 40 | 41 | /** 42 | * @throws Exception\UndefinedMetadataException if no metadata matching the 43 | * provided class is found in the map. 44 | */ 45 | public function get(string $class) : AbstractMetadata 46 | { 47 | if (! isset($this->map[$class])) { 48 | throw Exception\UndefinedMetadataException::create($class); 49 | } 50 | 51 | return $this->map[$class]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Metadata/MetadataMapFactory.php: -------------------------------------------------------------------------------- 1 | 30 | * [ 31 | * // Fully qualified class name of an AbstractMetadata type 32 | * '__class__' => MyMetadata::class, 33 | * 34 | * // additional key/value pairs as required by the metadata type. 35 | * // (See their respective factories) 36 | * ] 37 | * 38 | * 39 | * If you have created a custom metadata type, you have to register a factory 40 | * in your configuration to support it. Add an entry to the config array: 41 | * 42 | * 43 | * $config['zend-expressive-hal']['metadata-factories'][MyMetadata::class] = MyMetadataFactory::class; 44 | * 45 | * 46 | * The factory mapped should implement `MetadataFactoryInterface`. 47 | */ 48 | class MetadataMapFactory 49 | { 50 | public function __invoke(ContainerInterface $container) : MetadataMap 51 | { 52 | $config = $container->has('config') ? $container->get('config') : []; 53 | $metadataMapConfig = $config[MetadataMap::class] ?? []; 54 | 55 | if (! is_array($metadataMapConfig)) { 56 | throw Exception\InvalidConfigException::dueToNonArray($metadataMapConfig); 57 | } 58 | 59 | $metadataFactories = $config['zend-expressive-hal']['metadata-factories'] ?? []; 60 | 61 | return $this->populateMetadataMapFromConfig( 62 | new MetadataMap(), 63 | $metadataMapConfig, 64 | $metadataFactories 65 | ); 66 | } 67 | 68 | private function populateMetadataMapFromConfig( 69 | MetadataMap $metadataMap, 70 | array $metadataMapConfig, 71 | array $metadataFactories 72 | ) : MetadataMap { 73 | foreach ($metadataMapConfig as $metadata) { 74 | if (! is_array($metadata)) { 75 | throw Exception\InvalidConfigException::dueToNonArrayMetadata($metadata); 76 | } 77 | 78 | $this->injectMetadata($metadataMap, $metadata, $metadataFactories); 79 | } 80 | 81 | return $metadataMap; 82 | } 83 | 84 | /** 85 | * @throws Exception\InvalidConfigException if the metadata is missing a 86 | * "__class__" entry. 87 | * @throws Exception\InvalidConfigException if the "__class__" entry is not 88 | * a class. 89 | * @throws Exception\InvalidConfigException if the "__class__" entry is not 90 | * an AbstractMetadata class. 91 | * @throws Exception\InvalidConfigException if no matching `create*()` 92 | * method is found for the "__class__" entry. 93 | */ 94 | private function injectMetadata(MetadataMap $metadataMap, array $metadata, array $metadataFactories) 95 | { 96 | if (! isset($metadata['__class__'])) { 97 | throw Exception\InvalidConfigException::dueToMissingMetadataClass(); 98 | } 99 | 100 | if (! class_exists($metadata['__class__'])) { 101 | throw Exception\InvalidConfigException::dueToInvalidMetadataClass($metadata['__class__']); 102 | } 103 | 104 | $metadataClass = $metadata['__class__']; 105 | if (! in_array(AbstractMetadata::class, class_parents($metadataClass), true)) { 106 | throw Exception\InvalidConfigException::dueToNonMetadataClass($metadataClass); 107 | } 108 | 109 | if (isset($metadataFactories[$metadataClass])) { 110 | // A factory was registered. Use it! 111 | $metadataMap->add($this->createMetadataViaFactoryClass( 112 | $metadataClass, 113 | $metadata, 114 | $metadataFactories[$metadataClass] 115 | )); 116 | return; 117 | } 118 | 119 | // No factory was registered. Use the deprecated factory method. 120 | $metadataMap->add($this->createMetadataViaFactoryMethod( 121 | $metadataClass, 122 | $metadata 123 | )); 124 | } 125 | 126 | /** 127 | * Uses the registered factory class to create the metadata instance. 128 | * 129 | * @param string $metadataClass 130 | * @param string $factoryClass 131 | * @param array $metadata 132 | * @return AbstractMetadata 133 | */ 134 | private function createMetadataViaFactoryClass( 135 | string $metadataClass, 136 | array $metadata, 137 | string $factoryClass 138 | ) : AbstractMetadata { 139 | if (! in_array(MetadataFactoryInterface::class, class_implements($factoryClass), true)) { 140 | throw Exception\InvalidConfigException::dueToInvalidMetadataFactoryClass($factoryClass); 141 | } 142 | 143 | $factory = new $factoryClass(); 144 | /* @var $factory MetadataFactoryInterface */ 145 | return $factory->createMetadata($metadataClass, $metadata); 146 | } 147 | 148 | /** 149 | * Call the factory method in this class namend "createMyMetadata(array $metadata)". 150 | * 151 | * This function is to ensure backwards compatibility with versions prior to 0.6.0. 152 | * 153 | * @param string $metadataClass 154 | * @param array $metadata 155 | * @return AbstractMetadata 156 | */ 157 | private function createMetadataViaFactoryMethod(string $metadataClass, array $metadata) : AbstractMetadata 158 | { 159 | $normalizedClass = $this->stripNamespaceFromClass($metadataClass); 160 | $method = sprintf('create%s', $normalizedClass); 161 | 162 | if (! method_exists($this, $method)) { 163 | throw Exception\InvalidConfigException::dueToUnrecognizedMetadataClass($metadataClass); 164 | } 165 | 166 | return $this->$method($metadata); 167 | } 168 | 169 | private function stripNamespaceFromClass(string $class) : string 170 | { 171 | $segments = explode('\\', $class); 172 | return array_pop($segments); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/Metadata/RouteBasedCollectionMetadata.php: -------------------------------------------------------------------------------- 1 | class = $class; 31 | $this->collectionRelation = $collectionRelation; 32 | $this->route = $route; 33 | $this->paginationParam = $paginationParam; 34 | $this->paginationParamType = $paginationParamType; 35 | $this->routeParams = $routeParams; 36 | $this->queryStringArguments = $queryStringArguments; 37 | } 38 | 39 | public function getRoute() : string 40 | { 41 | return $this->route; 42 | } 43 | 44 | public function getRouteParams() : array 45 | { 46 | return $this->routeParams; 47 | } 48 | 49 | public function getQueryStringArguments() : array 50 | { 51 | return $this->queryStringArguments; 52 | } 53 | 54 | /** 55 | * Allow run-time overriding/injection of route parameters. 56 | * 57 | * In particular, this is useful for setting a parent identifier 58 | * in the route when dealing with child resources. 59 | */ 60 | public function setRouteParams(array $routeParams) : void 61 | { 62 | $this->routeParams = $routeParams; 63 | } 64 | 65 | /** 66 | * Allow run-time overriding/injection of query string arguments. 67 | * 68 | * In particular, this is useful for setting query string arguments for 69 | * searches, sorts, limits, etc. 70 | */ 71 | public function setQueryStringArguments(array $query) : void 72 | { 73 | $this->queryStringArguments = $query; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Metadata/RouteBasedCollectionMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 21 | * [ 22 | * // Fully qualified class name of the AbstractMetadata type. 23 | * '__class__' => RouteBasedCollectionMetadata::class, 24 | * 25 | * // Fully qualified class name of the collection class. 26 | * 'collection_class' => MyCollection::class, 27 | * 28 | * // The embedded relation for the collection in the generated resource. 29 | * 'collection_relation' => 'items', 30 | * 31 | * // The route to use when generating a self relational link for the 32 | * // collection resource. 33 | * 'route' => 'items.list', 34 | * 35 | * // Optional params 36 | * 37 | * // The name of the parameter indicating what page of data is present. 38 | * // Defaults to "page". 39 | * 'pagination_param' => 'page', 40 | * 41 | * // Whether the pagination parameter is a query string or path placeholder. 42 | * // Use either AbstractCollectionMetadata::TYPE_QUERY (the default) 43 | * // or AbstractCollectionMetadata::TYPE_PLACEHOLDER. 44 | * 'pagination_param_type' => AbstractCollectionMetadata::TYPE_QUERY, 45 | * 46 | * // An array of additional routing parameters to use when generating 47 | * // the self relational link for the collection resource. 48 | * // Defaults to an empty array. 49 | * 'route_params' => [], 50 | * 51 | * // An array of query string parameters to include when generating the 52 | * // self relational link for the collection resource. 53 | * // Defaults to an empty array. 54 | * 'query_string_arguments' => [], 55 | * ] 56 | * 57 | * @return AbstractMetadata 58 | * @throws Exception\InvalidConfigException 59 | */ 60 | public function createMetadata(string $requestedName, array $metadata) : AbstractMetadata 61 | { 62 | $requiredKeys = [ 63 | 'collection_class', 64 | 'collection_relation', 65 | 'route', 66 | ]; 67 | 68 | if ($requiredKeys !== array_intersect($requiredKeys, array_keys($metadata))) { 69 | throw Exception\InvalidConfigException::dueToMissingMetadata( 70 | RouteBasedCollectionMetadata::class, 71 | $requiredKeys 72 | ); 73 | } 74 | 75 | return new $requestedName( 76 | $metadata['collection_class'], 77 | $metadata['collection_relation'], 78 | $metadata['route'], 79 | $metadata['pagination_param'] ?? 'page', 80 | $metadata['pagination_param_type'] ?? RouteBasedCollectionMetadata::TYPE_QUERY, 81 | $metadata['route_params'] ?? [], 82 | $metadata['query_string_arguments'] ?? [] 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Metadata/RouteBasedResourceMetadata.php: -------------------------------------------------------------------------------- 1 | class = $class; 33 | $this->route = $route; 34 | $this->extractor = $extractor; 35 | $this->resourceIdentifier = $resourceIdentifier; 36 | $this->routeIdentifierPlaceholder = $routeIdentifierPlaceholder; 37 | $this->routeParams = $routeParams; 38 | } 39 | 40 | public function getRoute() : string 41 | { 42 | return $this->route; 43 | } 44 | 45 | public function getResourceIdentifier() : string 46 | { 47 | return $this->resourceIdentifier; 48 | } 49 | 50 | public function getRouteIdentifierPlaceholder() : string 51 | { 52 | return $this->routeIdentifierPlaceholder; 53 | } 54 | 55 | public function getRouteParams() : array 56 | { 57 | return $this->routeParams; 58 | } 59 | 60 | public function setRouteParams(array $routeParams) : void 61 | { 62 | $this->routeParams = $routeParams; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Metadata/RouteBasedResourceMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 21 | * [ 22 | * // Fully qualified class name of the AbstractMetadata type. 23 | * '__class__' => RouteBasedResourceMetadata::class, 24 | * 25 | * // Fully qualified class name of the resource class. 26 | * 'resource_class' => MyResource::class, 27 | * 28 | * // The route to use when generating a self relational link for 29 | * // the resource. 30 | * 'route' => 'my-resouce', 31 | * 32 | * // The extractor/hydrator service to use to extract resource data. 33 | * 'extractor' => 'MyExtractor', 34 | * 35 | * // Optional params 36 | * 37 | * // What property in the resource represents its identifier. 38 | * // Defaults to "id". 39 | * 'resource_identifier' => 'id', 40 | * 41 | * // What placeholder in the route string represents the resource 42 | * // identifier. Defaults to "id". 43 | * 'route_identifier_placeholder' => 'id', 44 | * 45 | * // An array of additional routing parameters to use when 46 | * // generating the self relational link for the collection 47 | * // resource. Defaults to an empty array. 48 | * 'route_params' => [], 49 | * ] 50 | * 51 | * @return AbstractMetadata 52 | * @throws Exception\InvalidConfigException 53 | */ 54 | public function createMetadata(string $requestedName, array $metadata) : AbstractMetadata 55 | { 56 | $requiredKeys = [ 57 | 'resource_class', 58 | 'route', 59 | 'extractor' 60 | ]; 61 | 62 | if ($requiredKeys !== array_intersect($requiredKeys, array_keys($metadata))) { 63 | throw Exception\InvalidConfigException::dueToMissingMetadata( 64 | RouteBasedResourceMetadata::class, 65 | $requiredKeys 66 | ); 67 | } 68 | 69 | return new $requestedName( 70 | $metadata['resource_class'], 71 | $metadata['route'], 72 | $metadata['extractor'], 73 | $metadata['resource_identifier'] ?? 'id', 74 | $metadata['route_identifier_placeholder'] ?? 'id', 75 | $metadata['route_params'] ?? [] 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Metadata/UrlBasedCollectionMetadata.php: -------------------------------------------------------------------------------- 1 | class = $class; 48 | $this->collectionRelation = $collectionRelation; 49 | $this->url = $url; 50 | $this->paginationParam = $paginationParam; 51 | $this->paginationParamType = $paginationParamType; 52 | } 53 | 54 | public function getUrl() : string 55 | { 56 | return $this->url; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Metadata/UrlBasedCollectionMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 21 | * [ 22 | * // Fully qualified class name of the AbstractMetadata type. 23 | * '__class__' => UrlBasedCollectionMetadata::class, 24 | * 25 | * // Fully qualified class name of the collection class. 26 | * 'collection_class' => MyCollection::class, 27 | * 28 | * // The embedded relation for the collection in the generated 29 | * // resource. 30 | * 'collection_relation' => 'items', 31 | * 32 | * // The URL to use when generating a self-relational link for 33 | * // the collection resource. 34 | * 'url' => 'https://example.org/my-collection', 35 | * 36 | * // Optional params 37 | * 38 | * // The name of the parameter indicating what page of data is 39 | * // present. Defaults to "page". 40 | * 'pagination_param' => 'page', 41 | * 42 | * // Whether the pagination parameter is a query string or path 43 | * // placeholder use either AbstractCollectionMetadata::TYPE_QUERY 44 | * // (the default) or AbstractCollectionMetadata::TYPE_PLACEHOLDER. 45 | * 'pagination_param_type' => AbstractCollectionMetadata::TYPE_QUERY, 46 | * ] 47 | * 48 | * @return AbstractMetadata 49 | * @throws Exception\InvalidConfigException 50 | */ 51 | public function createMetadata(string $requestedName, array $metadata) : AbstractMetadata 52 | { 53 | $requiredKeys = [ 54 | 'collection_class', 55 | 'collection_relation', 56 | 'url', 57 | ]; 58 | 59 | if ($requiredKeys !== array_intersect($requiredKeys, array_keys($metadata))) { 60 | throw Exception\InvalidConfigException::dueToMissingMetadata( 61 | UrlBasedCollectionMetadata::class, 62 | $requiredKeys 63 | ); 64 | } 65 | 66 | return new $requestedName( 67 | $metadata['collection_class'], 68 | $metadata['collection_relation'], 69 | $metadata['url'], 70 | $metadata['pagination_param'] ?? 'page', 71 | $metadata['pagination_param_type'] ?? UrlBasedCollectionMetadata::TYPE_QUERY 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Metadata/UrlBasedResourceMetadata.php: -------------------------------------------------------------------------------- 1 | class = $class; 20 | $this->url = $url; 21 | $this->extractor = $extractor; 22 | } 23 | 24 | public function getUrl() : string 25 | { 26 | return $this->url; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Metadata/UrlBasedResourceMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 21 | * [ 22 | * // Fully qualified class name of the AbstractMetadata type. 23 | * '__class__' => RouteBasedResourceMetadata::class, 24 | * 25 | * // Fully qualified class name of the resource class. 26 | * 'resource_class' => MyResource::class, 27 | * 28 | * // The URL to use when generating a self-relational link for 29 | * // the resource. 30 | * 'url' => 'https://example.org/my-resource', 31 | * 32 | * // The extractor/hydrator service to use to extract resource data. 33 | * 'extractor' => 'MyExtractor', 34 | * ] 35 | * 36 | * @return AbstractMetadata 37 | * @throws Exception\InvalidConfigException 38 | */ 39 | public function createMetadata(string $requestedName, array $metadata) : AbstractMetadata 40 | { 41 | $requiredKeys = [ 42 | 'resource_class', 43 | 'url', 44 | 'extractor', 45 | ]; 46 | 47 | if ($requiredKeys !== array_intersect($requiredKeys, array_keys($metadata))) { 48 | throw Exception\InvalidConfigException::dueToMissingMetadata( 49 | UrlBasedResourceMetadata::class, 50 | $requiredKeys 51 | ); 52 | } 53 | 54 | return new $requestedName( 55 | $metadata['resource_class'], 56 | $metadata['url'], 57 | $metadata['extractor'] 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Renderer/JsonRenderer.php: -------------------------------------------------------------------------------- 1 | jsonFlags = $jsonFlags; 29 | } 30 | 31 | public function render(HalResource $resource) : string 32 | { 33 | return json_encode($resource, $this->jsonFlags); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Renderer/RendererInterface.php: -------------------------------------------------------------------------------- 1 | formatOutput = true; 29 | $dom->appendChild($this->createResourceNode($dom, $resource->toArray())); 30 | return trim($dom->saveXML()); 31 | } 32 | 33 | private function createResourceNode(DOMDocument $doc, array $resource, string $resourceRel = 'self') : DOMNode 34 | { 35 | // Normalize resource 36 | $resource['_links'] = $resource['_links'] ?? []; 37 | $resource['_embedded'] = $resource['_embedded'] ?? []; 38 | 39 | $node = $doc->createElement('resource'); 40 | 41 | // Self-relational link attributes, if present and singular 42 | if (isset($resource['_links']['self']['href'])) { 43 | $node->setAttribute('rel', $resourceRel); 44 | $node->setAttribute('href', $resource['_links']['self']['href']); 45 | foreach ($resource['_links']['self'] as $attribute => $value) { 46 | if ($attribute === 'href') { 47 | continue; 48 | } 49 | $node->setAttribute($attribute, $value); 50 | } 51 | unset($resource['_links']['self']); 52 | } 53 | 54 | foreach ($resource['_links'] as $rel => $linkData) { 55 | if ($this->isAssocArray($linkData)) { 56 | $node->appendChild($this->createLinkNode($doc, $rel, $linkData)); 57 | continue; 58 | } 59 | 60 | foreach ($linkData as $linkDatum) { 61 | $node->appendChild($this->createLinkNode($doc, $rel, $linkDatum)); 62 | } 63 | } 64 | unset($resource['_links']); 65 | 66 | foreach ($resource['_embedded'] as $rel => $childData) { 67 | if ($this->isAssocArray($childData)) { 68 | $node->appendChild($this->createResourceNode($doc, $childData, $rel)); 69 | continue; 70 | } 71 | 72 | foreach ($childData as $childDatum) { 73 | $node->appendChild($this->createResourceNode($doc, $childDatum, $rel)); 74 | } 75 | } 76 | unset($resource['_embedded']); 77 | 78 | return $this->createNodeTree($doc, $node, $resource); 79 | } 80 | 81 | private function createLinkNode(DOMDocument $doc, string $rel, array $data) 82 | { 83 | $link = $doc->createElement('link'); 84 | $link->setAttribute('rel', $rel); 85 | foreach ($data as $key => $value) { 86 | $value = $this->normalizeConstantValue($value); 87 | $link->setAttribute($key, $value); 88 | } 89 | return $link; 90 | } 91 | 92 | /** 93 | * Convert true and false to appropriate strings. 94 | * 95 | * In all other cases, return the value as-is. 96 | * 97 | * @param mixed $value 98 | * @return string|mixed 99 | */ 100 | private function normalizeConstantValue($value) 101 | { 102 | $value = $value === true ? 'true' : $value; 103 | $value = $value === false ? 'false' : $value; 104 | return $value; 105 | } 106 | 107 | private function isAssocArray(array $value) : bool 108 | { 109 | return array_values($value) !== $value; 110 | } 111 | 112 | /** 113 | * @return DOMNode|DOMNode[] 114 | */ 115 | private function createResourceElement(DOMDocument $doc, string $name, $data) 116 | { 117 | if ($data === null) { 118 | return $doc->createElement($name, $data); 119 | } 120 | 121 | if (is_scalar($data)) { 122 | $data = $this->normalizeConstantValue($data); 123 | return $doc->createElement($name, $data); 124 | } 125 | 126 | if (is_object($data)) { 127 | $data = $this->createDataFromObject($data); 128 | return $doc->createElement($name, $data); 129 | } 130 | 131 | if (! is_array($data)) { 132 | throw Exception\InvalidResourceValueException::fromValue($data); 133 | } 134 | 135 | if ($this->isAssocArray($data)) { 136 | return $this->createNodeTree($doc, $doc->createElement($name), $data); 137 | } 138 | 139 | $elements = []; 140 | foreach ($data as $child) { 141 | $elements[] = $this->createResourceElement($doc, $name, $child); 142 | } 143 | return $elements; 144 | } 145 | 146 | private function createNodeTree(DOMDocument $doc, DOMNode $node, array $data) : DOMNode 147 | { 148 | foreach ($data as $key => $value) { 149 | $element = $this->createResourceElement($doc, $key, $value); 150 | if (! is_array($element)) { 151 | $node->appendChild($element); 152 | continue; 153 | } 154 | foreach ($element as $child) { 155 | $node->appendChild($child); 156 | } 157 | } 158 | 159 | return $node; 160 | } 161 | 162 | /** 163 | * @todo Detect JsonSerializable, and pass to 164 | * json_decode(json_encode($object), true), passing the final value 165 | * back to createResourceElement()? 166 | * @param object $object 167 | * @throws Exception\InvalidResourceValueException if unable to serialize 168 | * the data to a string. 169 | */ 170 | private function createDataFromObject($object) : string 171 | { 172 | if ($object instanceof DateTimeInterface) { 173 | return $object->format('c'); 174 | } 175 | 176 | if (! method_exists($object, '__toString')) { 177 | throw Exception\InvalidResourceValueException::fromObject($object); 178 | } 179 | 180 | return (string) $object; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/ResourceGenerator.php: -------------------------------------------------------------------------------- 1 | metadataMap = $metadataMap; 49 | $this->hydrators = $hydrators; 50 | $this->linkGenerator = $linkGenerator; 51 | } 52 | 53 | public function getHydrators() : ContainerInterface 54 | { 55 | return $this->hydrators; 56 | } 57 | 58 | public function getLinkGenerator() : LinkGenerator 59 | { 60 | return $this->linkGenerator; 61 | } 62 | 63 | public function getMetadataMap() : Metadata\MetadataMap 64 | { 65 | return $this->metadataMap; 66 | } 67 | 68 | /** 69 | * Link a metadata type to a strategy that can create a resource for it. 70 | * 71 | * @param string $metadataType 72 | * @param string|ResourceGenerator\StrategyInterface $strategy 73 | */ 74 | public function addStrategy(string $metadataType, $strategy) : void 75 | { 76 | if (! class_exists($metadataType) 77 | || ! in_array(Metadata\AbstractMetadata::class, class_parents($metadataType), true) 78 | ) { 79 | throw Exception\UnknownMetadataTypeException::forInvalidMetadataClass($metadataType); 80 | } 81 | 82 | if (is_string($strategy) 83 | && ( 84 | ! class_exists($strategy) 85 | || ! in_array(ResourceGenerator\StrategyInterface::class, class_implements($strategy), true) 86 | ) 87 | ) { 88 | throw Exception\InvalidStrategyException::forType($strategy); 89 | } 90 | 91 | if (is_string($strategy)) { 92 | $strategy = new $strategy(); 93 | } 94 | 95 | if (! $strategy instanceof ResourceGenerator\StrategyInterface) { 96 | throw Exception\InvalidStrategyException::forInstance($strategy); 97 | } 98 | 99 | $this->strategies[$metadataType] = $strategy; 100 | } 101 | 102 | /** 103 | * Returns the registered strategies. 104 | */ 105 | public function getStrategies() : array 106 | { 107 | return $this->strategies; 108 | } 109 | 110 | public function fromArray(array $data, string $uri = null) : HalResource 111 | { 112 | $resource = new HalResource($data); 113 | 114 | if (null !== $uri) { 115 | return $resource->withLink(new Link('self', $uri)); 116 | } 117 | 118 | return $resource; 119 | } 120 | 121 | /** 122 | * @param object $instance An object of any type; the type will be checked 123 | * against types registered in the metadata map. 124 | * @param ServerRequestInterface $request 125 | */ 126 | public function fromObject($instance, ServerRequestInterface $request) : HalResource 127 | { 128 | if (! is_object($instance)) { 129 | throw Exception\InvalidObjectException::forNonObject($instance); 130 | } 131 | 132 | $class = get_class($instance); 133 | if (! $this->metadataMap->has($class)) { 134 | throw Exception\InvalidObjectException::forUnknownType($class); 135 | } 136 | 137 | $metadata = $this->metadataMap->get($class); 138 | $metadataType = get_class($metadata); 139 | 140 | if (! isset($this->strategies[$metadataType])) { 141 | throw Exception\UnknownMetadataTypeException::forMetadata($metadata); 142 | } 143 | 144 | $strategy = $this->strategies[$metadataType]; 145 | return $strategy->createResource( 146 | $instance, 147 | $metadata, 148 | $this, 149 | $request 150 | ); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/ResourceGenerator/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | extractPaginator($collection, $metadata, $resourceGenerator, $request); 57 | } 58 | 59 | if ($collection instanceof DoctrinePaginator) { 60 | return $this->extractDoctrinePaginator($collection, $metadata, $resourceGenerator, $request); 61 | } 62 | 63 | return $this->extractIterator($collection, $metadata, $resourceGenerator, $request); 64 | } 65 | 66 | /** 67 | * Generates a paginated hal resource from a collection 68 | * 69 | * @param Paginator $collection 70 | * @param AbstractCollectionMetadata $metadata 71 | * @param ResourceGenerator $resourceGenerator 72 | * @param ServerRequestInterface $request 73 | * @return HalResource 74 | * @throws Exception\OutOfBoundsException if requested page if outside the available pages 75 | */ 76 | private function extractPaginator( 77 | Paginator $collection, 78 | AbstractCollectionMetadata $metadata, 79 | ResourceGenerator $resourceGenerator, 80 | ServerRequestInterface $request 81 | ) : HalResource { 82 | $data = ['_total_items' => $collection->getTotalItemCount()]; 83 | $pageCount = $collection->count(); 84 | 85 | return $this->createPaginatedCollectionResource( 86 | $pageCount, 87 | $data, 88 | function (int $page) use ($collection) { 89 | $collection->setCurrentPageNumber($page); 90 | }, 91 | $collection, 92 | $metadata, 93 | $resourceGenerator, 94 | $request 95 | ); 96 | } 97 | 98 | /** 99 | * Extract a collection from a Doctrine paginator. 100 | * 101 | * When pagination is requested, and a valid page is found, calls the 102 | * paginator's `setFirstResult()` method with an offset based on the 103 | * max results value set on the paginator. 104 | */ 105 | private function extractDoctrinePaginator( 106 | DoctrinePaginator $collection, 107 | AbstractCollectionMetadata $metadata, 108 | ResourceGenerator $resourceGenerator, 109 | ServerRequestInterface $request 110 | ) : HalResource { 111 | $query = $collection->getQuery(); 112 | $totalItems = count($collection); 113 | $perPage = $query->getMaxResults(); 114 | $pageCount = (int) ceil($totalItems / $perPage); 115 | 116 | $data = ['_total_items' => $totalItems]; 117 | 118 | return $this->createPaginatedCollectionResource( 119 | $pageCount, 120 | $data, 121 | function (int $page) use ($query, $perPage) { 122 | $query->setFirstResult($perPage * ($page - 1)); 123 | }, 124 | $collection, 125 | $metadata, 126 | $resourceGenerator, 127 | $request 128 | ); 129 | } 130 | 131 | private function extractIterator( 132 | Traversable $collection, 133 | AbstractCollectionMetadata $metadata, 134 | ResourceGenerator $resourceGenerator, 135 | ServerRequestInterface $request 136 | ) : HalResource { 137 | $isCountable = $collection instanceof Countable; 138 | $count = $isCountable ? $collection->count() : 0; 139 | 140 | $resources = []; 141 | foreach ($collection as $item) { 142 | $resources[] = $resourceGenerator->fromObject($item, $request); 143 | $count = $isCountable ? $count : $count + 1; 144 | } 145 | 146 | $data = ['_total_items' => $count]; 147 | $links = [$this->generateSelfLink( 148 | $metadata, 149 | $resourceGenerator, 150 | $request 151 | )]; 152 | 153 | return new HalResource($data, $links, [ 154 | $metadata->getCollectionRelation() => $resources, 155 | ]); 156 | } 157 | 158 | /** 159 | * Create a collection resource representing a paginated set. 160 | * 161 | * Determines if the metadata uses a query or placeholder pagination type. 162 | * If not, it generates a self relational link, and then immediately creates 163 | * and returns a collection resource containing every item in the collection. 164 | * 165 | * If it does, it pulls the pagination parameter from the request using the 166 | * appropriate source (query string arguments or routing parameter), and 167 | * then checks to see if we have a valid page number, throwing an out of 168 | * bounds exception if we do not. From the page, it then determines which 169 | * relational pagination links to create, including a `self` relation, 170 | * and aggregates the current page and total page count in the $data array 171 | * before calling on createCollectionResource() to generate the final 172 | * HAL resource instance. 173 | * 174 | * @param array $data Data to render in the root of the HAL 175 | * resource. 176 | * @param callable $notifyCollectionOfPage A callback that receives an integer 177 | * $page argument; this should be used to update the paginator instance 178 | * with the current page number. 179 | */ 180 | private function createPaginatedCollectionResource( 181 | int $pageCount, 182 | array $data, 183 | callable $notifyCollectionOfPage, 184 | iterable $collection, 185 | AbstractCollectionMetadata $metadata, 186 | ResourceGenerator $resourceGenerator, 187 | ServerRequestInterface $request 188 | ) : HalResource { 189 | $links = []; 190 | $paginationParamType = $metadata->getPaginationParamType(); 191 | 192 | if (! in_array($paginationParamType, $this->paginationTypes, true)) { 193 | $links[] = $this->generateSelfLink($metadata, $resourceGenerator, $request); 194 | return $this->createCollectionResource( 195 | $links, 196 | $data, 197 | $collection, 198 | $metadata, 199 | $resourceGenerator, 200 | $request 201 | ); 202 | } 203 | 204 | $paginationParam = $metadata->getPaginationParam(); 205 | $page = $paginationParamType === AbstractCollectionMetadata::TYPE_QUERY 206 | ? (int) ($request->getQueryParams()[$paginationParam] ?? 1) 207 | : (int) $request->getAttribute($paginationParam, 1); 208 | 209 | if ($page < 1 || ($page > $pageCount && $pageCount > 0)) { 210 | throw new Exception\OutOfBoundsException(sprintf( 211 | 'Page %d is out of bounds. Collection has %d page%s.', 212 | $page, 213 | $pageCount, 214 | $pageCount > 1 ? 's' : '' 215 | )); 216 | } 217 | 218 | $notifyCollectionOfPage($page); 219 | 220 | $links[] = $this->generateLinkForPage('self', $page, $metadata, $resourceGenerator, $request); 221 | if ($page > 1) { 222 | $links[] = $this->generateLinkForPage('first', 1, $metadata, $resourceGenerator, $request); 223 | $links[] = $this->generateLinkForPage('prev', $page - 1, $metadata, $resourceGenerator, $request); 224 | } 225 | if ($page < $pageCount) { 226 | $links[] = $this->generateLinkForPage('next', $page + 1, $metadata, $resourceGenerator, $request); 227 | $links[] = $this->generateLinkForPage('last', $pageCount, $metadata, $resourceGenerator, $request); 228 | } 229 | 230 | $data['_page'] = $page; 231 | $data['_page_count'] = $pageCount; 232 | 233 | return $this->createCollectionResource( 234 | $links, 235 | $data, 236 | $collection, 237 | $metadata, 238 | $resourceGenerator, 239 | $request 240 | ); 241 | } 242 | 243 | /** 244 | * Create the collection resource with its embedded resources. 245 | * 246 | * Iterates the collection, passing each item to the resource generator 247 | * to produce a HAL resource. These are then used to create an embedded 248 | * relation in a master HAL resource that contains metadata around the 249 | * collection itself (number of items, number of pages, etc.), and any 250 | * relational links. 251 | */ 252 | private function createCollectionResource( 253 | array $links, 254 | array $data, 255 | iterable $collection, 256 | AbstractCollectionMetadata $metadata, 257 | ResourceGenerator $resourceGenerator, 258 | ServerRequestInterface $request 259 | ) : HalResource { 260 | $resources = []; 261 | foreach ($collection as $item) { 262 | $resources[] = $resourceGenerator->fromObject($item, $request); 263 | } 264 | 265 | return new HalResource($data, $links, [ 266 | $metadata->getCollectionRelation() => $resources, 267 | ]); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/ResourceGenerator/ExtractInstanceTrait.php: -------------------------------------------------------------------------------- 1 | getHydrators(); 33 | $extractor = $hydrators->get($metadata->getExtractor()); 34 | if (! $extractor instanceof ExtractionInterface) { 35 | throw Exception\InvalidExtractorException::fromInstance($extractor); 36 | } 37 | 38 | $array = $extractor->extract($instance); 39 | 40 | // Extract nested resources if present in metadata map 41 | $metadataMap = $resourceGenerator->getMetadataMap(); 42 | foreach ($array as $key => $value) { 43 | if (! is_object($value)) { 44 | continue; 45 | } 46 | 47 | $childClass = get_class($value); 48 | if (! $metadataMap->has($childClass)) { 49 | continue; 50 | } 51 | 52 | $childData = $resourceGenerator->fromObject($value, $request); 53 | 54 | // Nested collections need to be merged. 55 | $childMetadata = $metadataMap->get($childClass); 56 | if ($childMetadata instanceof AbstractCollectionMetadata) { 57 | $childData = $childData->getElement($childMetadata->getCollectionRelation()); 58 | } 59 | 60 | $array[$key] = $childData; 61 | } 62 | 63 | return $array; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/ResourceGenerator/RouteBasedCollectionStrategy.php: -------------------------------------------------------------------------------- 1 | extractCollection($instance, $metadata, $resourceGenerator, $request); 43 | } 44 | 45 | /** 46 | * @param string $rel Relation to use when creating Link 47 | * @param int $page Page number for generated link 48 | * @param Metadata\AbstractCollectionMetadata $metadata Used to provide the 49 | * base URL, pagination parameter, and type of pagination used (query 50 | * string, path parameter) 51 | * @param ResourceGenerator $resourceGenerator Used to retrieve link 52 | * generator in order to generate link based on routing information. 53 | * @param ServerRequestInterface $request Passed to link generator when 54 | * generating link based on routing information. 55 | * @return Link 56 | */ 57 | protected function generateLinkForPage( 58 | string $rel, 59 | int $page, 60 | Metadata\AbstractCollectionMetadata $metadata, 61 | ResourceGenerator $resourceGenerator, 62 | ServerRequestInterface $request 63 | ) : Link { 64 | $route = $metadata->getRoute(); 65 | $paginationType = $metadata->getPaginationParamType(); 66 | $paginationParam = $metadata->getPaginationParam(); 67 | $routeParams = $metadata->getRouteParams(); 68 | $queryStringArgs = $metadata->getQueryStringArguments(); 69 | 70 | $paramsWithPage = [$paginationParam => $page]; 71 | $routeParams = $paginationType === Metadata\AbstractCollectionMetadata::TYPE_PLACEHOLDER 72 | ? array_merge($routeParams, $paramsWithPage) 73 | : $routeParams; 74 | $queryParams = $paginationType === Metadata\AbstractCollectionMetadata::TYPE_QUERY 75 | ? array_merge($queryStringArgs, $paramsWithPage) 76 | : $queryStringArgs; 77 | 78 | return $resourceGenerator 79 | ->getLinkGenerator() 80 | ->fromRoute( 81 | $rel, 82 | $request, 83 | $route, 84 | $routeParams, 85 | $queryParams 86 | ); 87 | } 88 | 89 | /** 90 | * @param Metadata\AbstractCollectionMetadata $metadata Provides base URL 91 | * for self link. 92 | * @param ResourceGenerator $resourceGenerator Used to retrieve link 93 | * generator in order to generate link based on routing information. 94 | * @param ServerRequestInterface $request Passed to link generator when 95 | * generating link based on routing information. 96 | * @return Link 97 | */ 98 | protected function generateSelfLink( 99 | Metadata\AbstractCollectionMetadata $metadata, 100 | ResourceGenerator $resourceGenerator, 101 | ServerRequestInterface $request 102 | ) { 103 | 104 | $routeParams = $metadata->getRouteParams() ?? []; 105 | $queryStringArgs = array_merge($request->getQueryParams() ?? [], $metadata->getQueryStringArguments() ?? []); 106 | 107 | return $resourceGenerator 108 | ->getLinkGenerator() 109 | ->fromRoute( 110 | 'self', 111 | $request, 112 | $metadata->getRoute(), 113 | $routeParams, 114 | $queryStringArgs 115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/ResourceGenerator/RouteBasedResourceStrategy.php: -------------------------------------------------------------------------------- 1 | extractInstance( 34 | $instance, 35 | $metadata, 36 | $resourceGenerator, 37 | $request 38 | ); 39 | 40 | $routeParams = $metadata->getRouteParams(); 41 | $resourceIdentifier = $metadata->getResourceIdentifier(); 42 | $routeIdentifier = $metadata->getRouteIdentifierPlaceholder(); 43 | 44 | if (isset($data[$resourceIdentifier])) { 45 | $routeParams[$routeIdentifier] = $data[$resourceIdentifier]; 46 | } 47 | 48 | return new HalResource($data, [ 49 | $resourceGenerator->getLinkGenerator()->fromRoute( 50 | 'self', 51 | $request, 52 | $metadata->getRoute(), 53 | $routeParams 54 | ) 55 | ]); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ResourceGenerator/StrategyInterface.php: -------------------------------------------------------------------------------- 1 | extractCollection($instance, $metadata, $resourceGenerator, $request); 51 | } 52 | 53 | /** 54 | * @param string $rel Relation to use when creating Link 55 | * @param int $page Page number for generated link 56 | * @param Metadata\AbstractCollectionMetadata $metadata Used to provide the 57 | * base URL, pagination parameter, and type of pagination used (query 58 | * string, path parameter) 59 | * @param ResourceGenerator $resourceGenerator Ignored; required to fulfill 60 | * abstract. 61 | * @param ServerRequestInterface $request Ignored; required to fulfill 62 | * abstract. 63 | * @return Link 64 | */ 65 | protected function generateLinkForPage( 66 | string $rel, 67 | int $page, 68 | Metadata\AbstractCollectionMetadata $metadata, 69 | ResourceGenerator $resourceGenerator, 70 | ServerRequestInterface $request 71 | ) : Link { 72 | $paginationParam = $metadata->getPaginationParam(); 73 | $paginationType = $metadata->getPaginationParamType(); 74 | $url = $metadata->getUrl() . '?' . http_build_query($request->getQueryParams()); 75 | 76 | switch ($paginationType) { 77 | case Metadata\AbstractCollectionMetadata::TYPE_PLACEHOLDER: 78 | $url = str_replace($url, $paginationParam, $page); 79 | break; 80 | case Metadata\AbstractCollectionMetadata::TYPE_QUERY: 81 | // fall-through 82 | default: 83 | $url = $this->stripUrlFragment($url); 84 | $url = $this->appendPageQueryToUrl($url, $page, $paginationParam); 85 | } 86 | 87 | return new Link($rel, $url); 88 | } 89 | 90 | /** 91 | * @param Metadata\AbstractCollectionMetadata $metadata Provides base URL 92 | * for self link. 93 | * @param ResourceGenerator $resourceGenerator Ignored; required to fulfill 94 | * abstract. 95 | * @param ServerRequestInterface $request Ignored; required to fulfill 96 | * abstract. 97 | * @return Link 98 | */ 99 | protected function generateSelfLink( 100 | Metadata\AbstractCollectionMetadata $metadata, 101 | ResourceGenerator $resourceGenerator, 102 | ServerRequestInterface $request 103 | ) { 104 | 105 | $queryStringArgs = $request->getQueryParams(); 106 | $url = $metadata->getUrl(); 107 | if ($queryStringArgs !== null) { 108 | $url .= '?' . http_build_query($queryStringArgs); 109 | } 110 | 111 | return new Link('self', $url); 112 | } 113 | 114 | private function stripUrlFragment(string $url) : string 115 | { 116 | $fragment = parse_url($url, PHP_URL_FRAGMENT); 117 | if (null === $fragment) { 118 | // parse_url returns null both for absence of fragment and empty fragment 119 | return preg_replace('/#$/', '', $url); 120 | } 121 | 122 | return str_replace('#' . $fragment, '', $url); 123 | } 124 | 125 | private function appendPageQueryToUrl(string $url, int $page, string $paginationParam) : string 126 | { 127 | $query = parse_url($url, PHP_URL_QUERY); 128 | if (null === $query) { 129 | // parse_url returns null both for absence of query and empty query 130 | $url = preg_replace('/\?$/', '', $url); 131 | return sprintf('%s?%s=%s', $url, $paginationParam, $page); 132 | } 133 | 134 | parse_str($query, $qsa); 135 | $qsa[$paginationParam] = $page; 136 | 137 | return str_replace('?' . $query, '?' . http_build_query($qsa), $url); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/ResourceGenerator/UrlBasedResourceStrategy.php: -------------------------------------------------------------------------------- 1 | extractInstance($instance, $metadata, $resourceGenerator, $request), 36 | [new Link('self', $metadata->getUrl())] 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ResourceGeneratorFactory.php: -------------------------------------------------------------------------------- 1 | linkGeneratorServiceName = $linkGeneratorServiceName; 39 | } 40 | 41 | public function __invoke(ContainerInterface $container) : ResourceGenerator 42 | { 43 | $generator = new ResourceGenerator( 44 | $container->get(Metadata\MetadataMap::class), 45 | $container->get(HydratorPluginManager::class), 46 | $container->get($this->linkGeneratorServiceName) 47 | ); 48 | 49 | $this->injectStrategies($container, $generator); 50 | 51 | return $generator; 52 | } 53 | 54 | /** 55 | * @throws InvalidConfigException if the config service is not an array or 56 | * ArrayAccess implementation. 57 | * @throws InvalidConfigException if the configured strategies value is not 58 | * an array or traversable. 59 | */ 60 | private function injectStrategies(ContainerInterface $container, ResourceGenerator $generator) : void 61 | { 62 | if (! $container->has('config')) { 63 | return; 64 | } 65 | 66 | $config = $container->get('config'); 67 | 68 | if (! is_array($config) && ! $config instanceof ArrayAccess) { 69 | throw InvalidConfigException::dueToNonArray($config); 70 | } 71 | 72 | if (! isset($config['zend-expressive-hal']['resource-generator']['strategies'])) { 73 | return; 74 | } 75 | 76 | $strategies = $config['zend-expressive-hal']['resource-generator']['strategies']; 77 | 78 | if (! is_array($strategies) && ! $strategies instanceof Traversable) { 79 | throw InvalidConfigException::dueToInvalidStrategies($strategies); 80 | } 81 | 82 | foreach ($strategies as $metadataType => $strategy) { 83 | $generator->addStrategy( 84 | $metadataType, 85 | $container->get($strategy) 86 | ); 87 | } 88 | } 89 | } 90 | --------------------------------------------------------------------------------