Skip to content

edgaralexanderfr/php-espresso

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

20 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

PHP Espresso Framework

PHP Espresso Example GIF

View last release PHP 8.0.0 Experimental Sockets Composer

PHP Espresso is a small PHP Framework I created to develop runtime web servers for PHP running CLI programs and scripts. Very similar to frameworks like Express for NodeJS, Gorilla Mux for Golang, etc.

IMPORTANT NOTE: This is just a proof of concept to test the reliability of a runtime web server for PHP, its use and implementation is discouraged for production-level projects as it's an experimental framework for learning purposes.

PHP was designed to be a Single-Threaded Non-Asynchronous programming language, hence, the implementation of these type of web servers is very difficult as there will be always blocking processes for each request, hence, this server/framework is non-scalable.

Table of contents πŸ“–
  1. Requirements
  2. Installation
  3. Usage

Requirements

  1. PHP 8.0.0 or major
  2. Have PHP sockets module installed and enabled
  3. Composer
  4. Have a initted Composer project

Installation

Install PHP Espresso via Composer:

composer require edgaralexanderfr/php-espresso

Usage

Creating a basic web server

Create a server.php file inside your project with the following program:

<?php

require_once 'vendor/autoload.php';

use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;

const PORT = 80;

$server = new Server();
$router = new Router();

$router->get('/', function (Request $request, Response $response) {
    return $response->send([
        'message' => 'Hello world!',
        'code' => 200,
    ]);
});

$server->use($router);

$server->listen(PORT, function () use ($server) {
    $server->log('Listening at port ' . PORT . '...');
});

Run the server:

php server.php # Use sudo if necessary for port 80

Visit http://localhost or execute:

curl http://localhost

And voila! πŸŽ‰

Serving a basic static HTML Page

<?php

require_once 'vendor/autoload.php';

use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;

$server = new Server();
$router = new Router();

$router->get('/php-espresso-page', function (Request $request, Response $response) {
    return $response->setPayload(
        <<<HTML
            <!DOCTYPE html>
            <html lang="en">
                <head>
                    <title>My Web Page with PHP Espresso!</title>
                </head>
                <body>
                    <h1>My Web Page with PHP Espresso!</h1>

                    <p>This page was served using PHP Espresso.</p>
                </body>
            </html>
        HTML
    );
});

$server->use($router);

$server->listen(80, function () use ($server) {
    $server->log('Listening at port 80...');
});

Visit http://localhost/php-espresso-page in your browser.

Create a POST request:

<?php

require_once 'vendor/autoload.php';

use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;

$server = new Server();
$router = new Router();

$router->post('/users', function (Request $request, Response $response) {
    $body_json = $request->getJSON();

    return $response->send([
        'message' => 'User created successfully',
        'code' => 201,
        'user' => $body_json,
    ], 201);
});

$server->use($router);

$server->listen(80, function () use ($server) {
    $server->log('Listening at port 80...');
});

Execute a POST request:

curl -X POST http://localhost/users -d '{"name":"Alexander The Great"}'

Complete Rest API CRUD example:

<?php

require_once 'vendor/autoload.php';

use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;

/** @var stdClass[] */
$users = [];
/** @var int */
$users_id = 1;

$server = new Server();
$router = new Router();

$router->get('/users', function (Request $request, Response $response) use (&$users) {
    return $response->send($users);
});

$router->get('/users/:id', function (Request $request, Response $response) use (&$users) {
    $id = $request->getId();

    foreach ($users as $user) {
        if (isset($user->{'id'}) && $user->id == $id) {
            return $response->send($user);
        }
    }

    return $response->send([
        'message' => 'User not found',
        'code' => 404,
    ], 404);
});

