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