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']['description'])) { 258 $this->return['description'] = $docInfo['return']['description']; 259 } 260 } 261 262 /** 263 * Parse a doc block 264 * 265 * @param string $doc 266 * @return array 267 */ 268 protected function parseDocBlock($doc) 269 { 270 // strip asterisks and leading spaces 271 $doc = preg_replace( 272 ['/^[ \t]*\/\*+[ \t]*/m', '/[ \t]*\*+[ \t]*/m', '/\*+\/\s*$/m', '/\s*\/\s*$/m'], 273 ['', '', '', ''], 274 $doc 275 ); 276 277 $doc = trim($doc); 278 279 // get all tags 280 $tags = []; 281 if (preg_match_all('/^@(\w+)\s+(.*)$/m', $doc, $matches, PREG_SET_ORDER)) { 282 foreach ($matches as $match) { 283 $tags[$match[1]][] = trim($match[2]); 284 } 285 } 286 $params = $this->extractDocTags($tags); 287 288 // strip the tags from the doc 289 $doc = preg_replace('/^@(\w+)\s+(.*)$/m', '', $doc); 290 291 [$summary, $description] = sexplode("\n\n", $doc, 2, ''); 292 return array_merge( 293 [ 294 'summary' => trim($summary), 295 'description' => trim($description), 296 'tags' => $tags, 297 ], 298 $params 299 ); 300 } 301 302 /** 303 * Process the param and return tags 304 * 305 * @param array $tags 306 * @return array 307 */ 308 protected function extractDocTags(&$tags) 309 { 310 $result = []; 311 312 if (isset($tags['param'])) { 313 foreach ($tags['param'] as $param) { 314 [$type, $name, $description] = array_map('trim', sexplode(' ', $param, 3, '')); 315 if ($name[0] !== '$') continue; 316 $name = substr($name, 1); 317 318 $result['args'][$name] = [ 319 'type' => $this->cleanTypeHint($type), 320 'description' => $description, 321 ]; 322 } 323 unset($tags['param']); 324 } 325 326 if (isset($tags['return'])) { 327 $return = $tags['return'][0]; 328 [$type, $description] = array_map('trim', sexplode(' ', $return, 2, '')); 329 $result['return'] = [ 330 'type' => $this->cleanTypeHint($type), 331 'description' => $description 332 ]; 333 unset($tags['return']); 334 } 335 336 return $result; 337 } 338 339 /** 340 * Matches the given type hint against the valid options for the remote API 341 * 342 * @param string $hint 343 * @return string 344 */ 345 protected function cleanTypeHint($hint) 346 { 347 $types = explode('|', $hint); 348 foreach ($types as $t) { 349 if (str_ends_with($t, '[]')) { 350 return 'array'; 351 } 352 if ($t === 'boolean' || $t === 'true' || $t === 'false') { 353 return 'bool'; 354 } 355 if (in_array($t, ['array', 'string', 'int', 'double', 'bool', 'null', 'date', 'file'])) { 356 return $t; 357 } 358 } 359 return 'string'; 360 } 361 362 /** 363 * Converts named arguments to positional arguments 364 * 365 * @fixme with PHP 8 we can use named arguments directly using the spread operator 366 * @param array $params 367 * @return array 368 */ 369 protected function namedArgsToPositional($params) 370 { 371 $args = []; 372 373 foreach (array_keys($this->args) as $arg) { 374 if (isset($params[$arg])) { 375 $args[] = $params[$arg]; 376 } else { 377 $args[] = null; 378 } 379 } 380 381 return $args; 382 } 383 384} 385