├── .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 | [](https://github.com/mezzio/mezzio-hal/actions/workflows/continuous-integration.yml)
4 | [](https://shepherd.dev/github/mezzio/mezzio-hal)
5 | [](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 |
--------------------------------------------------------------------------------