Passed
Push — master ( fbd180...23ab98 )
by y
02:18
created

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