api = new Api(); } /** * Generate the OpenAPI documentation * * @return string JSON encoded OpenAPI specification */ public function generate() { $this->documentation = []; $this->documentation['openapi'] = '3.1.0'; $this->documentation['info'] = [ 'title' => 'DokuWiki API', 'description' => 'The DokuWiki API OpenAPI specification', 'version' => ((string)ApiCore::API_VERSION), 'x-locale' => 'en-US', ]; $this->addServers(); $this->addSecurity(); $this->addMethods(); return json_encode($this->documentation, JSON_PRETTY_PRINT); } /** * Read all error codes used in ApiCore.php * * This is useful for the documentation, but also for checking if the error codes are unique * * @return array * @todo Getting all classes/methods registered with the API and reading their error codes would be even better * @todo This is super crude. Using the PHP Tokenizer would be more sensible */ public function getErrorCodes() { $lines = file(DOKU_INC . 'inc/Remote/ApiCore.php'); $codes = []; $method = ''; foreach ($lines as $no => $line) { if (preg_match('/ *function (\w+)/', $line, $match)) { $method = $match[1]; } if (preg_match('/^ *throw new RemoteException\(\'([^\']+)\'.*?, (\d+)/', $line, $match)) { $codes[] = [ 'line' => $no, 'exception' => 'RemoteException', 'method' => $method, 'code' => $match[2], 'message' => $match[1], ]; } if (preg_match('/^ *throw new AccessDeniedException\(\'([^\']+)\'.*?, (\d+)/', $line, $match)) { $codes[] = [ 'line' => $no, 'exception' => 'AccessDeniedException', 'method' => $method, 'code' => $match[2], 'message' => $match[1], ]; } } usort($codes, static fn($a, $b) => $a['code'] <=> $b['code']); return $codes; } /** * Add the current DokuWiki instance as a server * * @return void */ protected function addServers() { $this->documentation['servers'] = [ [ 'url' => DOKU_URL . 'lib/exe/jsonrpc.php', ], ]; } /** * Define the default security schemes * * @return void */ protected function addSecurity() { $this->documentation['components']['securitySchemes'] = [ 'basicAuth' => [ 'type' => 'http', 'scheme' => 'basic', ], 'jwt' => [ 'type' => 'http', 'scheme' => 'bearer', 'bearerFormat' => 'JWT', ] ]; $this->documentation['security'] = [ [ 'basicAuth' => [], ], [ 'jwt' => [], ], ]; } /** * Add all methods available in the API to the documentation * * @return void */ protected function addMethods() { $methods = $this->api->getMethods(); $this->documentation['paths'] = []; foreach ($methods as $method => $call) { $this->documentation['paths']['/' . $method] = [ 'post' => $this->getMethodDefinition($method, $call), ]; } } /** * Create the schema definition for a single API method * * @param string $method API method name * @param ApiCall $call The call definition * @return array */ protected function getMethodDefinition(string $method, ApiCall $call) { $description = $call->getDescription(); $links = $call->getDocs()->getTag('link'); if ($links) { $description .= "\n\n**See also:**"; foreach ($links as $link) { $description .= "\n\n* " . $this->generateLink($link); } } $retType = $call->getReturn()['type']; $result = array_merge( [ 'description' => $call->getReturn()['description'], 'examples' => [$this->generateExample('result', $retType->getOpenApiType())], ], $this->typeToSchema($retType) ); $definition = [ 'operationId' => $method, 'summary' => $call->getSummary() ?: $method, 'description' => $description, 'tags' => [PhpString::ucwords($call->getCategory())], 'requestBody' => [ 'required' => true, 'content' => [ 'application/json' => $this->getMethodArguments($call->getArgs()), ] ], 'responses' => [ 200 => [ 'description' => 'Result', 'content' => [ 'application/json' => [ 'schema' => [ 'type' => 'object', 'properties' => [ 'result' => $result, 'error' => [ 'type' => 'object', 'description' => 'Error object in case of an error', 'properties' => [ 'code' => [ 'type' => 'integer', 'description' => 'The error code', 'examples' => [0], ], 'message' => [ 'type' => 'string', 'description' => 'The error message', 'examples' => ['Success'], ], ], ], ], ], ], ], ], ] ]; if ($call->isPublic()) { $definition['security'] = [ new stdClass(), ]; $definition['description'] = 'This method is public and does not require authentication. ' . "\n\n" . $definition['description']; } if ($call->getDocs()->getTag('deprecated')) { $definition['deprecated'] = true; $definition['description'] = '**This method is deprecated.** ' . $call->getDocs()->getTag('deprecated')[0] . "\n\n" . $definition['description']; } return $definition; } /** * Create the schema definition for the arguments of a single API method * * @param array $args The arguments of the method as returned by ApiCall::getArgs() * @return array */ protected function getMethodArguments($args) { if (!$args) { // even if no arguments are needed, we need to define a body // this is to ensure the openapi spec knows that a application/json header is needed return ['schema' => ['type' => 'null']]; } $props = []; $reqs = []; $schema = [ 'schema' => [ 'type' => 'object', 'required' => &$reqs, 'properties' => &$props ] ]; foreach ($args as $name => $info) { $example = $this->generateExample($name, $info['type']->getOpenApiType()); $description = $info['description']; if ($info['optional'] && isset($info['default'])) { $description .= ' [_default: `' . json_encode($info['default'], JSON_THROW_ON_ERROR) . '`_]'; } $props[$name] = array_merge( [ 'description' => $description, 'examples' => [$example], ], $this->typeToSchema($info['type']) ); if (!$info['optional']) $reqs[] = $name; } return $schema; } /** * Generate an example value for the given parameter * * @param string $name The parameter's name * @param string $type The parameter's type * @return mixed */ protected function generateExample($name, $type) { switch ($type) { case 'integer': if ($name === 'rev') return 0; if ($name === 'revision') return 0; if ($name === 'timestamp') return time() - 60 * 24 * 30 * 2; return 42; case 'boolean': return true; case 'string': if ($name === 'page') return 'playground:playground'; if ($name === 'media') return 'wiki:dokuwiki-128.png'; return 'some-' . $name; case 'array': return ['some-' . $name, 'other-' . $name]; default: return new stdClass(); } } /** * Generates a markdown link from a dokuwiki.org URL * * @param $url * @return mixed|string */ protected function generateLink($url) { if (preg_match('/^https?:\/\/(www\.)?dokuwiki\.org\/(.+)$/', $url, $match)) { $name = $match[2]; $name = str_replace(['_', '#', ':'], [' ', ' ', ' '], $name); $name = PhpString::ucwords($name); return "[$name]($url)"; } else { return $url; } } /** * Generate the OpenAPI schema for the given type * * @param Type $type * @return array */ public function typeToSchema(Type $type) { $schema = [ 'type' => $type->getOpenApiType(), ]; // if a sub type is known, define the items if ($schema['type'] === 'array' && $type->getSubType()) { $schema['items'] = $this->typeToSchema($type->getSubType()); } // if this is an object, define the properties if ($schema['type'] === 'object') { try { $baseType = $type->getBaseType(); $doc = new DocBlockClass(new ReflectionClass($baseType)); $schema['properties'] = []; foreach ($doc->getPropertyDocs() as $property => $propertyDoc) { $schema['properties'][$property] = array_merge( [ 'description' => $propertyDoc->getSummary(), ], $this->typeToSchema($propertyDoc->getType()) ); } } catch (ReflectionException $e) { // The class is not available, so we cannot generate a schema } } return $schema; } }