Completed
Push — master ( c5b05e...5b26db )
by Robin
02:16
created

Connector::getResult()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

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