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