Completed
Push — getByRef-newstyle ( 81121f )
by Robin
02:50
created

Connector::getByRef()   C

Complexity

Conditions 8
Paths 8

Size

Total Lines 35
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

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