Passed
Push — master ( bcdfe5...bd5142 )
by y
02:13
created

Api.php$0 ➔ getUser()   A

Complexity

Conditions 1

Size

Total Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 2
cc 1
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
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 ?? static::$logger = new class implements LoggerInterface {
71
72
                public function log (string $info, string $path, ?array $payload): void {
73
                    // stub
74
                }
75
76
            };
77
    }
78
79
    /**
80
     * @param Cache $cache
81
     */
82
    public static function setCache (Cache $cache) {
83
        static::$cache = $cache;
84
    }
85
86
    /**
87
     * @param Api $default
88
     */
89
    public static function setDefault (Api $default) {
90
        self::$default = $default;
91
    }
92
93
    /**
94
     * @param LoggerInterface $logger
95
     */
96
    public static function setLogger (LoggerInterface $logger) {
97
        static::$logger = $logger;
98
    }
99
100
    /**
101
     * @param string $token
102
     */
103
    public function __construct (string $token) {
104
        $this->token = $token;
105
        if (!static::$default) {
106
            static::$default = $this;
107
        }
108
    }
109
110
    /**
111
     * HTTP DELETE
112
     *
113
     * @param string $path
114
     */
115
    public function delete (string $path): void {
116
        $this->exec('DELETE', $path);
117
    }
118
119
    /**
120
     * @param string $method
121
     * @param string $path
122
     * @param null|array $opts
123
     * @return null|array
124
     * @internal
125
     */
