xref: /dokuwiki/inc/Remote/ApiCall.php (revision 8ddd9b69189e5c591f86c9f4314a06ec13778622)
1<?php
2
3namespace dokuwiki\Remote;
4
5
6class ApiCall
7{
8    /** @var callable The method to be called for this endpoint */
9    protected $method;
10
11    /** @var bool Whether this call can be called without authentication */
12    protected bool $isPublic = false;
13
14    /** @var array Metadata on the accepted parameters */
15    protected array $args = [];
16
17    /** @var array Metadata on the return value */
18    protected array $return = [
19        'type' => 'string',
20        'description' => '',
21    ];
22
23    /** @var string The summary of the method */
24    protected string $summary = '';
25
26    /** @var string The description of the method */
27    protected string $description = '';
28
29    /** @var array[] The parsed tags */
30    protected $tags;
31
32    /**
33     * Make the given method available as an API call
34     *
35     * @param string|array $method Either [object,'method'] or 'function'
36     * @throws \ReflectionException
37     */
38    public function __construct($method)
39    {
40        if (!is_callable($method)) {
41            throw new \InvalidArgumentException('Method is not callable');
42        }
43
44        $this->method = $method;
45        $this->parseData();
46    }
47
48    /**
49     * Call the method
50     *
51     * Important: access/authentication checks need to be done before calling this!
52     *
53     * @param array $args
54     * @return mixed
55     */
56    public function __invoke($args)
57    {
58        if (!array_is_list($args)) {
59            $args = $this->namedArgsToPositional($args);
60        }
61        return call_user_func_array($this->method, $args);
62    }
63
64    /**
65     * @return bool
66     */
67    public function isPublic(): bool
68    {
69        return $this->isPublic;
70    }
71
72    /**
73     * @param bool $isPublic
74     * @return $this
75     */
76    public function setPublic(bool $isPublic = true): self
77    {
78        $this->isPublic = $isPublic;
79        return $this;
80    }
81
82
83    /**
84     * @return array
85     */
86    public function getArgs(): array
87    {
88        return $this->args;
89    }
90
91    /**
92     * Limit the arguments to the given ones
93     *
94     * @param string[] $args
95     * @return $this
96     */
97    public function limitArgs($args): self
98    {
99        foreach ($args as $arg) {
100            if (!isset($this->args[$arg])) {
101                throw new \InvalidArgumentException("Unknown argument $arg");
102            }
103        }
104        $this->args = array_intersect_key($this->args, array_flip($args));
105
106        return $this;
107    }
108
109    /**
110     * Set the description for an argument
111     *
112     * @param string $arg
113     * @param string $description
114     * @return $this
115     */
116    public function setArgDescription(string $arg, string $description): self
117    {
118        if (!isset($this->args[$arg])) {
119            throw new \InvalidArgumentException('Unknown argument');
120        }
121        $this->args[$arg]['description'] = $description;
122        return $this;
123    }
124
125    /**
126     * @return array
127     */
128    public function getReturn(): array
129    {
130        return $this->return;
131    }
132
133    /**
134     * Set the description for the return value
135     *
136     * @param string $description
137     * @return $this
138     */
139    public function setReturnDescription(string $description): self
140    {
141        $this->return['description'] = $description;
142        return $this;
143    }
144
145    /**
146     * @return string
147     */
148    public function getSummary(): string
149    {
150        return $this->summary;
151    }
152
153    /**
154     * @param string $summary
155     * @return $this
156     */
157    public function setSummary(string $summary): self
158    {
159        $this->summary = $summary;
160        return $this;
161    }
162
163    /**
164     * @return string
165     */
166    public function getDescription(): string
167    {
168        return $this->description;
169    }
170
171    /**
172     * @param string $description
173     * @return $this
174     */
175    public function setDescription(string $description): self
176    {
177        $this->description = $description;
178        return $this;
179    }
180
181    /**
182     * Returns the docblock tags that have not been processed specially
183     *
184     * @return array[]
185     */
186    public function getTags()
187    {
188        return $this->tags;
189    }
190
191    /**
192     * Returns any data that is available in the given docblock tag
193     *
194     * @param string $tag
195     * @return string[] returns an empty array if no such tags exists
196     */
197    public function getTag($tag)
198    {
199        if(isset($this->tags[$tag])) {
200            return $this->tags[$tag];
201        }
202        return [];
203    }
204
205    /**
206     * Fill in the metadata
207     *
208     * This uses Reflection to inspect the method signature and doc block
209     *
210     * @throws \ReflectionException
211     */
212    protected function parseData()
213    {
214        if (is_array($this->method)) {
215            $reflect = new \ReflectionMethod($this->method[0], $this->method[1]);
216        } else {
217            $reflect = new \ReflectionFunction($this->method);
218        }
219
220        $docInfo = $this->parseDocBlock($reflect->getDocComment());
221        $this->summary = $docInfo['summary'];
222        $this->description = $docInfo['description'];
223        $this->tags = $docInfo['tags'];
224
225        foreach ($reflect->getParameters() as $parameter) {
226            $name = $parameter->name;
227            $realType = $parameter->getType();
228            if ($realType) {
229                $type = $realType->getName();
230            } elseif (isset($docInfo['args'][$name]['type'])) {
231                $type = $docInfo['args'][$name]['type'];
232            } else {
233                $type = 'string';
234            }
235
236            if (isset($docInfo['args'][$name]['description'])) {
237                $description = $docInfo['args'][$name]['description'];
238            } else {
239                $description = '';
240            }
241
242            $this->args[$name] = [
243                'type' => $type,
244                'description' => trim($description),
245            ];
246        }
247
248        $returnType = $reflect->getReturnType();
249        if ($returnType) {
250            $this->return['type'] = $returnType->getName();
251        } elseif (isset($docInfo['return']['type'])) {
252            $this->return['type'] = $docInfo['return']['type'];
253        } else {
254            $this->return['type'] = 'string';
255        }
256
257        if (isset($docInfo['return']['response'])) {
258            $this->return['response'] = $docInfo['return']['response'];
259        }
260
261        if (isset($docInfo['return']['description'])) {
262            $this->return['description'] = $docInfo['return']['description'];
263        }
264    }
265
266    /**
267     * Parse a doc block
268     *
269     * @param string $doc
270     * @return array
271     */
272    protected function parseDocBlock($doc)
273    {
274        // strip asterisks and leading spaces
275        $doc = preg_replace(
276            ['/^[ \t]*\/\*+[ \t]*/m', '/[ \t]*\*+[ \t]*/m', '/\*+\/\s*$/m', '/\s*\/\s*$/m'],
277            ['', '', '', ''],
278            $doc
279        );
280
281        $doc = trim($doc);
282
283        // get all tags
284        $tags = [];
285        if (preg_match_all('/^@(\w+)\s+(.*)$/m', $doc, $matches, PREG_SET_ORDER)) {
286            foreach ($matches as $match) {
287                $tags[$match[1]][] = trim($match[2]);
288            }
289        }
290        $params = $this->extractDocTags($tags);
291
292        // strip the tags from the doc
293        $doc = preg_replace('/^@(\w+)\s+(.*)$/m', '', $doc);
294
295        [$summary, $description] = sexplode("\n\n", $doc, 2, '');
296        return array_merge(
297            [
298                'summary' => trim($summary),
299                'description' => trim($description),
300                'tags' => $tags,
301            ],
302            $params
303        );
304    }
305
306    /**
307     * Process the param and return tags
308     *
309     * @param array $tags
310     * @return array
311     */
312    protected function extractDocTags(&$tags)
313    {
314        $result = [];
315
316        if (isset($tags['param'])) {
317            foreach ($tags['param'] as $param) {
318                [$type, $name, $description] = array_map('trim', sexplode(' ', $param, 3, ''));
319                if ($name[0] !== '$') continue;
320                $name = substr($name, 1);
321
322                $result['args'][$name] = [
323                    'type' => $this->cleanTypeHint($type),
324                    'description' => $description,
325                ];
326            }
327            unset($tags['param']);
328        }
329
330        if (isset($tags['return'])) {
331            $return = $tags['return'][0];
332            [$type, $description] = array_map('trim', sexplode(' ', $return, 2, ''));
333            $result['return'] = [
334                'type' => $this->cleanTypeHint($type),
335                'response' => $type, // uncleaned
336                'description' => $description
337            ];
338            unset($tags['return']);
339        }
340
341        return $result;
342    }
343
344    /**
345     * Matches the given type hint against the valid options for the remote API
346     *
347     * @param string $hint
348     * @return string
349     */
350    protected function cleanTypeHint($hint)
351    {
352        $types = explode('|', $hint);
353        foreach ($types as $t) {
354            if (str_ends_with($t, '[]')) {
355                return 'array';
356            }
357            if ($t === 'boolean' || $t === 'true' || $t === 'false') {
358                return 'bool';
359            }
360            if (in_array($t, ['array', 'string', 'int', 'double', 'bool', 'null', 'date', 'file'])) {
361                return $t;
362            }
363        }
364        return 'string';
365    }
366
367    /**
368     * Converts named arguments to positional arguments
369     *
370     * @fixme with PHP 8 we can use named arguments directly using the spread operator
371     * @param array $params
372     * @return array
373     */
374    protected function namedArgsToPositional($params)
375    {
376        $args = [];
377
378        foreach (array_keys($this->args) as $arg) {
379            if (isset($params[$arg])) {
380                $args[] = $params[$arg];
381            } else {
382                $args[] = null;
383            }
384        }
385
386        return $args;
387    }
388
389}
390