app/resto/core/RestoRouter.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php
/*
 * Copyright 2018 Jérôme Gasperi
 *
 * Licensed under the Apache License, version 2.0 (the "License");
 * You may not use this file except in compliance with the License.
 * You may obtain a copy of the License at:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */

/**
 * resto REST router
 */
class RestoRouter
{

    /*
     * Resto core routes
     */
    const ROUTE_TO_API = '/api';
    const ROUTE_TO_ASSETS = '/assets';
    const ROUTE_TO_AUTH = '/auth';
    const ROUTE_TO_CATALOGS = '/catalogs';
    const ROUTE_TO_COLLECTIONS = '/collections';
    const ROUTE_TO_COLLECTION = RestoRouter::ROUTE_TO_COLLECTIONS . '/{collectionId}';
    const ROUTE_TO_CONFORMANCE = '/conformance';
    const ROUTE_TO_FEATURES = RestoRouter::ROUTE_TO_COLLECTION . '/items';
    const ROUTE_TO_FEATURE = RestoRouter::ROUTE_TO_FEATURES . '/{featureId}';
    const ROUTE_TO_FORGOT_PASSWORD = '/services/password/forgot';
    const ROUTE_TO_GROUPS = '/groups';
    const ROUTE_TO_OSDD = '/services/osdd';
    const ROUTE_TO_LIVENESS = '/_isLive';
    const ROUTE_TO_RESET_PASSWORD = '/services/password/reset';
    const ROUTE_TO_SEND_ACTIVATION_LINK = '/services/activation/send';
    const ROUTE_TO_STAC_CHILDREN = '/children';
    const ROUTE_TO_STAC_QUERYABLES = '/queryables';
    const ROUTE_TO_STAC_SEARCH = '/search';
    const ROUTE_TO_USERS = '/users';
    const ROUTE_TO_USER = RestoRouter::ROUTE_TO_USERS . '/{userid}';
    