126
    protected function exec (string $method, string $path, array $opts = null) {
127
        $ch = curl_init();
128
        curl_setopt_array($ch, [
1 ignored issue
show
Bug introduced by
It seems like $ch can also be of type false; however, parameter $ch of curl_setopt_array() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

128
        curl_setopt_array(/** @scrutinizer ignore-type */ $ch, [
Loading history...
129
            CURLOPT_CUSTOMREQUEST => $method,
130
            CURLOPT_URL => "https://app.asana.com/api/1.0/{$path}",
131
            CURLOPT_USERAGENT => 'hfw/asana',
132
            CURLOPT_FOLLOWLOCATION => false, // HTTP 201 includes Location
133
            CURLOPT_RETURNTRANSFER => true,
134
            CURLOPT_HEADER => true,
135
        ]);
136
        static::getLogger()->log($method, $path, $opts);
137
        $opts[CURLOPT_HTTPHEADER][] = "Authorization: Bearer {$this->token}";
138
        $opts[CURLOPT_HTTPHEADER][] = 'Accept: application/json';
139
        $opts[CURLOPT_HTTPHEADER][] = 'Expect:'; // prevent http 100
140
        curl_setopt_array($ch, $opts);
1 ignored issue
show
Bug introduced by
It seems like $opts can also be of type null; however, parameter $options of curl_setopt_array() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

140
        curl_setopt_array($ch, /** @scrutinizer ignore-type */ $opts);
Loading history...
141
        RETRY:
142
        $response = curl_exec($ch);
1 ignored issue
show
Bug introduced by
It seems like $ch can also be of type false; however, parameter $ch of curl_exec() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

142
        $response = curl_exec(/** @scrutinizer ignore-type */ $ch);
Loading history...
143
        $info = curl_getinfo($ch);
1 ignored issue
show
Bug introduced by
It seems like $ch can also be of type false; however, parameter $ch of curl_getinfo() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

143
        $info = curl_getinfo(/** @scrutinizer ignore-type */ $ch);
Loading history...
144
        if ($response === false) {
145
            throw new Error(curl_errno($ch), curl_error($ch), $info);
2 ignored issues
show
Bug introduced by
It seems like $ch can also be of type false; however, parameter $ch of curl_error() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

145
            throw new Error(curl_errno($ch), curl_error(/** @scrutinizer ignore-type */ $ch), $info);
Loading history...
Bug introduced by
It seems like $ch can also be of type false; however, parameter $ch of curl_errno() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

145
            throw new Error(curl_errno(/** @scrutinizer ignore-type */ $ch), curl_error($ch), $info);
Loading history...
146
        }
147
        [$head, $body] = explode("\r\n\r\n", $response, 2);
1 ignored issue
show
Bug introduced by
It seems like $response can also be of type true; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

147
        [$head, $body] = explode("\r\n\r\n", /** @scrutinizer ignore-type */ $response, 2);
Loading history...
148
        $info['response_headers'] = $headers = array_column(array_map(function($header) {
149
            return explode(': ', $header, 2);
150
        }, array_slice(explode("\r\n", $head), 1)), 1, 0);
151
        switch ($code = $info['http_code']) {
152
            case 200:
153
            case 201:
154
                return $this->jsonDecode($body);
155
            case 404:
156
                return null;
157
            case 429:
158
                $sleep = $headers['Retry-After'];
159
                static::getLogger()->log("Retry-After {$sleep}: {$method}", $path, null);
160
                sleep($sleep);
161
                goto RETRY;
162
            default:
163
                throw new Error($code, $body, $info);
164
        }
165
    }
166
167
    /**
168
     * Central point of object creation.
169
     *
170
     * Can be overridden to return subclasses.
171
     *
172
     * @param string $class
173
     * @param Api|Data $caller
174
     * @param array $data
175
     * @return mixed
176
     */
177
    public function factory (string $class, $caller, array $data = []) {
178
        return new $class($caller, $data);
179
    }
180
181
    /**
182
     * HTTP GET
183
     *
184
     * @param string $path
185
     * @param array $query
186
     * @param array $options
187
     * @return null|array
188
     */
189
    public function get (string $path, array $query = [], array $options = []) {
190
        foreach ($options as $name => $value) {
191
            $query["opt_{$name}"] = $value;
192
        }
193
        $response = $this->exec('GET', $this->getPath($path, $query));
194
        return $response['data'] ?? null;
195
    }
196
197
    /**
198
     * Loads and returns an Attachment.
199
     *
200
     * @param string $gid
201
     * @return null|Attachment
202
     */
203
    public function getAttachment (string $gid) {
204
        return $this->load(Attachment::class, $this, "attachments/{$gid}");
205
    }
206
207
    /**
208
     * Loads and returns a CustomField.
209
     *
210
     * @param string $gid
211
     * @return null|CustomField
212
     */
213
    public function getCustomField (string $gid) {
214
        return $this->load(CustomField::class, $this, "custom_fields/{$gid}");
215
    }
216
217
    /**
218
     * Returns the authenticated User.
219
     *
220
     * @return User
221
     */
222
    public function getMe () {
223
        return $this->getUser('me');
224
    }
225
226
    /**
227
     * Helper to format a request path.
228
     *
229
     * @param string $path
230
     * @param array $query
231
     * @return string
232
     */
233
    protected function getPath (string $path, array $query): string {
234
        return $query ? $path . '?' . http_build_query($query) : $path;
235
    }
236
237
    /**
238
     * Loads and returns a Portfolio.
239
     *
240
     * @param string $gid
241
     * @return null|Portfolio
242
     */
243
    public function getPortfolio (string $gid) {
244
        return $this->load(Portfolio::class, $this, "portfolios/{$gid}");
245
    }
246
247
    /**
248
     * Loads and returns a Project.
249
     *
250
     * @param string $gid
251
     * @return null|Project
252
     */
253
    public function getProject (string $gid) {
254
        return $this->load(Project::class, $this, "projects/{$gid}");
255
    }
256
257
    /**
258
     * Loads and returns a Section.
259
     *
260
     * @param string $gid
261
     * @return null|Section
262
     */
263
    public function getSection (string $gid) {
264
        return $this->load(Section::class, $this, "sections/{$gid}");
265
    }
266
267
    /**
268
     * Loads and returns a Story.
269
     *
270
     * @param string $gid
271
     * @return null|Story
272
     */
273
    public function getStory (string $gid) {
274
        return $this->load(Story::class, $this, "stories/{$gid}");
275
    }
276
277
    /**
278
     * Loads and returns a Tag.
279
     *
280
     * @param string $gid
281
     * @return null|Tag
282
     */
283
    public function getTag (string $gid) {
284
        return $this->load(Tag::class, $this, "tags/{$gid}");
285
    }
286
287
    /**
288
     * Loads and returns a Task.
289
     *
290
     * @param string $gid
291
     * @return null|Task
292
     */
293
    public function getTask (string $gid) {
294
        return $this->load(Task::class, $this, "tasks/{$gid}");
295
    }
296
297
    /**
298
     * Loads and returns a Team.
299
     *
300
     * @param string $gid
301
     * @return null|Team
302
     */
303
    public function getTeam (string $gid) {
304
        return $this->load(Team::class, $this, "teams/{$gid}");
305
    }
306
307
    /**
308
     * Loads and returns a User.
309
     *
310
     * @param string $gid
311
     * @return null|User
312
     */
313
    public function getUser (string $gid) {
314
        return $this->load(User::class, $this, "users/{$gid}");
315
    }
316
317
    /**
318
     * Expands received webhook data as a full event object.
319
     *
320
     * @see https://developers.asana.com/docs/#tocS_WebhookEvent
321
     *
322
     * @param array $data
323
     * @return ProjectEvent|TaskEvent|StoryEvent
324
     */
325
    public function getWebhookEvent (array $data) {
326
        static $classes = [
327
            Project::TYPE => ProjectEvent::class,
328
            Task::TYPE => TaskEvent::class,
329
            Story::TYPE => StoryEvent::class
330
        ];
331
        return $this->factory($classes[$data['type']], $this, $data);
332
    }
333
334
    /**
335
     * @param string $gid
336
     * @return null|Workspace
337
     */
338
    public function getWorkspace (string $gid) {
339
        return $this->load(Workspace::class, $this, "workspaces/{$gid}");
340
    }
341
342
    /**
343
     * @param string $name
344
     * @return null|Workspace
345
     */
346
    public function getWorkspaceByName (string $name) {
347
        foreach ($this->getMe()->getWorkspaces() as $workspace) {
348
            if ($workspace->getName() === $name) {
349
                return $workspace;
350
            }
351
        }
352
        return null;
353
    }
354
355
    /**
356
     * @param string $json
357
     * @return null|array
358
     */
359
    protected function jsonDecode (string $json) {
360
        return json_decode($json, true, JSON_BIGINT_AS_STRING | JSON_THROW_ON_ERROR);
361
    }
362
363
    /**
364
     * @param array $data
365
     * @return string
366
     */
367
    protected function jsonEncode (array $data): string {
368
        return json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
369
    }
370
371
    /**
372
     * Loads the entity found at the given path + query.
373
     *
374
     * The entity is cached.
375
     *
376
     * @param string $class
377
     * @param Api|Data $caller
378
     * @param string $path
379
     * @param array $query
380
     * @return null|mixed|AbstractEntity
381
     */
382
    public function load (string $class, $caller, string $path, array $query = []) {
383
        $key = $this->getPath($path, $query);
384
        return $this->getCache()->get($key, $caller, function($caller) use ($class, $path, $query) {
385
            $data = $this->get($path, $query, ['expand' => 'this']);
386
            return $data ? $this->factory($class, $caller, $data) : null;
387
        });
388
    }
389
390
    /**
391
     * Loads entities found at the given path + query.
392
     *
393
     * The result-set is not cached, but individual entities are.
394
     *
395
     * @param string $class
396
     * @param Api|Data $caller
397
     * @param string $path
398
     * @param array $query `limit` can be included, or excluded to default at `100` (the maximum).
399
     * @param int $pages If positive, stops after this many pages have been fetched.
400
     * @return array|AbstractEntity[]
401
     */
402
    public function loadAll (string $class, $caller, string $path, array $query = [], int $pages = 0) {
403
        $query['opt_expand'] = 'this';
404
        $query += ['limit' => 100];
405
        $path = $this->getPath($path, $query);
406
        $pageCount = 0;
407
        $all = [];
408
        do {
409
            $page = $this->exec('GET', $path);
410
            foreach ($page['data'] ?? [] as $data) {
411
                $all[] = $this->getCache()->get($data['gid'], $caller, function($caller) use ($class, $data) {
412
                    return $this->factory($class, $caller, $data);
413
                });
414
            }
415
            $path = substr($page['next_page']['path'] ?? null, 1); // removes leading slash
416
        } while ($path and ++$pageCount !== $pages);
417
        return $all;
418
    }
419
420
    /**
421
     * HTTP POST
422
     *
423
     * @param string $path
424
     * @param array $data
425
     * @param array $options
426
     * @return null|array
427
     */
428
    public function post (string $path, array $data = [], array $options = []) {
429
        $response = $this->exec('POST', $path, [
430
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
431
            CURLOPT_POSTFIELDS => $this->jsonEncode(['options' => $options, 'data' => $data])
432
        ]);
433
        return $response['data'] ?? null;
434
    }
435
436
    /**
437
     * HTTP PUT
438
     *
439
     * @param string $path
440
     * @param array $data
441
     * @param array $options
442
     * @return null|array
443
     */
444
    public function put (string $path, array $data = [], array $options = []) {
445
        $response = $this->exec('PUT', $path, [
446
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
447
            CURLOPT_POSTFIELDS => $this->jsonEncode(['options' => $options, 'data' => $data])
448
        ]);
449
        return $response['data'] ?? null;
450
    }
451
452
    /**
453
     * Polls for new events. This can be used as an alternative to webhooks.
454
     *
455
     * If the given sync token expired, a `412` is thrown, and a new sync token is set as the message.
456
     *
457
     * @see https://developers.asana.com/docs/#get-events-on-a-resource
458
     *
459
     * @param Project|Task $entity
460
     * @param null|string $token
461
     * @return ProjectEvent[]|TaskEvent[]|StoryEvent[]
462
     */
463
    public function sync ($entity, &$token) {
464
        try {
465
            $response = $this->exec('GET', $this->getPath('events', [
466
                'resource' => $entity->getGid(),
467
                'sync' => $token,
468
                'opt_expand' => 'this'
469
            ]));
470
        }
471
        catch (Error $error) {
472
            if ($error->getCode() !== 412) {
473
                throw $error;
474
            }
475
            throw new Error(412, $this->jsonDecode($error->getMessage())['sync'], $error->getCurlInfo());
476
        }
477
        $token = $response['sync'];
478
        return array_map(function(array $each) {
479
            static $classes = [
480
                Project::TYPE => ProjectEvent::class,
481
                Task::TYPE => TaskEvent::class,
482
                Story::TYPE => StoryEvent::class
483
            ];
484
            return $this->factory($classes[$each['resource']['resource_type']], $this, $each);
485
        }, $response['data'] ?? []);
486
    }
487
488
    /**
489
     * HTTP POST (multipart/form-data)
490
     *
491
     * @param string $path
492
     * @param string $file
493
     * @return null|array
494
     */
495
    public function upload (string $path, string $file) {
496
        $response = $this->exec('POST', $path, [
497
            CURLOPT_POSTFIELDS => ['file' => new CURLFile(realpath($file))] // multipart/form-data
498
        ]);
499
        return $response['data'] ?? null;
500
    }
501
}