Test Failed
Push — master ( 8e4667...0df70f )
by Jodie
02:22
created

Smokescreen::getRelationLoader()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 3
c 0
b 0
f 0
rs 10
cc 1
eloc 1
nc 1
nop 0
1
<?php
2
namespace Rexlabs\Smokescreen;
3
4
use Rexlabs\Smokescreen\Exception\InvalidTransformerException;
5
use Rexlabs\Smokescreen\Exception\JsonEncodeException;
6
use Rexlabs\Smokescreen\Exception\MissingResourceException;
7
use Rexlabs\Smokescreen\Exception\UnhandledResourceType;
8
use Rexlabs\Smokescreen\Includes\IncludeParser;
9
use Rexlabs\Smokescreen\Includes\IncludeParserInterface;
10
use Rexlabs\Smokescreen\Includes\Includes;
11
use Rexlabs\Smokescreen\Relations\RelationLoaderInterface;
12
use Rexlabs\Smokescreen\Resource\Collection;
13
use Rexlabs\Smokescreen\Resource\Item;
14
use Rexlabs\Smokescreen\Resource\ResourceInterface;
15
use Rexlabs\Smokescreen\Serializer\DefaultSerializer;
16
use Rexlabs\Smokescreen\Serializer\SerializerInterface;
17
use Rexlabs\Smokescreen\Transformer\TransformerInterface;
18
19
/**
20
 * Smokescreen is a library for transforming and serializing data - typically RESTful API output.
21
 * @package Rexlabs\Smokescreen
22
 */
