Working with requests

As you might recall from previous chapters, the main purpose of a web application is to process HTTP requests coming from the client and return a response. If that is the main goal of your application, managing requests and responses should be an important part of your code.

PHP is a language that can be used for scripts, but its main usage is in web applications. Due to this, the language comes ready with a lot of helpers for managing requests and responses. Still, the native way is not ideal, and as good OOP developers, we should come up with a set of classes that help with that. The main elements for this small project—still inside your application—are the request and the router. Let's start!

The request object

As we start our mini framework, we need to change our directory structure a bit. We will create the src/Core directory for all the classes related to the framework. As the configuration reader from the previous chapters is also part of the framework (rather than functionality for the user), we should move the Config.php file to this directory too.

The first thing to consider is what a request looks like. If you remember Chapter 2, Web Applications with PHP, a request is basically a message that goes to a URL, and has a method—GET or POST for now. The URL is at the same time composed of two parts: the domain of the web application, that is, the name of your server, and the path of the request inside the server. For example, if you try to access http://bookstore.com/my-books, the first part, http://bookstore.com, would be the domain and /my-books would be the path. In fact, http would not be part of the domain, but we do not need that level of granularity for our application. You can get this information from the global array $_SERVER that PHP populates for each request.

Our Request class should have a property for each of those three elements, followed by a set of getters and some other helpers that will be useful for the user. Also, we should initialize all the properties from $_SERVER in the constructor. Let's see what it would look like:

<?php

namespace BookstoreCore;

class Request {
    const GET = 'GET';
    const POST = 'POST';

    private $domain;
    private $path;
    private $method;

    public function __construct() {
        $this->domain = $_SERVER['HTTP_HOST'];
        $this->path = $_SERVER['REQUEST_URI'];
        $this->method = $_SERVER['REQUEST_METHOD'];
    }

    public function getUrl(): string {
        return $this->domain . $this->path;
    }

    public function getDomain(): string {
        return $this->domain;
    }

    public function getPath(): string {
        return $this->path;
    }

    public function getMethod(): string {
        return $this->method;
    }

    public function isPost(): bool {
        return $this->method === self::POST;
    }

    public function isGet(): bool {
        return $this->method === self::GET;
    }
}

We can see in the preceding code that other than the getters for each property, we added the methods getUrl, isPost, and isGet. The user could find the same information using the already existing getters, but as they will be needed a lot, it is always good to make it easier for the user. Also note that the properties are coming from the values of the $_SERVER array: HTTP_HOST, REQUEST_URI, and REQUEST_METHOD.

Filtering parameters from requests

Another important part of a request is the information that comes from the user, that is, the GET and POST parameters, and the cookies. As with the $_SERVER global array, this information comes from $_POST, $_GET, and $_COOKIE, but it is always good to avoid using them directly, without filtering, as the user could send malicious code.

We will now implement a class that will represent a map—key-value pairs—that can be filtered. We will call it FilteredMap, and will include it in our namespace, BookstoreCore. We will use it to contain the parameters GET and POST and the cookies as two new properties in our Request class. The map will contain only one property, the array of data, and will have some methods to fetch information from it. To construct the object, we need to send the array of data as an argument to the constructor:

<?php

namespace BookstoreCore;

class FilteredMap {
    private $map;

    public function __construct(array $baseMap) {
        $this->map = $baseMap;
    }
    
    public function has(string $name): bool {
        return isset($this->map[$name]);
    }

    public function get(string $name) {
        return $this->map[$name] ?? null;
    }
}

This class does not do much so far. We could have the same functionality with a normal array. The utility of this class comes when we add filters while fetching data. We will implement three filters, but you can add as many as you need:

public function getInt(string $name) {
    return (int) $this->get($name);
}

public function getNumber(string $name) {
    return (float) $this->get($name);
}

public function getString(string $name, bool $filter = true) {
    $value = (string) $this->get($name);
    return $filter ? addslashes($value) : $value;
}

These three methods in the preceding code allow the user to get parameters of a specific type. Let's say that the developer needs to get the ID of the book from the request. The best option is to use the getInt method to make sure that the returned value is a valid integer, and not some malicious code that can mess up our database. Also note the function getString, where we use the addSlashed method. This method adds slashes to some of the suspicious characters, such as slashes or quotes, trying to prevent malicious code with it.

Now we are ready to get the GET and POST parameters as well as the cookies from our Request class using our FilteredMap. The new code would look like the following:

<?php

namespace BookstoreCore;

class Request {
    // ...
    private $params;
    private $cookies;

    public function __construct() {
        $this->domain = $_SERVER['HTTP_HOST'];
        $this->path = explode('?', $_SERVER['REQUEST_URI'])[0];
        $this->method = $_SERVER['REQUEST_METHOD'];
        $this->params = new FilteredMap(
            array_merge($_POST, $_GET)
        );
        $this->cookies = new FilteredMap($_COOKIE);
    }

    // ...

    public function getParams(): FilteredMap {
        return $this->params;
    }

    public function getCookies(): FilteredMap {
        return $this->cookies;
    }
}

With this new addition, a developer could get the POST parameter price with the following line of code:

