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