23
class Smokescreen implements \JsonSerializable
24
{
25
    /** @var ResourceInterface Item or Collection to be transformed */
26
    protected $resource;
27
28
    /** @var SerializerInterface */
29
    protected $serializer;
30
31
    /** @var IncludeParserInterface */
32
    protected $includeParser;
33
34
    /** @var RelationLoaderInterface */
35
    protected $relationLoader;
36
37
    /** @var Includes */
38
    protected $includes;
39
40
    /**
41
     * Return the current resource
42
     * @return ResourceInterface|null
43
     */
44
    public function getResource()
45
    {
46
        return $this->resource;
47
    }
48
49
    /**
50
     * Set the resource item to be transformed
51
     * @param mixed                     $data
52
     * @param TransformerInterface|null $transformer
53
     * @param string|null               $key
54
     * @return $this
55
     */
56
    public function item($data, TransformerInterface $transformer = null, $key = null)
57
    {
58
        $this->resource = new Item($data, $transformer, $key);
59
60
        return $this;
61
    }
62
63
    /**
64
     * Set the resource collection to be transformed
65
     * @param mixed                     $data
66
     * @param TransformerInterface|null $transformer
67
     * @param string|null               $key
68
     * @param callable|null             $callback
69
     * @return $this
70
     */
71
    public function collection($data, TransformerInterface $transformer = null, $key = null, callable $callback = null)
72
    {
73
        $this->resource = new Collection($data, $transformer, $key);
74
        if ($callback !== null) {
75
            $callback($this->resource);
76
        }
77
78
        return $this;
79
    }
80
81
    /**
82
     * Sets the transformer to be used to transform the resource ... later
83
     * @return callable|null|TransformerInterface
84
     * @throws MissingResourceException
85
     */
86
    public function getTransformer()
87
    {
88
        if (!$this->resource) {
89
            throw new MissingResourceException('Resource must be specified before setting a transformer');
90
        }
91
92
        return $this->resource->getTransformer();
93
    }
94
95
    /**
96
     * Sets the transformer to be used to transform the resource ... later
97
     * @param TransformerInterface|null $transformer
98
     * @return $this
99
     * @throws MissingResourceException
100
     */
101
    public function setTransformer(TransformerInterface $transformer = null)
102
    {
103
        if (!$this->resource) {
104
            throw new MissingResourceException('Resource must be specified before setting a transformer');
105
        }
106
        $this->resource->setTransformer($transformer);
107
108
        return $this;
109
    }
110
111
    /**
112
     * Returns an object (stdClass) representation of the transformed/serialized data.
113
     *
114
     * @return \stdClass
115
     * @throws \Rexlabs\Smokescreen\Exception\MissingResourceException
116
     * @throws \Rexlabs\Smokescreen\Exception\InvalidTransformerException
117
     * @throws \Rexlabs\Smokescreen\Exception\JsonEncodeException
118
     */
119
    public function toObject(): \stdClass
120
    {
121
        return (object)json_decode($this->toJson(), false);
122
    }
123
124
    /**
125
     * Outputs a JSON string of the resulting transformed and serialized data.
126
     *
127
     * @param int $options
128
     *
129
     * @return string
130
     * @throws \Rexlabs\Smokescreen\Exception\UnhandledResourceType
131
     * @throws \Rexlabs\Smokescreen\Exception\MissingResourceException
132
     * @throws \Rexlabs\Smokescreen\Exception\InvalidTransformerException
133
     * @throws \Rexlabs\Smokescreen\Exception\JsonEncodeException
134
     */
135
    public function toJson($options = 0): string
136
    {
137
        $json = json_encode($this->jsonSerialize(), $options);
138
        if (JSON_ERROR_NONE !== json_last_error()) {
139
            throw new JsonEncodeException(json_last_error_msg());
140
        }
141
142
        return $json;
143
    }
144
145
    /**
146
     * Output the transformed and serialized data as an array.
147
     * Implements PHP's JsonSerializable interface.
148
     *
149
     * @return array
150
     * @see Smokescreen::toArray()
151
     * @throws \Rexlabs\Smokescreen\Exception\UnhandledResourceType
152
     * @throws \Rexlabs\Smokescreen\Exception\InvalidTransformerException
153
     * @throws \Rexlabs\Smokescreen\Exception\MissingResourceException
154
     */
155
    public function jsonSerialize(): array
156
    {
157
        return $this->toArray();
158
    }
159
160
    /**
161
     * Return the transformed data as an array
162
     *
163
     * @return array
164
     * @throws \Rexlabs\Smokescreen\Exception\UnhandledResourceType
165
     * @throws \Rexlabs\Smokescreen\Exception\InvalidTransformerException
166
     * @throws \Rexlabs\Smokescreen\Exception\MissingResourceException
167
     */
168
    public function toArray(): array
169
    {
170
        if (!$this->resource) {
171
            throw new MissingResourceException('No resource has been defined to transform');
172
        }
173
174
        // Kick of serialization of the resource
175
        return $this->serializeResource($this->resource, $this->getIncludes());
176
    }
177
178
    /**
179
     * @param ResourceInterface|mixed $resource
180
     * @param Includes                $includes
181
     * @return array|mixed
182
     * @throws \Rexlabs\Smokescreen\Exception\UnhandledResourceType
183
     * @throws \Rexlabs\Smokescreen\Exception\InvalidTransformerException
184
     */
185
    protected function serializeResource($resource, Includes $includes): array
186
    {
187
        // Relationship loading
188
        if ($resource instanceof ResourceInterface) {
189
            $this->loadRelations($resource);
190
        }
191
192
        // Build the output by recursively transforming each resource
193
        $output = null;
194
        if ($resource instanceof Collection) {
195
            $output = $this->serializeCollection($resource, $includes);
196
        } elseif ($resource instanceof Item) {
197
            $output = $this->serializeItem($resource, $includes);
198
        } elseif (\is_array($resource)) {
199
            $output = $resource;
200
        } elseif (\is_object($resource) && method_exists($resource, 'toArray')) {
201
            $output = $resource->toArray();
202
        } else {
203
            throw new UnhandledResourceType('Unable to serialize resource of type ' . \gettype($resource));
204
        }
205
206
        return $output;
207
    }
208
209
    /**
210
     * Fire the relation loader (if defined) for this resource
211
     * @param ResourceInterface $resource
212
     */
213
    protected function loadRelations(ResourceInterface $resource)
214
    {
215
        if ($this->relationLoader !== null) {
216
            $this->relationLoader->load($resource);
217
        }
218
    }
219
220
    /**
221
     * @param Collection $collection
222
     * @param Includes   $includes
223
     *
224
     * @return array
225
     * @throws \Rexlabs\Smokescreen\Exception\UnhandledResourceType
226
     * @throws InvalidTransformerException
227
     */
228
    protected function serializeCollection(Collection $collection, Includes $includes): array
229
    {
230
        // Get the globally set serializer (resource may override)
231
        $defaultSerializer = $this->getSerializer();
232
233
        // Collection resources implement IteratorAggregate ... so that's nice
234
        $items = [];
235
        foreach ($collection as $item) {
236
            // $item might be a Model or an array etc.
237
            $items[] = $this->transformItem($item, $collection->getTransformer(), $includes);
238
        }
239
240
        // The collection can have a custom serializer defined
241
        $serializer = $collection->getSerializer() ?? $defaultSerializer;
242
        $isSerializerInterface = $serializer instanceof SerializerInterface;
243
244
        if ($isSerializerInterface) {
245
            // Serialize via object implementing SerializerInterface
246
            $output = $serializer->collection($collection->getResourceKey(), $items);
247
        } elseif (\is_callable($serializer)) {
248
            // Serialize via a callable/closure
249
            $output = $serializer($collection->getResourceKey(), $items);
250
        } else {
251
            // No serialization
252
            $output = $items;
253
        }
254
255
        if ($isSerializerInterface && $collection->hasPaginator()) {
256
            $output = array_merge($output, $serializer->paginator($collection->getPaginator()));
257
        }
258
259
        return $output;
260
    }
261
262
    /**
263
     * @return SerializerInterface
264
     */
265
    public function getSerializer(): SerializerInterface
266
    {
267
        return $this->serializer ?? new DefaultSerializer();
268
    }
269
270
    /**
271
     * Set the serializer which will be used to output the transformed resource
272
     * @param SerializerInterface|null $serializer
273
     * @return $this
274
     */
275
    public function setSerializer(SerializerInterface $serializer = null)
276
    {
277
        $this->serializer = $serializer;
278
279
        return $this;
280
    }
281
282
    /**
283
     * Apply transformation to the item.
284
     * @param mixed                                    $item
285
     * @param mixed|TransformerInterface|callable|null $transformer
286
     * @param Includes                                 $includes
287
     *
288
     * @return array
289
     * @throws \Rexlabs\Smokescreen\Exception\UnhandledResourceType
290
     * @throws \Rexlabs\Smokescreen\Exception\InvalidTransformerException
291
     */
292
    protected function transformItem($item, $transformer, Includes $includes): array
293
    {
294
        if ($transformer === null) {
295
            // No transformation can be applied
296
            return (array)$item;
297
        }
298
        if (\is_callable($transformer)) {
299
            // Callable should simply return an array
300
            return (array)$transformer($item);
301
        }
302
303
        // Otherwise, we expect a transformer object
304
        if (!$transformer instanceof TransformerInterface) {
305
            throw new InvalidTransformerException('Transformer must implement TransformerInterface');
306
        }
307
308
        // We need to double-check it has a transform() method - perhaps this could be done on set?
309
        // Since PHP doesn't support contravariance, we can't define the transform() signature on the interface
310
        if (!method_exists($transformer, 'transform')) {
311
            throw new InvalidTransformerException('Transformer must provide a transform() method');
312
        }
313
314
        // Only these keys may be mapped
315
        $availableIncludeKeys = $transformer->getAvailableIncludes();
316
317
        // Wanted includes is a either the explicit includes requested, or the defaults for the transformer.
318
        $wantIncludeKeys = $includes->baseKeys() ?: $transformer->getDefaultIncludes();
319
320
        // Find the keys that are declared in the $includes of the transformer
321
        $mappedIncludeKeys = array_filter($wantIncludeKeys, function($includeKey) use ($availableIncludeKeys) {
322
            return \in_array($includeKey, $availableIncludeKeys, true);
323
        });
324
325
        // We can consider our props anything that has not been mapped
326
        $filterProps = array_filter($wantIncludeKeys, function($includeKey) use ($mappedIncludeKeys) {
327
            return !\in_array($includeKey, $mappedIncludeKeys, true);
328
        });
329
330
        // Were any filter props explicitly provided?
331
        // If not, see if defaults were provided from the transformer.
332
        if (empty($filterProps)) {
333
            // No explicit props provided
334
            $defaultProps = $transformer->getDefaultProps();
335
            if (!empty($defaultProps)) {
336
                $filterProps = $defaultProps;
337
            }
338
        }
339
340
        // Get the base data from the transformation
341
        $data = (array)$transformer->transform($item);
342
343
        // Filter the sparse field-set
344
        if (!empty($filterProps)) {
345
            $filteredData = array_filter($data, function($key) use ($filterProps) {
346
                return \in_array($key, $filterProps, true);
347
            }, ARRAY_FILTER_USE_KEY);
348
349
            // We must always have some data after filtering
350
            // If our filtered data is empty, we should just ignore it
351
            if (!empty($filteredData)) {
352
                $data = $filteredData;
353
            }
354
        }
355
356
        // Add includes to the payload
357
        $includeMap = $transformer->getIncludeMap();
358
        foreach ($mappedIncludeKeys as $includeKey) {
359
            $resource = $this->executeTransformerInclude($transformer, $includeMap[$includeKey], $item);
360
361
            if ($resource instanceof ResourceInterface) {
362
                // Resource object
363
                $data[$resource->getResourceKey() ?: $includeKey] = !$resource->getData() ? null : $this->serializeResource($resource,
364
                    $includes->splice($includeKey));
365
            } else {
366
                // Plain old array
367
                $data[$includeKey] = $this->serializeResource($resource, $includes->splice($includeKey));
368
            }
369
        }
370
371
        return $data;
372
    }
373
374
    /**
375
     * Execute the transformer
376
     * @param mixed $transformer
377
     * @param array $include
378
     * @param mixed $item
379
     *
380
     * @return mixed
381
     */
382
    protected function executeTransformerInclude($transformer, $include, $item)
383
    {
384
        return \call_user_func([$transformer, $include['method']], $item);
385
    }
386
387
    /**
388
     * Applies the serializer to the Item resource.
389
     * @param Item     $item
390
     * @param Includes $includes
391
     *
392
     * @return array
393
     * @throws \Rexlabs\Smokescreen\Exception\UnhandledResourceType
394
     * @throws InvalidTransformerException
395
     */
396
    protected function serializeItem(Item $item, Includes $includes): array
397
    {
398
        // Get the globally set serializer (resource may override)
399
        $defaultSerializer = $this->getSerializer();
400
401
        // The collection can have a custom serializer defined
402
        $serializer = $item->getSerializer() ?? $defaultSerializer;
403
        $isSerializerInterface = $serializer instanceof SerializerInterface;
404
405
        // Transform the item data
406
        $itemData = $this->transformItem($item->getData(), $item->getTransformer(), $includes);
407
408
        // Serialize the item data
409
        if ($isSerializerInterface) {
410
            // Serialize via object implementing SerializerInterface
411
            $output = $serializer->item($item->getResourceKey(), $itemData);
412
        } elseif (\is_callable($serializer)) {
413
            // Serialize via a callable/closure
414
            $output = $serializer($item->getResourceKey(), $itemData);
415
        } else {
416
            // No serialization
417
            $output = $itemData;
418
        }
419
420
        return $output;
421
    }
422
423
    /**
424
     * Get the current includes object
425
     * @return Includes
426
     */
427
    public function getIncludes(): Includes
428
    {
429
        return $this->includes ?? new Includes();
430
    }
431
432
    /**
433
     * Set the Includes object used for determining included resources
434
     * @param Includes $includes
435
     * @return $this
436
     */
437
    public function setIncludes(Includes $includes)
438
    {
439
        $this->includes = $includes;
440
441
        return $this;
442
    }
443
444
    /**
445
     * Parse the given string to generate a new Includes object
446
     * @param string $str
447
     * @return $this
448
     */
449
    public function parseIncludes($str)
450
    {
451
        $this->includes = $this->getIncludeParser()->parse(!empty($str) ? $str : '');
452
453
        return $this;
454
    }
455
456
    /**
457
     * Return the include parser object.
458
     * If not set explicitly via setIncludeParser(), it will return the default IncludeParser object
459
     * @return IncludeParserInterface
460
     * @see Smokescreen::setIncludeParser()
461
     */
462
    public function getIncludeParser(): IncludeParserInterface
463
    {
464
        return $this->includeParser ?? new IncludeParser();
465
    }
466
467
    /**
468
     * Set the include parser to handle converting a string to an Includes object
469
     * @param IncludeParserInterface $includeParser
470
     * @return $this
471
     */
472
    public function setIncludeParser(IncludeParserInterface $includeParser)
473
    {
474
        $this->includeParser = $includeParser;
475
476
        return $this;
477
    }
478
479
    /**
480
     * Get the current relation loader
481
     * @return RelationLoaderInterface|null
482
     */
483
    public function getRelationLoader()
484
    {
485
        return $this->relationLoader;
486
    }
487
488
    /**
489
     * Set the relationship loader
490
     * @param RelationLoaderInterface $relationLoader
491
     * @return $this
492
     */
493
    public function setRelationLoader(RelationLoaderInterface $relationLoader)
494
    {
495
        $this->relationLoader = $relationLoader;
496
497
        return $this;
498
    }
499
500
    /**
501
     * Returns true if a RelationLoaderInterface object has been defined
502
     * @return bool
503
     */
504
    public function hasRelationLoader(): bool
505
    {
506
        return $this->relationLoader !== null;
507
    }
508
}
509
510