Completed
Push — master ( 38b697...f99019 )
by Robin
8s
created

Connector::getByRef()   C

Complexity

Conditions 8
Paths 8

Size

Total Lines 35
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

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