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