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