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