1<?php 2 3namespace dokuwiki\Remote\OpenApiDoc; 4 5use dokuwiki\Remote\Api; 6use dokuwiki\Remote\ApiCall; 7use dokuwiki\Remote\ApiCore; 8use dokuwiki\Utf8\PhpString; 9use ReflectionClass; 10use ReflectionException; 11use stdClass; 12 13/** 14 * Generates the OpenAPI documentation for the DokuWiki API 15 */ 16class OpenAPIGenerator 17{ 18 /** @var Api */ 19 protected $api; 20 21 /** @var array Holds the documentation tree while building */ 22 protected $documentation = []; 23 24 /** 25 * OpenAPIGenerator constructor. 26 */ 27 public function __construct() 28 { 29 $this->api = new Api(); 30 } 31 32 /** 33 * Generate the OpenAPI documentation 34 * 35 * @return string JSON encoded OpenAPI specification 36 */ 37 public function generate() 38 { 39 $this->documentation = []; 40 $this->documentation['openapi'] = '3.1.0'; 41 $this->documentation['info'] = [ 42 'title' => 'DokuWiki API', 43 'description' => 'The DokuWiki API OpenAPI specification', 44 'version' => ((string)ApiCore::API_VERSION), 45 'x-locale' => 'en-US', 46 ]; 47 48 $this->addServers(); 49 $this->addSecurity(); 50 $this->addMethods(); 51 52 return json_encode($this->documentation, JSON_PRETTY_PRINT); 53 } 54 55 /** 56 * Read all error codes used in ApiCore.php 57 * 58 * This is useful for the documentation, but also for checking if the error codes are unique 59 * 60 * @return array 61 * @todo Getting all classes/methods registered with the API and reading their error codes would be even better 62 * @todo This is super crude. Using the PHP Tokenizer would be more sensible 63 */ 64 public function getErrorCodes() 65 { 66 $lines = file(DOKU_INC . 'inc/Remote/ApiCore.php'); 67 68 $codes = []; 69 $method = ''; 70 71 foreach ($lines as $no => $line) { 72 if (preg_match('/ *function (\w+)/', $line, $match)) { 73 $method = $match[1]; 74 } 75 if (preg_match('/^ *throw new RemoteException\(\'([^\']+)\'.*?, (\d+)/', $line, $match)) { 76 $codes[] = [ 77 'line' => $no, 78 'exception' => 'RemoteException', 79 'method' => $method, 80 'code' => $match[2], 81 'message' => $match[1], 82 ]; 83 } 84 if (preg_match('/^ *throw new AccessDeniedException\(\'([^\']+)\'.*?, (\d+)/', $line, $match)) { 85 $codes[] = [ 86 'line' => $no, 87 'exception' => 'AccessDeniedException', 88 'method' => $method, 89 'code' => $match[2], 90 'message' => $match[1], 91 ]; 92 } 93 } 94 95 usort($codes, static fn($a, $b) => $a['code'] <=> $b['code']); 96 97 return $codes; 98 } 99 100 101 /** 102 * Add the current DokuWiki instance as a server 103 * 104 * @return void 105 */ 106 protected function addServers() 107 { 108 $this->documentation['servers'] = [ 109 [ 110 'url' => DOKU_URL . 'lib/exe/jsonrpc.php', 111 ], 112 ]; 113 } 114 115 /** 116 * Define the default security schemes 117 * 118 * @return void 119 */ 120 protected function addSecurity() 121 { 122 $this->documentation['components']['securitySchemes'] = [ 123 'basicAuth' => [ 124 'type' => 'http', 125 'scheme' => 'basic', 126 ], 127 'jwt' => [ 128 'type' => 'http', 129 'scheme' => 'bearer', 130 'bearerFormat' => 'JWT', 131 ] 132 ]; 133 $this->documentation['security'] = [ 134 [ 135 'basicAuth' => [], 136 ], 137 [ 138 'jwt' => [], 139 ], 140 ]; 141 } 142 143 /** 144 * Add all methods available in the API to the documentation 145 * 146 * @return void 147 */ 148 protected function addMethods() 149 { 150 $methods = $this->api->getMethods(); 151 152 $this->documentation['paths'] = []; 153 foreach ($methods as $method => $call) { 154 $this->documentation['paths']['/' . $method] = [ 155 'post' => $this->getMethodDefinition($method, $call), 156 ]; 157 } 158 } 159 160 /** 161 * Create the schema definition for a single API method 162 * 163 * @param string $method API method name 164 * @param ApiCall $call The call definition 165 * @return array 166 */ 167 protected function getMethodDefinition(string $method, ApiCall $call) 168 { 169 $description = $call->getDescription(); 170 $links = $call->getDocs()->getTag('link'); 171 if ($links) { 172 $description .= "\n\n**See also:**"; 173 foreach ($links as $link) { 174 $description .= "\n\n* " . $this->generateLink($link); 175 } 176 } 177 178 $retType = $call->getReturn()['type']; 179 $result = array_merge( 180 [ 181 'description' => $call->getReturn()['description'], 182 'examples' => [$this->generateExample('result', $retType->getOpenApiType())], 183 ], 184 $this->typeToSchema($retType) 185 ); 186 187 $definition = [ 188 'operationId' => $method, 189 'summary' => $call->getSummary() ?: $method, 190 'description' => $description, 191 'tags' => [PhpString::ucwords($call->getCategory())], 192 'requestBody' => [ 193 'required' => true, 194 'content' => [ 195 'application/json' => $this->getMethodArguments($call->getArgs()), 196 ] 197 ], 198 'responses' => [ 199 200 => [ 200 'description' => 'Result', 201 'content' => [ 202 'application/json' => [ 203 'schema' => [ 204 'type' => 'object', 205 'properties' => [ 206 'result' => $result, 207 'error' => [ 208 'type' => 'object', 209 'description' => 'Error object in case of an error', 210 'properties' => [ 211 'code' => [ 212 'type' => 'integer', 213 'description' => 'The error code', 214 'examples' => [0], 215 ], 216 'message' => [ 217 'type' => 'string', 218 'description' => 'The error message', 219 'examples' => ['Success'], 220 ], 221 ], 222 ], 223 ], 224 ], 225 ], 226 ], 227 ], 228 ] 229 ]; 230 231 if ($call->isPublic()) { 232 $definition['security'] = [ 233 new stdClass(), 234 ]; 235 $definition['description'] = 'This method is public and does not require authentication. ' . 236 "\n\n" . $definition['description']; 237 } 238 239 if ($call->getDocs()->getTag('deprecated')) { 240 $definition['deprecated'] = true; 241 $definition['description'] = '**This method is deprecated.** ' . 242 $call->getDocs()->getTag('deprecated')[0] . 243 "\n\n" . $definition['description']; 244 } 245 246 return $definition; 247 } 248 249 /** 250 * Create the schema definition for the arguments of a single API method 251 * 252 * @param array $args The arguments of the method as returned by ApiCall::getArgs() 253 * @return array 254 */ 255 protected function getMethodArguments($args) 256 { 257 if (!$args) { 258 // even if no arguments are needed, we need to define a body 259 // this is to ensure the openapi spec knows that a application/json header is needed 260 return ['schema' => ['type' => 'null']]; 261 } 262 263 $props = []; 264 $reqs = []; 265 $schema = [ 266 'schema' => [ 267 'type' => 'object', 268 'required' => &$reqs, 269 'properties' => &$props 270 ] 271 ]; 272 273 foreach ($args as $name => $info) { 274 $example = $this->generateExample($name, $info['type']->getOpenApiType()); 275 276 $description = $info['description']; 277 if ($info['optional'] && isset($info['default'])) { 278 $description .= ' [_default: `' . json_encode($info['default'], JSON_THROW_ON_ERROR) . '`_]'; 279 } 280 281 $props[$name] = array_merge( 282 [ 283 'description' => $description, 284 'examples' => [$example], 285 ], 286 $this->typeToSchema($info['type']) 287 ); 288 if (!$info['optional']) $reqs[] = $name; 289 } 290 291 292 return $schema; 293 } 294 295 /** 296 * Generate an example value for the given parameter 297 * 298 * @param string $name The parameter's name 299 * @param string $type The parameter's type 300 * @return mixed 301 */ 302 protected function generateExample($name, $type) 303 { 304 switch ($type) { 305 case 'integer': 306 if ($name === 'rev') return 0; 307 if ($name === 'revision') return 0; 308 if ($name === 'timestamp') return time() - 60 * 24 * 30 * 2; 309 return 42; 310 case 'boolean': 311 return true; 312 case 'string': 313 if ($name === 'page') return 'playground:playground'; 314 if ($name === 'media') return 'wiki:dokuwiki-128.png'; 315 return 'some-' . $name; 316 case 'array': 317 return ['some-' . $name, 'other-' . $name]; 318 default: 319 return new stdClass(); 320 } 321 } 322 323 /** 324 * Generates a markdown link from a dokuwiki.org URL 325 * 326 * @param $url 327 * @return mixed|string 328 */ 329 protected function generateLink($url) 330 { 331 if (preg_match('/^https?:\/\/(www\.)?dokuwiki\.org\/(.+)$/', $url, $match)) { 332 $name = $match[2]; 333 334 $name = str_replace(['_', '#', ':'], [' ', ' ', ' '], $name); 335 $name = PhpString::ucwords($name); 336 337 return "[$name]($url)"; 338 } else { 339 return $url; 340 } 341 } 342 343 344 /** 345 * Generate the OpenAPI schema for the given type 346 * 347 * @param Type $type 348 * @return array 349 */ 350 public function typeToSchema(Type $type) 351 { 352 $schema = [ 353 'type' => $type->getOpenApiType(), 354 ]; 355 356 // if a sub type is known, define the items 357 if ($schema['type'] === 'array' && $type->getSubType()) { 358 $schema['items'] = $this->typeToSchema($type->getSubType()); 359 } 360 361 // if this is an object, define the properties 362 if ($schema['type'] === 'object') { 363 try { 364 $baseType = $type->getBaseType(); 365 $doc = new DocBlockClass(new ReflectionClass($baseType)); 366 $schema['properties'] = []; 367 foreach ($doc->getPropertyDocs() as $property => $propertyDoc) { 368 $schema['properties'][$property] = array_merge( 369 [ 370 'description' => $propertyDoc->getSummary(), 371 ], 372 $this->typeToSchema($propertyDoc->getType()) 373 ); 374 } 375 } catch (ReflectionException $e) { 376 // The class is not available, so we cannot generate a schema 377 } 378 } 379 380 return $schema; 381 } 382} 383