├── .laminas-ci.json ├── COPYRIGHT.md ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── psalm-baseline.xml ├── psalm.xml.dist ├── renovate.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 ├── MezzioUrlGenerator.php ├── MezzioUrlGeneratorFactory.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 ├── Psr17ResponseFactoryTrait.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 ├── GenerateSelfLinkTrait.php ├── RouteBasedCollectionStrategy.php ├── RouteBasedResourceStrategy.php ├── StrategyInterface.php ├── UrlBasedCollectionStrategy.php └── UrlBasedResourceStrategy.php ├── ResourceGeneratorFactory.php ├── ResourceGeneratorInterface.php └── Response └── CallableResponseFactoryDecorator.php /.laminas-ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_php_platform_requirements": { 3 | "8.4": true 4 | }, 5 | "backwardCompatibilityCheck": true 6 | } 7 | -------------------------------------------------------------------------------- /COPYRIGHT.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Laminas Project a Series of LF Projects, LLC. (https://getlaminas.org/) 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Laminas Project a Series of LF Projects, LLC. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | - Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | - Neither the name of Laminas Foundation nor the names of its contributors may 14 | be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hypertext Application Language (HAL) for PSR-7 Applications 2 | 3 | [![Build Status](https://github.com/mezzio/mezzio-hal/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/mezzio/mezzio-hal/actions/workflows/continuous-integration.yml) 4 | [![type-coverage](https://shepherd.dev/github/mezzio/mezzio-hal/coverage.svg)](https://shepherd.dev/github/mezzio/mezzio-hal) 5 | [![Psalm level](https://shepherd.dev/github/mezzio/mezzio-hal/level.svg)](https://shepherd.dev/github/mezzio/mezzio-hal) 6 | 7 | > ## 🇷🇺 Русским гражданам 8 | > 9 | > Мы, участники Laminas, родились и живем в разных странах. У многих из нас есть друзья, родственники и коллеги как в России, так и в Украине. Некоторые из нас родились в России. Некоторые из нас живут в России. У некоторых бабушки и дедушки сражались с фашистами во Второй мировой войне. Здесь никто не поддерживает фашизм. 10 | > 11 | > У одного из нас есть украинская родственница, которая спаслась из дома вместе с сыном. Поезд задержался из-за бомбежки на дороге впереди. У нас есть друзья, которые прячутся в бомбоубежищах. Мы с тревогой ждем весточки от них после воздушных налетов, которые беспорядочно наносят удары и попадают по больницам, школам, детским садам и домам. Мы не берем это из каких-либо СМИ. Мы наблюдаем это напрямую. 12 | > 13 | > Вы доверяете нам достаточно, чтоб использовать наши программы, и мы просим вас довериться нам вновь. Мы нуждаемся в помощи. Выходите и протестуйте против этой бесполезной войны. Остановите кровопролитие. Скажите "Нет войне!" 14 | > 15 | > ## 🇺🇸 To Citizens of Russia 16 | > 17 | > We at Laminas come from all over the world. Many of us have friends, family and colleagues in both Russia and Ukraine. Some of us were born in Russia. Some of us currently live in Russia. Some have grandparents who fought Nazis in World War II. Nobody here supports fascism. 18 | > 19 | > One team member has a Ukrainian relative who fled her home with her son. The train was delayed due to bombing on the road ahead. We have friends who are hiding in bomb shelters. We anxiously follow up on them after the air raids, which indiscriminately fire at hospitals, schools, kindergartens and houses. We're not taking this from any media. These are our actual experiences. 20 | > 21 | > You trust us enough to use our software. We ask that you trust us to say the truth on this. We need your help. Go out and protest this unnecessary war. Stop the bloodshed. Say "stop the war!" 22 | 23 | This library provides utilities for modeling HAL resources with links and generating [PSR-7](https://www.php-fig.org/psr/psr-7/) responses representing both JSON and XML serializations of them. 24 | (The library consumes [PSR-17](https://www.php-fig.org/psr/psr-17/) `ResponseFactoryInterface` implementations in order to provide HAL response instances.) 25 | 26 | ## Installation 27 | 28 | Run the following to install this library: 29 | 30 | ```bash 31 | $ composer require mezzio/mezzio-hal 32 | ``` 33 | 34 | ## Documentation 35 | 36 | Documentation is [in the doc tree](docs/book/), and can be compiled using [mkdocs](https://www.mkdocs.org): 37 | 38 | ```bash 39 | $ mkdocs build 40 | ``` 41 | 42 | You may also [browse the documentation online](https://docs.mezzio.dev/mezzio-hal/). 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mezzio/mezzio-hal", 3 | "description": "Hypertext Application Language implementation for PHP and PSR-7", 4 | "license": "BSD-3-Clause", 5 | "keywords": [ 6 | "laminas", 7 | "mezzio", 8 | "hal", 9 | "http", 10 | "psr", 11 | "psr-7", 12 | "psr-11", 13 | "psr-13", 14 | "psr-17", 15 | "rest" 16 | ], 17 | "homepage": "https://mezzio.dev", 18 | "support": { 19 | "docs": "https://docs.mezzio.dev/mezzio-hal/", 20 | "issues": "https://github.com/mezzio/mezzio-hal/issues", 21 | "source": "https://github.com/mezzio/mezzio-hal", 22 | "rss": "https://github.com/mezzio/mezzio-hal/releases.atom", 23 | "chat": "https://laminas.dev/chat", 24 | "forum": "https://discourse.laminas.dev" 25 | }, 26 | "config": { 27 | "sort-packages": true, 28 | "platform": { 29 | "php": "8.1.99" 30 | }, 31 | "allow-plugins": { 32 | "dealerdirect/phpcodesniffer-composer-installer": true 33 | } 34 | }, 35 | "extra": { 36 | "laminas": { 37 | "config-provider": "Mezzio\\Hal\\ConfigProvider" 38 | } 39 | }, 40 | "require": { 41 | "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", 42 | "ext-dom": "*", 43 | "ext-json": "*", 44 | "psr/container": "^1.1.2 || ^2.0.2", 45 | "psr/http-factory": "^1.0", 46 | "psr/http-message": "^1.0.1 || ^2.0.0", 47 | "psr/link": "^1.0", 48 | "webmozart/assert": "^1.10", 49 | "willdurand/negotiation": "^3.0" 50 | }, 51 | "require-dev": { 52 | "doctrine/orm": "^2.20.2", 53 | "laminas/laminas-coding-standard": "~3.1.0", 54 | "laminas/laminas-hydrator": "^4.16", 55 | "laminas/laminas-paginator": "^2.19.0", 56 | "mezzio/mezzio-helpers": "^5.17", 57 | "phpspec/prophecy-phpunit": "^2.3.0", 58 | "phpunit/phpunit": "^9.6.22", 59 | "psalm/plugin-phpunit": "^0.19.0", 60 | "vimeo/psalm": "^5.26.1" 61 | }, 62 | "provide": { 63 | "psr/link-implementation": "1.0" 64 | }, 65 | "suggest": { 66 | "laminas/laminas-hydrator": "^4.3 in order to use the ResourceGenerator to create Resource instances from objects", 67 | "laminas/laminas-paginator": "^2.11 in order to provide paginated collections", 68 | "mezzio/mezzio-helpers": "^5.7 in order to use UrlHelper/ServerUrlHelper-based MezzioUrlGenerator with the LinkGenerator", 69 | "psr/container-implementation": "^1.0 in order to use the provided PSR-11 factories" 70 | }, 71 | "autoload": { 72 | "psr-4": { 73 | "Mezzio\\Hal\\": "src/" 74 | } 75 | }, 76 | "autoload-dev": { 77 | "psr-4": { 78 | "MezzioTest\\Hal\\": "test/" 79 | } 80 | }, 81 | "scripts": { 82 | "check": [ 83 | "@cs-check", 84 | "@test" 85 | ], 86 | "cs-check": "phpcs", 87 | "cs-fix": "phpcbf", 88 | "static-analysis": "psalm --shepherd --stats", 89 | "test": "phpunit --colors=always", 90 | "test-coverage": "phpunit --colors=always --coverage-clover clover.xml" 91 | }, 92 | "conflict": { 93 | "zendframework/zend-expressive-hal": "*" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /psalm-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | $item->toArray()]]> 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | embedded]]> 58 | embedded]]> 59 | embedded]]> 60 | 61 | 62 | 63 | aggregateEmbeddedCollection($name, $resource, $context)]]> 64 | 65 | 66 | 67 | 68 | 69 | getValue(), 'json')]]> 70 | 71 | 72 | getValue()]]> 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | validateRelation($relation)]]> 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | get($this->urlHelperServiceName)]]> 139 | has(ServerUrlHelper::class) 140 | ? $container->get(ServerUrlHelper::class) 141 | : ($container->has(\Zend\Expressive\Helper\ServerUrlHelper::class) 142 | ? $container->get(\Zend\Expressive\Helper\ServerUrlHelper::class) 143 | : null)]]> 144 | 145 | 146 | 147 | 148 | 149 | get($this->urlGeneratorServiceName)]]> 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | $method($metadata)]]> 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 208 | 209 | 210 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 'id'], 238 | $metadata['max_depth'] ?? 10 239 | )]]> 240 | 241 | 242 | 'id'], 249 | $metadata['max_depth'] ?? 10 250 | )]]> 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 265 | 266 | 267 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 287 | 288 | 289 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | >>]]> 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 354 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | getExtractor()]]> 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | getQueryStringArguments()]]> 402 | getQueryStringArguments()]]> 403 | getRoute()]]> 404 | getRouteParams()]]> 405 | 406 | 407 | 408 | 409 | 410 | 411 | getQueryParams(), $metadata->getQueryStringArguments())]]> 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | get($strategy)]]> 459 | get($this->linkGeneratorServiceName)]]> 460 | get(HydratorPluginManager::class)]]> 461 | get(Metadata\MetadataMap::class)]]> 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | getHref()]]> 480 | getRels()]]> 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | $embedded]]]> 504 | 'bar']]]> 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 607 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 629 | 639 | 649 | 659 | 669 | 675 | 681 | 687 | 693 | 699 | 705 | 711 | 717 | 723 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 | 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | 765 | 766 | 767 | 768 | 769 | 770 | 771 | 772 | 773 | 774 | 775 | 776 | 777 | 778 | 779 | 780 | 781 | 782 | reveal()]]> 783 | reveal()]]> 784 | reveal()]]> 785 | 786 | 787 | 788 | 789 | 790 | 791 | 792 | 793 | 794 | 795 | 796 | 797 | 798 | 799 | 800 | 801 | 802 | 803 | 804 | 805 | 806 | 807 | 808 | array_key_exists('foo_id', $params) 809 | && array_key_exists('bar_id', $params) 810 | && $params['foo_id'] === 1234 811 | && $params['bar_id'] === $i)]]> 812 | array_key_exists('foo_id', $params) 813 | && array_key_exists('p', $params) 814 | && $params['foo_id'] === 1234 815 | && $params['p'] === $page)]]> 816 | 817 | 818 | reveal()]]> 819 | reveal()]]> 820 | createCollectionItems( 821 | $linkGenerator, 822 | $request 823 | )]]> 824 | 825 | 826 | 827 | 828 | 829 | 830 | 831 | 832 | 833 | 834 | array_key_exists('foo_id', $params) 835 | && array_key_exists('bar_id', $params) 836 | && $params['foo_id'] === 1234 837 | && $params['bar_id'] === $i)]]> 838 | 839 | 840 | reveal()]]> 841 | createCollectionItems($linkGenerator, $request)]]> 842 | 843 | 844 | 845 | 846 | 847 | 848 | 849 | 850 | 851 | 852 | 853 | 854 | 855 | 856 | 857 | container->reveal()]]> 858 | container->reveal()]]> 859 | container->reveal()]]> 860 | container->reveal()]]> 861 | 862 | 863 | 864 | 865 | 866 | 867 | 868 | 869 | 870 | 871 | 872 | 873 | 874 | 875 | 876 | 877 | 878 | 879 | 880 | 881 | 882 | 883 | 884 | 885 | 886 | 887 | 888 | 889 | 890 | 891 | 892 | 893 | 894 | array_key_exists('foo_bar_id', $params) 895 | && array_key_exists('test', $params) 896 | && $params['foo_bar_id'] === $i 897 | && $params['test'] === 'param')]]> 898 | array_key_exists('foo_bar_id', $params) 899 | && array_key_exists('test', $params) 900 | && $params['foo_bar_id'] === $i 901 | && $params['test'] === 'param')]]> 902 | array_key_exists('foo_bar_id', $params) 903 | && array_key_exists('test', $params) 904 | && $params['foo_bar_id'] === 'XXXX-YYYY-ZZZZ' 905 | && $params['test'] === 'param')]]> 906 | 907 | 908 | 909 | 910 | 911 | 912 | 913 | 914 | 915 | 916 | 917 | 918 | 919 | 920 | 921 | 922 | 923 | 924 | 925 | 926 | 927 | 928 | 929 | 930 | 931 | 932 | 933 | 934 | 935 | 936 | 937 | 938 | 939 | 940 | 941 | 942 | 943 | 944 | 945 | 946 | 947 | 948 | 949 | 950 | 951 | 952 | 953 | 954 | 955 | 956 | 957 | 958 | 959 | 960 | 961 | 962 | 963 | 964 | 965 | 966 | 967 | 968 | 973 | 974 | 975 | 976 | 977 | 978 | 979 | 980 | 981 | 982 | 983 | 984 | 985 | 986 | 987 | 988 | 989 | 990 | 991 | 992 | 993 | 994 | 995 | 996 | 997 | 998 | -------------------------------------------------------------------------------- /psalm.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>mezzio/.github:renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | $this->getDependencies(), 30 | 'mezzio-hal' => $this->getHalConfig(), 31 | ]; 32 | } 33 | 34 | public function getDependencies(): array 35 | { 36 | return [ 37 | 'aliases' => [ 38 | UrlGeneratorInterface::class => MezzioUrlGenerator::class, 39 | ResourceGeneratorInterface::class => ResourceGenerator::class, 40 | 41 | // Legacy Zend Framework aliases 42 | \Zend\Expressive\Hal\LinkGenerator\UrlGeneratorInterface::class => UrlGeneratorInterface::class, 43 | \Zend\Expressive\Hal\HalResponseFactory::class => HalResponseFactory::class, 44 | \Zend\Expressive\Hal\LinkGenerator::class => LinkGenerator::class, 45 | ExpressiveUrlGenerator::class => MezzioUrlGenerator::class, 46 | \Zend\Expressive\Hal\Metadata\MetadataMap::class => MetadataMap::class, 47 | \Zend\Expressive\Hal\ResourceGenerator::class => ResourceGenerator::class, 48 | \Zend\Expressive\Hal\RouteBasedCollectionStrategy::class => RouteBasedCollectionStrategy::class, 49 | \Zend\Expressive\Hal\RouteBasedResourceStrategy::class => RouteBasedResourceStrategy::class, 50 | \Zend\Expressive\Hal\UrlBasedCollectionStrategy::class => UrlBasedCollectionStrategy::class, 51 | \Zend\Expressive\Hal\UrlBasedResourceStrategy::class => UrlBasedResourceStrategy::class, 52 | ], 53 | 'factories' => [ 54 | HalResponseFactory::class => HalResponseFactoryFactory::class, 55 | LinkGenerator::class => LinkGeneratorFactory::class, 56 | MezzioUrlGenerator::class => LinkGenerator\MezzioUrlGeneratorFactory::class, 57 | MetadataMap::class => Metadata\MetadataMapFactory::class, 58 | ResourceGenerator::class => ResourceGeneratorFactory::class, 59 | ], 60 | 'invokables' => [ 61 | RouteBasedCollectionStrategy::class => RouteBasedCollectionStrategy::class, 62 | RouteBasedResourceStrategy::class => RouteBasedResourceStrategy::class, 63 | UrlBasedCollectionStrategy::class => UrlBasedCollectionStrategy::class, 64 | UrlBasedResourceStrategy::class => UrlBasedResourceStrategy::class, 65 | ], 66 | ]; 67 | } 68 | 69 | public function getHalConfig(): array 70 | { 71 | return [ 72 | 'embed-empty-collections' => false, 73 | 'resource-generator' => [ 74 | 'strategies' => [ // The registered strategies and their metadata types 75 | RouteBasedCollectionMetadata::class => RouteBasedCollectionStrategy::class, 76 | RouteBasedResourceMetadata::class => RouteBasedResourceStrategy::class, 77 | UrlBasedCollectionMetadata::class => UrlBasedCollectionStrategy::class, 78 | UrlBasedResourceMetadata::class => UrlBasedResourceStrategy::class, 79 | ], 80 | ], 81 | 'metadata-factories' => [ // The factories for the metadata types 82 | RouteBasedCollectionMetadata::class => RouteBasedCollectionMetadataFactory::class, 83 | RouteBasedResourceMetadata::class => RouteBasedResourceMetadataFactory::class, 84 | UrlBasedCollectionMetadata::class => UrlBasedCollectionMetadataFactory::class, 85 | UrlBasedResourceMetadata::class => UrlBasedResourceMetadataFactory::class, 86 | ], 87 | ]; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | > */ 42 | private $embedded = []; 43 | 44 | /** 45 | * @param LinkInterface[] $links 46 | * @param HalResource[][] $embedded 47 | */ 48 | public function __construct( 49 | array $data = [], 50 | array $links = [], 51 | array $embedded = [], 52 | private bool $embedEmptyCollections = false 53 | ) { 54 | $this->embedEmptyCollections = $embedEmptyCollections; 55 | 56 | $context = self::class; 57 | 58 | array_walk($data, function ($value, $name) use ($context) { 59 | $this->validateElementName($name, $context); 60 | 61 | if ($value instanceof self || $this->isResourceCollection($value)) { 62 | $this->embedded[$name] = $value; 63 | return; 64 | } 65 | 66 | $this->data[$name] = $value; 67 | }); 68 | 69 | array_walk($embedded, function ($resource, $name) use ($context) { 70 | $this->validateElementName($name, $context); 71 | $this->detectCollisionWithData($name, $context); 72 | 73 | if ( 74 | $resource instanceof self || 75 | $resource === [] || 76 | $this->isResourceCollection($resource) 77 | ) { 78 | $this->embedded[$name] = $resource; 79 | return; 80 | } 81 | 82 | throw new InvalidArgumentException(sprintf( 83 | 'Invalid embedded resource provided to %s constructor with name "%s":"%s"', 84 | $context, 85 | $name, 86 | get_debug_type($resource) 87 | )); 88 | }); 89 | 90 | if ( 91 | array_reduce( 92 | $links, 93 | fn($containsNonLinkItem, $link) 94 | => $containsNonLinkItem || ! $link instanceof LinkInterface, 95 | false 96 | ) 97 | ) { 98 | throw new InvalidArgumentException('Non-Link item provided in $links array'); 99 | } 100 | $this->links = $links; 101 | } 102 | 103 | /** 104 | * Retrieve a named element from the resource. 105 | * 106 | * If the element does not exist, but a corresponding embedded resource 107 | * is present, the embedded resource will be returned. 108 | * 109 | * If the element does not exist at all, a null value is returned. 110 | * 111 | * @return mixed 112 | * @throws InvalidArgumentException If $name is empty. 113 | * @throws InvalidArgumentException If $name is a reserved keyword. 114 | */ 115 | public function getElement(string $name) 116 | { 117 | $this->validateElementName($name, __METHOD__); 118 | 119 | if (! isset($this->data[$name]) && ! isset($this->embedded[$name])) { 120 | return null; 121 | } 122 | 123 | return $this->embedded[$name] ?? $this->data[$name]; 124 | } 125 | 126 | /** 127 | * Retrieve all elements of the resource. 128 | * 129 | * Returned as a set of key/value pairs. Embedded resources are mixed 130 | * in as `HalResource` instances under the associated key. 131 | */ 132 | public function getElements(): array 133 | { 134 | return array_merge($this->data, $this->embedded); 135 | } 136 | 137 | /** 138 | * Return an instance including the named element. 139 | * 140 | * If the value is another resource, proxies to embed(). 141 | * 142 | * If the $name existed in the original instance, it will be overwritten 143 | * by $value in the returned instance. 144 | * 145 | * @param mixed $value 146 | * @throws InvalidArgumentException If $name is empty. 147 | * @throws InvalidArgumentException If $name is a reserved keyword. 148 | * @throws RuntimeException If $name is already in use for an embedded 149 | * resource. 150 | */ 151 | public function withElement(string $name, $value): HalResource 152 | { 153 | $this->validateElementName($name, __METHOD__); 154 | 155 | if ( 156 | $value instanceof self || $this->isResourceCollection($value) 157 | ) { 158 | return $this->embed($name, $value); 159 | } 160 | 161 | $this->detectCollisionWithEmbeddedResource($name, __METHOD__); 162 | 163 | $new = clone $this; 164 | $new->data[$name] = $value; 165 | return $new; 166 | } 167 | 168 | /** 169 | * Return an instance removing the named element or embedded resource. 170 | * 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 HalResource|HalResource[] $resource 212 | * @param bool $forceCollection Whether a single resource or an 213 | * array containing a single resource should be represented as an array of 214 | * resources during representation. 215 | */ 216 | public function embed(string $name, $resource, bool $forceCollection = false): HalResource 217 | { 218 | $this->validateElementName($name, __METHOD__); 219 | $this->detectCollisionWithData($name, __METHOD__); 220 | if (! $resource instanceof self && ! $this->isResourceCollection($resource)) { 221 | throw new InvalidArgumentException(sprintf( 222 | '%s expects a %s instance or array of %s instances; received %s', 223 | __METHOD__, 224 | self::class, 225 | self::class, 226 | get_debug_type($resource) 227 | )); 228 | } 229 | $new = clone $this; 230 | $new->embedded[$name] = $this->aggregateEmbeddedResource($name, $resource, __METHOD__, $forceCollection); 231 | return $new; 232 | } 233 | 234 | public function toArray(): array 235 | { 236 | $resource = $this->data; 237 | 238 | $links = $this->serializeLinks(); 239 | if (! empty($links)) { 240 | $resource['_links'] = $links; 241 | } 242 | 243 | $embedded = $this->serializeEmbeddedResources(); 244 | if (! empty($embedded)) { 245 | $resource['_embedded'] = $embedded; 246 | } 247 | 248 | return $resource; 249 | } 250 | 251 | public function jsonSerialize(): array 252 | { 253 | return $this->toArray(); 254 | } 255 | 256 | /** 257 | * @throws InvalidArgumentException If $name is empty. 258 | * @throws InvalidArgumentException If $name is a reserved keyword. 259 | */ 260 | private function validateElementName(string $name, string $context): void 261 | { 262 | if (empty($name)) { 263 | throw new InvalidArgumentException(sprintf( 264 | '$name provided to %s cannot be empty', 265 | $context 266 | )); 267 | } 268 | if (in_array($name, ['_links', '_embedded'], true)) { 269 | throw new InvalidArgumentException(sprintf( 270 | 'Error calling %s: %s is not a reserved element $name and cannot be retrieved', 271 | $context, 272 | $name 273 | )); 274 | } 275 | } 276 | 277 | private function detectCollisionWithData(string $name, string $context): void 278 | { 279 | if (isset($this->data[$name])) { 280 | throw new RuntimeException(sprintf( 281 | 'Collision detected in %s; attempt to embed resource matching element name "%s"', 282 | $context, 283 | $name 284 | )); 285 | } 286 | } 287 | 288 | private function detectCollisionWithEmbeddedResource(string $name, string $context): void 289 | { 290 | if (isset($this->embedded[$name])) { 291 | throw new RuntimeException(sprintf( 292 | 'Collision detected in %s; attempt to add element matching resource name "%s"', 293 | $context, 294 | $name 295 | )); 296 | } 297 | } 298 | 299 | /** 300 | * Determine how to aggregate an embedded resource. 301 | * 302 | * If no embedded resource exists with the given name, returns it verbatim. 303 | * 304 | * If another does, it compares the new resource with the old, raising an 305 | * exception if they differ in structure, and returning an array containing 306 | * both if they do not. 307 | * 308 | * If another does as an array, it compares the new resource with the 309 | * structure of the first element; if they are comparable, then it appends 310 | * the new one to the list. 311 | * 312 | * @param array|HalResource $resource 313 | * @return HalResource|HalResource[] 314 | */ 315 | private function aggregateEmbeddedResource(string $name, $resource, string $context, bool $forceCollection) 316 | { 317 | if (! isset($this->embedded[$name])) { 318 | return $forceCollection ? [$resource] : $resource; 319 | } 320 | 321 | // $resource is an collection; existing individual or collection resource exists 322 | if ( 323 | is_array($resource) 324 | && array_reduce( 325 | $resource, 326 | fn(bool $allAreResources, $resource): bool 327 | => $allAreResources && $resource instanceof HalResource, 328 | true 329 | ) 330 | ) { 331 | return $this->aggregateEmbeddedCollection($name, $resource, $context); 332 | } 333 | 334 | if (is_array($resource)) { 335 | throw InvalidResourceValueException::fromValue($resource); 336 | } 337 | 338 | // $resource is a HalResource; existing resource is also a HalResource 339 | if ($this->embedded[$name] instanceof self) { 340 | return [$this->embedded[$name], $resource]; 341 | } 342 | 343 | $collection = $this->embedded[$name]; 344 | Assert::allIsInstanceOf($collection, self::class); 345 | 346 | $collectionFirstResource = $this->firstResource($collection); 347 | if (null === $collectionFirstResource) { 348 | throw new InvalidArgumentException(sprintf( 349 | '%s detected structurally inequivalent resources for element %s', 350 | $context, 351 | $name 352 | )); 353 | } 354 | 355 | // $resource is a HalResource; existing collection present 356 | $collection[] = $resource; 357 | 358 | return $collection; 359 | } 360 | 361 | /** 362 | * @param HalResource[] $collection 363 | */ 364 | private function aggregateEmbeddedCollection(string $name, array $collection, string $context): array 365 | { 366 | $original = $this->embedded[$name] instanceof self ? [$this->embedded[$name]] : $this->embedded[$name]; 367 | 368 | $originalFirstResource = $this->firstResource($original); 369 | $collectionFirstResource = $this->firstResource($collection); 370 | 371 | if (null === $originalFirstResource && null === $collectionFirstResource) { 372 | return []; 373 | } 374 | 375 | if (null === $originalFirstResource || null === $collectionFirstResource) { 376 | throw new InvalidArgumentException(sprintf( 377 | '%s detected structurally inequivalent resources for element %s', 378 | $context, 379 | $name 380 | )); 381 | } 382 | 383 | return $original + $collection; 384 | } 385 | 386 | /** 387 | * Return the first resource in a list. 388 | * 389 | * Exists as array_shift is destructive, and we cannot necessarily know the 390 | * index of the first element. 391 | * 392 | * @param HalResource[] $resources 393 | */ 394 | private function firstResource(array $resources): ?HalResource 395 | { 396 | foreach ($resources as $resource) { 397 | return $resource; 398 | } 399 | return null; 400 | } 401 | 402 | /** 403 | * @param null|object|HalResource[] $value 404 | */ 405 | private function isResourceCollection($value): bool 406 | { 407 | if (! is_array($value)) { 408 | return false; 409 | } 410 | 411 | if ($value === []) { 412 | return $this->embedEmptyCollections; 413 | } 414 | 415 | return array_reduce($value, static fn($isResource, $item) => $isResource && $item instanceof self, true); 416 | } 417 | 418 | private function serializeLinks(): array 419 | { 420 | $relations = array_reduce($this->links, function (array $byRelation, LinkInterface $link) { 421 | $representation = array_merge($link->getAttributes(), [ 422 | 'href' => $link->getHref(), 423 | ]); 424 | if ($link->isTemplated()) { 425 | $representation['templated'] = true; 426 | } 427 | 428 | $linkRels = $link->getRels(); 429 | array_walk($linkRels, function ($rel) use (&$byRelation, $representation) { 430 | $forceCollection = array_key_exists(Link::AS_COLLECTION, $representation) 431 | && $representation[Link::AS_COLLECTION]; 432 | unset($representation[Link::AS_COLLECTION]); 433 | 434 | if (! isset($byRelation[$rel])) { 435 | $byRelation[$rel] = []; 436 | } 437 | 438 | /** @var array $relation */ 439 | $relation = &$byRelation[$rel]; 440 | $relation[] = $representation; 441 | 442 | // If we're forcing a collection, and the current relation only 443 | // has one item, mark the relation to force a collection 444 | if (1 === count($relation) && $forceCollection) { 445 | $relation[Link::AS_COLLECTION] = true; 446 | } 447 | 448 | // If we have more than one link for the relation, and the 449 | // marker for forcing a collection is present, remove the 450 | // marker; it's redundant. Check for a count greater than 2, 451 | // as the marker itself will affect the count! 452 | if (2 < count($relation) && isset($relation[Link::AS_COLLECTION])) { 453 | unset($relation[Link::AS_COLLECTION]); 454 | } 455 | }); 456 | 457 | return $byRelation; 458 | }, []); 459 | 460 | array_walk($relations, function ($links, $key) use (&$relations) { 461 | if (isset($relations[$key][Link::AS_COLLECTION])) { 462 | // If forcing a collection, do nothing to the links, but DO 463 | // remove the marker indicating a collection should be 464 | // returned. 465 | unset($relations[$key][Link::AS_COLLECTION]); 466 | return; 467 | } 468 | 469 | $relations[$key] = 1 === count($links) ? array_shift($links) : $links; 470 | }); 471 | 472 | return $relations; 473 | } 474 | 475 | private function serializeEmbeddedResources(): array 476 | { 477 | $embedded = []; 478 | array_walk( 479 | $this->embedded, 480 | /** 481 | * @param array|self $resource 482 | */ 483 | function ($resource, string $name) use (&$embedded): void { 484 | $embedded[$name] = $resource instanceof self 485 | ? $resource->toArray() 486 | : array_map(fn($item) => $item->toArray(), $resource); 487 | } 488 | ); 489 | 490 | return $embedded; 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /src/HalResponseFactory.php: -------------------------------------------------------------------------------- 1 | $responseFactory() 58 | ); 59 | } 60 | 61 | $this->responseFactory = $responseFactory; 62 | $this->jsonRenderer = $jsonRenderer ?: new Renderer\JsonRenderer(); 63 | $this->xmlRenderer = $xmlRenderer ?: new Renderer\XmlRenderer(); 64 | } 65 | 66 | public function createResponse( 67 | ServerRequestInterface $request, 68 | HalResource $resource, 69 | string $mediaType = self::DEFAULT_CONTENT_TYPE 70 | ): ResponseInterface { 71 | $accept = $request->getHeaderLine('Accept') ?: '*/*'; 72 | $matchedType = (new Negotiator())->getBest($accept, self::NEGOTIATION_PRIORITIES); 73 | 74 | switch (true) { 75 | case $matchedType && str_contains($matchedType->getValue(), 'json'): 76 | $renderer = $this->jsonRenderer; 77 | $mediaType .= '+json'; 78 | break; 79 | case ! $matchedType: 80 | // fall-through 81 | default: 82 | $renderer = $this->xmlRenderer; 83 | $mediaType .= '+xml'; 84 | break; 85 | } 86 | 87 | $response = $this->responseFactory->createResponse(); 88 | $response->getBody()->write($renderer->render($resource)); 89 | return $response->withHeader('Content-Type', $mediaType); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/HalResponseFactoryFactory.php: -------------------------------------------------------------------------------- 1 | has(Renderer\JsonRenderer::class) 32 | ? $container->get(Renderer\JsonRenderer::class) 33 | : ($container->has(JsonRenderer::class) 34 | ? $container->get(JsonRenderer::class) 35 | : new Renderer\JsonRenderer()); 36 | 37 | $xmlRenderer = $container->has(Renderer\XmlRenderer::class) 38 | ? $container->get(Renderer\XmlRenderer::class) 39 | : ($container->has(XmlRenderer::class) 40 | ? $container->get(XmlRenderer::class) 41 | : new Renderer\XmlRenderer()); 42 | 43 | return new HalResponseFactory( 44 | $this->detectResponseFactory($container), 45 | $jsonRenderer, 46 | $xmlRenderer 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Link.php: -------------------------------------------------------------------------------- 1 | relations = $this->validateRelation($relation); 48 | $this->uri = is_string($uri) ? $uri : (string) $uri; 49 | $this->attributes = $this->validateAttributes($attributes); 50 | } 51 | 52 | /** 53 | * {@inheritDoc} 54 | */ 55 | public function getHref() 56 | { 57 | return $this->uri; 58 | } 59 | 60 | /** 61 | * {@inheritDoc} 62 | */ 63 | public function isTemplated() 64 | { 65 | return $this->isTemplated; 66 | } 67 | 68 | /** 69 | * {@inheritDoc} 70 | */ 71 | public function getRels() 72 | { 73 | return $this->relations; 74 | } 75 | 76 | /** 77 | * {@inheritDoc} 78 | */ 79 | public function getAttributes() 80 | { 81 | return $this->attributes; 82 | } 83 | 84 | /** 85 | * {@inheritDoc} 86 | * 87 | * @throws InvalidArgumentException If $href is not a string, and not an 88 | * object implementing __toString. 89 | */ 90 | public function withHref($href) 91 | { 92 | if ( 93 | ! is_string($href) 94 | && ! (is_object($href) && method_exists($href, '__toString')) 95 | ) { 96 | throw new InvalidArgumentException(sprintf( 97 | '%s expects a string URI or an object implementing __toString; received %s', 98 | __METHOD__, 99 | get_debug_type($href) 100 | )); 101 | } 102 | $new = clone $this; 103 | $new->uri = (string) $href; 104 | return $new; 105 | } 106 | 107 | /** 108 | * {@inheritDoc} 109 | * 110 | * @throws InvalidArgumentException If $rel is not a string. 111 | */ 112 | public function withRel($rel) 113 | { 114 | if (! is_string($rel) || empty($rel)) { 115 | throw new InvalidArgumentException(sprintf( 116 | '%s expects a non-empty string relation type; received %s', 117 | __METHOD__, 118 | get_debug_type($rel) 119 | )); 120 | } 121 | 122 | if (in_array($rel, $this->relations, true)) { 123 | return $this; 124 | } 125 | 126 | $new = clone $this; 127 | $new->relations[] = $rel; 128 | return $new; 129 | } 130 | 131 | /** 132 | * {@inheritDoc} 133 | */ 134 | public function withoutRel($rel) 135 | { 136 | if (! is_string($rel) || empty($rel)) { 137 | return $this; 138 | } 139 | 140 | if (! in_array($rel, $this->relations, true)) { 141 | return $this; 142 | } 143 | 144 | $new = clone $this; 145 | $new->relations = array_filter($this->relations, fn($value) => $rel !== $value); 146 | return $new; 147 | } 148 | 149 | /** 150 | * {@inheritDoc} 151 | * 152 | * @throws InvalidArgumentException If $attribute is not a string or is empty. 153 | * @throws InvalidArgumentException If $value is neither a scalar nor an array. 154 | * @throws InvalidArgumentException If $value is an array, but one or more values 155 | * is not a string. 156 | */ 157 | public function withAttribute($attribute, $value) 158 | { 159 | $this->validateAttributeName($attribute, __METHOD__); 160 | $this->validateAttributeValue($value, __METHOD__); 161 | 162 | $new = clone $this; 163 | $new->attributes[$attribute] = $value; 164 | return $new; 165 | } 166 | 167 | /** 168 | * {@inheritDoc} 169 | */ 170 | public function withoutAttribute($attribute) 171 | { 172 | if (! is_string($attribute) || empty($attribute)) { 173 | return $this; 174 | } 175 | 176 | if (! isset($this->attributes[$attribute])) { 177 | return $this; 178 | } 179 | 180 | $new = clone $this; 181 | unset($new->attributes[$attribute]); 182 | return $new; 183 | } 184 | 185 | /** 186 | * @param mixed $name 187 | * @throws InvalidArgumentException If $attribute is not a string or is empty. 188 | */ 189 | private function validateAttributeName($name, string $context): void 190 | { 191 | if (! is_string($name) || empty($name)) { 192 | throw new InvalidArgumentException(sprintf( 193 | '%s expects the $name argument to be a non-empty string; received %s', 194 | $context, 195 | get_debug_type($name) 196 | )); 197 | } 198 | } 199 | 200 | /** 201 | * @param mixed $value 202 | * @throws InvalidArgumentException If $value is neither a scalar nor an array. 203 | * @throws InvalidArgumentException If $value is an array, but one or more values 204 | * is not a string. 205 | */ 206 | private function validateAttributeValue($value, string $context): void 207 | { 208 | if (! is_scalar($value) && ! is_array($value)) { 209 | throw new InvalidArgumentException(sprintf( 210 | '%s expects the $value to be a PHP primitive or array of strings; received %s', 211 | $context, 212 | get_debug_type($value) 213 | )); 214 | } 215 | 216 | if ( 217 | is_array($value) && array_reduce($value, fn($isInvalid, $value) => $isInvalid || ! is_string($value), false) 218 | ) { 219 | throw new InvalidArgumentException(sprintf( 220 | '%s expects $value to contain an array of strings; one or more values was not a string', 221 | $context 222 | )); 223 | } 224 | } 225 | 226 | private function validateAttributes(array $attributes): array 227 | { 228 | foreach ($attributes as $name => $value) { 229 | $this->validateAttributeName($name, self::class); 230 | $this->validateAttributeValue($value, self::class); 231 | } 232 | return $attributes; 233 | } 234 | 235 | /** 236 | * @param mixed $relation 237 | * @return string|string[] 238 | * @throws InvalidArgumentException If $relation is neither a string nor an array. 239 | * @throws InvalidArgumentException If $relation is an array, but any given value in it is not a string. 240 | */ 241 | private function validateRelation($relation) 242 | { 243 | if (! is_array($relation) && (! is_string($relation) || empty($relation))) { 244 | throw new InvalidArgumentException(sprintf( 245 | '$relation argument must be a string or array of strings; received %s', 246 | get_debug_type($relation) 247 | )); 248 | } 249 | 250 | if ( 251 | is_array($relation) && false === array_reduce( 252 | $relation, 253 | fn($isString, $value) => 254 | $isString === false || is_string($value) || empty($value), 255 | true 256 | ) 257 | ) { 258 | throw new InvalidArgumentException( 259 | 'When passing an array for $relation, each value must be a non-empty string; ' 260 | . 'one or more non-string or empty values were present' 261 | ); 262 | } 263 | 264 | return is_string($relation) ? [$relation] : $relation; 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/LinkCollection.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public function getLinks(): array 26 | { 27 | return $this->links; 28 | } 29 | 30 | /** 31 | * {@inheritDoc} 32 | * 33 | * @return LinkInterface[] 34 | * @psalm-return array 35 | */ 36 | public function getLinksByRel($rel): array 37 | { 38 | return array_filter($this->links, function (LinkInterface $link) use ($rel) { 39 | $rels = $link->getRels(); 40 | return in_array($rel, $rels, true); 41 | }); 42 | } 43 | 44 | /** 45 | * {@inheritDoc} 46 | */ 47 | public function withLink(LinkInterface $link): self 48 | { 49 | if (in_array($link, $this->links, true)) { 50 | return $this; 51 | } 52 | 53 | $new = clone $this; 54 | $new->links[] = $link; 55 | return $new; 56 | } 57 | 58 | /** 59 | * {@inheritDoc} 60 | */ 61 | public function withoutLink(LinkInterface $link): self 62 | { 63 | if (! in_array($link, $this->links, true)) { 64 | return $this; 65 | } 66 | 67 | $new = clone $this; 68 | $new->links = array_filter($this->links, fn(LinkInterface $compare) => $link !== $compare); 69 | return $new; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/LinkGenerator.php: -------------------------------------------------------------------------------- 1 | $routeParams 18 | * @param array $queryParams 19 | * @param array $attributes 20 | */ 21 | public function fromRoute( 22 | string $relation, 23 | ServerRequestInterface $request, 24 | string $routeName, 25 | array $routeParams = [], 26 | array $queryParams = [], 27 | array $attributes = [] 28 | ): Link { 29 | return new Link($relation, $this->urlGenerator->generate( 30 | $request, 31 | $routeName, 32 | $routeParams, 33 | $queryParams 34 | ), false, $attributes); 35 | } 36 | 37 | /** 38 | * Creates a templated link 39 | * 40 | * @param array $routeParams 41 | * @param array $queryParams 42 | * @param array $attributes 43 | */ 44 | public function templatedFromRoute( 45 | string $relation, 46 | ServerRequestInterface $request, 47 | string $routeName, 48 | array $routeParams = [], 49 | array $queryParams = [], 50 | array $attributes = [] 51 | ): Link { 52 | return new Link($relation, $this->urlGenerator->generate( 53 | $request, 54 | $routeName, 55 | $routeParams, 56 | $queryParams 57 | ), true, $attributes); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/LinkGenerator/MezzioUrlGenerator.php: -------------------------------------------------------------------------------- 1 | urlHelper->generate($routeName, $routeParams, $queryParams); 26 | 27 | if (! $this->serverUrlHelper) { 28 | return $path; 29 | } 30 | 31 | $serverUrlHelper = clone $this->serverUrlHelper; 32 | $serverUrlHelper->setUri($request->getUri()); 33 | return $serverUrlHelper($path); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/LinkGenerator/MezzioUrlGeneratorFactory.php: -------------------------------------------------------------------------------- 1 | has($this->urlHelperServiceName)) { 36 | throw new RuntimeException(sprintf( 37 | '%s requires a %s in order to generate a %s instance; none found', 38 | self::class, 39 | $this->urlHelperServiceName, 40 | MezzioUrlGenerator::class 41 | )); 42 | } 43 | 44 | return new MezzioUrlGenerator( 45 | $container->get($this->urlHelperServiceName), 46 | $container->has(ServerUrlHelper::class) 47 | ? $container->get(ServerUrlHelper::class) 48 | : ($container->has(\Zend\Expressive\Helper\ServerUrlHelper::class) 49 | ? $container->get(\Zend\Expressive\Helper\ServerUrlHelper::class) 50 | : null) 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/LinkGenerator/UrlGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | $routeParams 27 | * @param array $queryParams 28 | */ 29 | public function generate( 30 | ServerRequestInterface $request, 31 | string $routeName, 32 | array $routeParams = [], 33 | array $queryParams = [] 34 | ): string; 35 | } 36 | -------------------------------------------------------------------------------- /src/LinkGeneratorFactory.php: -------------------------------------------------------------------------------- 1 | get($this->urlGeneratorServiceName) 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Metadata/AbstractCollectionMetadata.php: -------------------------------------------------------------------------------- 1 | collectionRelation; 24 | } 25 | 26 | public function getPaginationParam(): string 27 | { 28 | return $this->paginationParam; 29 | } 30 | 31 | public function getPaginationParamType(): string 32 | { 33 | return $this->paginationParamType; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Metadata/AbstractMetadata.php: -------------------------------------------------------------------------------- 1 | class; 19 | } 20 | 21 | public function hasReachedMaxDepth(int $currentDepth): bool 22 | { 23 | return false; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Metadata/AbstractResourceMetadata.php: -------------------------------------------------------------------------------- 1 | extractor; 23 | } 24 | 25 | public function hasReachedMaxDepth(int $currentDepth): bool 26 | { 27 | return $currentDepth > $this->maxDepth; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Metadata/Exception/DuplicateMetadataException.php: -------------------------------------------------------------------------------- 1 | 18 | * [ 19 | * '__class__' => 'Fully qualified class name of an AbstractMetadata type', 20 | * // additional key/value pairs as required by the metadata type. 21 | * ] 22 | * 23 | * 24 | * The '__class__' key decides which AbstractMetadata should be used 25 | * (and which corresponding factory will be called to create it). 26 | */ 27 | public function createMetadata(string $requestedName, array $metadata): AbstractMetadata; 28 | } 29 | -------------------------------------------------------------------------------- /src/Metadata/MetadataMap.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | private $map = []; 16 | 17 | /** 18 | * @throws Exception\DuplicateMetadataException If metadata matching the 19 | * class of the provided metadata already exists in the map. 20 | * @throws Exception\UndefinedClassException If the class in the provided 21 | * metadata does not exist. 22 | */ 23 | public function add(AbstractMetadata $metadata): void 24 | { 25 | $class = $metadata->getClass(); 26 | if (isset($this->map[$class])) { 27 | throw Exception\DuplicateMetadataException::create($class); 28 | } 29 | 30 | if (! class_exists($class)) { 31 | throw Exception\UndefinedClassException::create($class); 32 | } 33 | 34 | $this->map[$class] = $metadata; 35 | } 36 | 37 | public function has(string $class): bool 38 | { 39 | return isset($this->map[$class]); 40 | } 41 | 42 | /** 43 | * @throws Exception\UndefinedMetadataException If no metadata matching the 44 | * provided class is found in the map. 45 | */ 46 | public function get(string $class): AbstractMetadata 47 | { 48 | if (! isset($this->map[$class])) { 49 | throw Exception\UndefinedMetadataException::create($class); 50 | } 51 | 52 | return $this->map[$class]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Metadata/MetadataMapFactory.php: -------------------------------------------------------------------------------- 1 | 27 | * [ 28 | * // Fully qualified class name of an AbstractMetadata type 29 | * '__class__' => MyMetadata::class, 30 | * 31 | * // additional key/value pairs as required by the metadata type. 32 | * // (See their respective factories) 33 | * ] 34 | * 35 | * 36 | * If you have created a custom metadata type, you have to register a factory 37 | * in your configuration to support it. Add an entry to the config array: 38 | * 39 | * 40 | * $config['mezzio-hal']['metadata-factories'][MyMetadata::class] = MyMetadataFactory::class; 41 | * 42 | * 43 | * The factory mapped should implement `MetadataFactoryInterface`. 44 | */ 45 | class MetadataMapFactory 46 | { 47 | public function __invoke(ContainerInterface $container): MetadataMap 48 | { 49 | $config = $container->has('config') ? $container->get('config') : []; 50 | $metadataMapConfig = $config[MetadataMap::class] ?? []; 51 | 52 | if (! is_array($metadataMapConfig)) { 53 | throw Exception\InvalidConfigException::dueToNonArray($metadataMapConfig); 54 | } 55 | 56 | $metadataFactories = $config['mezzio-hal']['metadata-factories'] ?? []; 57 | 58 | return $this->populateMetadataMapFromConfig( 59 | new MetadataMap(), 60 | $metadataMapConfig, 61 | $metadataFactories 62 | ); 63 | } 64 | 65 | private function populateMetadataMapFromConfig( 66 | MetadataMap $metadataMap, 67 | array $metadataMapConfig, 68 | array $metadataFactories 69 | ): MetadataMap { 70 | foreach ($metadataMapConfig as $metadata) { 71 | if (! is_array($metadata)) { 72 | throw Exception\InvalidConfigException::dueToNonArrayMetadata($metadata); 73 | } 74 | 75 | $this->injectMetadata($metadataMap, $metadata, $metadataFactories); 76 | } 77 | 78 | return $metadataMap; 79 | } 80 | 81 | /** 82 | * @throws Exception\InvalidConfigException If the metadata is missing a 83 | * "__class__" entry. 84 | * @throws Exception\InvalidConfigException If the "__class__" entry is not 85 | * a class. 86 | * @throws Exception\InvalidConfigException If the "__class__" entry is not 87 | * an AbstractMetadata class. 88 | * @throws Exception\InvalidConfigException If no matching `create*()` 89 | * method is found for the "__class__" entry. 90 | */ 91 | private function injectMetadata(MetadataMap $metadataMap, array $metadata, array $metadataFactories): void 92 | { 93 | if (! isset($metadata['__class__'])) { 94 | throw Exception\InvalidConfigException::dueToMissingMetadataClass(); 95 | } 96 | 97 | if (! class_exists($metadata['__class__'])) { 98 | throw Exception\InvalidConfigException::dueToInvalidMetadataClass($metadata['__class__']); 99 | } 100 | 101 | $metadataClass = $metadata['__class__']; 102 | if (! in_array(AbstractMetadata::class, class_parents($metadataClass), true)) { 103 | throw Exception\InvalidConfigException::dueToNonMetadataClass($metadataClass); 104 | } 105 | 106 | if (isset($metadataFactories[$metadataClass])) { 107 | // A factory was registered. Use it! 108 | $metadataMap->add($this->createMetadataViaFactoryClass( 109 | $metadataClass, 110 | $metadata, 111 | $metadataFactories[$metadataClass] 112 | )); 113 | return; 114 | } 115 | 116 | // No factory was registered. Use the deprecated factory method. 117 | $metadataMap->add($this->createMetadataViaFactoryMethod( 118 | $metadataClass, 119 | $metadata 120 | )); 121 | } 122 | 123 | /** 124 | * Uses the registered factory class to create the metadata instance. 125 | */ 126 | private function createMetadataViaFactoryClass( 127 | string $metadataClass, 128 | array $metadata, 129 | string $factoryClass 130 | ): AbstractMetadata { 131 | if (! in_array(MetadataFactoryInterface::class, class_implements($factoryClass), true)) { 132 | throw Exception\InvalidConfigException::dueToInvalidMetadataFactoryClass($factoryClass); 133 | } 134 | 135 | $factory = new $factoryClass(); 136 | /** @var MetadataFactoryInterface $factory */ 137 | return $factory->createMetadata($metadataClass, $metadata); 138 | } 139 | 140 | /** 141 | * Call the factory method in this class namend "createMyMetadata(array $metadata)". 142 | * 143 | * This function is to ensure backwards compatibility with versions prior to 0.6.0. 144 | */ 145 | private function createMetadataViaFactoryMethod(string $metadataClass, array $metadata): AbstractMetadata 146 | { 147 | $normalizedClass = $this->stripNamespaceFromClass($metadataClass); 148 | $method = sprintf('create%s', $normalizedClass); 149 | 150 | if (! method_exists($this, $method)) { 151 | throw Exception\InvalidConfigException::dueToUnrecognizedMetadataClass($metadataClass); 152 | } 153 | 154 | return $this->$method($metadata); 155 | } 156 | 157 | private function stripNamespaceFromClass(string $class): string 158 | { 159 | $segments = explode('\\', $class); 160 | return array_pop($segments); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Metadata/RouteBasedCollectionMetadata.php: -------------------------------------------------------------------------------- 1 | $routeParams 11 | * @param array $queryStringArguments 12 | */ 13 | public function __construct( 14 | string $class, 15 | string $collectionRelation, 16 | private readonly string $route, 17 | string $paginationParam = 'page', 18 | string $paginationParamType = self::TYPE_QUERY, 19 | private array $routeParams = [], 20 | private array $queryStringArguments = [] 21 | ) { 22 | $this->class = $class; 23 | $this->collectionRelation = $collectionRelation; 24 | $this->paginationParam = $paginationParam; 25 | $this->paginationParamType = $paginationParamType; 26 | } 27 | 28 | public function getRoute(): string 29 | { 30 | return $this->route; 31 | } 32 | 33 | /** @return array */ 34 | public function getRouteParams(): array 35 | { 36 | return $this->routeParams; 37 | } 38 | 39 | /** @return array */ 40 | public function getQueryStringArguments(): array 41 | { 42 | return $this->queryStringArguments; 43 | } 44 | 45 | /** 46 | * Allow run-time overriding/injection of route parameters. 47 | * 48 | * In particular, this is useful for setting a parent identifier 49 | * in the route when dealing with child resources. 50 | * 51 | * @param array $routeParams 52 | */ 53 | public function setRouteParams(array $routeParams): void 54 | { 55 | $this->routeParams = $routeParams; 56 | } 57 | 58 | /** 59 | * Allow run-time overriding/injection of query string arguments. 60 | * 61 | * In particular, this is useful for setting query string arguments for 62 | * searches, sorts, limits, etc. 63 | * 64 | * @param array $query 65 | */ 66 | public function setQueryStringArguments(array $query): void 67 | { 68 | $this->queryStringArguments = $query; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Metadata/RouteBasedCollectionMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 18 | * [ 19 | * // Fully qualified class name of the AbstractMetadata type. 20 | * '__class__' => RouteBasedCollectionMetadata::class, 21 | * 22 | * // Fully qualified class name of the collection class. 23 | * 'collection_class' => MyCollection::class, 24 | * 25 | * // The embedded relation for the collection in the generated resource. 26 | * 'collection_relation' => 'items', 27 | * 28 | * // The route to use when generating a self relational link for the 29 | * // collection resource. 30 | * 'route' => 'items.list', 31 | * 32 | * // Optional params 33 | * 34 | * // The name of the parameter indicating what page of data is present. 35 | * // Defaults to "page". 36 | * 'pagination_param' => 'page', 37 | * 38 | * // Whether the pagination parameter is a query string or path placeholder. 39 | * // Use either AbstractCollectionMetadata::TYPE_QUERY (the default) 40 | * // or AbstractCollectionMetadata::TYPE_PLACEHOLDER. 41 | * 'pagination_param_type' => AbstractCollectionMetadata::TYPE_QUERY, 42 | * 43 | * // An array of additional routing parameters to use when generating 44 | * // the self relational link for the collection resource. 45 | * // Defaults to an empty array. 46 | * 'route_params' => [], 47 | * 48 | * // An array of query string parameters to include when generating the 49 | * // self relational link for the collection resource. 50 | * // Defaults to an empty array. 51 | * 'query_string_arguments' => [], 52 | * ] 53 | * 54 | * @throws Exception\InvalidConfigException 55 | */ 56 | public function createMetadata(string $requestedName, array $metadata): AbstractMetadata 57 | { 58 | $requiredKeys = [ 59 | 'collection_class', 60 | 'collection_relation', 61 | 'route', 62 | ]; 63 | 64 | if ($requiredKeys !== array_intersect($requiredKeys, array_keys($metadata))) { 65 | throw Exception\InvalidConfigException::dueToMissingMetadata( 66 | RouteBasedCollectionMetadata::class, 67 | $requiredKeys 68 | ); 69 | } 70 | 71 | return new $requestedName( 72 | $metadata['collection_class'], 73 | $metadata['collection_relation'], 74 | $metadata['route'], 75 | $metadata['pagination_param'] ?? 'page', 76 | $metadata['pagination_param_type'] ?? RouteBasedCollectionMetadata::TYPE_QUERY, 77 | $metadata['route_params'] ?? [], 78 | $metadata['query_string_arguments'] ?? [] 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Metadata/RouteBasedResourceMetadata.php: -------------------------------------------------------------------------------- 1 | class = $class; 33 | $this->route = $route; 34 | $this->extractor = $extractor; 35 | $this->resourceIdentifier = $resourceIdentifier; 36 | $this->routeParams = $routeParams; 37 | $this->identifiersToPlaceHoldersMapping = $identifiersToPlaceholdersMapping; 38 | $this->maxDepth = $maxDepth; 39 | } 40 | 41 | public function getRoute(): string 42 | { 43 | return $this->route; 44 | } 45 | 46 | public function getIdentifiersToPlaceholdersMapping(): array 47 | { 48 | return $this->identifiersToPlaceHoldersMapping; 49 | } 50 | 51 | /** 52 | * This method has been kept for BC and should be deprecated. 53 | */ 54 | public function getResourceIdentifier(): string 55 | { 56 | return $this->resourceIdentifier; 57 | } 58 | 59 | public function getRouteParams(): array 60 | { 61 | return $this->routeParams; 62 | } 63 | 64 | public function setRouteParams(array $routeParams): void 65 | { 66 | $this->routeParams = $routeParams; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Metadata/RouteBasedResourceMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 18 | * [ 19 | * // Fully qualified class name of the AbstractMetadata type. 20 | * '__class__' => RouteBasedResourceMetadata::class, 21 | * 22 | * // Fully qualified class name of the resource class. 23 | * 'resource_class' => MyResource::class, 24 | * 25 | * // The route to use when generating a self relational link for 26 | * // the resource. 27 | * 'route' => 'my-resouce', 28 | * 29 | * // The extractor/hydrator service to use to extract resource data. 30 | * 'extractor' => 'MyExtractor', 31 | * 32 | * // Optional params 33 | * 34 | * // What property in the resource represents its identifier. 35 | * // Defaults to "id". 36 | * 'resource_identifier' => 'id', 37 | * 38 | * // An array of additional routing parameters to use when 39 | * // generating the self relational link for the collection 40 | * // resource. Defaults to an empty array. 41 | * 'route_params' => [], 42 | * 43 | * // An array mapping resource properties to routing placeholder 44 | * // names. This can also be used to replace the 45 | * // 'route_identifier_placeholder' setting, which will be removed 46 | * // in version 2.0. 47 | * 'identifiers_to_placeholders_mapping' => ['id' => 'id'], 48 | * 49 | * // Max depth to render 50 | * // Defaults to 10. 51 | * 'max_depth' => 10, 52 | * ] 53 | * 54 | * @throws Exception\InvalidConfigException 55 | */ 56 | public function createMetadata(string $requestedName, array $metadata): AbstractMetadata 57 | { 58 | $requiredKeys = [ 59 | 'resource_class', 60 | 'route', 61 | 'extractor', 62 | ]; 63 | 64 | if ($requiredKeys !== array_intersect($requiredKeys, array_keys($metadata))) { 65 | throw Exception\InvalidConfigException::dueToMissingMetadata( 66 | RouteBasedResourceMetadata::class, 67 | $requiredKeys 68 | ); 69 | } 70 | 71 | return new $requestedName( 72 | $metadata['resource_class'], 73 | $metadata['route'], 74 | $metadata['extractor'], 75 | $metadata['resource_identifier'] ?? 'id', 76 | $metadata['route_params'] ?? [], 77 | $metadata['identifiers_to_placeholders_mapping'] ?? ['id' => 'id'], 78 | $metadata['max_depth'] ?? 10 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Metadata/UrlBasedCollectionMetadata.php: -------------------------------------------------------------------------------- 1 | class = $class; 42 | $this->collectionRelation = $collectionRelation; 43 | $this->paginationParam = $paginationParam; 44 | $this->paginationParamType = $paginationParamType; 45 | } 46 | 47 | public function getUrl(): string 48 | { 49 | return $this->url; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Metadata/UrlBasedCollectionMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 18 | * [ 19 | * // Fully qualified class name of the AbstractMetadata type. 20 | * '__class__' => UrlBasedCollectionMetadata::class, 21 | * 22 | * // Fully qualified class name of the collection class. 23 | * 'collection_class' => MyCollection::class, 24 | * 25 | * // The embedded relation for the collection in the generated 26 | * // resource. 27 | * 'collection_relation' => 'items', 28 | * 29 | * // The URL to use when generating a self-relational link for 30 | * // the collection resource. 31 | * 'url' => 'https://example.org/my-collection', 32 | * 33 | * // Optional params 34 | * 35 | * // The name of the parameter indicating what page of data is 36 | * // present. Defaults to "page". 37 | * 'pagination_param' => 'page', 38 | * 39 | * // Whether the pagination parameter is a query string or path 40 | * // placeholder use either AbstractCollectionMetadata::TYPE_QUERY 41 | * // (the default) or AbstractCollectionMetadata::TYPE_PLACEHOLDER. 42 | * 'pagination_param_type' => AbstractCollectionMetadata::TYPE_QUERY, 43 | * ] 44 | * 45 | * @throws Exception\InvalidConfigException 46 | */ 47 | public function createMetadata(string $requestedName, array $metadata): AbstractMetadata 48 | { 49 | $requiredKeys = [ 50 | 'collection_class', 51 | 'collection_relation', 52 | 'url', 53 | ]; 54 | 55 | if ($requiredKeys !== array_intersect($requiredKeys, array_keys($metadata))) { 56 | throw Exception\InvalidConfigException::dueToMissingMetadata( 57 | UrlBasedCollectionMetadata::class, 58 | $requiredKeys 59 | ); 60 | } 61 | 62 | return new $requestedName( 63 | $metadata['collection_class'], 64 | $metadata['collection_relation'], 65 | $metadata['url'], 66 | $metadata['pagination_param'] ?? 'page', 67 | $metadata['pagination_param_type'] ?? UrlBasedCollectionMetadata::TYPE_QUERY 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Metadata/UrlBasedResourceMetadata.php: -------------------------------------------------------------------------------- 1 | class = $class; 12 | $this->extractor = $extractor; 13 | $this->maxDepth = $maxDepth; 14 | } 15 | 16 | public function getUrl(): string 17 | { 18 | return $this->url; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Metadata/UrlBasedResourceMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 18 | * [ 19 | * // Fully qualified class name of the AbstractMetadata type. 20 | * '__class__' => RouteBasedResourceMetadata::class, 21 | * 22 | * // Fully qualified class name of the resource class. 23 | * 'resource_class' => MyResource::class, 24 | * 25 | * // The URL to use when generating a self-relational link for 26 | * // the resource. 27 | * 'url' => 'https://example.org/my-resource', 28 | * 29 | * // The extractor/hydrator service to use to extract resource data. 30 | * 'extractor' => 'MyExtractor', 31 | * 32 | * // Max depth to render 33 | * // Defaults to 10. 34 | * 'max_depth' => 10, 35 | * ] 36 | * 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 | $metadata['max_depth'] ?? 10 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Psr17ResponseFactoryTrait.php: -------------------------------------------------------------------------------- 1 | has(ResponseFactoryInterface::class); 23 | 24 | if (! $psr17FactoryAvailable) { 25 | return $this->createResponseFactoryFromDeprecatedCallable($container); 26 | } 27 | 28 | if ($this->doesConfigurationProvidesDedicatedResponseFactory($container)) { 29 | return $this->createResponseFactoryFromDeprecatedCallable($container); 30 | } 31 | 32 | $responseFactory = $container->get(ResponseFactoryInterface::class); 33 | Assert::isInstanceOf($responseFactory, ResponseFactoryInterface::class); 34 | return $responseFactory; 35 | } 36 | 37 | private function createResponseFactoryFromDeprecatedCallable( 38 | ContainerInterface $container 39 | ): ResponseFactoryInterface { 40 | /** @var callable():ResponseInterface $responseFactory */ 41 | $responseFactory = $container->get(ResponseInterface::class); 42 | 43 | return new CallableResponseFactoryDecorator($responseFactory); 44 | } 45 | 46 | private function doesConfigurationProvidesDedicatedResponseFactory(ContainerInterface $container): bool 47 | { 48 | if (! $container->has('config')) { 49 | return false; 50 | } 51 | 52 | $config = $container->get('config'); 53 | Assert::isArrayAccessible($config); 54 | $dependencies = $config['dependencies'] ?? []; 55 | Assert::isMap($dependencies); 56 | 57 | $delegators = $dependencies['delegators'] ?? []; 58 | $aliases = $dependencies['aliases'] ?? []; 59 | Assert::isArrayAccessible($delegators); 60 | Assert::isArrayAccessible($aliases); 61 | 62 | if (isset($delegators[ResponseInterface::class]) || isset($aliases[ResponseInterface::class])) { 63 | // Even tho, aliases could point to a different service, we assume that there is a dedicated factory 64 | // available. The alias resolving is not worth it. 65 | return true; 66 | } 67 | 68 | /** @psalm-suppress MixedAssignment */ 69 | $deprecatedResponseFactory = $dependencies['factories'][ResponseInterface::class] ?? null; 70 | 71 | return $deprecatedResponseFactory !== null && $deprecatedResponseFactory !== ResponseFactoryFactory::class; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Renderer/JsonRenderer.php: -------------------------------------------------------------------------------- 1 | jsonFlags); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Renderer/RendererInterface.php: -------------------------------------------------------------------------------- 1 | formatOutput = true; 26 | $dom->appendChild($this->createResourceNode($dom, $resource->toArray())); 27 | return trim($dom->saveXML()); 28 | } 29 | 30 | private function createResourceNode(DOMDocument $doc, array $resource, string $resourceRel = 'self'): DOMNode 31 | { 32 | // Normalize resource 33 | $resource['_links'] ??= []; 34 | $resource['_embedded'] ??= []; 35 | 36 | $node = $doc->createElement('resource'); 37 | 38 | // Self-relational link attributes, if present and singular 39 | if (isset($resource['_links']['self']['href'])) { 40 | $node->setAttribute('rel', $resourceRel); 41 | $node->setAttribute('href', $resource['_links']['self']['href']); 42 | foreach ($resource['_links']['self'] as $attribute => $value) { 43 | if ($attribute === 'href') { 44 | continue; 45 | } 46 | $node->setAttribute($attribute, $value); 47 | } 48 | unset($resource['_links']['self']); 49 | } 50 | 51 | /** 52 | * @var string $rel 53 | * @var array $linkData 54 | */ 55 | foreach ($resource['_links'] as $rel => $linkData) { 56 | if ($this->isAssocArray($linkData)) { 57 | $node->appendChild($this->createLinkNode($doc, $rel, $linkData)); 58 | continue; 59 | } 60 | 61 | foreach ($linkData as $linkDatum) { 62 | $node->appendChild($this->createLinkNode($doc, $rel, $linkDatum)); 63 | } 64 | } 65 | unset($resource['_links']); 66 | 67 | /** 68 | * @var string $rel 69 | * @var array $childData 70 | */ 71 | foreach ($resource['_embedded'] as $rel => $childData) { 72 | if ($this->isAssocArray($childData)) { 73 | $node->appendChild($this->createResourceNode($doc, $childData, $rel)); 74 | continue; 75 | } 76 | 77 | foreach ($childData as $childDatum) { 78 | $node->appendChild($this->createResourceNode($doc, $childDatum, $rel)); 79 | } 80 | } 81 | unset($resource['_embedded']); 82 | 83 | return $this->createNodeTree($doc, $node, $resource); 84 | } 85 | 86 | private function createLinkNode(DOMDocument $doc, string $rel, array $data): DOMNode 87 | { 88 | $link = $doc->createElement('link'); 89 | $link->setAttribute('rel', $rel); 90 | foreach ($data as $key => $value) { 91 | $value = $this->normalizeConstantValue($value); 92 | $link->setAttribute($key, $value); 93 | } 94 | return $link; 95 | } 96 | 97 | /** 98 | * Convert true and false to appropriate strings. 99 | * 100 | * In all other cases, return the value as-is. 101 | * 102 | * @param mixed $value 103 | * @return string|mixed 104 | */ 105 | private function normalizeConstantValue($value) 106 | { 107 | $value = $value === true ? 'true' : $value; 108 | $value = $value === false ? 'false' : $value; 109 | return $value; 110 | } 111 | 112 | private function isAssocArray(array $value): bool 113 | { 114 | return array_values($value) !== $value; 115 | } 116 | 117 | /** 118 | * @param mixed $data 119 | * @return ((DOMNode|DOMNode[])[]|DOMNode|false)[]|DOMNode|false 120 | * @psalm-return DOMNode|false|list>> 121 | */ 122 | private function createResourceElement(DOMDocument $doc, string $name, $data) 123 | { 124 | if ($data === null) { 125 | return $doc->createElement($name); 126 | } 127 | 128 | if (is_scalar($data)) { 129 | $data = $this->normalizeConstantValue($data); 130 | $element = $doc->createElement($name); 131 | $textNode = $doc->createTextNode((string) $data); 132 | $element->appendChild($textNode); 133 | 134 | return $element; 135 | } 136 | 137 | if (is_object($data)) { 138 | $data = $this->createDataFromObject($data); 139 | return $doc->createElement($name, $data); 140 | } 141 | 142 | if (! is_array($data)) { 143 | throw Exception\InvalidResourceValueException::fromValue($data); 144 | } 145 | 146 | if ($this->isAssocArray($data)) { 147 | return $this->createNodeTree($doc, $doc->createElement($name), $data); 148 | } 149 | 150 | $elements = []; 151 | foreach ($data as $child) { 152 | $elements[] = $this->createResourceElement($doc, $name, $child); 153 | } 154 | return $elements; 155 | } 156 | 157 | private function createNodeTree(DOMDocument $doc, DOMNode $node, array $data): DOMNode 158 | { 159 | foreach ($data as $key => $value) { 160 | $element = $this->createResourceElement($doc, $key, $value); 161 | if (! is_array($element)) { 162 | $node->appendChild($element); 163 | continue; 164 | } 165 | foreach ($element as $child) { 166 | $node->appendChild($child); 167 | } 168 | } 169 | 170 | return $node; 171 | } 172 | 173 | /** 174 | * @todo Detect JsonSerializable, and pass to 175 | * json_decode(json_encode($object), true), passing the final value 176 | * back to createResourceElement()? 177 | * @param object $object 178 | * @throws Exception\InvalidResourceValueException If unable to serialize 179 | * the data to a string. 180 | */ 181 | private function createDataFromObject($object): string 182 | { 183 | if ($object instanceof DateTimeInterface) { 184 | return $object->format('c'); 185 | } 186 | 187 | if (! method_exists($object, '__toString')) { 188 | throw Exception\InvalidResourceValueException::fromObject($object); 189 | } 190 | 191 | return (string) $object; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/ResourceGenerator.php: -------------------------------------------------------------------------------- 1 | */ 21 | private array $strategies = []; 22 | 23 | /** 24 | * @param Metadata\MetadataMap $metadataMap Metadata on known objects. 25 | * @param ContainerInterface $hydrators Service locator for hydrators. 26 | * @param LinkGenerator $linkGenerator Route-based link generation. 27 | */ 28 | public function __construct( 29 | private readonly Metadata\MetadataMap $metadataMap, 30 | private readonly ContainerInterface $hydrators, 31 | private readonly LinkGenerator $linkGenerator 32 | ) { 33 | } 34 | 35 | public function getHydrators(): ContainerInterface 36 | { 37 | return $this->hydrators; 38 | } 39 | 40 | public function getLinkGenerator(): LinkGenerator 41 | { 42 | return $this->linkGenerator; 43 | } 44 | 45 | public function getMetadataMap(): Metadata\MetadataMap 46 | { 47 | return $this->metadataMap; 48 | } 49 | 50 | /** 51 | * Link a metadata type to a strategy that can create a resource for it. 52 | * 53 | * @param class-string|StrategyInterface $strategy 54 | */ 55 | public function addStrategy(string $metadataType, $strategy): void 56 | { 57 | if ( 58 | ! class_exists($metadataType) 59 | || ! in_array(AbstractMetadata::class, class_parents($metadataType), true) 60 | ) { 61 | throw Exception\UnknownMetadataTypeException::forInvalidMetadataClass($metadataType); 62 | } 63 | 64 | if ( 65 | is_string($strategy) 66 | && ( 67 | ! class_exists($strategy) 68 | || ! in_array(StrategyInterface::class, class_implements($strategy), true) 69 | ) 70 | ) { 71 | throw Exception\InvalidStrategyException::forType($strategy); 72 | } 73 | 74 | if (is_string($strategy)) { 75 | $strategy = new $strategy(); 76 | } 77 | 78 | if (! $strategy instanceof StrategyInterface) { 79 | throw Exception\InvalidStrategyException::forInstance($strategy); 80 | } 81 | 82 | $this->strategies[$metadataType] = $strategy; 83 | } 84 | 85 | /** 86 | * Returns the registered strategies. 87 | * 88 | * @return array 89 | */ 90 | public function getStrategies(): array 91 | { 92 | return $this->strategies; 93 | } 94 | 95 | public function fromArray(array $data, ?string $uri = null): HalResource 96 | { 97 | /** @var array $config */ 98 | $config = $this->hydrators->has('config') ? $this->hydrators->get('config') : []; 99 | 100 | $embedEmptyCollections = (bool) ($config['mezzio-hal']['embed-empty-collections'] ?? false); 101 | 102 | $resource = new HalResource($data, [], [], $embedEmptyCollections); 103 | 104 | if (null !== $uri) { 105 | return $resource->withLink(new Link('self', $uri)); 106 | } 107 | 108 | return $resource; 109 | } 110 | 111 | /** 112 | * @param object $instance An object of any type; the type will be checked 113 | * against types registered in the metadata map. 114 | */ 115 | public function fromObject(object $instance, ServerRequestInterface $request, int $depth = 0): HalResource 116 | { 117 | $metadata = $this->getClassMetadata($instance); 118 | $metadataType = $metadata::class; 119 | 120 | if (! isset($this->strategies[$metadataType])) { 121 | throw Exception\UnknownMetadataTypeException::forMetadata($metadata); 122 | } 123 | 124 | $strategy = $this->strategies[$metadataType]; 125 | return $strategy->createResource( 126 | $instance, 127 | $metadata, 128 | $this, 129 | $request, 130 | $depth 131 | ); 132 | } 133 | 134 | private function getClassMetadata(object $instance): AbstractMetadata 135 | { 136 | $class = $instance::class; 137 | if (! $this->metadataMap->has($class)) { 138 | foreach (class_parents($instance) as $parent) { 139 | if ($this->metadataMap->has($parent)) { 140 | return $this->metadataMap->get($parent); 141 | } 142 | } 143 | throw Exception\InvalidObjectException::forUnknownType($class); 144 | } 145 | 146 | return $this->metadataMap->get($class); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/ResourceGenerator/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | extractPaginator($collection, $metadata, $resourceGenerator, $request, $depth); 57 | } 58 | 59 | if ($collection instanceof DoctrinePaginator) { 60 | return $this->extractDoctrinePaginator($collection, $metadata, $resourceGenerator, $request, $depth); 61 | } 62 | 63 | return $this->extractIterator($collection, $metadata, $resourceGenerator, $request, $depth); 64 | } 65 | 66 | /** 67 | * Generates a paginated hal resource from a collection 68 | * 69 | * @throws Exception\OutOfBoundsException If requested page if outside the available pages. 70 | */ 71 | private function extractPaginator( 72 | Paginator $collection, 73 | AbstractCollectionMetadata $metadata, 74 | ResourceGeneratorInterface $resourceGenerator, 75 | ServerRequestInterface $request, 76 | int $depth = 0 77 | ): HalResource { 78 | $data = ['_total_items' => $collection->getTotalItemCount()]; 79 | $pageCount = $collection->count(); 80 | 81 | return $this->createPaginatedCollectionResource( 82 | $pageCount, 83 | $data, 84 | function (int $page) use ($collection) { 85 | $collection->setCurrentPageNumber($page); 86 | }, 87 | $collection, 88 | $metadata, 89 | $resourceGenerator, 90 | $request, 91 | $depth 92 | ); 93 | } 94 | 95 | /** 96 | * Extract a collection from a Doctrine paginator. 97 | * 98 | * When pagination is requested, and a valid page is found, calls the 99 | * paginator's `setFirstResult()` method with an offset based on the 100 | * max results value set on the paginator. 101 | */ 102 | private function extractDoctrinePaginator( 103 | DoctrinePaginator $collection, 104 | AbstractCollectionMetadata $metadata, 105 | ResourceGeneratorInterface $resourceGenerator, 106 | ServerRequestInterface $request, 107 | int $depth = 0 108 | ): HalResource { 109 | $query = $collection->getQuery(); 110 | $totalItems = count($collection); 111 | $perPage = $query->getMaxResults(); 112 | $pageCount = (int) ceil($totalItems / $perPage); 113 | 114 | $data = ['_total_items' => $totalItems]; 115 | 116 | return $this->createPaginatedCollectionResource( 117 | $pageCount, 118 | $data, 119 | function (int $page) use ($query, $perPage) { 120 | $query->setFirstResult($perPage * ($page - 1)); 121 | }, 122 | $collection, 123 | $metadata, 124 | $resourceGenerator, 125 | $request, 126 | $depth 127 | ); 128 | } 129 | 130 | private function extractIterator( 131 | Traversable $collection, 132 | AbstractCollectionMetadata $metadata, 133 | ResourceGeneratorInterface $resourceGenerator, 134 | ServerRequestInterface $request, 135 | int $depth = 0 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, $depth + 1); 143 | $count = $isCountable ? $count : $count + 1; 144 | } 145 | 146 | $data = ['_total_items' => $count]; 147 | $links = [ 148 | $this->generateSelfLink( 149 | $metadata, 150 | $resourceGenerator, 151 | $request 152 | ), 153 | ]; 154 | 155 | return new HalResource($data, $links, [ 156 | $metadata->getCollectionRelation() => $resources, 157 | ]); 158 | } 159 | 160 | /** 161 | * Create a collection resource representing a paginated set. 162 | * 163 | * Determines if the metadata uses a query or placeholder pagination type. 164 | * If not, it generates a self relational link, and then immediately creates 165 | * and returns a collection resource containing every item in the collection. 166 | * 167 | * If it does, it pulls the pagination parameter from the request using the 168 | * appropriate source (query string arguments or routing parameter), and 169 | * then checks to see if we have a valid page number, throwing an out of 170 | * bounds exception if we do not. From the page, it then determines which 171 | * relational pagination links to create, including a `self` relation, 172 | * and aggregates the current page and total page count in the $data array 173 | * before calling on createCollectionResource() to generate the final 174 | * HAL resource instance. 175 | * 176 | * @param array $data Data to render in the root of the HAL 177 | * resource. 178 | * @param callable $notifyCollectionOfPage A callback that receives an integer 179 | * $page argument; this should be used to update the paginator instance 180 | * with the current page number. 181 | */ 182 | private function createPaginatedCollectionResource( 183 | int $pageCount, 184 | array $data, 185 | callable $notifyCollectionOfPage, 186 | iterable $collection, 187 | AbstractCollectionMetadata $metadata, 188 | ResourceGeneratorInterface $resourceGenerator, 189 | ServerRequestInterface $request, 190 | int $depth = 0 191 | ): HalResource { 192 | $links = []; 193 | $paginationParamType = $metadata->getPaginationParamType(); 194 | 195 | if (! in_array($paginationParamType, $this->paginationTypes, true)) { 196 | $links[] = $this->generateSelfLink($metadata, $resourceGenerator, $request); 197 | return $this->createCollectionResource( 198 | $links, 199 | $data, 200 | $collection, 201 | $metadata, 202 | $resourceGenerator, 203 | $request, 204 | $depth 205 | ); 206 | } 207 | 208 | $paginationParam = $metadata->getPaginationParam(); 209 | $page = $paginationParamType === AbstractCollectionMetadata::TYPE_QUERY 210 | ? (int) ($request->getQueryParams()[$paginationParam] ?? 1) 211 | : (int) $request->getAttribute($paginationParam, 1); 212 | 213 | if ($page < 1 || ($page > $pageCount && $pageCount > 0)) { 214 | throw new Exception\OutOfBoundsException(sprintf( 215 | 'Page %d is out of bounds. Collection has %d page%s.', 216 | $page, 217 | $pageCount, 218 | $pageCount > 1 ? 's' : '' 219 | )); 220 | } 221 | 222 | $notifyCollectionOfPage($page); 223 | 224 | $links[] = $this->generateLinkForPage('self', $page, $metadata, $resourceGenerator, $request); 225 | if ($page > 1) { 226 | $links[] = $this->generateLinkForPage('first', 1, $metadata, $resourceGenerator, $request); 227 | $links[] = $this->generateLinkForPage('prev', $page - 1, $metadata, $resourceGenerator, $request); 228 | } 229 | if ($page < $pageCount) { 230 | $links[] = $this->generateLinkForPage('next', $page + 1, $metadata, $resourceGenerator, $request); 231 | $links[] = $this->generateLinkForPage('last', $pageCount, $metadata, $resourceGenerator, $request); 232 | } 233 | 234 | $data['_page'] = $page; 235 | $data['_page_count'] = $pageCount; 236 | 237 | return $this->createCollectionResource( 238 | $links, 239 | $data, 240 | $collection, 241 | $metadata, 242 | $resourceGenerator, 243 | $request, 244 | $depth 245 | ); 246 | } 247 | 248 | /** 249 | * Create the collection resource with its embedded resources. 250 | * 251 | * Iterates the collection, passing each item to the resource generator 252 | * to produce a HAL resource. These are then used to create an embedded 253 | * relation in a master HAL resource that contains metadata around the 254 | * collection itself (number of items, number of pages, etc.), and any 255 | * relational links. 256 | */ 257 | private function createCollectionResource( 258 | array $links, 259 | array $data, 260 | iterable $collection, 261 | AbstractCollectionMetadata $metadata, 262 | ResourceGeneratorInterface $resourceGenerator, 263 | ServerRequestInterface $request, 264 | int $depth = 0 265 | ): HalResource { 266 | $resources = []; 267 | foreach ($collection as $item) { 268 | $resources[] = $resourceGenerator->fromObject($item, $request, $depth + 1); 269 | } 270 | 271 | return new HalResource($data, $links, [ 272 | $metadata->getCollectionRelation() => $resources, 273 | ]); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/ResourceGenerator/ExtractInstanceTrait.php: -------------------------------------------------------------------------------- 1 | getHydrators(); 29 | $extractor = $hydrators->get($metadata->getExtractor()); 30 | if (! $extractor instanceof ExtractionInterface) { 31 | throw Exception\InvalidExtractorException::fromInstance($extractor); 32 | } 33 | 34 | $array = $extractor->extract($instance); 35 | 36 | if ($metadata->hasReachedMaxDepth($depth)) { 37 | return $array; 38 | } 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 = $value::class; 48 | if (! $metadataMap->has($childClass)) { 49 | continue; 50 | } 51 | 52 | $childData = $resourceGenerator->fromObject($value, $request, $depth + 1); 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/GenerateSelfLinkTrait.php: -------------------------------------------------------------------------------- 1 | extractCollection($instance, $metadata, $resourceGenerator, $request, $depth); 42 | } 43 | 44 | /** 45 | * @param string $rel Relation to use when creating Link 46 | * @param int $page Page number for generated link 47 | * @param Metadata\AbstractCollectionMetadata $metadata Used to provide the 48 | * base URL, pagination parameter, and type of pagination used (query 49 | * string, path parameter) 50 | * @param ResourceGeneratorInterface $resourceGenerator Used to retrieve link 51 | * generator in order to generate link based on routing information. 52 | * @param ServerRequestInterface $request Passed to link generator when 53 | * generating link based on routing information. 54 | */ 55 | protected function generateLinkForPage( 56 | string $rel, 57 | int $page, 58 | Metadata\AbstractCollectionMetadata $metadata, 59 | ResourceGeneratorInterface $resourceGenerator, 60 | ServerRequestInterface $request 61 | ): Link { 62 | $route = $metadata->getRoute(); 63 | $paginationType = $metadata->getPaginationParamType(); 64 | $paginationParam = $metadata->getPaginationParam(); 65 | $routeParams = $metadata->getRouteParams(); 66 | $queryStringArgs = array_merge($request->getQueryParams(), $metadata->getQueryStringArguments()); 67 | 68 | $paramsWithPage = [$paginationParam => $page]; 69 | $routeParams = $paginationType === Metadata\AbstractCollectionMetadata::TYPE_PLACEHOLDER 70 | ? array_merge($routeParams, $paramsWithPage) 71 | : $routeParams; 72 | $queryParams = $paginationType === Metadata\AbstractCollectionMetadata::TYPE_QUERY 73 | ? array_merge($queryStringArgs, $paramsWithPage) 74 | : $queryStringArgs; 75 | 76 | return $resourceGenerator 77 | ->getLinkGenerator() 78 | ->fromRoute( 79 | $rel, 80 | $request, 81 | $route, 82 | $routeParams, 83 | $queryParams 84 | ); 85 | } 86 | 87 | /** 88 | * @param Metadata\AbstractCollectionMetadata $metadata Provides base URL 89 | * for self link. 90 | * @param ResourceGeneratorInterface $resourceGenerator Used to retrieve link 91 | * generator in order to generate link based on routing information. 92 | * @param ServerRequestInterface $request Passed to link generator when 93 | * generating link based on routing information. 94 | * @return Link 95 | */ 96 | protected function generateSelfLink( 97 | Metadata\AbstractCollectionMetadata $metadata, 98 | ResourceGeneratorInterface $resourceGenerator, 99 | ServerRequestInterface $request 100 | ) { 101 | return $resourceGenerator 102 | ->getLinkGenerator() 103 | ->fromRoute( 104 | 'self', 105 | $request, 106 | $metadata->getRoute(), 107 | $metadata->getRouteParams(), 108 | array_merge($request->getQueryParams(), $metadata->getQueryStringArguments()) 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/ResourceGenerator/RouteBasedResourceStrategy.php: -------------------------------------------------------------------------------- 1 | extractInstance( 35 | $instance, 36 | $metadata, 37 | $resourceGenerator, 38 | $request, 39 | $depth 40 | ); 41 | 42 | $routeParams = $metadata->getRouteParams(); 43 | $placeholderMap = $metadata->getIdentifiersToPlaceholdersMapping(); 44 | 45 | // Inject all scalar entity keys automatically into route parameters 46 | foreach ($data as $key => $value) { 47 | if (! is_scalar($value)) { 48 | continue; 49 | } 50 | 51 | if (array_key_exists($key, $placeholderMap)) { 52 | $routeParams[$placeholderMap[$key]] = $value; 53 | continue; 54 | } 55 | 56 | $routeParams[$key] = $value; 57 | } 58 | 59 | if ($metadata->hasReachedMaxDepth($depth)) { 60 | $data = []; 61 | } 62 | 63 | return new HalResource($data, [ 64 | $resourceGenerator->getLinkGenerator()->fromRoute( 65 | 'self', 66 | $request, 67 | $metadata->getRoute(), 68 | $routeParams 69 | ), 70 | ]); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ResourceGenerator/StrategyInterface.php: -------------------------------------------------------------------------------- 1 | extractCollection($instance, $metadata, $resourceGenerator, $request, $depth); 50 | } 51 | 52 | /** 53 | * @param string $rel Relation to use when creating Link 54 | * @param int $page Page number for generated link 55 | * @param Metadata\AbstractCollectionMetadata $metadata Used to provide the 56 | * base URL, pagination parameter, and type of pagination used (query 57 | * string, path parameter) 58 | * @param ResourceGeneratorInterface $resourceGenerator Ignored; required to fulfill 59 | * abstract. 60 | * @param ServerRequestInterface $request Ignored; required to fulfill 61 | * abstract. 62 | */ 63 | protected function generateLinkForPage( 64 | string $rel, 65 | int $page, 66 | Metadata\AbstractCollectionMetadata $metadata, 67 | ResourceGeneratorInterface $resourceGenerator, 68 | ServerRequestInterface $request 69 | ): Link { 70 | $paginationParam = $metadata->getPaginationParam(); 71 | $paginationType = $metadata->getPaginationParamType(); 72 | $url = $metadata->getUrl() . '?' . http_build_query($request->getQueryParams()); 73 | 74 | switch ($paginationType) { 75 | case Metadata\AbstractCollectionMetadata::TYPE_PLACEHOLDER: 76 | $url = str_replace($url, $paginationParam, (string) $page); 77 | break; 78 | case Metadata\AbstractCollectionMetadata::TYPE_QUERY: 79 | // fall-through 80 | default: 81 | $url = $this->stripUrlFragment($url); 82 | $url = $this->appendPageQueryToUrl($url, $page, $paginationParam); 83 | } 84 | 85 | return new Link($rel, $url); 86 | } 87 | 88 | /** 89 | * @param Metadata\AbstractCollectionMetadata $metadata Provides base URL 90 | * for self link. 91 | * @param ResourceGeneratorInterface $resourceGenerator Ignored; required to fulfill 92 | * abstract. 93 | * @param ServerRequestInterface $request Ignored; required to fulfill 94 | * abstract. 95 | * @return Link 96 | */ 97 | protected function generateSelfLink( 98 | Metadata\AbstractCollectionMetadata $metadata, 99 | ResourceGeneratorInterface $resourceGenerator, 100 | ServerRequestInterface $request 101 | ) { 102 | $queryStringArgs = $request->getQueryParams(); 103 | $url = $metadata->getUrl(); 104 | if ($queryStringArgs !== []) { 105 | $url .= '?' . http_build_query($queryStringArgs); 106 | } 107 | 108 | return new Link('self', $url); 109 | } 110 | 111 | private function stripUrlFragment(string $url): string 112 | { 113 | $fragment = parse_url($url, PHP_URL_FRAGMENT); 114 | if (null === $fragment) { 115 | // parse_url returns null both for absence of fragment and empty fragment 116 | return preg_replace('/#$/', '', $url); 117 | } 118 | 119 | return str_replace('#' . $fragment, '', $url); 120 | } 121 | 122 | private function appendPageQueryToUrl(string $url, int $page, string $paginationParam): string 123 | { 124 | $query = parse_url($url, PHP_URL_QUERY); 125 | if (null === $query) { 126 | // parse_url returns null both for absence of query and empty query 127 | $url = preg_replace('/\?$/', '', $url); 128 | return sprintf('%s?%s=%s', $url, $paginationParam, $page); 129 | } 130 | 131 | parse_str($query, $qsa); 132 | $qsa[$paginationParam] = $page; 133 | 134 | return str_replace('?' . $query, '?' . http_build_query($qsa), $url); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/ResourceGenerator/UrlBasedResourceStrategy.php: -------------------------------------------------------------------------------- 1 | extractInstance($instance, $metadata, $resourceGenerator, $request, $depth), 34 | [new Link('self', $metadata->getUrl())] 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ResourceGeneratorFactory.php: -------------------------------------------------------------------------------- 1 | get(Metadata\MetadataMap::class), 39 | $container->get(HydratorPluginManager::class), 40 | $container->get($this->linkGeneratorServiceName) 41 | ); 42 | 43 | $this->injectStrategies($container, $generator); 44 | 45 | return $generator; 46 | } 47 | 48 | /** 49 | * @throws InvalidConfigException If the config service is not an array or 50 | * ArrayAccess implementation. 51 | * @throws InvalidConfigException If the configured strategies value is not 52 | * an array or traversable. 53 | */ 54 | private function injectStrategies(ContainerInterface $container, ResourceGenerator $generator): void 55 | { 56 | if (! $container->has('config')) { 57 | return; 58 | } 59 | 60 | $config = $container->get('config'); 61 | 62 | if (! is_array($config) && ! $config instanceof ArrayAccess) { 63 | throw InvalidConfigException::dueToNonArray($config); 64 | } 65 | 66 | if (! isset($config['mezzio-hal']['resource-generator']['strategies'])) { 67 | return; 68 | } 69 | 70 | $strategies = $config['mezzio-hal']['resource-generator']['strategies']; 71 | 72 | if (! is_array($strategies) && ! $strategies instanceof Traversable) { 73 | throw InvalidConfigException::dueToInvalidStrategies($strategies); 74 | } 75 | 76 | foreach ($strategies as $metadataType => $strategy) { 77 | if (! is_string($metadataType) || empty($metadataType)) { 78 | continue; 79 | } 80 | 81 | $generator->addStrategy( 82 | $metadataType, 83 | $container->get($strategy) 84 | ); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/ResourceGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | responseFactory = $responseFactory; 25 | } 26 | 27 | public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface 28 | { 29 | return $this->getResponseFromCallable()->withStatus($code, $reasonPhrase); 30 | } 31 | 32 | public function getResponseFromCallable(): ResponseInterface 33 | { 34 | return ($this->responseFactory)(); 35 | } 36 | } 37 | --------------------------------------------------------------------------------