Completed
Push — master ( 1df944...a24ffc )
by Robin
02:32
created

Connector::aggregate()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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