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, 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}
383