$price = $request->getParams()->getNumber('price');

This is way safer than the usual call to the global array:

$price = $_POST['price'];

Mapping routes to controllers

If you can recall from any URL that you use daily, you will probably not see any PHP file as part of the path, like we have with http://localhost:8000/init.php. Websites try to format their URLs to make them easier to remember instead of depending on the file that should handle that request. Also, as we've already mentioned, all our requests go through the same file, index.php, regardless of their path. Because of this, we need to keep a map of the URL paths, and who should handle them.

Sometimes, we have URLs that contain parameters as part of their path, which is different from when they contain the GET or POST parameters. For example, to get the page that shows a specific book, we might include the ID of the book as part of the URL, such as /book/12 or /book/3. The ID will change for each different book, but the same controller should handle all of these requests. To achieve this, we say that the URL contains an argument, and we could represent it by /book/:id, where id is the argument that identifies the ID of the book. Optionally, we could specify the kind of value this argument can take, for example, number, string, and so on.

Controllers, the ones in charge of processing requests, are defined by a method's class. This method takes as arguments all the arguments that the URL's path defines, such as the ID of the book. We group controllers by their functionality, that is, a BookController class will contain the methods related to requests about books.

Having defined all the elements of a route—a URL-controller relationship—we are ready to create our routes.json file, a configuration file that will keep this map. Each entry of this file should contain a route, the key being the URL, and the value, a map of information about the controller. Let's see an example:

{
  "books/:page": {
    "controller": "Book",
    "method": "getAllWithPage",
    "params": {
      "page": "number"
    }
  }
}

The route in the preceding example refers to all the URLs that follow the pattern /books/:page, with page being any number. Thus, this route will match URLs such as /books/23 or /books/2, but it should not match /books/one or /books. The controller that will handle this request should be the getAllWithPage method from BookController; we will append Controller to all the class names. Given the parameters that we defined, the definition of the method should be something like the following:

public function getAllWithPage(int $page): string {
    //...
}

There is one last thing we should consider when defining a route. For some endpoints, we should enforce the user to be authenticated, such as when the user is trying to access their own sales. We could define this rule in several ways, but we chose to do it as part of the route, adding the entry "login": true as part of the controller's information. With that in mind, let's add the rest of the routes that define all the views that we expect to have:

{
//...
  "books": {
    "controller": "Book",
    "method": "getAll"
  },
  "book/:id": {
    "controller": "Book",
    "method": "get",
    "params": {
      "id": "number"
    }
  },
  "books/search": {
    "controller": "Book",
    "method": "search"
  }, 
  "login": {
    "controller": "Customer",
    "method": "login"
  },
  "sales": {
    "controller": "Sales",
    "method": "getByUser" ,
    "login": true
  },
  "sales/:id": {
    "controller": "Sales",
    "method": "get",
    "login": true,
    "params": {
      "id": "number"
    }
  },
  "my-books": {
    "controller": "Book",
    "method": "getByUser",
    "login": true
  }
}

These routes define all the pages we need; we can get all the books in a paginated way or specific books by their ID, we can search books, list the sales of the user, show a specific sale by its ID, and list all the books that a certain user has borrowed. However, we are still lacking some of the endpoints that our application should be able to handle. For all those actions that are trying to modify data rather than requesting it, that is, borrowing a book or buying it, we need to add endpoints too. Add the following to your routes.json file:

{
  // ...
  "book/:id/buy": {
    "controller": "Sales",
    "method": "add",
    "login": true
    "params": {
      "id": "number"
    }
  },
  "book/:id/borrow": {
    "controller": "Book",
    "method": "borrow",
    "login": true
    "params": {
      "id": "number"
    }
  },
  "book/:id/return": {
    "controller": "Book",
    "method": "returnBook",
    "login": true
    "params": {
      "id": "number"
    }
  }
}

The router

The router will be by far the most complicated piece of code in our application. The main goal is to receive a Request object, decide which controller should handle it, invoke it with the necessary parameters, and return the response from that controller. The main goal of this section is to understand the importance of the router rather than its detailed implementation, but we will try to describe each of its parts. Copy the following content as your src/Core/Router.php file:

<?php

namespace BookstoreCore;

use BookstoreControllersErrorController;
use BookstoreControllersCustomerController;

class Router {
    private $routeMap;
    private static $regexPatters = [
        'number' => 'd+',
        'string' => 'w'
    ];

    public function __construct() {
        $json = file_get_contents(
            __DIR__ . '/../../config/routes.json'
        );
        $this->routeMap = json_decode($json, true);
    }

    public function route(Request $request): string {
        $path = $request->getPath();

        foreach ($this->routeMap as $route => $info) {
            $regexRoute = $this->getRegexRoute($route, $info);
            if (preg_match("@^/$regexRoute$@", $path)) {
                return $this->executeController(
                    $route, $path, $info, $request
                );
            }
        }

        $errorController = new ErrorController($request);
        return $errorController->notFound();
    }
}

