Passed
Push — master ( 023346...878e6c )
by y
02:05
created

Api::load()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 2
eloc 4
nc 1
nop 4
dl 0
loc 5
rs 10
c 1
b 0
f 1
1
<?php
2
3
namespace Helix\Asana;
4
5
use CURLFile;
6
use Helix\Asana\Api\Cache;
7
use Helix\Asana\Api\Logger;
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
18
/**
19
 * API and entity cache access.
20
 *
21
 * @see https://app.asana.com/-/developer_console
22
 */
23
class Api {
24
25
    /**
26
     * @var Cache
27
     */
28
    protected $cache;
29
30
    /**
31
     * @var LoggerInterface
32
     */
33
    protected $logger;
34
35
    /**
36
     * @var string
37
     */
38
    protected $token;
39
40
    /**
41
     * @param string $token
42
     */
43
    public function __construct (string $token) {
44
        $this->token = $token;
45
        $this->cache = new Cache();
46
        $this->logger = new Logger();
47
    }
48
49
    /**
50
     * HTTP DELETE
51
     *
52
     * @param string $path
53
     */
54
    public function delete (string $path): void {
55
        $this->exec('DELETE', $path);
56
    }
57
58
    /**
59
     * @param string $method
60
     * @param string $path
61
     * @param null|array $opts
62
     * @return null|array
63
     * @internal
64
     */
65
    protected function exec (string $method, string $path, array $opts = null) {
66
        $ch = curl_init();
67
        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

67
        curl_setopt_array(/** @scrutinizer ignore-type */ $ch, [
Loading history...
68
            CURLOPT_CUSTOMREQUEST => $method,
69
            CURLOPT_URL => "https://app.asana.com/api/1.0/{$path}",
70
            CURLOPT_USERAGENT => 'hfw/asana',
71
            CURLOPT_FOLLOWLOCATION => false, // HTTP 201 includes Location
72
            CURLOPT_RETURNTRANSFER => true,
73
            CURLOPT_HEADER => true,
74
        ]);
75
        $this->logger->log($method, $path, $opts);
76
        $opts[CURLOPT_HTTPHEADER][] = "Authorization: Bearer {$this->token}";
77
        $opts[CURLOPT_HTTPHEADER][] = 'Accept: application/json';
78
        $opts[CURLOPT_HTTPHEADER][] = 'Expect:'; // prevent http 100
79
        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

79
        curl_setopt_array($ch, /** @scrutinizer ignore-type */ $opts);
Loading history...
80
        RETRY:
81
        $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

81
        $response = curl_exec(/** @scrutinizer ignore-type */ $ch);
Loading history...
82
        $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

82
        $info = curl_getinfo(/** @scrutinizer ignore-type */ $ch);
Loading history...
83
        if ($response === false) {
84
            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_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

84
            throw new Error(curl_errno(/** @scrutinizer ignore-type */ $ch), curl_error($ch), $info);
Loading history...
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

84
            throw new Error(curl_errno($ch), curl_error(/** @scrutinizer ignore-type */ $ch), $info);
Loading history...
85
        }
86
        [$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

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