Passed
Push — master ( 24e1a2...15b43b )
by y
01:43
created

Api::loadEach()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 14
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

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