Passed
Push — master ( b66c10...181037 )
by y
02:15
created

Api.php$0 ➔ _exec()   B

Complexity

Conditions 6

Size

Total Lines 40

Duplication

Lines 0
Ratio 0 %

Importance

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