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, function ($a, $b) { 96 return $a['code'] <=> $b['code']; 97 }); 98 99 return $codes; 100 } 101 102 103 /** 104 * Add the current DokuWiki instance as a server 105 * 106 * @return void 107 */ 108 protected function addServers() 109 { 110 $this->documentation['servers'] = [ 111 [ 112 'url' => DOKU_URL . 'lib/exe/jsonrpc.php', 113 ], 114 ]; 115 } 116 117 /** 118 * Define the default security schemes 119 * 120 * @return void 121 */ 122 protected function addSecurity() 123 { 124 $this->documentation['components']['securitySchemes'] = [ 125 'basicAuth' => [ 126 'type' => 'http', 127 'scheme' => 'basic', 128 ], 129 'jwt' => [ 130 'type' => 'http', 131 'scheme' => 'bearer', 132 'bearerFormat' => 'JWT', 133 ] 134 ]; 135 $this->documentation['security'] = [ 136 [ 137 'basicAuth' => [], 138 ], 139 [ 140 'jwt' => [], 141 ], 142 ]; 143 } 144 145 /** 146 * Add all methods available in the API to the documentation 147 * 148 * @return void 149 */ 150 protected function addMethods() 151 { 152 $methods = $this->api->getMethods(); 153 154 $this->documentation['paths'] = []; 155 foreach ($methods as $method => $call) { 156 $this->documentation['paths']['/' . $method] = [ 157 'post' => $this->getMethodDefinition($method, $call), 158 ]; 159 } 160 } 161 162 /** 163 * Create the schema definition for a single API method 164 * 165 * @param string $method API method name 166 * @param ApiCall $call The call definition 167 * @return array 168 */ 169 protected function getMethodDefinition(string $method, ApiCall $call) 170 { 171 $description = $call->getDescription(); 172 $links = $call->getDocs()->getTag('link'); 173 if ($links) { 174 $description .= "\n\n**See also:**"; 175 foreach ($links as $link) { 176 $description .= "\n\n* " . $this->generateLink($link); 177 } 178 } 179 180 $retType = $call->getReturn()['type']; 181 $result = array_merge( 182 [ 183 'description' => $call->getReturn()['description'], 184 'examples' => [$this->generateExample('result', $retType->getOpenApiType())], 185 ], 186 $this->typeToSchema($retType) 187 ); 188 189 $definition = [ 190 'operationId' => $method, 191 'summary' => $call->getSummary(), 192 'description' => $description, 193 'tags' => [PhpString::ucwords($call->getCategory())], 194 'requestBody' => [ 195 'required' => true, 196 'content' => [ 197 'application/json' => $this->getMethodArguments($call->getArgs()), 198 ] 199 ], 200 'responses' => [ 201 200 => [ 202 'description' => 'Result', 203 'content' => [ 204 'application/json' => [ 205 'schema' => [ 206 'type' => 'object', 207 'properties' => [ 208 'result' => $result, 209 'error' => [ 210 'type' => 'object', 211 'description' => 'Error object in case of an error', 212 'properties' => [ 213 'code' => [ 214 'type' => 'integer', 215 'description' => 'The error code', 216 'examples' => [0], 217 ], 218 'message' => [ 219 'type' => 'string', 220 'description' => 'The error message', 221 'examples' => ['Success'], 222 ], 223 ], 224 ], 225 ], 226 ], 227 ], 228 ], 229 ], 230 ] 231 ]; 232 233 if ($call->isPublic()) { 234 $definition['security'] = [ 235 new stdClass(), 236 ]; 237 $definition['description'] = 'This method is public and does not require authentication. ' . 238 "\n\n" . $definition['description']; 239 } 240 241 if ($call->getDocs()->getTag('deprecated')) { 242 $definition['deprecated'] = true; 243 $definition['description'] = '**This method is deprecated.** ' . 244 $call->getDocs()->getTag('deprecated')[0] . 245 "\n\n" . $definition['description']; 246 } 247 248 return $definition; 249 } 250 251 /** 252 * Create the schema definition for the arguments of a single API method 253 * 254 * @param array $args The arguments of the method as returned by ApiCall::getArgs() 255 * @return array 256 */ 257 protected function getMethodArguments($args) 258 { 259 if (!$args) { 260 // even if no arguments are needed, we need to define a body 261 // this is to ensure the openapi spec knows that a application/json header is needed 262 return ['schema' => ['type' => 'null']]; 263 } 264 265 $props = []; 266 $reqs = []; 267 $schema = [ 268 'schema' => [ 269 'type' => 'object', 270 'required' => &$reqs, 271 'properties' => &$props 272 ] 273 ]; 274 275 foreach ($args as $name => $info) { 276 $example = $this->generateExample($name, $info['type']->getOpenApiType()); 277 278 $description = $info['description']; 279 if ($info['optional'] && isset($info['default'])) { 280 $description .= ' [_default: `' . json_encode($info['default']) . '`_]'; 281 } 282 283 $props[$name] = array_merge( 284 [ 285 'description' => $description, 286 'examples' => [$example], 287 ], 288 $this->typeToSchema($info['type']) 289 ); 290 if (!$info['optional']) $reqs[] = $name; 291 } 292 293 294 return $schema; 295 } 296 297 /** 298 * Generate an example value for the given parameter 299 * 300 * @param string $name The parameter's name 301 * @param string $type The parameter's type 302 * @return mixed 303 */ 304 protected function generateExample($name, $type) 305 { 306 switch ($type) { 307 case 'integer': 308 if ($name === 'rev') return 0; 309 if ($name === 'revision') return 0; 310 if ($name === 'timestamp') return time() - 60 * 24 * 30 * 2; 311 return 42; 312 case 'boolean': 313 return true; 314 case 'string': 315 if ($name === 'page') return 'playground:playground'; 316 if ($name === 'media') return 'wiki:dokuwiki-128.png'; 317 return 'some-' . $name; 318 case 'array': 319 return ['some-' . $name, 'other-' . $name]; 320 default: 321 return new stdClass(); 322 } 323 } 324 325 /** 326 * Generates a markdown link from a dokuwiki.org URL 327 * 328 * @param $url 329 * @return mixed|string 330 */ 331 protected function generateLink($url) 332 { 333 if (preg_match('/^https?:\/\/(www\.)?dokuwiki\.org\/(.+)$/', $url, $match)) { 334 $name = $match[2]; 335 336 $name = str_replace(['_', '#', ':'], [' ', ' ', ' '], $name); 337 $name = PhpString::ucwords($name); 338 339 return "[$name]($url)"; 340 } else { 341 return $url; 342 } 343 } 344 345 346 /** 347 * Generate the OpenAPI schema for the given type 348 * 349 * @param Type $type 350 * @return array 351 * @todo add example generation here 352 */ 353 public function typeToSchema(Type $type) 354 { 355 $schema = [ 356 'type' => $type->getOpenApiType(), 357 ]; 358 359 // if a sub type is known, define the items 360 if ($schema['type'] === 'array' && $type->getSubType()) { 361 $schema['items'] = $this->typeToSchema($type->getSubType()); 362 } 363 364 // if this is an object, define the properties 365 if ($schema['type'] === 'object') { 366 try { 367 $baseType = $type->getBaseType(); 368 $doc = new DocBlockClass(new ReflectionClass($baseType)); 369 $schema['properties'] = []; 370 foreach ($doc->getPropertyDocs() as $property => $propertyDoc) { 371 $schema['properties'][$property] = array_merge( 372 [ 373 'description' => $propertyDoc->getSummary(), 374 ], 375 $this->typeToSchema($propertyDoc->getType()) 376 ); 377 } 378 } catch (ReflectionException $e) { 379 // The class is not available, so we cannot generate a schema 380 } 381 } 382 383 return $schema; 384 } 385 386} 387