Simple REST api for php hosted website

Sometimes it is necessary to deploy a small rest api for your site made using SPA technology (Vue, React, etc.) without using any frameworks, CMS or something like that, and at the same time you want to use regular php hosting with minimal effort on implementation and development. At the same time, it is also desirable to place the SPA site itself there (in our case, on vue).

Using php allows you to use even static php files to build api endpoints, which are placed simply in folders on the hosting, which provide results when you directly access them. And although, apparently at one time, this approach served as a wide spread of php, we will consider further a more programmer approach to creating api, which is very similar to that used in the Node.js Express library and therefore is intuitive and easy to learn. For this we need the “pecee/simple-router” library.

Further, we assume that you already have an environment to run the code locally (LAMP, XAMP, docker) or otherwise, and you are configured to redirect all requests to an index file (index.php). Also, we assume that you can install dependencies via composer.

Project Structure

On Fig.1.  the general structure of the project is presented.  The entry point is a file
On Fig.1. the general structure of the project is presented. The entry point is a file

index.php in the web folder. The web folder itself is a publicly accessible folder, and must be specified as the root folder in the server settings. The config folder will contain the settings for the routes of our endpoints. The controller folder will contain route endpoint handlers. In the middlewares folder, we will place intermediate route handlers to perform authorization before the start of the main endpoint code. The exceptions, views and models folders will contain exceptions, html template and object models, respectively. Full project code here.

Installation and launch

To work, you need to install the following contents of composer.json (composer install in the root of the project).

// composer.json
{
    "require": {
        "pecee/simple-router": "*",
        "lcobucci/jwt": "^3.4",
        "ext-json": "*"
    },
    "autoload": {
        "psr-4": {
            "app\\": ""
        }
    }
}

Note that ‘app\’ is declared as a prefix for the namespace. This prefix will be used when declaring class namespaces.

The rest of the code is started by calling the static Router::route() method in the index.php file

<?php
//index.php

use Pecee\SimpleRouter\SimpleRouter as Router;

require_once __DIR__ . '/../vendor/autoload.php';
require_once (__DIR__ . '/../config/routes.php');
    
Router::start();

The routes defined in the config/routes.php file are also connected here.

Connecting SPA on Vue.js 2 to a project on php

If you are deploying the vue assembly separately from the api, then this section can be skipped.

Let’s now consider how to connect a vue project in this configuration using the appropriate routes. To do this, the contents of the assembly must be placed in the web folder. In the routes file (‘/config/routes.php’) we write two rules:

<?php

use Pecee\{
    SimpleRouter\SimpleRouter as Router
};

Router::setDefaultNamespace('app\controllers');
Router::get('/', 'VueController@run'); // правило 1
Router::get('/controller', 'VueController@run')
    ->setMatch('/\/([\w]+)/'); // правило 2

For an empty (root) route ‘/’, the run method of the VueController class is called. The second rule specifies that for any explicitly unspecified path, the VueController will also be called so that the route processing occurs on the vue side. This rule should always be the last one so that it only works when the others have already failed. The run method is simply rendering the view file using the renderTemplate() method defined in the parent controller class. Here we also set the prefix for the classes whose methods are used in the routes using setDefaultNamespace.

<?php

namespace app\controllers;

class VueController extends AbstractController
{
    public function run()
    {
        return $this->renderTemplate('../views/vue/vue_page.php');
    }
}

In turn, the vue_page.php view is also just a rendering of the vue build index file.

<?php
// vue_page.php
include (__DIR__ . '/../../web/index.html');

In total, we connected the vue project to the php project, which is already ready to be deployed on the hosting. This approach can be used for any php projects. It remains only to consider what the parent class AbstractController is.

<?php

namespace app\controllers;

use Pecee\Http\Request;
use Pecee\Http\Response;
use Pecee\SimpleRouter\SimpleRouter as Router;

abstract class AbstractController
{
    /**
     * @var Response
     */
    protected $response;
    /**
     * @var Request
     */
    protected $request;

    public function __construct()
    {
        $this->request = Router::router()->getRequest();
        $this->response =  new Response($this->request);
    }

    public function renderTemplate($template) {
        ob_start();
        include $template;
        return ob_get_clean();
    }

    public function setCors()
    {
        $this->response->header('Access-Control-Allow-Origin: *');
        $this->response->header('Access-Control-Request-Method: OPTIONS');
        $this->response->header('Access-Control-Allow-Credentials: true');
        $this->response->header('Access-Control-Max-Age: 3600');
    }
}

The AbstractController class constructor defines the $request and $response fields. $request stores the request parsed by the Pecee\Http\Router class. And $response will be used to create responses to api requests. The renderTemplate method defined here is used to render views (html pages). In addition, a method is defined here that sets the headers to work with the CORS policy. It should be used if requests to api are not from the same address, i.e. if the vue build is running on another webserver. Now let’s move on to creating the API.

