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