Proyectos de Subversion Moodle

Rev

Autoría | Ultima modificación | Ver Log |

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

namespace core\router\schema;

use coding_exception;
use core\router\response\invalid_parameter_response;
use core\router\response\not_found_response;
use core\router\route;
use core\router\route_loader_interface;
use core\router\schema\objects\type_base;
use core\router\schema\response\response;
use core\router\util;
use core\url;
use stdClass;

/**
 * Moodle OpenApi Specification class.
 *
 * @package    core
 * @copyright  2023 Andrew Lyons <andrew@nicols.co.uk>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class specification implements
    \JsonSerializable
{
    /** @var string The OpenAPI version represented in this specification */
    public const OPENAPI_VERSION = '3.1.0';

    /** @var stdClass The data which forms the specification */
    protected stdClass $data;

    /** @var bool Whether the data has been finalised for output yet */
    protected bool $finalised = false;

    /** @var callable[] A list of common responses that are frequently found in paths */
    protected array $commonresponses = [];

    /**
     * Constructor to configure base information.
     */
    public function __construct() {
        $this->data = (object) [
            'openapi' => self::OPENAPI_VERSION,
            'info' => (object) [
                'title' => 'Moodle LMS',
                'description' => 'REST API for Moodle LMS',
                'summary' => 'Moodle LMS REST API',
                'license' => (object) [
                    'name' => 'GNU GPL v3 or later',
                    'url' => 'https://www.gnu.org/licenses/gpl-3.0.html',
                ],
            ],

            // Servers are added during output.
            'servers' => [],

            // Paths are added after initialisation.
            'paths' => (object) [],

            'components' => (object) [
                // Note: This list must be kept in-sync with add_component.
                'schemas' => (object) [],
                'responses' => (object) [],
                'parameters' => (object) [],
                'examples' => (object) [],
                'requestBodies' => (object) [],
                'headers' => (object) [],

                // The add_component method does not support securitySchemes because we hard-code these.
                'securitySchemes' => (object) [
                    'api_key' => (object) [
                        'type' => 'apiKey',
                        'name' => 'api_key',
                        'in' => parameter::IN_HEADER,
                    ],
                    'cookie' => (object) [
                        'type' => 'apiKey',
                        'name' => 'MoodleSession',
                        'in' => parameter::IN_COOKIE,
                    ],
                    // TODO MDL-82242: Add support for OAuth2.
                ],
            ],
            // TODO MDL-82242: Add support for OAuth2.
            'security' => [
                (object) [
                    'api_key' => [],
                    'cookie' => [],
                ],
            ],
            'externalDocs' => (object) [
                'description' => 'Moodle Developer Docs',
                'url' => 'https://moodledev.io',
            ],
        ];

        $this->generate_common_responses();
    }

    /**
     * Generate the callables for common responses that are frequently found in paths.
     *
     * @return specification
     */
    protected function generate_common_responses(): self {
        $invalidresponse = new invalid_parameter_response();
        $notfoundresponse = new not_found_response();

        $this->commonresponses[] = function (
            route $route,
            stdClass $data
        ) use (
            $invalidresponse,
            $notfoundresponse,
        ): stdClass {
            if ($route->has_any_validatable_parameter()) {
                if (!array_key_exists($invalidresponse::get_exception_status_code(), $data->responses)) {
                    $data->responses[$invalidresponse::get_exception_status_code()] = $invalidresponse->get_openapi_schema($this);
                }
                if (!array_key_exists($notfoundresponse::get_exception_status_code(), $data->responses)) {
                    $data->responses[$notfoundresponse::get_exception_status_code()] = $notfoundresponse->get_openapi_schema($this);
                }
            }

            return $data;
        };
        return $this;
    }

    /**
     * Get the common request responses.
     *
     * @return callable[]
     */
    public function get_common_request_responses(): array {
        if (empty($this->commonresponses)) {
            $this->generate_common_responses(); // @codeCoverageIgnore
        }

        return $this->commonresponses;
    }

    /**
     * Finalise the data and prepare it for consumption.
     */
    protected function finalise(): self {
        global $CFG;

        if ($this->finalised) {
            return $this;
        }

        // Add the Moodle site version here.
        $this->data->info->version = $CFG->version;

        // Add the server configuration.
        $serverdescription = str_replace("'", "\'", format_string(get_site()->fullname));
        $this->add_server(
            url::routed_path(route_loader_interface::ROUTE_GROUP_API)->out(),
            $serverdescription,
        );

        $this->finalised = true;

        return $this;
    }

    /**
     * Implement the json serialisation interface.
     *
     * @return mixed
     */
    public function jsonSerialize(): mixed {
        return $this->get_schema();
    }

    /**
     * Get the OpenAPI schema.
     *
     * @return stdClass
     */
    final public function get_schema(): stdClass {
        return $this
            ->finalise()
            ->data;
    }

    /**
     * Add a component to the components object.
     *
     * https://spec.openapis.org/oas/v3.1.0#components-object
     *
     * Note: The following component types are supported:
     *
     * - schemas
     * - responses
     * - parameters
     * - examples
     * - requestBodies
     * - headers
     *
     * At this time, other component types are not supported.
     *
     * @param openapi_base $object
     * @return specification
     * @throws coding_exception If the component type is unknown.
     */
    public function add_component(openapi_base $object): self {
        match (true) {
            is_a($object, header_object::class) => $this->add_header($object),
            is_a($object, parameter::class) => $this->add_parameter($object),
            is_a($object, response::class) => $this->add_response($object),
            is_a($object, example::class) => $this->add_example($object),
            is_a($object, request_body::class) => $this->add_request_body($object),
            is_a($object, type_base::class) => $this->add_schema($object),
            default => throw new coding_exception("Unknown object type."),
        };

        return $this;
    }

    /**
     * Add a server to the specification.
     *
     * @param string $url The URL of the API base
     * @param string $description
     * @return specification
     */
    public function add_server(
        string $url,
        string $description,
    ): self {
        $this->data->servers[] = (object) [
            'url' => $url,
            'description' => $description,
        ];

        return $this;
    }

    /**
     * Add an API Path.
     *
     * @param string $component The Moodle component
     * @param route $route The route which handles this request
     * @return specification
     */
    public function add_path(
        string $component,
        route $route,
    ): self {
        // Compile the final path, complete with component prefix.
        $path = "/";
        $path .= util::normalise_component_path($component);
        $path .= $route->get_path();

        // Helper to add the path to the specification.
        // Note: We use this helper because OpenAPI does not support optional parameters.
        // Therefore we must handle that in Moodle, adding path variants with and without each optional parameter.
        $addpath = function (string $path) use ($route, $component) {
            $path = str_replace(
                [
                    // Remove the optional parameters delimiters from the path.
                    '[',
                    ']',

                    // Remove the greedy and non-greedy unlimited delimters from the path too.
                    // These are a FastRoute feature not compatible with OpenAPI.
                    ':.*?',
                    ':.*',
                ],
                '',
                $path,
            );

            // Get the OpenAPI description for this path with the updated path.
            $pathdocs = $this->get_openapi_schema_for_route(
                route: $route,
                component: $component,
                path: $path,
            );

            if (!property_exists($this->data->paths, $path)) {
                $this->data->paths->$path = (object) [];
            }

            foreach ((array) $pathdocs as $method => $methoddata) {
                // Copy each of the pathdocs into place.
                $this->data->paths->{$path}->{$method} = $methoddata;
            }
        };

        // First add the entire path complete with all optional parameters.
        // The optional parameter delimiters are `[` and `]`, and are removed in `$addpath`.
        $addpath($path);

        // Check for any optional parameters.
        // OpenAPI does not support optional parameters so we have to duplicate routes instead.
        // We can determine if this is optional if there is any `[` character before it in the path.
        // There can be no required parameter after any optional parameter.
        $optionalparameters = array_filter(
            array: $route->get_path_parameters(),
            callback: fn ($parameter) => !$parameter->is_required($route),
        );

        if (!empty($optionalparameters)) {
            // Go through the path from end to start removing optional parameters and adding them to the path list.
            while (strrpos($path, '[') !== false) {
                $path = substr($path, 0, strrpos($path, '['));
                $addpath($path);
            }
        }

        return $this;
    }

    /**
     * Add a schema to the shared components section of the specification.
     *
     * @param type_base $schema
     * @return specification
     */
    protected function add_schema(
        type_base $schema,
    ): self {
        $name = $schema->get_reference(qualify: false);
        if (!property_exists($this->data->components->schemas, $name)) {
            $this->data->components->schemas->$name = $schema->get_openapi_description($this);
        }

        return $this;
    }

    /**
     * Add a schema to the shared components section of the specification.
     *
     * @param parameter $parameter
     * @return specification
     */
    protected function add_parameter(
        parameter $parameter,
    ): self {
        $name = $parameter->get_reference(qualify: false);
            $this->data->components->parameters->$name = $parameter->get_openapi_description($this);

        return $this;
    }

    /**
     * Add a header to the shared components section of the specification.
     *
     * @param header_object $header
     * @return self
     */
    protected function add_header(
        header_object $header,
    ): self {
        $name = $header->get_reference(qualify: false);
        $this->data->components->headers->$name = $header->get_openapi_description($this);

        return $this;
    }

    /**
     * Add a response to the shared components section of the specification.
     *
     * @param response $response
     * @return specification
     */
    protected function add_response(
        response $response,
    ): self {
        $name = $response->get_reference(qualify: false);
        $this->data->components->responses->$name = $response->get_openapi_description($this);

        return $this;
    }

    /**
     * Add an example to the shared components section of the specification.
     *
     * @param example $example
     * @return specification
     */
    protected function add_example(
        example $example,
    ): self {
        $name = $example->get_reference(qualify: false);
        $this->data->components->examples->$name = $example->get_openapi_description($this);

        return $this;
    }

    /**
     * Add a request body to the shared components section of the specification.
     *
     * @param request_body $body
     * @return specification
     */
    protected function add_request_body(
        request_body $body,
    ): self {
        $name = $body->get_reference(qualify: false);
        $this->data->components->requestBodies->$name = $body->get_openapi_description($this);

        return $this;
    }

    /**
     * Check whether a reference is defined
     *
     * @param string $ref
     * @return bool
     */
    public function is_reference_defined(
        string $ref,
    ): bool {
        if (!str_starts_with($ref, '#/components/')) {
            return false;
        }

        // Remove the leading #/components/ part.
        $ref = substr($ref, strlen('#/components/'));

        // Split the path and name.
        [$path, $name] = explode('/', $ref, 2);

        if (!property_exists($this->data->components, $path)) {
            return false;
        }

        return property_exists($this->data->components->$path, $name);
    }


    /**
     * Get the OpenAPI description for this route.
     *
     * @param route $route
     * @param string $component
     * @param string $path
     * @return stdClass
     */
    public function get_openapi_schema_for_route(
        route $route,
        string $component,
        string $path,
    ): stdClass {
        $data = (object) [
            'description' => $route->description,
            'summary' => $route->title,
            'tags' => [$component, ...$route->tags],
            'parameters' => [],
            'responses' => [],
        ];

        if ($route->get_request_body()) {
            $data->requestBody = $route->get_request_body()->get_openapi_schema(
                api: $this,
                path: $path,
            );
        }

        if ($route->security !== null) {
            $data->security = $route->security;
        }

        if ($route->deprecated) {
            $data->deprecated = true;
        }

        foreach ($route->get_responses() as $response) {
            $data->responses[$response->get_status_code()] = $response->get_openapi_schema(
                api: $this,
                path: $path,
            );
        }

        $data->parameters = array_values(array_filter(
            array_map(
                fn($param) => $param->get_openapi_schema(
                    api: $this,
                    path: $path,
                ),
                array_merge(
                    $route->get_path_parameters(),
                    $route->get_query_parameters(),
                    $route->get_header_parameters(),
                ),
            ),
            fn($param) => $param !== null,
        ));

        foreach ($this->get_common_request_responses() as $callable) {
            $data = $callable($route, $data);
        }

        $methoddata = [];
        $methods = $route->get_methods(['GET']);

        foreach ($methods as $method) {
            $methoddata[strtolower($method)] = $data;
        }

        return (object) $methoddata;
    }
}