Passed
Push — master ( 866c3b...50a671 )
by y
08:13
created

Api.php$0 ➔ getDefaultWorkspace()   A

Complexity

Conditions 1

Size

Total Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
c 0
b 0
f 0
dl 0
loc 2
rs 10
1
<?php
2
3
namespace Helix\Asana;
4
5
use CURLFile;
6
use Helix\Asana\Api\Cache;
7
use Helix\Asana\Api\LoggerInterface;
8
use Helix\Asana\Base\AbstractEntity;
9
use Helix\Asana\Base\Data;
10
use Helix\Asana\Event\ProjectEvent;
11
use Helix\Asana\Event\StoryEvent;
12
use Helix\Asana\Event\TaskEvent;
13
use Helix\Asana\Project\Section;
14
use Helix\Asana\Task\Attachment;
15
use Helix\Asana\Task\Story;
16
use Helix\Asana\User\TaskList;
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
     * Expands received webhook data as a full event object.
362
     *
363
     * @see https://developers.asana.com/docs/event
364
     *
365
     * @param array $data
366
     * @return ProjectEvent|TaskEvent|StoryEvent
367
     */
368
    public function getWebhookEvent (array $data) {
369
        static $classes = [
370
            Project::TYPE => ProjectEvent::class,
371
            Task::TYPE => TaskEvent::class,
372
            Story::TYPE => StoryEvent::class
373
        ];
374
        return $this->factory($classes[$data['type']], $this, $data);
375
    }
376
377
    /**
378
     * Loads and returns a {@see Workspace}.
379
     *
380
     * @param string $gid
381
     * @return null|Workspace
382
     */
383
    public function getWorkspace (string $gid) {
384
        return $this->load(Workspace::class, $this, "workspaces/{$gid}");
385
    }
386
387
    /**
388
     * Loads and returns a {@see Workspace} by name.
389
     *
390
     * @param string $name
391
     * @return null|Workspace
392
     */
393
    public function getWorkspaceByName (string $name) {
394
        foreach ($this->getWorkspaces() as $workspace) {
395
            if ($workspace->getName() === $name) {
396
                return $workspace;
397
            }
398
        }
399
        return null;
400
    }
401
402
    /**
403
     * All workspaces visible to the authorized user.
404
     *
405
     * @return Workspace[]
406
     */
407
    public function getWorkspaces () {
408
        return $this->getMe()->getWorkspaces();
409
    }
410
411
    /**
412
     * Loads the entity found at the given path + query.
413
     *
414
     * The entity is cached.
415
     *
416
     * @param string $class
417
     * @param Api|Data $caller
418
     * @param string $path
419
     * @param array $query
420
     * @return null|mixed|AbstractEntity
421
     */
422
    public function load (string $class, $caller, string $path, array $query = []) {
423
        $key = $this->_getPath($path, $query);
424
        return $this->getCache()->get($key, $caller, function($caller) use ($class, $path, $query) {
425
            $data = $this->get($path, $query, ['expand' => 'this']);
426
            return $data ? $this->factory($class, $caller, $data) : null;
427
        });
428
    }
429
430
    /**
431
     * Loads entities found at the given path + query.
432
     *
433
     * The result-set is not cached, but individual entities are.
434
     *
435
     * @param string $class
436
     * @param Api|Data $caller
437
     * @param string $path
438
     * @param array $query `limit` can be included, or excluded to default at `100` (the maximum).
439
     * @param int $pages If positive, stops after this many pages have been fetched.
440
     * @return array|AbstractEntity[]
441
     */
442
    public function loadAll (string $class, $caller, string $path, array $query = [], int $pages = 0) {
443
        $query['opt_expand'] = 'this';
444
        $query += ['limit' => 100];
445
        $path = $this->_getPath($path, $query);
446
        $pageCount = 0;
447
        $all = [];
448
        do {
449
            $page = $this->_exec('GET', $path);
450
            foreach ($page['data'] ?? [] as $data) {
451
                $all[] = $this->getCache()->get($data['gid'], $caller, function($caller) use ($class, $data) {
452
                    return $this->factory($class, $caller, $data);
453
                });
454
            }
455
            $path = substr($page['next_page']['path'] ?? null, 1); // removes leading slash
456
        } while ($path and ++$pageCount !== $pages);
457
        return $all;
458
    }
459
460
    /**
461
     * `HTTP POST`
462
     *
463
     * @param string $path
464
     * @param array $data
465
     * @param array $options
466
     * @return null|array
467
     */
468
    public function post (string $path, array $data = [], array $options = []) {
469
        $response = $this->_exec('POST', $path, [
470
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
471
            CURLOPT_POSTFIELDS => $this->_jsonEncode(['options' => $options, 'data' => $data])
472
        ]);
473
        return $response['data'] ?? null;
474
    }
475
476
    /**
477
     * `HTTP PUT`
478
     *
479
     * @param string $path
480
     * @param array $data
481
     * @param array $options
482
     * @return null|array
483
     */
484
    public function put (string $path, array $data = [], array $options = []) {
485
        $response = $this->_exec('PUT', $path, [
486
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
487
            CURLOPT_POSTFIELDS => $this->_jsonEncode(['options' => $options, 'data' => $data])
488
        ]);
489
        return $response['data'] ?? null;
490
    }
491
492
    /**
493
     * Polls for new events. This can be used as an alternative to webhooks.
494
     *
495
     * If the given sync token expired, a `412` is thrown, and a new sync token is set as the message.
496
     *
497
     * @see https://developers.asana.com/docs/get-events-on-a-resource
498
     *
499
     * @param Project|Task $entity
500
     * @param null|string $token
501
     * @return ProjectEvent[]|TaskEvent[]|StoryEvent[]
502
     */
503
    public function sync ($entity, ?string &$token) {
504
        try {
505
            $response = $this->_exec('GET', $this->_getPath('events', [
506
                'resource' => $entity->getGid(),
507
                'sync' => $token,
508
                'opt_expand' => 'this'
509
            ]));
510
        }
511
        catch (Error $error) {
512
            if ($error->getCode() !== 412) {
513
                throw $error;
514
            }
515
            $response = $this->_jsonDecode($error->getMessage());
516
            if (isset($token)) {
517
                throw new Error(412, $response['sync'], $error->getCurlInfo());
518
            }
519
        }
520
        $token = $response['sync'];
521
        return array_map(function(array $each) {
522
            static $classes = [
523
                Project::TYPE => ProjectEvent::class,
524
                Task::TYPE => TaskEvent::class,
525
                Story::TYPE => StoryEvent::class
526
            ];
527
            return $this->factory($classes[$each['resource']['resource_type']], $this, $each);
528
        }, $response['data'] ?? []);
529
    }
530
531
    /**
532
     * `HTTP POST` (multipart/form-data)
533
     *
534
     * @param string $path
535
     * @param string $file
536
     * @return null|array
537
     */
538
    public function upload (string $path, string $file) {
539
        $response = $this->_exec('POST', $path, [
540
            CURLOPT_POSTFIELDS => ['file' => new CURLFile(realpath($file))] // multipart/form-data
541
        ]);
542
        return $response['data'] ?? null;
543
    }
544
}