$router->post('/users', function (Request $request, Response $response) use (&$users, &$users_id) {
    $body = $request->getJSON();

    $email = $body->email ?? null;
    $name = $body->name ?? null;

    if (!$email || !$name) {
        return $response->send([
            'message' => 'Email and Name are required',
            'code' => 400,
        ], 400);
    }

    $user = (object) [
        'id' => $users_id++,
        'email' => $email,
        'name' => $name,
    ];

    $users[] = $user;

    return $response->send([
        'message' => 'User created successfully',
        'code' => 201,
        'user' => $user,
    ], 201);
});

$router->patch('/users/:id', function (Request $request, Response $response) use (&$users) {
    $id = $request->getId();
    $body = $request->getJSON();

    foreach ($users as &$user) {
        if (isset($user->{'id'}) && $user->id == $id) {
            $user->email = $body->email ?? $user->email;
            $user->name = $body->name ?? $user->name;

            return $response->send([
                'message' => 'User updated successfully',
                'code' => 200,
                'user' => $user,
            ]);
        }
    }

    return $response->send([
        'message' => 'User not found',
        'code' => 404,
    ], 404);
});

$router->delete('/users/:id', function (Request $request, Response $response) use (&$users) {
    $id = $request->getId();

    foreach ($users as $i => &$user) {
        if (isset($user->{'id'}) && $user->id == $id) {
            array_splice($users, $i, 1);

            return $response->send([
                'message' => 'User deleted successfully',
                'code' => 200,
            ]);
        }
    }

    return $response->send([
        'message' => 'User not found',
        'code' => 404,
    ], 404);
});

$server->use($router);

$server->listen(80, function () use ($server) {
    $server->log('Listening at port 80...');
});

Create a couple of users:

curl -X POST http://localhost/users -d '{"email":"john.doe@example.com","name":"John Doe"}'
curl -X POST http://localhost/users -d '{"email":"jane.doe@example.com","name":"Jane Doe"}'

Retrieve all created users:

curl http://localhost/users

Retrieve user with id 2:

curl http://localhost/users/2

Update user with id 1:

curl -X PATCH http://localhost/users/1 -d '{"name":"John James Doe"}'

Delete user with id 2:

curl -X DELETE http://localhost/users/2

Defining middlewares

PHP Espresso supports global and route middlewares. You can assign as much middlewares to a single route as you want.

To do so, you can create a new middlewares.php file and add the following code:

<?php

require_once 'vendor/autoload.php';

use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;

define('AUTH_CREDENTIALS', (object) [
    'user' => 'john.doe@example.com',
    'pass' => '1234567890', // Please... don't...
]);

/**
 * Middleware for admin authentication.
 */
function auth(Request $request, Response $response, callable $next)
{
    $authorization = $request->getHeader('Authorization') ?? '';
    $auth = explode(' ', $authorization);
    $type = $auth[0] ?? '';
    $token = $auth[1] ?? '';

    $credentials = explode(':', base64_decode($token));
    $user = $credentials[0] ?? null;
    $pass = $credentials[1] ?? null;

    if ($type != 'Bearer' || $user != AUTH_CREDENTIALS->user || $pass != AUTH_CREDENTIALS->pass) {
        return $response->send([
            'message' => Espresso\Http\CODES[401],
            'code' => 401,
        ], 401);
    }

    $next();
}

/** @var stdClass[] */
$users = [];
/** @var int */
$users_id = 1;

$server = new Server();
$router = new Router();

// Global middleware to check service status:
$server->use(function (Request $request, Response $response, callable $next) use ($argv) {
    $status = $argv[1] ?? '';

    if ($status == 'service-closed') {
        return $response->send([
            'message' => 'Service unavailable temporary due to maintenance',
            'code' => 503,
        ], 503);
    }

    $next();
});

$router->get('/users', function (Request $request, Response $response) use (&$users) {
    return $response->send($users);
});

