Passed
Push — master ( 23ab98...82cce0 )
by y
01:46
created

Api.php$0 ➔ getWebhook()   A

Complexity

Conditions 2

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
dl 0
loc 10
rs 9.9332
c 0
b 0
f 0
1
<?php
2
3
namespace Helix\Asana;
4
5
use CURLFile;
6
use Generator;
7
use Helix\Asana\Api\Cache;
8
use Helix\Asana\Api\LoggerInterface;
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
18
/**
19
 * API access.
20
 *
21
 * @see https://app.asana.com/-/developer_console
22
 */
23
class Api {
24
25
    /**
26
     * Cache is shared across all instances.
27
     *
28
     * @var Cache
29
     */
30
    protected static $cache;
31
32
    /**
33
     * The "default" instance (for entity deserialization).
34
     *
35
     * Defaults to the first instance created.
36
     *
37
     * @var static
38
     */
39
    protected static $default;
40
41
    /**
42
     * The logger is shared across all instances.
43
     *
44
     * @var LoggerInterface
45
     */
46
    protected static $logger;
47
48
    /**
49
     * @var string
50
     */
51
    protected $token;
52
53
    /**
54
     * @return Cache
55
     */
56
    public static function getCache () {
57
        return static::$cache ?? static::$cache = new Cache();
58
    }
59
60
    /**
61
     * @return Api
62
     */
63
    public static function getDefault () {
64
        return self::$default;
65
    }
66
67
    /**
68
     * @return LoggerInterface
69
     */
70
    public static function getLogger () {
71
        return static::$logger
72
            ?? static::$logger = new class implements LoggerInterface {
73
74
                public function log (string $message, string $path, array $data = null): void {
75
                    // stub
76
                }
77
78
            };
79
    }
80
81
    /**
82
     * @param Cache $cache
83
     */
84
    public static function setCache (Cache $cache) {
85
        static::$cache = $cache;
86
    }
87
88
    /**
89
     * @param Api $default
90
     */
91
    public static function setDefault (Api $default) {
92
        self::$default = $default;
93
    }
94
95
    /**
96
     * @param LoggerInterface $logger
97
     */
98
    public static function setLogger (LoggerInterface $logger) {
99
        static::$logger = $logger;
100
    }
101
102
    /**
103
     * @param string $token
104
     */
105
    public function __construct (string $token) {
106
        $this->token = $token;
107
        if (!static::$default) {
108
            static::$default = $this;
109
        }
110
    }
111
112
    /**
113
     * cURL transport.
114
     *
115
     * @param string $method
116
     * @param string $path
117
     * @param array $opts
118
     * @return null|array
119
     * @internal
120
     */
121
    protected function _exec (string $method, string $path, array $opts = []) {
122
        static::getLogger()->log($method, $path, $opts);
123
        /** @var resource $ch */
124
        $ch = curl_init();
125
        curl_setopt_array($ch, [
126
            CURLOPT_CUSTOMREQUEST => $method,
127
            CURLOPT_URL => "https://app.asana.com/api/1.0/{$path}",
128
            CURLOPT_USERAGENT => 'hfw/asana',
129
            CURLOPT_FOLLOWLOCATION => false, // HTTP 201 includes Location
130
            CURLOPT_RETURNTRANSFER => true,
131
            CURLOPT_HEADER => true,
132
        ]);
133
        $opts[CURLOPT_HTTPHEADER][] = "Authorization: Bearer {$this->token}";
134
        $opts[CURLOPT_HTTPHEADER][] = 'Accept: application/json';
135
        $opts[CURLOPT_HTTPHEADER][] = 'Expect:'; // prevent http 100
136
        curl_setopt_array($ch, $opts);
137
        RETRY:
138
        $response = curl_exec($ch);
139
        $info = curl_getinfo($ch);
140
        if ($response === false) {
141
            throw new Error(curl_errno($ch), curl_error($ch), $info);
142
        }
143
        /** @var string $response */
144
        [$head, $body] = explode("\r\n\r\n", $response, 2);
145
        $info['response_headers'] = $headers = array_column(array_map(function($header) {
146
            return explode(': ', $header, 2);
147
        }, array_slice(explode("\r\n", $head), 1)), 1, 0);
148
        switch ($info['http_code']) {
149
            case 200:
150
            case 201:
151
                return $this->_jsonDecode($body);
152
            case 404:
153
                return null;
154
            case 429:
155
                $sleep = $headers['Retry-After'];
156
                static::getLogger()->log("Retry-After {$sleep}: {$method}", $path);
157
                sleep($sleep);
158
                goto RETRY;
159
            default:
160
                throw new Error($info['http_code'], $body, $info);
161
        }
162
    }
163
164
    /**
165
     * @param string $path
166
     * @param array $query
167
     * @return string
168
     * @internal
169
     */
170
    protected function _getPath (string $path, array $query): string {
171
        return $query ? $path . '?' . http_build_query($query) : $path;
172
    }
173
174
    /**
175
     * @param string $json
176
     * @return null|array
177
     * @internal
178
     */
179
    protected function _jsonDecode (string $json) {
180
        return json_decode($json, true, JSON_BIGINT_AS_STRING | JSON_THROW_ON_ERROR);
181
    }
182
183
    /**
184
     * @param array $data
185
     * @return string
186
     * @internal
187
     */
188
    protected function _jsonEncode (array $data): string {
189
        return json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
190
    }
191
192
    /**
193
     * `HTTP DELETE`
194
     *
195
     * @param string $path
196
     */
197
    public function delete (string $path): void {
198
        $this->_exec('DELETE', $path);
199
    }
200
201
    /**
202
     * The central point of object creation.
203
     *
204
     * This can be overridden to return custom extensions.
205
     *
206
     * @param string $class
207
     * @param Api|Data $caller
208
     * @param array $data
209
     * @return mixed|Data|AbstractEntity
210
     */
211
    public function factory (string $class, $caller, array $data = []) {
212
        return new $class($caller, $data);
213
    }
214
215
    /**
216
     * `HTTP GET`
217
     *
218
     * @param string $path
219
     * @param array $query
220
     * @param array $options
221
     * @return null|array
222
     */
223
    public function get (string $path, array $query = [], array $options = []) {
224
        foreach ($options as $name => $value) {
225
            $query["opt_{$name}"] = $value;
226
        }
227
        $response = $this->_exec('GET', $this->_getPath($path, $query));
228
        return $response['data'] ?? null;
229
    }
230
231
    /**
232
     * Loads and returns an {@see Attachment}.
233
     *
234
     * @param string $gid
235
     * @return null|Attachment
236
     */
237
    public function getAttachment (string $gid) {
238
        return $this->load(Attachment::class, $this, "attachments/{$gid}");
239
    }
240
241
    /**
242
     * Loads and returns a {@see CustomField}.
243
     *
244
     * @param string $gid
245
     * @return null|CustomField
246
     */
247
    public function getCustomField (string $gid) {
248
        return $this->load(CustomField::class, $this, "custom_fields/{$gid}");
249
    }
250
251
    /**
252
     * Returns the first known workspace of the authorized user.
253
     * You should only rely on this if the authorized user is in exactly one workspace.
254
     *
255
     * @return Workspace
256
     */
257
    public function getDefaultWorkspace () {
258
        return $this->getMe()->getDefaultWorkspace();
259
    }
260
261
    /**
262
     * Returns the authorized user.
263
     *
264
     * @return User
265
     */
266
    public function getMe () {
267
        return $this->getUser('me');
268
    }
269
270
    /**
271
     * Loads and returns a {@see Portfolio}.
272
     *
273
     * @param string $gid
274
     * @return null|Portfolio
275
     */
276
    public function getPortfolio (string $gid) {
277
        return $this->load(Portfolio::class, $this, "portfolios/{$gid}");
278
    }
279
280
    /**
281
     * Loads and returns a {@see Project}.
282
     *
283
     * @param string $gid
284
     * @return null|Project
285
     */
286
    public function getProject (string $gid) {
287
        return $this->load(Project::class, $this, "projects/{$gid}");
288
    }
289
290
    /**
291
     * Loads and returns a {@see Section}.
292
     *
293
     * @param string $gid
294
     * @return null|Section
295
     */
296
    public function getSection (string $gid) {
297
        return $this->load(Section::class, $this, "sections/{$gid}");
298
    }
299
300
    /**
301
     * Loads and returns a {@see Story}.
302
     *
303
     * @param string $gid
304
     * @return null|Story
305
     */
306
    public function getStory (string $gid) {
307
        return $this->load(Story::class, $this, "stories/{$gid}");
308
    }
309
310
    /**
311
     * Loads and returns a {@see Tag}.
312
     *
313
     * @param string $gid
314
     * @return null|Tag
315
     */
316
    public function getTag (string $gid) {
317
        return $this->load(Tag::class, $this, "tags/{$gid}");
318
    }
319
320
    /**
321
     * Loads and returns a {@see Task}.
322
     *
323
     * @param string $gid
324
     * @return null|Task
325
     */
326
    public function getTask (string $gid) {
327
        return $this->load(Task::class, $this, "tasks/{$gid}");
328
    }
329
330
    /**
331
     * Loads and returns a {@see TaskList}.
332
     *
333
     * @param string $gid
334
     * @return null|TaskList
335
     */
336
    public function getTaskList (string $gid) {
337
        return $this->load(TaskList::class, $this, "user_task_lists/{$gid}");
338
    }
339
340
    /**
341
     * Loads and returns a {@see Team}.
342
     *
343
     * @param string $gid
344
     * @return null|Team
345
     */
346
    public function getTeam (string $gid) {
347
        return $this->load(Team::class, $this, "teams/{$gid}");
348
    }
349
350
    /**
351
     * Loads and returns a {@see User}.
352
     *
353
     * @param string $gid
354
     * @return null|User
355
     */
356
    public function getUser (string $gid) {
357
        return $this->load(User::class, $this, "users/{$gid}");
358
    }
359
360
    /**
361
     * @param string $gid
362
     * @return ProjectWebhook|TaskWebhook
363
     */
364
    public function getWebhook (string $gid) {
365
        return $this->getCache()->get($gid, $this, function() use ($gid) {
366
            static $classes = [
367
                Project::TYPE => ProjectWebhook::class,
368
                Task::TYPE => TaskWebhook::class
369
            ];
370
            if ($remote = $this->get("webhooks/{$gid}", [], ['expand' => 'this'])) {
371
                return $this->factory($classes[$remote['resource_type']], $this, $remote);
372
            }
373
            return null;
374
        });
375
    }
376
377
    /**
378
     * Expands received webhook data as a full event object.
379
     *
380
     * @see https://developers.asana.com/docs/event
381
     *
382
     * @param array $data
383
     * @return Event
384
     */
385
    public function getWebhookEvent (array $data) {
386
        return $this->factory(Event::class, $this, $data);
387
    }
388
389
    /**
390
     * Loads and returns a {@see Workspace}.
391
     *
392
     * @param string $gid
393
     * @return null|Workspace
394
     */
395
    public function getWorkspace (string $gid) {
396
        return $this->load(Workspace::class, $this, "workspaces/{$gid}");
397
    }
398
399
    /**
400
     * Loads and returns a {@see Workspace} by name.
401
     *
402
     * @param string $name
403
     * @return null|Workspace
404
     */
405
    public function getWorkspaceByName (string $name) {
406
        foreach ($this->getWorkspaces() as $workspace) {
407
            if ($workspace->getName() === $name) {
408
                return $workspace;
409
            }
410
        }
411
        return null;
412
    }
413
414
    /**
415
     * All workspaces visible to the authorized user.
416
     *
417
     * @return Workspace[]
418
     */
419
    public function getWorkspaces () {
420
        return $this->getMe()->getWorkspaces();
421
    }
422
423
    /**
424
     * Loads the entity found at the given path + query.
425
     *
426
     * The entity is cached.
427
     *
428
     * @param string $class
429
     * @param Api|Data $caller
430
     * @param string $path
431
     * @param array $query
432
     * @return null|mixed|AbstractEntity
433
     */
434
    public function load (string $class, $caller, string $path, array $query = []) {
435
        $key = $this->_getPath($path, $query);
436
        return $this->getCache()->get($key, $caller, function($caller) use ($class, $path, $query) {
437
            $data = $this->get($path, $query, ['expand' => 'this']);
438
            return $data ? $this->factory($class, $caller, $data) : null;
439
        });
440
    }
441
442
    /**
443
     * Returns all results from {@see loadEach()}
444
     *
445
     * @param string $class
446
     * @param Api|Data $caller
447
     * @param string $path
448
     * @param array $query
449
     * @param int $pages
450
     * @return array|AbstractEntity[]
451
     */
452
    public function loadAll (string $class, $caller, string $path, array $query = [], int $pages = 0) {
453
        return iterator_to_array($this->loadEach(...func_get_args()));
454
    }
455
456
    /**
457
     * Loads and yields each entity found at the given path + query.
458
     *
459
     * The result-set is not cached, but individual entities are.
460
     *
461
     * @param string $class
462
     * @param Api|Data $caller
463
     * @param string $path
464
     * @param array $query `limit` can be included, or excluded to default at `100` (the maximum).
465
     * @param int $pages If positive, stops after this many pages have been fetched.
466
     * @return Generator|AbstractEntity[]
467
     */
468
    public function loadEach (string $class, $caller, string $path, array $query = [], int $pages = 0) {
469
        $query['opt_expand'] = 'this';
470
        $query += ['limit' => 100];
471
        $path = $this->_getPath($path, $query);
472
        $pageCount = 0;
473
        do {
474
            $page = $this->_exec('GET', $path);
475
            foreach ($page['data'] ?? [] as $data) {
476
                yield $this->getCache()->get($data['gid'], $caller, function($caller) use ($class, $data) {
477
                    return $this->factory($class, $caller, $data);
478
                });
479
            }
480
            $path = substr($page['next_page']['path'] ?? null, 1); // removes leading slash
481
        } while ($path and ++$pageCount !== $pages);
482
    }
483
484
    /**
485
     * `HTTP POST`
486
     *
487
     * @param string $path
488
     * @param array $data
489
     * @param array $options
490
     * @return null|array
491
     */
492
    public function post (string $path, array $data = [], array $options = []) {
493
        $response = $this->_exec('POST', $path, [
494
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
495
            CURLOPT_POSTFIELDS => $this->_jsonEncode(['options' => $options, 'data' => $data])
496
        ]);
497
        return $response['data'] ?? null;
498
    }
499
500
    /**
501
     * `HTTP PUT`
502
     *
503
     * @param string $path
504
     * @param array $data
505
     * @param array $options
506
     * @return null|array
507
     */
508
    public function put (string $path, array $data = [], array $options = []) {
509
        $response = $this->_exec('PUT', $path, [
510
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
511
            CURLOPT_POSTFIELDS => $this->_jsonEncode(['options' => $options, 'data' => $data])
512
        ]);
513
        return $response['data'] ?? null;
514
    }
515
516
    /**
517
     * Polls for new events. This can be used as an alternative to webhooks.
518
     *
519
     * If the given sync token expired, a `412` is thrown, and a new sync token is set as the message.
520
     *
521
     * @see https://developers.asana.com/docs/get-events-on-a-resource
522
     *
523
     * @param Project|Task $entity
524
     * @param null|string $token
525
     * @return Event[]
526
     */
527
    public function sync ($entity, ?string &$token) {
528
        try {
529
            $response = $this->_exec('GET', $this->_getPath('events', [
530
                'resource' => $entity->getGid(),
531
                'sync' => $token,
532
                'opt_expand' => 'this'
533
            ]));
534
        }
535
        catch (Error $error) {
536
            if ($error->getCode() !== 412) {
537
                throw $error;
538
            }
539
            $response = $this->_jsonDecode($error->getMessage());
540
            if (isset($token)) {
541
                throw new Error(412, $response['sync'], $error->getCurlInfo());
542
            }
543
        }
544
        $token = $response['sync'];
545
        return array_map(function(array $each) {
546
            return $this->factory(Event::class, $this, $each);
547
        }, $response['data'] ?? []);
548
    }
549
550
    /**
551
     * `HTTP POST` (multipart/form-data)
552
     *
553
     * @param string $path
554
     * @param string $file
555
     * @return null|array
556
     */
557
    public function upload (string $path, string $file) {
558
        $response = $this->_exec('POST', $path, [
559
            CURLOPT_POSTFIELDS => ['file' => new CURLFile(realpath($file))] // multipart/form-data
560
        ]);
561
        return $response['data'] ?? null;
562
    }
563
}