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