    /*
     * Default routes
     */
    private $defaultRoutes = array(

        // Liveness and readyness for cloud
        array('GET',    '/', false, 'ServicesAPI::hello'),                                                                              // Landing page
        array('GET',    RestoRouter::ROUTE_TO_LIVENESS, false, 'StatusAPI::isLive'),                                                    // Liveness
        
        // Landing page and conformance (see WFS 3.0)                                                                           
        array('GET',    RestoRouter::ROUTE_TO_API, false, 'ServicesAPI::api'),                                                          // API page
        array('GET',    RestoRouter::ROUTE_TO_CONFORMANCE, false, 'ServicesAPI::conformance'),                                          // Conform
        // API for users
        array('GET',    RestoRouter::ROUTE_TO_USERS, true, 'UsersAPI::getUsersProfiles'),                                               // List users profiles
        array('POST',   RestoRouter::ROUTE_TO_USERS, false, 'UsersAPI::createUser'),                                                    // Create user
        array('GET',    RestoRouter::ROUTE_TO_USER, true, 'UsersAPI::getUserProfile'),                                                  // Show user profile
        array('PUT',    RestoRouter::ROUTE_TO_USER, true, 'UsersAPI::updateUserProfile'),                                               // Update :userid profile
        array('GET',    RestoRouter::ROUTE_TO_USER . '/logs', true, 'UsersAPI::getUserLogs'),                                           // Show user logs
        
        // API for groups
        array('GET'   , RestoRouter::ROUTE_TO_GROUPS, true, 'GroupAPI::getGroups'),                                                     // List users profiles
        array('GET'   , RestoRouter::ROUTE_TO_GROUPS . '/{id}', true, 'GroupAPI::getGroup'),                                            // Get group
        array('POST'  , RestoRouter::ROUTE_TO_GROUPS, true, 'GroupAPI::createGroup'),                                                   // Create group
        array('DELETE', RestoRouter::ROUTE_TO_GROUPS . '/{id}', true, 'GroupAPI::deleteGroup'),                                         // Delete group
        array('GET'   , RestoRouter::ROUTE_TO_USER . '/groups', true, 'GroupAPI::getUserGroups'),                                       // Show user groups
        array('POST'  , RestoRouter::ROUTE_TO_GROUPS . '/{id}/users', true, 'GroupAPI::addUser'),                                       // Add user to group
        array('DELETE', RestoRouter::ROUTE_TO_GROUPS . '/{id}/users/{userid}', true, 'GroupAPI::deleteUser'),                           // Delete user from group

        // API for rights
        array('GET',    RestoRouter::ROUTE_TO_USER . '/rights', true, 'RightsAPI::getUserRights'),                                      // Show user rights
        array('GET',    RestoRouter::ROUTE_TO_GROUPS . '/{id}/rights', true, 'RightsAPI::getGroupRights'),                              // Show group rights
        array('POST'  , RestoRouter::ROUTE_TO_USER . '/rights', true, 'RightsAPI::setUserRights'),                                      // Set user rights
        array('POST'  , RestoRouter::ROUTE_TO_GROUPS . '/{id}/rights', true, 'RightsAPI::setGroupRights'),                              // Set group rights
        
        // API for collections
        array('GET',    RestoRouter::ROUTE_TO_COLLECTIONS, false, 'CollectionsAPI::getCollections'),                                    // List all collections
        array('POST',   RestoRouter::ROUTE_TO_COLLECTIONS, true, 'CollectionsAPI::createCollection'),                                   // Create collection
        array('GET',    RestoRouter::ROUTE_TO_COLLECTION, false, 'CollectionsAPI::getCollection'),                                      // Get :collectionId description
        array('PUT',    RestoRouter::ROUTE_TO_COLLECTION, true, 'CollectionsAPI::updateCollection'),                                    // Update :collectionId
        array('DELETE', RestoRouter::ROUTE_TO_COLLECTION, true, 'CollectionsAPI::deleteCollection'),                                    // Delete :collectionId
        array('GET',    RestoRouter::ROUTE_TO_COLLECTION . '/queryables', false, 'STAC::getQueryables'),                                // OAFeature API - Queryables

        // API for features
        array('GET',    RestoRouter::ROUTE_TO_FEATURES, false, 'FeaturesAPI::getFeaturesInCollection'),                                 // Search features in :collectionId
        array('POST',   RestoRouter::ROUTE_TO_FEATURES, array('auth' => true, 'upload' => 'files'), 'CollectionsAPI::insertFeatures'),  // Insert feature(s)
        array('GET',    RestoRouter::ROUTE_TO_FEATURE, false, 'FeaturesAPI::getFeature'),                                               // Get feature :featureId
        array('PUT',    RestoRouter::ROUTE_TO_FEATURE, true, 'FeaturesAPI::updateFeature'),                                             // Update feature :featureId
        array('DELETE', RestoRouter::ROUTE_TO_FEATURE, true, 'FeaturesAPI::deleteFeature'),                                             // Delete :featureId
        array('PUT',    RestoRouter::ROUTE_TO_FEATURE . '/properties/{property}', true, 'FeaturesAPI::updateFeatureProperty'),          // Update feature :featureId single property
        
        // API for authentication (token based)
        array('GET',    RestoRouter::ROUTE_TO_AUTH, true, 'AuthAPI::getToken'),
        array('GET',    RestoRouter::ROUTE_TO_AUTH . '/create', true, 'AuthAPI::createToken'),                                          // Return a valid auth token
        array('GET',    RestoRouter::ROUTE_TO_AUTH . '/check/{token}', false, 'AuthAPI::checkToken'),                                   // Check auth token validity
        array('DELETE', RestoRouter::ROUTE_TO_AUTH . '/revoke/{token}', true, 'AuthAPI::revokeToken'),                                  // Revoke auth token
        array('PUT',    RestoRouter::ROUTE_TO_AUTH . '/activate/{token}', false, 'AuthAPI::activateUser'),                              // Activate owner of the token

        // API for services
        array('GET',    RestoRouter::ROUTE_TO_OSDD, false, 'ServicesAPI::getOSDD'),                                                     // Opensearch service description at collections level
        array('GET',    RestoRouter::ROUTE_TO_OSDD . '/{collectionId}', false, 'ServicesAPI::getOSDDForCollection'),                    // Opensearch service description for products on {collection}
        array('POST',   RestoRouter::ROUTE_TO_SEND_ACTIVATION_LINK, false, 'ServicesAPI::sendActivationLink'),                          // Send activation link
        array('POST',   RestoRouter::ROUTE_TO_FORGOT_PASSWORD, false, 'ServicesAPI::forgotPassword'),                                   // Send reset password link
        array('POST',   RestoRouter::ROUTE_TO_RESET_PASSWORD, false, 'ServicesAPI::resetPassword'),                                     // Reset password

        // STAC
        array('GET',    RestoRouter::ROUTE_TO_ASSETS . '/{urlInBase64}', false, 'STAC::getAsset'),                                      // Get an asset using HTTP 301 permanent redirect
        array('GET',    RestoRouter::ROUTE_TO_CATALOGS . '/*', false, 'STAC::getCatalogs'),                                             // Get catalogs
        array('GET',    RestoRouter::ROUTE_TO_STAC_CHILDREN, false, 'STAC::getChildren'),                                               // STAC API - Children
        array('GET',    RestoRouter::ROUTE_TO_STAC_QUERYABLES, false, 'STAC::getQueryables'),                                           // STAC/OAFeature API - Queryables
        array('GET',    RestoRouter::ROUTE_TO_STAC_SEARCH, false, 'STAC::search'),                                                      // STAC API - core search (GET)
        array('POST',   RestoRouter::ROUTE_TO_STAC_SEARCH, false, 'STAC::search'),                                                      // STAC API - core search (POST)
    
        // STAC Catalog
        array('POST',   RestoRouter::ROUTE_TO_CATALOGS , true , 'STACCatalog::addCatalog'),                                             // STAC - Add a catalog
        array('PUT' ,   RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STACCatalog::updateCatalog'),                                    // STAC - Update a catalog
        array('DELETE', RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STACCatalog::removeCatalog')                                     // STAC - Remove a catalog
    );

    /*
     * RestoContext
     */
    private $context;

    /*
     * RestoUser
     */
    private $user;

    /*
     * Temporary upload directory for POST/PUT input data
     */
    private $uploadDirectory = '/tmp/resto_uploads';

    /*
     * Registered routes sorted by method
     */
    private $routes = array();

    /**
     * Constructor
     */
    public function __construct($context, $user)
    {
        $this->context = $context;
        $this->user = $user;

        /*
         * Add default routes
         */
        $this->addRoutes(isset($this->context->core['defaultRoutes']) && count($this->context->core['defaultRoutes']) > 0 ? $this->context->core['defaultRoutes'] : $this->defaultRoutes);

        /*
         * Add add-ons routes
         */
        foreach (array_keys($this->context->addons) as $addonName) {
            if (isset($this->context->addons[$addonName]['routes'])) {
                $this->addRoutes($this->context->addons[$addonName]['routes']);
            }
        }
    }

    /**
     * Add a route to routes list
     *
     * @param array $route // Mandatory format is (HTTP Verb, path, need authentication ?, Class name::Method name)
     *                     // e.g. ('GET', '/collections/{collectionId}', false, 'RestoRouteGet::getFeatures')
     */
    public function addRoute($route)
    {
        if (!is_array($route) || count($route) !== 4) {
            return false;
        }
        if (!isset($this->routes[$route[1]])) {
            $this->routes[$route[1]] = array();
        }
        
        $this->routes[$route[1]][$route[0]] = array($route[2], $route[3]);
        
        return true;
    }

    /**
     * Add routes to routes list
     *
     * @param array $routes // array of route
     */
    public function addRoutes($routes)
    {
        if (!is_array($routes)) {
            return false;
        }
        for ($i = 0, $ii = count($routes); $i < $ii; $i++) {
            $this->addRoute($routes[$i]);
        }
        return true;
    }

    /**
     * Process route
     *
     * Route structure is :
     *  array('path', 'isAuthenticated', 'className::methodName')
     *
     * Some path examples:
     *
     *      /collections/{collectionId}, myFunction::myClass
     *
     * Will call function myFunction($params) from class myClass with $params = array('collectionId' => // The value of collectionId in path)
     *
     *      /anyroute/isvalidafter/*, myFunction::myClass
     *
     * Will call function function myFunction($params) from class myClass with $params = array('segments' => array('a', 'b', 'c', etc.))
     * Where (a, b, c, etc.) are the values of everything after '/anyroute/isvalidafter/' splitted by '/' character
     *
     * @param string $method // One of GET, POST, PUT or DELETE
     * @param string $path // url to process
     * @param array $query // queryParams array
     */
    public function process($method, $path, $query)
    {

        // Special case for doc generation
        if ( $path === '/_routes' ) {
            return $this->getRoutesList();
        }

        $segments = explode('/', $path);
        $workingRoutes = $this->getWorkingRoutes(count($segments));
        $nbOfRoutes = count($workingRoutes);
        $workIndex = 0;
        $params = array();
        $validRoute = null;

        // Look through route segments
        while ($workIndex < $nbOfRoutes) {
            $routeSegments = explode('/', $workingRoutes[$workIndex][0]);

            // Compare segments one by one to match a valid route
            for ($i = 1, $ii = count($segments); $i < $ii; $i++) {
                // Segments match - keep the path and continue to match against following segments
                if ($routeSegments[$i] === $segments[$i]) {
                    $validRoute = $workingRoutes[$workIndex];
                    continue;
                }

                // Non constraint route case i.e. '*'
                if ($routeSegments[$i] === '*') {
                    $params = array(
                        'segments' => array_slice($segments, $i)
                    );
                    // Break only if the method is valid - otherwise continue
                    if (isset($validRoute) && isset($validRoute[1][$method])) {
                        break;
                    }
                }

                // Parameter case - add an entry to params and jump to next segment
                if (substr($routeSegments[$i], 0, 1) === '{') {
                    $validRoute = $workingRoutes[$workIndex];
                    $params[substr($routeSegments[$i], 1, strlen($routeSegments[$i]) - 2)] = RestoUtil::sanitize($segments[$i]);
                    continue;
                }

                // If we reach this point, reset everything
                $validRoute = null;
                $params = array();
                break;
            }

            // Iterate if nothing matched
            if (isset($validRoute)) {
                break;
            }
            $workIndex++;
        }
        
        // No route found
        if (!isset($validRoute)) {
            RestoLogUtil::httpError(404);
        }

        return isset($validRoute[1][$method]) ? $this->instantiateRoute($validRoute[1][$method], $method, array_merge($query, $params)) : RestoLogUtil::httpError(405);
    }

    /**
     * Return all routes
     */
    public function getRoutesList()
    {
        $routesList = array();
        foreach ($this->routes as $path => $value) {
            foreach (array_keys($value) as $method ) {
                $routesList[] = $method . ':' . $path;
            }
        }
        header('HTTP/1.1 200 OK');
        header('Content-Type: ' . RestoUtil::$contentTypes['text']);
        echo join("\n", $routesList);
        return null;
    }

    /**
     * Instantiate a valid route
     *
     * @param array $validRoute
     * @param string $method
     * @param array $params
     */
    private function instantiateRoute($validRoute, $method, $params)
    {
        /*
         * In resto 5.x first element of route is an "authenticationIsRequired" boolean
         * In restto >=6.x first element of route can also be an array
         */
        if (is_bool($validRoute[0])) {
            $validRoute[0] = array(
                'auth' => $validRoute[0]
            );
        }

        /*
         * Authentication is required
         */
        if (isset($validRoute[0]['auth']) && $validRoute[0]['auth'] && !isset($this->user->profile['id'])) {
            return RestoLogUtil::httpError(401);
        }

        /*
         * Instantiates route class and calls method
         */
        list($className, $methodName) = explode('::', $validRoute[1]);

        /*
         * Read input data
         */
        $data = null;
        if ($method === 'POST' || $method === 'PUT') {
            /*
             * File upload is allowed - upload files and populate data with file paths...
             * In this case, the target $className->$methodName is responsible of the uploaded files
             */
            if (isset($validRoute[0]['upload']) && isset($_FILES[$validRoute[0]['upload']]) && is_array($_FILES[$validRoute[0]['upload']])) {
                $data = $this->uploadFiles($_FILES[$validRoute[0]['upload']]);
            }
            /*
             * ...or read the input body content and directly populate data with it
             */
            else {
                $data = $this->readStream();
            }
        }
        
        return (new $className($this->context, $this->user))->$methodName(
            $params,
            $data
        );
    }

    /**
     * Return an array of potentially valid routes i.e.
     *  - routes with exactly the same number of segments as input path
     *  - routes with last segment equal to '*'
     *
     * @param integer $length Number of segments in input path
     */
    private function getWorkingRoutes($length)
    {
        $workingRoutes = array();
        foreach ($this->routes as $key => $value) {
            $segments = explode('/', $key);
            $count = count($segments);
            if ($count === $length || ($segments[$count - 1] === '*' && $count < $length)) {
                $workingRoutes[] = array($key, $value);
            }
        }

        return $workingRoutes;
    }

    /**
     * Read file content within header body of POST request
     *
     * @return array
     * @throws Exception
     */
    private static function readStream()
    {
        $content = file_get_contents('php://input');
        if (!isset($content)) {
            return null;
        }

        /*
         * Assume that input data format is JSON by default
         */
        $json = json_decode($content, true);

        return $json === null ? explode("\n", $content) : $json;
    }

    /**
     * Upload files locally and return array of file paths
     *
     * @param array $files
     * @return array
     * @throws Exception
     */
    private function uploadFiles($files)
    {
        $filePaths = [];

        // All files will be uploaded within a dedicated directory with a random name
        $uploadDirectory = $this->uploadDirectory . DIRECTORY_SEPARATOR . (substr(sha1(mt_rand(0, 100000) . microtime()), 0, 15));

        try {
            for ($i = count($files['tmp_name']); $i--;) {
                $fileToUpload = $files['tmp_name'][$i];

                if (is_uploaded_file($fileToUpload)) {
                    if (!is_dir($uploadDirectory)) {
                        mkdir($uploadDirectory, 0777, true);
                    }

                    $fileName = $uploadDirectory . DIRECTORY_SEPARATOR . $files['name'][$i];
                    move_uploaded_file($fileToUpload, $fileName);

                    $filePaths[] = $fileName;
                }
            }
        } catch (Exception $e) {
            RestoLogUtil::httpError(500, 'Cannot upload file(s)');
        }

        return array(
            'uploadDir' => $uploadDirectory,
            'files' => $filePaths
        );
    }
}