Creating REST API endpoints

To work with api, we need to perform additional processing of the incoming request, because the library used does not parse raw data. To do this, we will create an intermediate layer ProccessRawBody and add it as middleware to the routes for API requests.

<?php

namespace app\middlewares;

use Pecee\Http\Middleware\IMiddleware;
use Pecee\Http\Request;

class ProccessRawBody implements IMiddleware
{

    /**
     * @inheritDoc
     */
    public function handle(Request $request): void
    {
        $rawBody = file_get_contents('php://input');

        if ($rawBody) {
            try {
             $body = json_decode($rawBody, true);
             foreach ($body as $key => $value) {
                 $request->$key = $value;
             }
            } catch (\Throwable $e) {

            }
        }
    }
}

Here we are reading from the input stream and putting it into the $request object for further access from the code in the controllers. ProccessRawBody implements the IMIddleware interface required for all middleware.

Now let’s create a group of routes to work with API using this middle layer.

<?php
// routes.php
Router::group([
    'prefix' => 'api/v1',
    'middleware' => [
        ProccessRawBody::class
    ]
], function () {
    Router::post('/auth/sign-in', 'AuthController@signin');
    Router::get('/project', 'ProjectController@index');
});

This group has the “api/v1” prefix defined (i.e., the full path of the request should be, for example, ‘/api/v1/auth/sign-in’), and the middleware ProccessRawBody::class, so that input variables are available in controllers inherited from AbstractController via $request. AuthController will be discussed a little later, but now we can already use methods that do not require authorization, such as ProjectController::index.

<?php

namespace app\controllers;

class ProjectController extends AbstractController
{
    public function index():string
    {
	// Какая-то логика для получения данных тут

        return $this->response->json([
            [
                'name' => 'project 1'
            ],
            [
                'name' => 'project 2'
            ]
        ]);
    }
}

As you can see, on an incoming request, data about projects is returned in the response.

The rest of the routes are created in the same way.

Authorization by JWT token

Now let’s move on to routes that require authorization. But before that, let’s implement the login and get the jwt token. To create a token and validate it, we will use the “lcobucci/jwt” library. All this will be performed on the route defined earlier by ‘/auth/sign-in’. Accordingly, in AuthController::singin, we have the logic for issuing a jwt token after user authorization.

<?php

namespace app\controllers;

use app\models\Request;
use ArgumentCountError;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;

class AuthController extends AbstractController
{
    public function signin()
    {
	      // Тут код авторизующий пользователя

        $config = Configuration::forSymmetricSigner(
            new Sha256(),
            InMemory::plainText('секретный_ключ')
        );
        $now   = new DateTimeImmutable();
        $token = $config->builder()
            // Configures the issuer (iss claim)
            ->issuedBy('http://example.com')
            // Configures the audience (aud claim)
            ->permittedFor('http://example.org')
            // Configures the id (jti claim)
            ->identifiedBy('4f1g23a12aa')
            // Configures the time that the token was issue (iat claim)
            ->issuedAt($now)
            // Configures the expiration time of the token (exp claim)
            ->expiresAt($now->modify('+2 minutes'))
            // Configures a new claim, called "uid"
            ->withClaim('uid', $user->id)
            // Configures a new header, called "foo"
            ->withHeader('foo', 'bar')
            // Builds a new token
            ->getToken($config->signer(), $config->signingKey());
        
        return $this->response->json([
            'accessToken' => $token->toString()
        ]);
    }
}

This uses a symmetric signature for the jwt using the secret key ‘secret_key’. It will be used to check the validity of the token when making requests to the api. You can also use asymmetric signature using a key pair.

It can also be noted that you can create as many claims as you like ->withClaim(‘uid’, $user->id) and store data there that can then be retrieved from the key. For example, a user id to further identify requests from that user. The token was issued for 2 minutes (->expiresAt($now->modify(‘+2 minutes’))) after which it becomes invalid. ->issuedBy and ->permittedFor are used for oath2.

Now let’s create a group of routes protected by authorization. To do this, we define an Authenticate: intermediate layer for the route group:class.

<?php
//routes.php
Router::group([
    'prefix' => 'api/v1',
    'middleware' => [
        ProccessRawBody::class
    ]
], function () {

    Router::post('/auth/sign-in', 'AuthController@signin');
    Router::get('/project', 'ProjectController@index');

    Router::group([
        'middleware' => [
            Authenticate::class
        ]
    ], function () {
        // authenticated routes
        Router::post('/project/create', 'ProjectController@create');
        Router::post('/project/update/{id}', 'ProjectController@update')
            ->where(['id' => '[\d]+']);
    });
});

As you can see, the authorization group is declared inside the group with the “api/v1” prefix. Consider the ‘/project/update/{id}’ route. The id parameter is declared here and is defined as a number. The $id variable containing the value of this parameter will be passed to the update method of the Projectcontroller controller. Below is an example request and response.