$router->post('/users', 'auth', function (Request $request, Response $response) use (&$users, &$users_id) {
    $body = $request->getJSON();

    $email = $body->email ?? null;
    $name = $body->name ?? null;

    if (!$email || !$name) {
        return $response->send([
            'message' => 'Email and Name are required',
            'code' => 400,
        ], 400);
    }

    $user = (object) [
        'id' => $users_id++,
        'email' => $email,
        'name' => $name,
    ];

    $users[] = $user;

    return $response->send([
        'message' => 'User created successfully',
        'code' => 201,
        'user' => $user,
    ], 201);
});

$server->use($router);

$server->listen(80, function () use ($server) {
    $server->log('Listening at port 80...');
});

If you run:

php middlewares.php service-closed

And do:

curl http://localhost/users

Or:

curl -X POST http://localhost/users -d '{"email":"john.doe@example.com","name":"John Doe"}'

You will get the following message:

{"message":"Service unavailable temporary due to maintenance","code":503}

If you kill the previous server with CTRL+C and then run:

php middlewares.php

You will be able to retrieve the users list now, e.g:

curl http://localhost/users

To create a new user you need to be authenticated, to do so, assign an encoded Bearer Token using base64 to a variable and then pass the Authorization Header to curl command:

AUTH_TOKEN=$(echo 'john.doe@example.com:1234567890' | base64)
curl -X POST http://localhost/users -d '{"email":"john.doe@example.com","name":"John Doe"}' -H "Authorization: Bearer ${AUTH_TOKEN}"

Asynchronous programming

It's still possible to do asynchronous programming with PHP Espresso by creating an asynchronous server and using the async and $next functions and callables:

<?php

require_once 'vendor/autoload.php';

use function Espresso\Event\async;
use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;

const SMALLER_FILE_PATH = __DIR__ . '/files/smaller-file.txt';
const BIGGER_FILE_PATH = __DIR__ . '/files/bigger-file.txt';

function read_file(string $path, int $bytes, callable $callable = null): void
{
    $file = fopen($path, 'r');
    $file_size = filesize($path);
    $content = '';
    $read_bytes = 0;

    async(function () use ($bytes, $callable, &$file, $file_size, &$content, &$read_bytes) {
        if ($read_bytes < $file_size) {
            $chunk_size = min($file_size - $read_bytes, $bytes);
            $chunk = fread($file, $chunk_size);
            $content .= $chunk;
            $read_bytes += $chunk_size;

            return false;
        }

        if ($callable) {
            $callable($content);
        }
    });
}

$server = new Server();
$router = new Router();

$router->get('/read-file', function (Request $request, Response $response, callable $next) {
    $size = $request->getParam('size');

    $file_path = $size == 'big' ? BIGGER_FILE_PATH : SMALLER_FILE_PATH;

    read_file($file_path, 8, function (string $content) use ($request, $response, $next, $size) {
        $response->send([
            'file_content' => $content,
            'size' => $size,
        ]);

        $next();
    });
});

$server->use($router);
$server->async(true);

$server->listen(80, function () use ($server) {
    $server->log('Listening at port 80...');
});

The async function initiates an Event Looper inside of the listen method when running in async mode by setting $server->async(true);.

async may return a boolean value (false) when the async call is not done yet and returns true or nothing when it's finished.

In this example, the async call inside of the read_file function will return false as long as the requested file is not completed yet, this by reading $bytes as a step for each chunk read through every call inside the Event Loop as an asynchronous process.

Once the whole file is read, the $callable callback will be called, passing in the content of the file on the async call by returning nothing at the very end of the function.

If you execute:

curl 'http://localhost/read-file?size=big'\
& curl 'http://localhost/read-file?size=small'\
& wait

The smaller file request will respond earlier than the larger file request despite of being executed right at the same time.

This could be a way to implement asynchronous programs and libraries for streaming, networking, databases, files, I/O operations, etc, although it's not perfect, it would require a vast work to implement lots of PHP libraries that were designed initially to be Single-Threaded and Synchronous.

Maybe the future of PHP is promising for this purpose with the introduction of tools like Fibers and stuff, but yet, we will see how it goes. πŸ™‚πŸ˜