dokuwiki/inc/Remote/OpenApiDoc/OpenAPIGenerator.php

<?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),
            '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;
    }
}