Passed
Push — master ( 262356...2c0ae5 )
by y
01:43
created

Api::call()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 16
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 14
nc 1
nop 3
dl 0
loc 16
rs 9.7998
c 0
b 0
f 0
1
<?php
2
3
namespace Helix\Asana;
4
5
use Generator;
6
use Helix\Asana\Api\AsanaError;
7
use Helix\Asana\Api\Pool;
8
use Helix\Asana\Base\AbstractEntity;
9
use Helix\Asana\Base\Data;
10
use Helix\Asana\Project\Section;
11
use Helix\Asana\Task\Attachment;
12
use Helix\Asana\Task\Story;
13
use Helix\Asana\User\TaskList;
14
use Helix\Asana\Webhook\ProjectWebhook;
15
use Helix\Asana\Webhook\TaskWebhook;
16
use Psr\Log\LoggerInterface;
17
use Psr\Log\NullLogger;
18
19
/**
20
 * API access.
21
 *
22
 * @see https://app.asana.com/-/developer_console
23
 */
24
class Api {
25
26
    /**
27
     * @var LoggerInterface
28
     */
29
    protected $log;
30
31
    /**
32
     * @var Pool
33
     */
34
    protected $pool;
35
36
    /**
37
     * @var string
38
     */
39
    protected $token;
40
41
    /**
42
     * @param string $token
43
     * @param null|Pool $pool
44
     */
45
    public function __construct (string $token, Pool $pool = null) {
46
        $this->token = $token;
47
        $this->pool = $pool ?? new Pool();
48
    }
49
50
    /**
51
     * cURL transport.
52
     *
53
     * @param string $method
54
     * @param string $path
55
     * @param array $curlOpts
56
     * @return null|array
57
     */
58
    public function call (string $method, string $path, array $curlOpts = []) {
59
        $this->getLog()->log(LOG_DEBUG, "{$method} {$path}", $curlOpts);
60
        $ch = curl_init();
61
        curl_setopt_array($ch, [
62
            CURLOPT_CUSTOMREQUEST => $method,
63
            CURLOPT_URL => "https://app.asana.com/api/1.0/{$path}",
64
            CURLOPT_USERAGENT => 'hfw/asana',
65
            CURLOPT_FOLLOWLOCATION => false, // HTTP 201 includes Location
66
            CURLOPT_RETURNTRANSFER => true,
67
            CURLOPT_HEADER => true,
68
        ]);
69
        $curlOpts[CURLOPT_HTTPHEADER][] = "Authorization: Bearer {$this->token}";
70
        $curlOpts[CURLOPT_HTTPHEADER][] = 'Accept: application/json';
71
        $curlOpts[CURLOPT_HTTPHEADER][] = 'Expect:'; // prevent http 100
72
        curl_setopt_array($ch, $curlOpts);
73
        return $this->call_loop($ch);
74
    }
75
76
    /**
77
     * @param resource $ch
78
     * @return null|array
79
     */
80
    protected function call_loop ($ch) {
81
        $response = curl_exec($ch);
82
        $info = curl_getinfo($ch);
83
        if ($response === false) {
84
            throw new AsanaError(curl_errno($ch), curl_error($ch), $info);
85
        }
86
        [$head, $body] = explode("\r\n\r\n", $response, 2);
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 ($info['http_code']) {
91
            case 200:
92
            case 201:
93
                return json_decode($body, true, JSON_BIGINT_AS_STRING | JSON_THROW_ON_ERROR);
94
            case 404:
95
                return null;
96
            case 429:
97
                $this->getLog()->log(LOG_DEBUG, "Retry-After: {$headers['Retry-After']}");
98
                sleep($headers['Retry-After']);
99
                return $this->call_loop($ch);
100
            default:
101
                $this->getLog()->log(LOG_ERR, "Asana {$info['http_code']}: {$body}", $info);
102
            case 412: // normal sync error. skip log.
103
                throw new AsanaError($info['http_code'], $body, $info);
104
        }
105
    }
106
107
    /**
108
     * `HTTP DELETE`
109
     *
110
     * @param string $path
111
     */
112
    public function delete (string $path): void {
113
        $this->call('DELETE', $path);
114
    }
115
116
    /**
117
     * The central point of object creation.
118
     *
119
     * This can be overridden to return custom extensions.
120
     *
121
     * @param Api|Data $caller
122
     * @param string $class
123
     * @param array $data
124
     * @return mixed|Data|AbstractEntity
125
     */
126
    public function factory ($caller, string $class, array $data = []) {
127
        return new $class($caller, $data);
128
    }
129
130
    /**
131
     * `HTTP GET`
132
     *
133
     * @param string $path
134
     * @param array $query
135
     * @return null|array
136
     */
137
    public function get (string $path, array $query = []) {
138
        return $this->call('GET', $path . '?' . http_build_query($query))['data'] ?? null;
139
    }
140
141
    /**
142
     * Loads an {@link Attachment}.
143
     *
144
     * @param string $gid
145
     * @return null|Attachment
146
     */
147
    public function getAttachment (string $gid) {
148
        return $this->load($this, Attachment::class, "attachments/{$gid}");
149
    }
150
151
    /**
152
     * Loads a {@link CustomField}.
153
     *
154
     * @param string $gid
155
     * @return null|CustomField
156
     */
157
    public function getCustomField (string $gid) {
158
        return $this->load($this, CustomField::class, "custom_fields/{$gid}");
159
    }
160
161
    /**
162
     * The API user's default workspace.
163
     *
164
     * You should only rely on this if the API user is in exactly one workspace.
165
     *
166
     * @return Workspace
167
     */
168
    public function getDefaultWorkspace () {
169
        return $this->getMe()->getDefaultWorkspace();
170
    }
171
172
    /**
173
     * @return LoggerInterface
174
     */
175
    public function getLog () {
176
        return $this->log ?? $this->log = new NullLogger();
177
    }
178
179
    /**
180
     * @return User
181
     */
182
    public function getMe () {
183
        return $this->getUser('me');
184
    }
185
186
    /**
187
     * @return Pool
188
     */
189
    public function getPool () {
190
        return $this->pool;
191
    }
192
193
    /**
194
     * Loads a {@link Portfolio}.
195
     *
196
     * @param string $gid
197
     * @return null|Portfolio
198
     */
199
    public function getPortfolio (string $gid) {
200
        return $this->load($this, Portfolio::class, "portfolios/{$gid}");
201
    }
202
203
    /**
204
     * Loads a {@link Project}.
205
     *
206
     * @param string $gid
207
     * @return null|Project
208
     */
209
    public function getProject (string $gid) {
210
        return $this->load($this, Project::class, "projects/{$gid}");
211
    }
212
213
    /**
214
     * @param string $gid
215
     * @return null|ProjectWebhook
216
     */
217
    public function getProjectWebhook (string $gid) {
218
        return $this->load($this, ProjectWebhook::class, "webhooks/{$gid}");
219
    }
220
221
    /**
222
     * Loads a {@link Section}.
223
     *
224
     * @param string $gid
225
     * @return null|Section
226
     */
227
    public function getSection (string $gid) {
228
        return $this->load($this, Section::class, "sections/{$gid}");
229
    }
230
231
    /**
232
     * Loads a {@link Story}.
233
     *
234
     * @param string $gid
235
     * @return null|Story
236
     */
237
    public function getStory (string $gid) {
238
        return $this->load($this, Story::class, "stories/{$gid}");
239
    }
240
241
    /**
242
     * Loads a {@link Tag}.
243
     *
244
     * @param string $gid
245
     * @return null|Tag
246
     */
247
    public function getTag (string $gid) {
248
        return $this->load($this, Tag::class, "tags/{$gid}");
249
    }
250
251
    /**
252
     * Loads a {@link Task}.
253
     *
254
     * @param string $gid
255
     * @return null|Task
256
     */
257
    public function getTask (string $gid) {
258
        return $this->load($this, Task::class, "tasks/{$gid}");
259
    }
260
261
    /**
262
     * Loads a {@link TaskList}.
263
     *
264
     * @param string $gid
265
     * @return null|TaskList
266
     */
267
    public function getTaskList (string $gid) {
268
        return $this->load($this, TaskList::class, "user_task_lists/{$gid}");
269
    }
270
271
    /**
272
     * @param string $gid
273
     * @return null|TaskWebhook
274
     */
275
    public function getTaskWebhook (string $gid) {
276
        return $this->load($this, TaskWebhook::class, "webhooks/{$gid}");
277
    }
278
279
    /**
280
     * Loads a {@link Team}.
281
     *
282
     * @param string $gid
283
     * @return null|Team
284
     */
285
    public function getTeam (string $gid) {
286
        return $this->load($this, Team::class, "teams/{$gid}");
287
    }
288
289
    /**
290
     * Loads a {@link User}.
291
     *
292
     * @param string $gid
293
     * @return null|User
294
     */
295
    public function getUser (string $gid) {
296
        return $this->load($this, User::class, "users/{$gid}");
297
    }
298
299
    /**
300
     * Expands received webhook data as a full event object.
301
     *
302
     * @see https://developers.asana.com/docs/event
303
     *
304
     * @param array $data
305
     * @return Event
306
     */
307
    public function getWebhookEvent (array $data) {
308
        return $this->factory($this, Event::class, $data);
309
    }
310
311
    /**
312
     * Loads a {@link Workspace}.
313
     *
314
     * @param string $gid
315
     * @return null|Workspace
316
     */
317
    public function getWorkspace (string $gid) {
318
        return $this->load($this, Workspace::class, "workspaces/{$gid}");
319
    }
320
321
    /**
322
     * Loads the entity found at the given path + query.
323
     *
324
     * @param Api|Data $caller
325
     * @param string $class
326
     * @param string $path
327
     * @param array $query
328
     * @return null|mixed|AbstractEntity
329
     */
330
    public function load ($caller, string $class, string $path, array $query = []) {
331
        $key = rtrim($path . '?' . http_build_query($query), '?');
332
        $query['opt_expand'] = 'this';
333
        return $this->pool->get($key, $caller, function($caller) use ($class, $path, $query) {
334
            if ($data = $this->get($path, $query)) {
335
                return $this->factory($caller, $class, $data);
336
            }
337
            return null;
338
        });
339
    }
340
341
    /**
342
     * All results from {@link loadEach()}
343
     *
344
     * @param Api|Data $caller
345
     * @param string $class
346
     * @param string $path
347
     * @param array $query
348
     * @return array|AbstractEntity[]
349
     */
350
    public function loadAll ($caller, string $class, string $path, array $query = []) {
351
        return iterator_to_array($this->loadEach(...func_get_args()));
352
    }
353
354
    /**
355
     * Loads and yields each entity found at the given path + query.
356
     *
357
     * The result-set is not pooled, but individual entities are.
358
     *
359
     * @param Api|Data $caller
360
     * @param string $class
361
     * @param string $path
362
     * @param array $query `limit` can exceed `100` here.
363
     * @return Generator|AbstractEntity[]
364
     */
365
    public function loadEach ($caller, string $class, string $path, array $query = []) {
366
        $query['opt_expand'] = 'this';
367
        $remain = $query['limit'] ?? PHP_INT_MAX;
368
        do {
369
            $query['limit'] = min($remain, 100);
370
            $page = $this->call('GET', $path . '?' . http_build_query($query));
371
            foreach ($page['data'] as $each) {
372
                yield $this->pool->get($each['gid'], $caller, function($caller) use ($class, $each) {
373
                    return $this->factory($caller, $class, $each);
374
                });
375
                $remain--;
376
            }
377
            $query['offset'] = $page['next_page']['offset'] ?? null;
378
        } while ($remain and $query['offset']);
379
    }
380
381
    /**
382
     * `HTTP POST`
383
     *
384
     * @param string $path
385
     * @param array $data
386
     * @param array $options
387
     * @return null|array
388
     */
389
    public function post (string $path, array $data = [], array $options = []) {
390
        return $this->call('POST', $path, [
391
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
392
                CURLOPT_POSTFIELDS => json_encode([
393
                    'options' => $options,
394
                    'data' => $data
395
                ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)
396
            ])['data'] ?? null;
397
    }
398
399
    /**
400
     * `HTTP PUT`
401
     *
402
     * @param string $path
403
     * @param array $data
404
     * @param array $options
405
     * @return null|array
406
     */
407
    public function put (string $path, array $data = [], array $options = []) {
408
        return $this->call('PUT', $path, [
409
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
410
                CURLOPT_POSTFIELDS => json_encode([
411
                    'options' => $options,
412
                    'data' => $data
413
                ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)
414
            ])['data'] ?? null;
415
    }
416
417
    /**
418
     * @param LoggerInterface $log
419
     */
420
    public function setLog (LoggerInterface $log) {
421
        $this->log = $log;
422
    }
423
}