[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/inc/Remote/OpenApiDoc/ -> OpenAPIGenerator.php (source)

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