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