Completed
Push — master ( 16849f...042227 )
by
unknown
01:52
created

Connector::getLastJsonError()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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