Completed
Push — getByRef ( 1383a0 )
by
unknown
02:48
created

Connector::getBinary()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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