Passed
Push — master ( 52c16b...02e6b9 )
by y
01:47
created

Api::getWorkspaces()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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