The constructor of this class reads from the routes.json file, and stores the content as an array. Its main method, route, takes a Request object and returns a string, which is what we will send as output to the client. This method iterates all the routes from the array, trying to match each with the path of the given request. Once it finds one, it tries to execute the controller related to that route. If none of the routes are a good match to the request, the router will execute the notFound method of the ErrorController, which will then return an error page.

URLs matching with regular expressions

While matching a URL with the route, we need to take care of the arguments for dynamic URLs, as they do not let us perform a simple string comparison. PHP—and other languages—has a very strong tool for performing string comparisons with dynamic content: regular expressions. Being an expert in regular expressions takes time, and it is outside the scope of this book, but we will give you a brief introduction to them.

A regular expression is a string that contains some wildcard characters that will match the dynamic content. Some of the most important ones are as follows:

  • ^: This is used to specify that the matching part should be the start of the whole string
  • $: This is used to specify that the matching part should be the end of the whole string
  • d: This is used to match a digit
  • w: This is used to match a word
  • +: This is used for following a character or expression, to let that character or expression to appear at least once or many times
  • *: This is used for following a character or expression, to let that character or expression to appear zero or many times
  • .: This is used to match any single character

Let's see some examples:

  • The pattern .* will match anything, even an empty string
  • The pattern .+ will match anything that contains at least one character
  • The pattern ^d+$ will match any number that has at least one digit

In PHP, we have different functions to work with regular expressions. The easiest of them, and the one that we will use, is pregmatch. This function takes a pattern as its first argument (delimited by two characters, usually @ or /), the string that we are trying to match as the second argument, and optionally, an array where PHP stores the occurrences found. The function returns a Boolean value, being true if there was a match, false otherwise. We use it as follows in our Route class:

preg_match("@^/$regexRoute$@", $path)

The $path variable contains the path of the request, for example, /books/2. We match using a pattern that is delimited by @, has the ^ and $ wildcards to force the pattern to match the whole string, and contains the concatenation of / and the variable $regexRoute. The content of this variable is given by the following method; add this as well to your Router class:

private function getRegexRoute(
    string $route,
    array $info
): string {
    if (isset($info['params'])) {
        foreach ($info['params'] as $name => $type) {
            $route = str_replace(
                ':' . $name, self::$regexPatters[$type], $route
            );
        }
    }

    return $route;
}

The preceding method iterates the parameters list coming from the information of the route. For each parameter, the function replaces the name of the parameter inside the route by the wildcard character corresponding to the type of parameter—check the static array, $regexPatterns. To illustrate the usage of this function, let's see some examples:

  • The route /books will be returned without a change, as it does not contain any argument
  • The route books/:id/borrow will be changed to books/d+/borrow, as the URL argument, id, is a number

Extracting the arguments of the URL

In order to execute the controller, we need three pieces of data: the name of the class to instantiate, the name of the method to execute, and the arguments that the method needs to receive. We already have the first two as part of the route $info array, so let's focus our efforts on finding the third one. Add the following method to the Router class:

private function extractParams(
    string $route,
    string $path
): array {
    $params = [];

    $pathParts = explode('/', $path);
    $routeParts = explode('/', $route);

    foreach ($routeParts as $key => $routePart) {
        if (strpos($routePart, ':') === 0) {
            $name = substr($routePart, 1);
            $params[$name] = $pathParts[$key+1];
        }
    }

    return $params;
}

This last method expects that both the path of the request and the URL of the route follow the same pattern. With the explode method, we get two arrays that should match each of their entries. We iterate them, and for each entry in the route array that looks like a parameter, we fetch its value in the URL. For example, if we had the route /books/:id/borrow and the path /books/12/borrow, the result of this method would be the array ['id' => 12].

Executing the controller

We end this section by implementing the method that executes the controller in charge of a given route. We already have the name of the class, the method, and the arguments that the method needs, so we could make use of the call_user_func_array native function that, given an object, a method name, and the arguments for the method, invokes the method of the object passing the arguments. We have to make use of it as the number of arguments is not fixed, and we cannot perform a normal invocation.

But we are still missing a behavior introduced when creating our routes.json file. There are some routes that force the user to be logged in, which, in our case, means that the user has a cookie with the user ID. Given a route that enforces authorization, we will check whether our request contains the cookie, in which case we will set it to the controller class through setCustomerId. If the user does not have a cookie, instead of executing the controller for the current route, we will execute the showLogin method of the CustomerController class, which will render the template for the login form. Let's see how everything would look on adding the last method of our Router class:

private function executeController(
    string $route,
    string $path,
    array $info,
    Request $request
): string {
    $controllerName = 'BookstoreControllers'
        . $info['controller'] . 'Controller';
    $controller = new $controllerName($request);

    if (isset($info['login']) && $info['login']) {
        if ($request->getCookies()->has('user')) {
            $customerId = $request->getCookies()->get('user');
            $controller->setCustomerId($customerId);
        } else {
            $errorController = new CustomerController($request);
            return $errorController->login();
        }
    }

    $params = $this->extractParams($route, $path);
    return call_user_func_array(
        [$controller, $info['method']], $params
    );
}

We have already warned you about the lack of security in our application, as this is just a project with didactic purposes. So, avoid copying the authorization system implemented here.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.149.253.210