diff options
Diffstat (limited to 'inc/Remote/OpenApiDoc/OpenAPIGenerator.php')
-rw-r--r-- | inc/Remote/OpenApiDoc/OpenAPIGenerator.php | 337 |
1 files changed, 337 insertions, 0 deletions
diff --git a/inc/Remote/OpenApiDoc/OpenAPIGenerator.php b/inc/Remote/OpenApiDoc/OpenAPIGenerator.php new file mode 100644 index 000000000..c61ce0bb8 --- /dev/null +++ b/inc/Remote/OpenApiDoc/OpenAPIGenerator.php @@ -0,0 +1,337 @@ +<?php + +namespace dokuwiki\Remote\OpenApiDoc; + +use dokuwiki\Remote\Api; +use dokuwiki\Remote\ApiCall; +use dokuwiki\Remote\ApiCore; +use dokuwiki\Utf8\PhpString; +use ReflectionClass; +use ReflectionException; +use stdClass; + +/** + * Generates the OpenAPI documentation for the DokuWiki API + */ +class OpenAPIGenerator +{ + /** @var Api */ + protected $api; + + /** @var array Holds the documentation tree while building */ + protected $documentation = []; + + /** + * OpenAPIGenerator constructor. + */ + public function __construct() + { + $this->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), + ]; + + $this->addServers(); + $this->addSecurity(); + $this->addMethods(); + + return json_encode($this->documentation, JSON_PRETTY_PRINT); + } + + /** + * 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(), + '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']) . '`_]'; + } + + $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 + * @todo add example generation here + */ + 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; + } + +} |