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