<?php

namespace app\controllers;

class ProjectController extends AbstractController
{
    /**
     * post /api/v1/project/update/3
     * body:
        {
            "project": {
                "prop": "value"
            }
        }
     */
    public function update(int $id): string
    {
				// код обновляющий проект
        return $this->response->json([
            [
                'response' => 'OK',
                'request' => $this->request->project,
                'id' => $id
            ]
        ]);
    }
}

Let’s now return to the Authenticate::class intermediate layer, which is used to authorize requests to the api.

<?php

namespace app\middlewares;

use app\exceptions\NotAuthorizedHttpException;
use DateTimeImmutable;
use Lcobucci\Clock\FrozenClock;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\Constraint\ValidAt;
use Pecee\Http\Middleware\IMiddleware;
use Pecee\Http\Request;

class Authenticate implements IMiddleware
{
    public function handle(Request $request): void
    {
        $headers = getallheaders();
        $tokenString = substr($headers['Authorization'] ?? '', 7);

        $config = Configuration::forSymmetricSigner(
            new Sha256(),
            InMemory::plainText('секретный_ключ')
        );

        $token = $config->parser()->parse($tokenString);

        if (
            !$config->validator()->validate(
                $token,
                new SignedWith(
                    new Sha256(),
                    InMemory::plainText('секретный_ключ')
                ),
                new ValidAt(new FrozenClock(new DateTimeImmutable()))
            )
        ) {
            throw new NotAuthorizedHttpException('Токен доступа не валиден или просрочен');
        }
        $userId = $token->claims()->get('uid');
        $request['uid'] = $userId;
    }
}

Here, the header ‘Authorization: Bearer is read [token]’ (the so-called bearer authorization) and a token is extracted from there, which clients receive after login and must be sent with all requests requiring authorization. Further, using the parser, the jwt-token-string is parsed. And then, with the help of a validator, the parsed token is validated. The validate() method returns true or false. If the token is invalid, a NotAuthorizedException is thrown. If the token is valid, then we extract the user id $token->claims()->get(‘uid’) from it and save it to the $request request variable so that it can be used further in the controller. NotAuthorizedException is defined like this:

<?php

namespace app\exceptions;

class NotAuthorizedHttpException extends \Exception
{

}

Finally, let’s take a look at error handling. In the routes.php file, write the following lines:

<?php
//routes.php
Router::error(function(Request $request, Exception $exception) {
    $response = Router::response();
    switch (get_class($exception)) {
        case NotAuthorizedHttpException::class: {
            $response->httpCode(401);
            break;
        }
        case Exception::class: {
            $response->httpCode(500);
            break;
        }
    }
    if (PROD) {
        return $response->json([]);
    } else {
        return $response->json([
            'status' => 'error',
            'message' => $exception->getMessage()
        ]);
    }
});

As a result, the routes.php file will look like this:

Rice.  2. Final structure of the project
Rice. 2. Final structure of the project
<?php
//routes.php
use app\exceptions\{
    NotAuthorizedHttpException
};
use app\middlewares\{
    Authenticate,
    ProccessRawBody
};
use Pecee\{
    Http\Request,
    SimpleRouter\SimpleRouter as Router
};

const PROD = false;

Router::setDefaultNamespace('app\controllers');

Router::get('/', 'VueController@run');

Router::group([
    'prefix' => 'api/v1',
    'middleware' => [
        ProccessRawBody::class
    ]
], function () {
    Router::post('/auth/sign-in', 'AuthController@signin');
    Router::get('/project', 'ProjectController@index');
    Router::group([
        'middleware' => [
            Authenticate::class
        ]
    ], function () {
        // authenticated routes
        Router::post('/project/create', 'ProjectController@create');
        Router::post('/project/update/{id}', 'ProjectController@update')
            ->where(['id' => '[\d]+']);
    });
});

Router::get('/controller', 'VueController@run')
    ->setMatch('/\/([\w]+)/');

Router::error(function(Request $request, Exception $exception) {
    $response = Router::response();
    switch (get_class($exception)) {
        case NotAuthorizedHttpException::class: {
            $response->httpCode(401);
            break;
        }
        case Exception::class: {
            $response->httpCode(500);
            break;
        }
    }
    if (PROD) {
        return $response->json([]);
    } else {
        return $response->json([
            'status' => 'error',
            'message' => $exception->getMessage()
        ]);
    }
});

Conclusion

As a result, we got a small, simple REST api for small projects that can be used on a regular php hosting with minimal effort to configure it (hosting). Full project code here.

More route settings can be found here. Instead of the considered “pecee/simple-router” library, you can use any other similar library or even the Slim microframework.

Ps. If you are using a public repository or following best practices, then you should not store the private key in your code. To do this, you can use environment variables or local files that are not added to the repository. The code for working with jwt tokens can be separated into a separate class in the services folder.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *