You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

383 lines
12 KiB

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