Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1441 ariadna 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
namespace core\router\schema;
18
 
19
use coding_exception;
20
use core\router\response\invalid_parameter_response;
21
use core\router\response\not_found_response;
22
use core\router\route;
23
use core\router\route_loader_interface;
24
use core\router\schema\objects\type_base;
25
use core\router\schema\response\response;
26
use core\router\util;
27
use core\url;
28
use stdClass;
29
 
30
/**
31
 * Moodle OpenApi Specification class.
32
 *
33
 * @package    core
34
 * @copyright  2023 Andrew Lyons <andrew@nicols.co.uk>
35
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36
 */
37
class specification implements
38
    \JsonSerializable
39
{
40
    /** @var string The OpenAPI version represented in this specification */
41
    public const OPENAPI_VERSION = '3.1.0';
42
 
43
    /** @var stdClass The data which forms the specification */
44
    protected stdClass $data;
45
 
46
    /** @var bool Whether the data has been finalised for output yet */
47
    protected bool $finalised = false;
48
 
49
    /** @var callable[] A list of common responses that are frequently found in paths */
50
    protected array $commonresponses = [];
51
 
52
    /**
53
     * Constructor to configure base information.
54
     */
55
    public function __construct() {
56
        $this->data = (object) [
57
            'openapi' => self::OPENAPI_VERSION,
58
            'info' => (object) [
59
                'title' => 'Moodle LMS',
60
                'description' => 'REST API for Moodle LMS',
61
                'summary' => 'Moodle LMS REST API',
62
                'license' => (object) [
63
                    'name' => 'GNU GPL v3 or later',
64
                    'url' => 'https://www.gnu.org/licenses/gpl-3.0.html',
65
                ],
66
            ],
67
 
68
            // Servers are added during output.
69
            'servers' => [],
70
 
71
            // Paths are added after initialisation.
72
            'paths' => (object) [],
73
 
74
            'components' => (object) [
75
                // Note: This list must be kept in-sync with add_component.
76
                'schemas' => (object) [],
77
                'responses' => (object) [],
78
                'parameters' => (object) [],
79
                'examples' => (object) [],
80
                'requestBodies' => (object) [],
81
                'headers' => (object) [],
82
 
83
                // The add_component method does not support securitySchemes because we hard-code these.
84
                'securitySchemes' => (object) [
85
                    'api_key' => (object) [
86
                        'type' => 'apiKey',
87
                        'name' => 'api_key',
88
                        'in' => parameter::IN_HEADER,
89
                    ],
90
                    'cookie' => (object) [
91
                        'type' => 'apiKey',
92
                        'name' => 'MoodleSession',
93
                        'in' => parameter::IN_COOKIE,
94
                    ],
95
                    // TODO MDL-82242: Add support for OAuth2.
96
                ],
97
            ],
98
            // TODO MDL-82242: Add support for OAuth2.
99
            'security' => [
100
                (object) [
101
                    'api_key' => [],
102
                    'cookie' => [],
103
                ],
104
            ],
105
            'externalDocs' => (object) [
106
                'description' => 'Moodle Developer Docs',
107
                'url' => 'https://moodledev.io',
108
            ],
109
        ];
110
 
111
        $this->generate_common_responses();
112
    }
113
 
114
    /**
115
     * Generate the callables for common responses that are frequently found in paths.
116
     *
117
     * @return specification
118
     */
119
    protected function generate_common_responses(): self {
120
        $invalidresponse = new invalid_parameter_response();
121
        $notfoundresponse = new not_found_response();
122
 
123
        $this->commonresponses[] = function (
124
            route $route,
125
            stdClass $data
126
        ) use (
127
            $invalidresponse,
128
            $notfoundresponse,
129
        ): stdClass {
130
            if ($route->has_any_validatable_parameter()) {
131
                if (!array_key_exists($invalidresponse::get_exception_status_code(), $data->responses)) {
132
                    $data->responses[$invalidresponse::get_exception_status_code()] = $invalidresponse->get_openapi_schema($this);
133
                }
134
                if (!array_key_exists($notfoundresponse::get_exception_status_code(), $data->responses)) {
135
                    $data->responses[$notfoundresponse::get_exception_status_code()] = $notfoundresponse->get_openapi_schema($this);
136
                }
137
            }
138
 
139
            return $data;
140
        };
141
        return $this;
142
    }
143
 
144
    /**
145
     * Get the common request responses.
146
     *
147
     * @return callable[]
148
     */
149
    public function get_common_request_responses(): array {
150
        if (empty($this->commonresponses)) {
151
            $this->generate_common_responses(); // @codeCoverageIgnore
152
        }
153
 
154
        return $this->commonresponses;
155
    }
156
 
157
    /**
158
     * Finalise the data and prepare it for consumption.
159
     */
160
    protected function finalise(): self {
161
        global $CFG;
162
 
163
        if ($this->finalised) {
164
            return $this;
165
        }
166
 
167
        // Add the Moodle site version here.
168
        $this->data->info->version = $CFG->version;
169
 
170
        // Add the server configuration.
171
        $serverdescription = str_replace("'", "\'", format_string(get_site()->fullname));
172
        $this->add_server(
173
            url::routed_path(route_loader_interface::ROUTE_GROUP_API)->out(),
174
            $serverdescription,
175
        );
176
 
177
        $this->finalised = true;
178
 
179
        return $this;
180
    }
181
 
182
    /**
183
     * Implement the json serialisation interface.
184
     *
185
     * @return mixed
186
     */
187
    public function jsonSerialize(): mixed {
188
        return $this->get_schema();
189
    }
190
 
191
    /**
192
     * Get the OpenAPI schema.
193
     *
194
     * @return stdClass
195
     */
196
    final public function get_schema(): stdClass {
197
        return $this
198
            ->finalise()
199
            ->data;
200
    }
201
 
202
    /**
203
     * Add a component to the components object.
204
     *
205
     * https://spec.openapis.org/oas/v3.1.0#components-object
206
     *
207
     * Note: The following component types are supported:
208
     *
209
     * - schemas
210
     * - responses
211
     * - parameters
212
     * - examples
213
     * - requestBodies
214
     * - headers
215
     *
216
     * At this time, other component types are not supported.
217
     *
218
     * @param openapi_base $object
219
     * @return specification
220
     * @throws coding_exception If the component type is unknown.
221
     */
222
    public function add_component(openapi_base $object): self {
223
        match (true) {
224
            is_a($object, header_object::class) => $this->add_header($object),
225
            is_a($object, parameter::class) => $this->add_parameter($object),
226
            is_a($object, response::class) => $this->add_response($object),
227
            is_a($object, example::class) => $this->add_example($object),
228
            is_a($object, request_body::class) => $this->add_request_body($object),
229
            is_a($object, type_base::class) => $this->add_schema($object),
230
            default => throw new coding_exception("Unknown object type."),
231
        };
232
 
233
        return $this;
234
    }
235
 
236
    /**
237
     * Add a server to the specification.
238
     *
239
     * @param string $url The URL of the API base
240
     * @param string $description
241
     * @return specification
242
     */
243
    public function add_server(
244
        string $url,
245
        string $description,
246
    ): self {
247
        $this->data->servers[] = (object) [
248
            'url' => $url,
249
            'description' => $description,
250
        ];
251
 
252
        return $this;
253
    }
254
 
255
    /**
256
     * Add an API Path.
257
     *
258
     * @param string $component The Moodle component
259
     * @param route $route The route which handles this request
260
     * @return specification
261
     */
262
    public function add_path(
263
        string $component,
264
        route $route,
265
    ): self {
266
        // Compile the final path, complete with component prefix.
267
        $path = "/";
268
        $path .= util::normalise_component_path($component);
269
        $path .= $route->get_path();
270
 
271
        // Helper to add the path to the specification.
272
        // Note: We use this helper because OpenAPI does not support optional parameters.
273
        // Therefore we must handle that in Moodle, adding path variants with and without each optional parameter.
274
        $addpath = function (string $path) use ($route, $component) {
275
            $path = str_replace(
276
                [
277
                    // Remove the optional parameters delimiters from the path.
278
                    '[',
279
                    ']',
280
 
281
                    // Remove the greedy and non-greedy unlimited delimters from the path too.
282
                    // These are a FastRoute feature not compatible with OpenAPI.
283
                    ':.*?',
284
                    ':.*',
285
                ],
286
                '',
287
                $path,
288
            );
289
 
290
            // Get the OpenAPI description for this path with the updated path.
291
            $pathdocs = $this->get_openapi_schema_for_route(
292
                route: $route,
293
                component: $component,
294
                path: $path,
295
            );
296
 
297
            if (!property_exists($this->data->paths, $path)) {
298
                $this->data->paths->$path = (object) [];
299
            }
300
 
301
            foreach ((array) $pathdocs as $method => $methoddata) {
302
                // Copy each of the pathdocs into place.
303
                $this->data->paths->{$path}->{$method} = $methoddata;
304
            }
305
        };
306
 
307
        // First add the entire path complete with all optional parameters.
308
        // The optional parameter delimiters are `[` and `]`, and are removed in `$addpath`.
309
        $addpath($path);
310
 
311
        // Check for any optional parameters.
312
        // OpenAPI does not support optional parameters so we have to duplicate routes instead.
313
        // We can determine if this is optional if there is any `[` character before it in the path.
314
        // There can be no required parameter after any optional parameter.
315
        $optionalparameters = array_filter(
316
            array: $route->get_path_parameters(),
317
            callback: fn ($parameter) => !$parameter->is_required($route),
318
        );
319
 
320
        if (!empty($optionalparameters)) {
321
            // Go through the path from end to start removing optional parameters and adding them to the path list.
322
            while (strrpos($path, '[') !== false) {
323
                $path = substr($path, 0, strrpos($path, '['));
324
                $addpath($path);
325
            }
326
        }
327
 
328
        return $this;
329
    }
330
 
331
    /**
332
     * Add a schema to the shared components section of the specification.
333
     *
334
     * @param type_base $schema
335
     * @return specification
336
     */
337
    protected function add_schema(
338
        type_base $schema,
339
    ): self {
340
        $name = $schema->get_reference(qualify: false);
341
        if (!property_exists($this->data->components->schemas, $name)) {
342
            $this->data->components->schemas->$name = $schema->get_openapi_description($this);
343
        }
344
 
345
        return $this;
346
    }
347
 
348
    /**
349
     * Add a schema to the shared components section of the specification.
350
     *
351
     * @param parameter $parameter
352
     * @return specification
353
     */
354
    protected function add_parameter(
355
        parameter $parameter,
356
    ): self {
357
        $name = $parameter->get_reference(qualify: false);
358
            $this->data->components->parameters->$name = $parameter->get_openapi_description($this);
359
 
360
        return $this;
361
    }
362
 
363
    /**
364
     * Add a header to the shared components section of the specification.
365
     *
366
     * @param header_object $header
367
     * @return self
368
     */
369
    protected function add_header(
370
        header_object $header,
371
    ): self {
372
        $name = $header->get_reference(qualify: false);
373
        $this->data->components->headers->$name = $header->get_openapi_description($this);
374
 
375
        return $this;
376
    }
377
 
378
    /**
379
     * Add a response to the shared components section of the specification.
380
     *
381
     * @param response $response
382
     * @return specification
383
     */
384
    protected function add_response(
385
        response $response,
386
    ): self {
387
        $name = $response->get_reference(qualify: false);
388
        $this->data->components->responses->$name = $response->get_openapi_description($this);
389
 
390
        return $this;
391
    }
392
 
393
    /**
394
     * Add an example to the shared components section of the specification.
395
     *
396
     * @param example $example
397
     * @return specification
398
     */
399
    protected function add_example(
400
        example $example,
401
    ): self {
402
        $name = $example->get_reference(qualify: false);
403
        $this->data->components->examples->$name = $example->get_openapi_description($this);
404
 
405
        return $this;
406
    }
407
 
408
    /**
409
     * Add a request body to the shared components section of the specification.
410
     *
411
     * @param request_body $body
412
     * @return specification
413
     */
414
    protected function add_request_body(
415
        request_body $body,
416
    ): self {
417
        $name = $body->get_reference(qualify: false);
418
        $this->data->components->requestBodies->$name = $body->get_openapi_description($this);
419
 
420
        return $this;
421
    }
422
 
423
    /**
424
     * Check whether a reference is defined
425
     *
426
     * @param string $ref
427
     * @return bool
428
     */
429
    public function is_reference_defined(
430
        string $ref,
431
    ): bool {
432
        if (!str_starts_with($ref, '#/components/')) {
433
            return false;
434
        }
435
 
436
        // Remove the leading #/components/ part.
437
        $ref = substr($ref, strlen('#/components/'));
438
 
439
        // Split the path and name.
440
        [$path, $name] = explode('/', $ref, 2);
441
 
442
        if (!property_exists($this->data->components, $path)) {
443
            return false;
444
        }
445
 
446
        return property_exists($this->data->components->$path, $name);
447
    }
448
 
449
 
450
    /**
451
     * Get the OpenAPI description for this route.
452
     *
453
     * @param route $route
454
     * @param string $component
455
     * @param string $path
456
     * @return stdClass
457
     */
458
    public function get_openapi_schema_for_route(
459
        route $route,
460
        string $component,
461
        string $path,
462
    ): stdClass {
463
        $data = (object) [
464
            'description' => $route->description,
465
            'summary' => $route->title,
466
            'tags' => [$component, ...$route->tags],
467
            'parameters' => [],
468
            'responses' => [],
469
        ];
470
 
471
        if ($route->get_request_body()) {
472
            $data->requestBody = $route->get_request_body()->get_openapi_schema(
473
                api: $this,
474
                path: $path,
475
            );
476
        }
477
 
478
        if ($route->security !== null) {
479
            $data->security = $route->security;
480
        }
481
 
482
        if ($route->deprecated) {
483
            $data->deprecated = true;
484
        }
485
 
486
        foreach ($route->get_responses() as $response) {
487
            $data->responses[$response->get_status_code()] = $response->get_openapi_schema(
488
                api: $this,
489
                path: $path,
490
            );
491
        }
492
 
493
        $data->parameters = array_values(array_filter(
494
            array_map(
495
                fn($param) => $param->get_openapi_schema(
496
                    api: $this,
497
                    path: $path,
498
                ),
499
                array_merge(
500
                    $route->get_path_parameters(),
501
                    $route->get_query_parameters(),
502
                    $route->get_header_parameters(),
503
                ),
504
            ),
505
            fn($param) => $param !== null,
506
        ));
507
 
508
        foreach ($this->get_common_request_responses() as $callable) {
509
            $data = $callable($route, $data);
510
        }
511
 
512
        $methoddata = [];
513
        $methods = $route->get_methods(['GET']);
514
 
515
        foreach ($methods as $method) {
516
            $methoddata[strtolower($method)] = $data;
517
        }
518
 
519
        return (object) $methoddata;
520
    }
521
}