Completed
Push — master ( f1f969...38d384 )
by Robin
01:58
created

Connector::getByRef()   C

Complexity

Conditions 12
Paths 21

Size

Total Lines 40

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 40
rs 6.9666
c 0
b 0
f 0
cc 12
nc 21
nop 2

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Communibase;
4
5
use Communibase\Logging\QueryLogger;
6
use GuzzleHttp\Client;
7
use GuzzleHttp\ClientInterface;
8
use GuzzleHttp\Exception\ClientException;
9
use GuzzleHttp\Exception\ConnectException;
10
use Psr\Http\Message\StreamInterface;
11
12
/**
13
 * Communibase (https://communibase.nl) data Connector for PHP
14
 *
15
 * For more information see https://communibase.nl
16
 *
17
 * @package Communibase
18
 * @author Kingsquare ([email protected])
19
 * @copyright Copyright (c) Kingsquare BV (http://www.kingsquare.nl)
20
 * @license http://opensource.org/licenses/MIT The MIT License (MIT)
21
 */
22
class Connector implements ConnectorInterface
23
{
24
    /**
25
     * The official service URI; can be overridden via the constructor
26
     *
27
     * @var string
28
     */
29
    const SERVICE_PRODUCTION_URL = 'https://api.communibase.nl/0.1/';
30
31
    /**
32
     * The API key which is to be used for the api.
33
     * Is required to be set via the constructor.
34
     *
35
     * @var string
36
     */
37
    private $apiKey;
38
39
    /**
40
     * The url which is to be used for this connector. Defaults to the production url.
41
     * Can be set via the constructor.
42
     *
43
     * @var string
44
     */
45
    private $serviceUrl;
46
47
    /**
48
     * @var array of extra headers to send with each request
49
     */
50
    private $extraHeaders = [];
51
52
    /**
53
     * @var QueryLogger
54
     */
55
    private $logger;
56
57
    /**
58
     * @var ClientInterface
59
     */
60
    private $client;
61
62
    /**
63
     * Create a new Communibase Connector instance based on the given api-key and possible serviceUrl
64
     *
65
     * @param string $apiKey The API key for Communibase
66
     * @param string $serviceUrl The Communibase API endpoint; defaults to self::SERVICE_PRODUCTION_URL
67
     * @param ClientInterface $client An optional GuzzleHttp Client (or Interface for mocking)
68
     */
69
    public function __construct(
70
        $apiKey,
71
        $serviceUrl = self::SERVICE_PRODUCTION_URL,
72
        ClientInterface $client = null
73
    ) {
74
        $this->apiKey = $apiKey;
75
        $this->serviceUrl = $serviceUrl;
76
        $this->client = $client;
77
    }
78
79
    /**
80
     * Returns an array that has all the fields according to the definition in Communibase.
81
     *
82
     * @param string $entityType
83
     *
84
     * @return array
85
     *
86
     * @throws Exception
87
     */
88
    public function getTemplate($entityType)
89
    {
90
        $params = [
91
            'fields' => 'attributes.title',
92
            'limit' => 1,
93
        ];
94
        $definition = $this->search('EntityType', ['title' => $entityType], $params);
95
96
        return array_fill_keys(array_merge(['_id'], array_column($definition[0]['attributes'], 'title')), null);
97
    }
98
99
    /**
100
     * Get a single Entity by its id
101
     *
102
     * @param string $entityType
103
     * @param string $id
104
     * @param array $params (optional)
105
     * @param string|null $version
106
     *
107
     * @return array entity
108
     *
109
     * @throws Exception
110
     */
111
    public function getById($entityType, $id, array $params = [], $version = null)
112
    {
113
        if (empty($id)) {
114
            throw new Exception('Id is empty');
115
        }
116
117
        if (!static::isIdValid($id)) {
118
            throw new Exception('Id is invalid, please use a correctly formatted id');
119
        }
120
121
        return ($version === null)
122
            ? $this->doGet($entityType . '.json/crud/' . $id, $params)
123
            : $this->doGet($entityType . '.json/history/' . $id . '/' . $version, $params);
124
    }
125
126
    /**
127
     * Get a single Entity by a ref-string
128
     *
129
     * @todo if the ref is a parent.parent.parent the code would need further improvement.
130
     *
131
     * @param array $ref
132
     * @param array $parentEntity (optional)
133
     *
134
     * @return array the referred Entity data
135
     *
136
     * @throws Exception
137
     */
138
    public function getByRef(array $ref, array $parentEntity = [])
139
    {
140
        if (empty($ref['rootDocumentEntityType']) && empty($ref['path'])) {
141
            throw new \InvalidArgumentException('Missing rootDocumentEntityType / path keys in $ref');
142
        }
143
144
        $document = $parentEntity;
145
        if (strpos($ref['rootDocumentEntityType'], 'parent') === false) {
146
            if (empty($document['_id']) || $document['_id'] !== $ref['rootDocumentId']) {
147
                $document = $this->getById($ref['rootDocumentEntityType'], $ref['rootDocumentId']);
148
            }
149
150
            if (count($document) === 0) {
151
                throw new Exception('Invalid document reference (document cannot be found by Id)');
152
            }
153
        }
154
155
        $container = $document;
156
        foreach ($ref['path'] as $pathInDocument) {
157
            if (!array_key_exists($pathInDocument['field'], $container)) {
158
                throw new Exception('Could not find the path in document');
159
            }
160
            $container = $container[$pathInDocument['field']];
161
            if (empty($pathInDocument['objectId'])) {
162
                continue;
163
            }
164
165
            if (!is_array($container)) {
166
                throw new Exception('Invalid value for path in document');
167
            }
168
            $result = array_filter($container, function ($item) use ($pathInDocument) {
169
                return $item['_id'] === $pathInDocument['objectId'];
170
            });
171
            if (count($result) === 0) {
172
                throw new Exception('Empty result of reference');
173
            }
174
            $container = reset($result);
175
        }
176
        return $container;
177
    }
178
179
    /**
180
     * Get an array of entities by their ids
181
     *
182
     * @param string $entityType
183
     * @param array $ids
184
     * @param array $params (optional)
185
     *
186
     * @return array entities
187
     *
188
     * @throws Exception
189
     */
190
    public function getByIds($entityType, array $ids, array $params = [])
191
    {
192
        $validIds = array_values(array_unique(array_filter($ids, [__CLASS__, 'isIdValid'])));
193
194
        if ($entityType === '' || count($validIds) === 0) {
195
            return [];
196
        }
197
198
        $doSortByIds = empty($params['sort']);
199
        $results = $this->search($entityType, ['_id' => ['$in' => $validIds]], $params);
200
        if (!$doSortByIds) {
201
            return $results;
202
        }
203
204
        $flipped = array_flip($validIds);
205
        foreach ($results as $result) {
206
            $flipped[$result['_id']] = $result;
207
        }
208
        return array_filter(array_values($flipped), function ($result) {
209
            return is_array($result) && count($result) > 0;
210
        });
211
212
    }
213
214
    /**
215
     * Get all entities of a certain type
216
     *
217
     * @param string $entityType
218
     * @param array $params (optional)
219
     *
220
     * @return array|null
221
     *
222
     * @throws Exception
223
     */
224
    public function getAll($entityType, array $params = [])
225
    {
226
        return $this->doGet($entityType . '.json/crud/', $params);
227
    }
228
229
    /**
230
     * Get result entityIds of a certain search
231
     *
232
     * @param string $entityType
233
     * @param array $selector (optional)
234
     * @param array $params (optional)
235
     *
236
     * @return array
237
     *
238
     * @throws Exception
239
     */
240
    public function getIds($entityType, array $selector = [], array $params = [])
241
    {
242
        $params['fields'] = '_id';
243
244
        return array_column($this->search($entityType, $selector, $params), '_id');
245
    }
246
247
    /**
248
     * Get the id of an entity based on a search
249
     *
250
     * @param string $entityType i.e. Person
251
     * @param array $selector (optional) i.e. ['firstName' => 'Henk']
252
     *
253
     * @return array resultData
254
     *
255
     * @throws Exception
256
     */
257
    public function getId($entityType, array $selector = [])
258
    {
259
        $params = ['limit' => 1];
260
        $ids = (array)$this->getIds($entityType, $selector, $params);
261
262
        return array_shift($ids);
263
    }
264
265
    /**
266
     * Call the aggregate endpoint with a given set of pipeline definitions:
267
     * E.g. [
268
     * { "$match": { "_id": {"$ObjectId": "52f8fb85fae15e6d0806e7c7"} } },
269
     * { "$unwind": "$participants" },
270
     * { "$group": { "_id": "$_id", "participantCount": { "$sum": 1 } } }
271
     * ]
272
     *
273
     * @see http://docs.mongodb.org/manual/core/aggregation-pipeline/
274
     *
275
     * @param $entityType
276
     * @param array $pipeline
277
     * @return array
278
     *
279
     * @throws Exception
280
     */
281
    public function aggregate($entityType, array $pipeline)
282
    {
283
        return $this->doPost($entityType . '.json/aggregate', [], $pipeline);
284
    }
285
286
    /**
287
     * Returns an array of the history for the entity with the following format:
288
     *
289
     * <code>
290
     *  [
291
     *        [
292
     *            'updatedBy' => '', // name of the user
293
     *            'updatedAt' => '', // a string according to the DateTime::ISO8601 format
294
     *            '_id' => '', // the ID of the entity which can ge fetched separately
295
     *        ],
296
     *        ...
297
     * ]
298
     * </code>
299
     *
300
     * @param string $entityType
301
     * @param string $id
302
     *
303
     * @return array
304
     *
305
     * @throws Exception
306
     */
307
    public function getHistory($entityType, $id)
308
    {
309
        return $this->doGet($entityType . '.json/history/' . $id);
310
    }
311
312
    /**
313
     * Search for the given entity by optional passed selector/params
314
     *
315
     * @param string $entityType
316
     * @param array $querySelector
317
     * @param array $params (optional)
318
     *
319
     * @return array
320
     *
321
     * @throws Exception
322
     */
323
    public function search($entityType, array $querySelector, array $params = [])
324
    {
325
        return $this->doPost($entityType . '.json/search', $params, $querySelector);
326
    }
327
328
    /**
329
     * This will save an entity in Communibase. When a _id-field is found, this entity will be updated
330
     *
331
     * NOTE: When updating, depending on the Entity, you may need to include all fields.
332
     *
333
     * @param string $entityType
334
     * @param array $properties - the to-be-saved entity data
335
     *
336
     * @return array resultData
337
     *
338
     * @throws Exception
339
     */
340
    public function update($entityType, array $properties)
341
    {
342
        if (empty($properties['_id'])) {
343
            return $this->doPost($entityType . '.json/crud/', [], $properties);
344
        }
345
        return $this->doPut($entityType . '.json/crud/' . $properties['_id'], [], $properties);
346
    }
347
348
    /**
349
     * Finalize an invoice by adding an invoiceNumber to it.
350
     * Besides, invoice items will receive a 'generalLedgerAccountNumber'.
351
     * This number will be unique and sequential within the 'daybook' of the invoice.
352
     *
353
     * NOTE: this is Invoice specific
354
     *
355
     * @param string $entityType
356
     * @param string $id
357
     *
358
     * @return array
359
     *
360
     * @throws Exception
361
     */
362
    public function finalize($entityType, $id)
363
    {
364
        if ($entityType !== 'Invoice') {
365
            throw new Exception('Cannot call finalize on ' . $entityType);
366
        }
367
368
        return $this->doPost($entityType . '.json/finalize/' . $id);
369
    }
370
371
    /**
372
     * Delete something from Communibase
373
     *
374
     * @param string $entityType
375
     * @param string $id
376
     *
377
     * @return array resultData
378
     *
379
     * @throws Exception
380
     */
381
    public function destroy($entityType, $id)
382
    {
383
        return $this->doDelete($entityType . '.json/crud/' . $id);
384
    }
385
386
    /**
387
     * Get the binary contents of a file by its ID
388
     *
389
     * NOTE: for meta-data like filesize and mimetype, one can use the getById()-method.
390
     *
391
     * @param string $id id string for the file-entity
392
     *
393
     * @return StreamInterface Binary contents of the file.
394
     * Since the stream can be made a string this works like a charm!
395
     *
396
     * @throws Exception
397
     */
398
    public function getBinary($id)
399
    {
400
        if (!static::isIdValid($id)) {
401
            throw new Exception('Invalid $id passed. Please provide one.');
402
        }
403
404
        return $this->call('get', ['File.json/binary/' . $id])->getBody();
405
    }
406
407
    /**
408
     * Uploads the contents of the resource (this could be a file handle) to Communibase
409
     *
410
     * @param StreamInterface $resource
411
     * @param string $name
412
     * @param string $destinationPath
413
     * @param string $id
414
     *
415
     * @return array|mixed
416
     *
417
     * @throws \RuntimeException | Exception
418
     */
419
    public function updateBinary(StreamInterface $resource, $name, $destinationPath, $id = '')
420
    {
421
        $metaData = ['path' => $destinationPath];
422
        if (!empty($id)) {
423
            if (!static::isIdValid($id)) {
424
                throw new Exception('Id is invalid, please use a correctly formatted id');
425
            }
426
427
            return $this->doPut('File.json/crud/' . $id, [], [
428
                'filename' => $name,
429
                'length' => $resource->getSize(),
430
                'uploadDate' => date('c'),
431
                'metadata' => $metaData,
432
                'content' => base64_encode($resource->getContents()),
433
            ]);
434
        }
435
436
        $options = [
437
            'multipart' => [
438
                [
439
                    'name' => 'File',
440
                    'filename' => $name,
441
                    'contents' => $resource
442
                ],
443
                [
444
                    'name' => 'metadata',
445
                    'contents' => json_encode($metaData),
446
                ]
447
            ]
448
        ];
449
450
        $response = $this->call('post', ['File.json/binary', $options]);
451
452
        return $this->parseResult($response->getBody(), $response->getStatusCode());
453
    }
454
455
    /**
456
     * Perform the actual GET
457
     *
458
     * @param string $path
459
     * @param array $params
460
     * @param array $data
461
     *
462
     * @return array
463
     *
464
     * @throws Exception
465
     */
466
    protected function doGet($path, array $params = null, array $data = null)
467
    {
468
        return $this->getResult('GET', $path, $params, $data);
469
    }
470
471
    /**
472
     * Perform the actual POST
473
     *
474
     * @param string $path
475
     * @param array $params
476
     * @param array $data
477
     *
478
     * @return array
479
     *
480
     * @throws Exception
481
     */
482
    protected function doPost($path, array $params = null, array $data = null)
483
    {
484
        return $this->getResult('POST', $path, $params, $data);
485
    }
486
487
    /**
488
     * Perform the actual PUT
489
     *
490
     * @param string $path
491
     * @param array $params
492
     * @param array $data
493
     *
494
     * @return array
495
     *
496
     * @throws Exception
497
     */
498
    protected function doPut($path, array $params = null, array $data = null)
499
    {
500
        return $this->getResult('PUT', $path, $params, $data);
501
    }
502
503
    /**
504
     * Perform the actual DELETE
505
     *
506
     * @param string $path
507
     * @param array $params
508
     * @param array $data
509
     *
510
     * @return array
511
     *
512
     * @throws Exception
513
     */
514
    protected function doDelete($path, array $params = null, array $data = null)
515
    {
516
        return $this->getResult('DELETE', $path, $params, $data);
517
    }
518
519
    /**
520
     * Process the request
521
     *
522
     * @param string $method
523
     * @param string $path
524
     * @param array $params
525
     * @param array $data
526
     *
527
     * @return array i.e. [success => true|false, [errors => ['message' => 'this is broken', ..]]]
528
     *
529
     * @throws Exception
530
     */
531
    protected function getResult($method, $path, array $params = null, array $data = null)
532
    {
533
        if (strpos($path, '.json') === 0) {
534
            throw new \InvalidArgumentException('Missing entitytype in request');
535
        }
536
537
        if ($params === null) {
538
            $params = [];
539
        }
540
        $options = [
541
            'query' => $this->preParseParams($params),
542
        ];
543
        if (is_array($data) && count($data) > 0) {
544
            $options['json'] = $data;
545
        }
546
547
        $response = $this->call($method, [$path, $options]);
548
549
        $responseData = $this->parseResult($response->getBody(), $response->getStatusCode());
550
551
        if ($response->getStatusCode() !== 200) {
552
            throw new Exception(
553
                $responseData['message'],
554
                $responseData['code'],
555
                null,
556
                (($_ =& $responseData['errors']) ?: [])
557
            );
558
        }
559
560
        return $responseData;
561
    }
562
563
    /**
564
     * @param array $params
565
     *
566
     * @return mixed
567
     */
568
    private function preParseParams(array $params)
569
    {
570
        if (!array_key_exists('fields', $params) || !is_array($params['fields'])) {
571
            return $params;
572
        }
573
574
        $fields = [];
575
        foreach ($params['fields'] as $index => $field) {
576
            if (!is_numeric($index)) {
577
                $fields[$index] = $field;
578
                continue;
579
            }
580
581
            $modifier = 1;
582
            /** @noinspection SubStrUsedAsArrayAccessInspection */
583
            $firstChar = substr($field, 0, 1);
584
            if ($firstChar === '+' || $firstChar === '-') {
585
                $modifier = $firstChar === '+' ? 1 : 0;
586
                $field = substr($field, 1);
587
            }
588
            $fields[$field] = $modifier;
589
        }
590
        $params['fields'] = $fields;
591
592
        return $params;
593
    }
594
595
    /**
596
     * Parse the Communibase result and if necessary throw an exception
597
     *
598
     * @param string $response
599
     * @param int $httpCode
600
     *
601
     * @return array
602
     *
603
     * @throws Exception
604
     */
605
    private function parseResult($response, $httpCode)
606
    {
607
        $result = json_decode($response, true);
608
609
        if (is_array($result)) {
610
            return $result;
611
        }
612
613
        throw new Exception('"' . $this->getLastJsonError() . '" in ' . $response, $httpCode);
614
    }
615
616
    /**
617
     * Error message based on the most recent JSON error.
618
     *
619
     * @see http://nl1.php.net/manual/en/function.json-last-error.php
620
     *
621
     * @return string
622
     */
623
    private function getLastJsonError()
624
    {
625
        static $messages = [
626
            JSON_ERROR_DEPTH => 'Maximum stack depth exceeded',
627
            JSON_ERROR_STATE_MISMATCH => 'Underflow or the modes mismatch',
628
            JSON_ERROR_CTRL_CHAR => 'Unexpected control character found',
629
            JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON',
630
            JSON_ERROR_UTF8 => 'Malformed UTF-8 characters, possibly incorrectly encoded',
631
        ];
632
        $jsonLastError = json_last_error();
633
634
        return array_key_exists($jsonLastError, $messages) ? $messages[$jsonLastError] : 'Empty response received';
635
    }
636
637
    /**
638
     * Verify the given $id is a valid Communibase string according to format
639
     *
640
     * @param string $id
641
     *
642
     * @return bool
643
     */
644
    public static function isIdValid($id)
645
    {
646
        if (empty($id)) {
647
            return false;
648
        }
649
650
        if (preg_match('#^[0-9a-fA-F]{24}$#', $id) === 0) {
651
            return false;
652
        }
653
654
        return true;
655
    }
656
657
    /**
658
     * Generate a Communibase compatible ID, that consists of:
659
     *
660
     * a 4-byte timestamp,
661
     * a 3-byte machine identifier,
662
     * a 2-byte process id, and
663
     * a 3-byte counter, starting with a random value.
664
     *
665
     * @return string
666
     */
667
    public static function generateId()
668
    {
669
        static $inc = 0;
670
671
        $ts = pack('N', time());
672
        $m = substr(md5(gethostname()), 0, 3);
673
        $pid = pack('n', 1);
674
        $trail = substr(pack('N', $inc++), 1, 3);
675
676
        $bin = sprintf('%s%s%s%s', $ts, $m, $pid, $trail);
677
        $id = '';
678
        for ($i = 0; $i < 12; $i++) {
679
            $id .= sprintf('%02X', ord($bin[$i]));
680
        }
681
682
        return strtolower($id);
683
    }
684
685
    /**
686
     * Add extra headers to be added to each request
687
     *
688
     * @see http://php.net/manual/en/function.header.php
689
     *
690
     * @param array $extraHeaders
691
     */
692
    public function addExtraHeaders(array $extraHeaders)
693
    {
694
        $this->extraHeaders = array_change_key_case($extraHeaders, CASE_LOWER);
695
    }
696
697
    /**
698
     * @param QueryLogger $logger
699
     */
700
    public function setQueryLogger(QueryLogger $logger)
701
    {
702
        $this->logger = $logger;
703
    }
704
705
    /**
706
     * @return QueryLogger
707
     */
708
    public function getQueryLogger()
709
    {
710
        return $this->logger;
711
    }
712
713
    /**
714
     * @todo inroduce overloadability of default GuzzleClient settings
715
     * @return ClientInterface
716
     *
717
     * @throws Exception
718
     */
719
    protected function getClient()
720
    {
721
        if ($this->client instanceof ClientInterface) {
722
            return $this->client;
723
        }
724
725
        if (empty($this->apiKey)) {
726
            throw new Exception('Use of connector not possible without API key', Exception::INVALID_API_KEY);
727
        }
728
729
        $this->client = new Client([
730
            'connect_timeout' => 10,
731
            'base_uri' => $this->serviceUrl,
732
            'headers' => array_merge($this->extraHeaders, [
733
                'User-Agent' => 'Connector-PHP/2',
734
                'X-Api-Key' => $this->apiKey,
735
            ])
736
        ]);
737
738
        return $this->client;
739
    }
740
741
    /**
742
     * Perform the actual call to Communibase
743
     *
744
     * @param string $method
745
     * @param array $arguments
746
     *
747
     * @return \Psr\Http\Message\ResponseInterface
748
     *
749
     * @throws Exception
750
     */
751
    private function call($method, array $arguments)
752
    {
753
        try {
754
755
            /**
756
             * Due to GuzzleHttp not passing a default host header given to the client to _every_ request made by the
757
             * client we manually check to see if we need to add a hostheader to requests.
758
             * When the issue is resolved the foreach can be removed (as the function might even?)
759
             *
760
             * @see https://github.com/guzzle/guzzle/issues/1297
761
             */
762
            if (isset($this->extraHeaders['host'])) {
763
                $arguments[1]['headers']['Host'] = $this->extraHeaders['host'];
764
            }
765
766
            if ($this->logger) {
767
                $this->logger->startQuery($method . ' ' . reset($arguments), $arguments);
768
            }
769
770
            $response = call_user_func_array([$this->getClient(), $method], $arguments);
771
772
            if ($this->logger) {
773
                $this->logger->stopQuery();
774
            }
775
776
            return $response;
777
778
            // try to catch the Guzzle client exception (404's, validation errors etc) and wrap them into a CB exception
779
        } catch (ClientException $e) {
780
781
            $response = $e->getResponse();
782
            $response = json_decode($response === null ? '' : $response->getBody(), true);
783
784
            throw new Exception(
785
                $response['message'],
786
                $response['code'],
787
                $e,
788
                (($_ =& $response['errors']) ?: [])
789
            );
790
791
        } catch (ConnectException $e) {
792
793
            throw new Exception('Can not connect', 500, $e, []);
794
795
        }
796
    }
797
}
798