Passed
Push — master ( 0658f3...f968a7 )
by y
02:18
created

Api::getWorkspaces()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 2
rs 10
1
<?php
2
3
namespace Helix\Asana;
4
5
use CURLFile;
6
use Generator;
7
use Helix\Asana\Api\AsanaError;
8
use Helix\Asana\Api\Pool;
9
use Helix\Asana\Base\AbstractEntity;
10
use Helix\Asana\Base\Data;
11
use Helix\Asana\Project\Section;
12
use Helix\Asana\Task\Attachment;
13
use Helix\Asana\Task\Story;
14
use Helix\Asana\User\TaskList;
15
use Helix\Asana\Webhook\ProjectWebhook;
16
use Helix\Asana\Webhook\TaskWebhook;
17
use Psr\Log\LoggerInterface;
18
use Psr\Log\NullLogger;
19
20
/**
21
 * API access.
22
 *
23
 * @see https://app.asana.com/-/developer_console
24
 */
25
class Api {
26
27
    /**
28
     * @var LoggerInterface
29
     */
30
    protected $log;
31
32
    /**
33
     * @var User
34
     */
35
    protected $me;
36
37
    /**
38
     * @var Pool
39
     */
40
    protected $pool;
41
42
    /**
43
     * @var string
44
     */
45
    protected $token;
46
47
    /**
48
     * @param string $token
49
     * @param Pool|null $pool
50
     */
51
    public function __construct (string $token, Pool $pool = null) {
52
        $this->token = $token;
53
        $this->pool = $pool ?? new Pool();
54
    }
55
56
    /**
57
     * cURL transport.
58
     *
59
     * @param string $method
60
     * @param string $path
61
     * @param array $curlOpts
62
     * @return null|array
63
     * @internal
64
     */
65
    protected function _exec (string $method, string $path, array $curlOpts = []) {
66
        $log = $this->getLog();
67
        $log->log(LOG_DEBUG, "{$method} {$path}", $curlOpts);
68
        /** @var resource $ch */
69
        $ch = curl_init();
70
        curl_setopt_array($ch, [
71
            CURLOPT_CUSTOMREQUEST => $method,
72
            CURLOPT_URL => "https://app.asana.com/api/1.0/{$path}",
73
            CURLOPT_USERAGENT => 'hfw/asana',
74
            CURLOPT_FOLLOWLOCATION => false, // HTTP 201 includes Location
75
            CURLOPT_RETURNTRANSFER => true,
76
            CURLOPT_HEADER => true,
77
        ]);
78
        $curlOpts[CURLOPT_HTTPHEADER][] = "Authorization: Bearer {$this->token}";
79
        $curlOpts[CURLOPT_HTTPHEADER][] = 'Accept: application/json';
80
        $curlOpts[CURLOPT_HTTPHEADER][] = 'Expect:'; // prevent http 100
81
        curl_setopt_array($ch, $curlOpts);
82
        RETRY:
83
        $response = curl_exec($ch);
84
        $curlInfo = curl_getinfo($ch);
85
        if ($response === false) {
86
            throw new AsanaError(curl_errno($ch), curl_error($ch), $curlInfo);
87
        }
88
        /** @var string $response */
89
        [$head, $body] = explode("\r\n\r\n", $response, 2);
90
        $curlInfo['response_headers'] = $headers = array_column(array_map(function($header) {
91
            return explode(': ', $header, 2);
92
        }, array_slice(explode("\r\n", $head), 1)), 1, 0);
93
        switch ($curlInfo['http_code']) {
94
            case 200:
95
            case 201:
96
                return $this->_jsonDecode($body);
97
            case 404:
98
                return null;
99
            case 429:
100
                $log->log(LOG_DEBUG, "Retry-After: {$headers['Retry-After']}");
101
                sleep($headers['Retry-After']);
102
                goto RETRY;
103
            default:
104
                $log->log(LOG_ERR, "Asana {$curlInfo['http_code']}: {$body}", $curlInfo);
105
            case 412:
106
                throw new AsanaError($curlInfo['http_code'], $body, $curlInfo);
107
        }
108
    }
109
110
    /**
111
     * @param string $path
112
     * @param array $query
113
     * @return string
114
     * @internal
115
     */
116
    protected function _getPath (string $path, array $query): string {
117
        if ($query) {
1 ignored issue
show
Bug Best Practice introduced by
The expression $query of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
118
            return $path . '?' . http_build_query($query);
119
        }
120
        return $path;
121
    }
122
123
    /**
124
     * @param string $json
125
     * @return array
126
     * @internal
127
     */
128
    protected function _jsonDecode (string $json) {
129
        return json_decode($json, true, JSON_BIGINT_AS_STRING | JSON_THROW_ON_ERROR);
130
    }
131
132
    /**
133
     * @param array $data
134
     * @return string
135
     * @internal
136
     */
137
    protected function _jsonEncode (array $data): string {
138
        return json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
139
    }
140
141
    /**
142
     * `HTTP DELETE`
143
     *
144
     * @param string $path
145
     */
146
    public function delete (string $path): void {
147
        $this->_exec('DELETE', $path);
148
    }
149
150
    /**
151
     * The central point of object creation.
152
     *
153
     * This can be overridden to return custom extensions.
154
     *
155
     * @param Api|Data $caller
156
     * @param string $class
157
     * @param array $data
158
     * @return mixed|Data|AbstractEntity
159
     */
160
    public function factory ($caller, string $class, array $data = []) {
161
        return new $class($caller, $data);
162
    }
163
164
    /**
165
     * `HTTP GET`
166
     *
167
     * @param string $path
168
     * @param array $query
169
     * @param array $opt
170
     * @return null|array
171
     */
172
    public function get (string $path, array $query = [], array $opt = []) {
173
        foreach ($opt as $name => $value) {
174
            $query["opt_{$name}"] = $value;
175
        }
176
        $response = $this->_exec('GET', $this->_getPath($path, $query));
177
        return $response['data'] ?? null;
178
    }
179
180
    /**
181
     * Loads and returns an {@see Attachment}.
182
     *
183
     * @param string $gid
184
     * @return null|Attachment
185
     */
186
    public function getAttachment (string $gid) {
187
        return $this->load($this, Attachment::class, "attachments/{$gid}");
188
    }
189
190
    /**
191
     * Loads and returns a {@see CustomField}.
192
     *
193
     * @param string $gid
194
     * @return null|CustomField
195
     */
196
    public function getCustomField (string $gid) {
197
        return $this->load($this, CustomField::class, "custom_fields/{$gid}");
198
    }
199
200
    /**
201
     * Returns the first known workspace of the authorized user.
202
     * You should only rely on this if the authorized user is in exactly one workspace.
203
     *
204
     * @return Workspace
205
     */
206
    public function getDefaultWorkspace () {
207
        return $this->getMe()->getDefaultWorkspace();
208
    }
209
210
    /**
211
     * @return LoggerInterface
212
     */
213
    public function getLog () {
214
        return $this->log ?? $this->log = new NullLogger();
215
    }
216
217
    /**
218
     * Returns the authorized user.
219
     *
220
     * @return User
221
     */
222
    public function getMe () {
223
        return $this->me ?? $this->me = $this->getUser('me');
224
    }
225
226
    /**
227
     * @return Pool
228
     */
229
    final public function getPool () {
230
        return $this->pool;
231
    }
232
233
    /**
234
     * Loads and returns a {@see Portfolio}.
235
     *
236
     * @param string $gid
237
     * @return null|Portfolio
238
     */
239
    public function getPortfolio (string $gid) {
240
        return $this->load($this, Portfolio::class, "portfolios/{$gid}");
241
    }
242
243
    /**
244
     * Loads and returns a {@see Project}.
245
     *
246
     * @param string $gid
247
     * @return null|Project
248
     */
249
    public function getProject (string $gid) {
250
        return $this->load($this, Project::class, "projects/{$gid}");
251
    }
252
253
    /**
254
     * Loads and returns a {@see Section}.
255
     *
256
     * @param string $gid
257
     * @return null|Section
258
     */
259
    public function getSection (string $gid) {
260
        return $this->load($this, Section::class, "sections/{$gid}");
261
    }
262
263
    /**
264
     * Loads and returns a {@see Story}.
265
     *
266
     * @param string $gid
267
     * @return null|Story
268
     */
269
    public function getStory (string $gid) {
270
        return $this->load($this, Story::class, "stories/{$gid}");
271
    }
272
273
    /**
274
     * Loads and returns a {@see Tag}.
275
     *
276
     * @param string $gid
277
     * @return null|Tag
278
     */
279
    public function getTag (string $gid) {
280
        return $this->load($this, Tag::class, "tags/{$gid}");
281
    }
282
283
    /**
284
     * Loads and returns a {@see Task}.
285
     *
286
     * @param string $gid
287
     * @return null|Task
288
     */
289
    public function getTask (string $gid) {
290
        return $this->load($this, Task::class, "tasks/{$gid}");
291
    }
292
293
    /**
294
     * Loads and returns a {@see TaskList}.
295
     *
296
     * @param string $gid
297
     * @return null|TaskList
298
     */
299
    public function getTaskList (string $gid) {
300
        return $this->load($this, TaskList::class, "user_task_lists/{$gid}");
301
    }
302
303
    /**
304
     * Loads and returns a {@see Team}.
305
     *
306
     * @param string $gid
307
     * @return null|Team
308
     */
309
    public function getTeam (string $gid) {
310
        return $this->load($this, Team::class, "teams/{$gid}");
311
    }
312
313
    /**
314
     * Loads and returns a {@see User}.
315
     *
316
     * @param string $gid
317
     * @return null|User
318
     */
319
    public function getUser (string $gid) {
320
        return $this->load($this, User::class, "users/{$gid}");
321
    }
322
323
    /**
324
     * @param string $gid
325
     * @return ProjectWebhook|TaskWebhook
326
     */
327
    public function getWebhook (string $gid) {
328
        return $this->pool->get($gid, $this, function() use ($gid) {
329
            static $classes = [
330
                Project::TYPE => ProjectWebhook::class,
331
                Task::TYPE => TaskWebhook::class
332
            ];
333
            if ($remote = $this->get("webhooks/{$gid}", [], ['expand' => 'this'])) {
334
                return $this->factory($this, $classes[$remote['resource_type']], $remote);
335
            }
336
            return null;
337
        });
338
    }
339
340
    /**
341
     * Expands received webhook data as a full event object.
342
     *
343
     * @see https://developers.asana.com/docs/event
344
     *
345
     * @param array $data
346
     * @return Event
347
     */
348
    public function getWebhookEvent (array $data) {
349
        return $this->factory($this, Event::class, $data);
350
    }
351
352
    /**
353
     * Loads and returns a {@see Workspace}.
354
     *
355
     * @param string $gid
356
     * @return null|Workspace
357
     */
358
    public function getWorkspace (string $gid) {
359
        return $this->load($this, Workspace::class, "workspaces/{$gid}");
360
    }
361
362
    /**
363
     * Loads and returns a {@see Workspace} by name.
364
     *
365
     * @param string $name
366
     * @return null|Workspace
367
     */
368
    public function getWorkspaceByName (string $name) {
369
        foreach ($this->getWorkspaces() as $workspace) {
370
            if ($workspace->getName() === $name) {
371
                return $workspace;
372
            }
373
        }
374
        return null;
375
    }
376
377
    /**
378
     * All workspaces visible to the authorized user.
379
     *
380
     * @return Workspace[]
381
     */
382
    public function getWorkspaces () {
383
        return $this->getMe()->getWorkspaces();
384
    }
385
386
    /**
387
     * Loads the entity found at the given path + query.
388
     *
389
     * @param Api|Data $caller
390
     * @param string $class
391
     * @param string $path
392
     * @param array $query
393
     * @return null|mixed|AbstractEntity
394
     */
395
    public function load ($caller, string $class, string $path, array $query = []) {
396
        $key = $this->_getPath($path, $query);
397
        return $this->pool->get($key, $caller, function($caller) use ($class, $path, $query) {
398
            if ($data = $this->get($path, $query, ['expand' => 'this'])) {
399
                return $this->factory($caller, $class, $data);
400
            }
401
            return null;
402
        });
403
    }
404
405
    /**
406
     * Returns all results from {@see loadEach()}
407
     *
408
     * @param Api|Data $caller
409
     * @param string $class
410
     * @param string $path
411
     * @param array $query
412
     * @return array|AbstractEntity[]
413
     */
414
    public function loadAll ($caller, string $class, string $path, array $query = []) {
415
        return iterator_to_array($this->loadEach(...func_get_args()));
416
    }
417
418
    /**
419
     * Loads and yields each entity found at the given path + query.
420
     *
421
     * The result-set is not pooled, but individual entities are.
422
     *
423
     * @param Api|Data $caller
424
     * @param string $class
425
     * @param string $path
426
     * @param array $query `limit` can exceed `100` here. Empty `limit` for all pages.
427
     * @return Generator|AbstractEntity[]
428
     */
429
    public function loadEach ($caller, string $class, string $path, array $query = []) {
430
        $query['opt_expand'] = 'this';
431
        if (!$i = $query['limit'] ?? 0) {
432
            $i = PHP_INT_MAX;
433
        }
434
        do {
435
            $query['limit'] = min($i, 100);
436
            $page = $this->_exec('GET', $this->_getPath($path, $query));
437
            for ($data = $page['data']; $each = current($data) and $i--; next($data)) {
438
                yield $this->pool->get($each['gid'], $caller, function($caller) use ($class, $each) {
439
                    return $this->factory($caller, $class, $each);
440
                });
441
            }
442
        } while ($i and $page['next_page'] and $query['offset'] = $page['next_page']['offset']);
443
    }
444
445
    /**
446
     * `HTTP POST`
447
     *
448
     * @param string $path
449
     * @param array $data
450
     * @param array $opt
451
     * @return null|array
452
     */
453
    public function post (string $path, array $data = [], array $opt = []) {
454
        $response = $this->_exec('POST', $path, [
455
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
456
            CURLOPT_POSTFIELDS => $this->_jsonEncode(['options' => $opt, 'data' => $data])
457
        ]);
458
        return $response['data'] ?? null;
459
    }
460
461
    /**
462
     * `HTTP PUT`
463
     *
464
     * @param string $path
465
     * @param array $data
466
     * @param array $opt
467
     * @return null|array
468
     */
469
    public function put (string $path, array $data = [], array $opt = []) {
470
        $response = $this->_exec('PUT', $path, [
471
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
472
            CURLOPT_POSTFIELDS => $this->_jsonEncode(['options' => $opt, 'data' => $data])
473
        ]);
474
        return $response['data'] ?? null;
475
    }
476
477
    /**
478
     * @param LoggerInterface $log
479
     */
480
    public function setLog (LoggerInterface $log) {
481
        $this->log = $log;
482
    }
483
484
    /**
485
     * @param Pool $pool
486
     */
487
    public function setPool (Pool $pool) {
488
        $this->pool = $pool;
489
    }
490
491
    /**
492
     * Polls for new events.
493
     *
494
     * If the given sync token expired, a `412` is thrown.
495
     *
496
     * If the given token is `null`, this returns an empty array.
497
     *
498
     * @see https://developers.asana.com/docs/get-events-on-a-resource
499
     *
500
     * @param string $gid A project or task GID.
501
     * @param null|string $token Updated to the new token.
502
     * @return Event[]
503
     */
504
    public function sync (string $gid, ?string &$token) {
505
        try {
506
            /** @var array $remote Asana throws 400 for missing entities here. */
507
            $remote = $this->_exec('GET', $this->_getPath('events', [
508
                'resource' => $gid,
509
                'sync' => $token,
510
                'opt_expand' => 'this'
511
            ]));
512
        }
513
        catch (AsanaError $error) {
514
            if ($error->getCode() === 412) {
515
                $remote = $this->_jsonDecode($error->getMessage());
516
                if (!isset($token)) {
517
                    // API docs say: "The response will be the same as for an expired sync token."
518
                    // The caller knowingly gave a null token, so we don't need to rethrow.
519
                    $token = $remote['sync'];
520
                    return [];
521
                }
522
                // Token expired. Update and rethrow.
523
                $token = $remote['sync'];
524
            }
525
            throw $error;
526
        }
527
        $token = $remote['sync'];
528
        $events = array_map(function(array $each) {
529
            return $this->factory($this, Event::class, $each);
530
        }, $remote['data']);
531
        usort($events, function(Event $a, Event $b) {
532
            return $a->getCreatedAt() <=> $b->getCreatedAt();
533
        });
534
        return $events;
535
    }
536
537
    /**
538
     * `HTTP POST` (multipart/form-data)
539
     *
540
     * @param string $file
541
     * @param string $to
542
     * @return null|array
543
     */
544
    public function upload (string $file, string $to) {
545
        $response = $this->_exec('POST', $to, [
546
            CURLOPT_POSTFIELDS => ['file' => new CURLFile(realpath($file))] // multipart/form-data
547
        ]);
548
        return $response['data'] ?? null;
549
    }
550
}