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