Notice: This post has been submitted to Toptal as part of the recognition of PHP-related skills process.
Should we be in a RESTful context or not, a request made to a server is always a solicitation to perform an operation and, as such, can be interpreted as one of those 3 verbs:
So, basically we only have three types of operations which, in turn, can easily be mapped with the methods defined by the HTTP protocol:
The underlying idea suggested by this article is that it is possible to decompose an application logic into trivial controllers.
To achieve so, all it takes is to associate each route with an 'operation' and to define a single entry-point that routes each request to the appropriate controller through a 'run' method.
In a web server context, if we use index.php
as entry-point, the query string of the URI should bear the following info:
?request_type=[public|private:]path_to_controller
along with some optional parameters.
The advantage of such architecture is that it allows to be invoked in similar ways whatever the context:
php run.php --get=public:qinoa_tests --id=1 --test=2 --announce=true
run('get', 'public:qinoa_tests', ['id'=>1, 'test'=>2]);
/index.php?get=qinoa_tests&id=1&test=2
For instance a controller can easily rely on the result of another controller, being invoked inside a same request.
In that logic, a controller is nothing more than a PHP script in charge of:
In addition, it would be nice for every controller to provide a short description of what it does and what is its intended usage.
Here is an example of such an announce()
method from file public/packages/demo/data/books/suggestions.php
list($params, $providers) = announce([
'description' => 'Fetches a resource ',
'params' => [
'keywords' => [
'description' => "Keywords that catch your child attention",
'type' => 'array',
'default' => []
],
'age' => [
'description' => 'Your child age',
'type' => 'integer',
'min' => 4,
'required' => true
]
],
'response' => [
'content-type' => 'application/json',
'charset' => 'utf-8'
],
'providers' => ['orm', 'context']
]);
And here the sample code that handles the valid requests:
list($context) = [$providers['context']];
$store = [
4 => ['Goldielocks and the three bears', 'Three little pigs'],
5 => ["Charlotte's web", 'The Little Prince'],
8 => ['Charly and the chocolate factory', 'Alice in Wonderland'],
10 => ['Harry Potter', 'The Jungle book']
];
$result = [];
foreach($store as $age => $books) {
if($age <= $params['age']) {
if(count($params['keywords'])) {
foreach($params['keywords'] as $keyword) {
foreach($books as $book) {
if(stripos($book, $keyword) !== false) {
$result[] = $book;
}
}
}
}
else {
$result = $result + $books;
}
}
else break;
}
$context->httpResponse()->body($result)->send();
This controller can then be invoked these ways:
php run.php --get=demo_books_suggestions --keywords=bears,chocolate --age=10
/index.php?get=demo_books_suggestions&age=4
Finally, here is an excerpt of (a simplified version of) the run()
method:
public static function run($type, $operation, $body=[], $root=false) {
$result = '';
$resolved = [
'type' => $type, // 'do', 'get' or 'show'
'operation' => null, // {package}_{script_path}
'visibility'=> 'public', // 'public' or 'private'
'package' => null, // {package}
'script' => null // {path/to/script.php}
];
// define valid operations specifications
$operations = array(
'do' => array('kind' => 'ACTION_HANDLER','dir' => 'actions'),
'get' => array('kind' => 'DATA_PROVIDER', 'dir' => 'data'),
'show' => array('kind' => 'APPLICATION', 'dir' => 'apps')
);
// retrieve services container instance
$container = Container::getInstance();
$context = $container->get('context');
// adapt current request
$request = $context->httpRequest();
$request->body($body);
// extract parts from given operation
$operation = explode(':', $operation);
if(count($operation) > 1) {
$visibility = array_shift($operation);
if($visibility == 'private') $resolved['visibility'] = $visibility;
}
$resolved['operation'] = $operation[0];
// include resolved script, if any
if(isset($operations[$resolved['type']])) {
$operation_conf = $operations[$resolved['type']];
// store current operation into context
$context->set('operation', $resolved['operation']);
$filename = 'packages/'.
$resolved['package'].'/'.
$operation_conf['dir'].'/'.
$resolved['script'];
// set current dir according to visibility (i.e. 'public' or 'private')
chdir(QN_BASE_DIR.'/'.$resolved['visibility']);
include($filename);
}
}
In such context, an API simply defines entry points to interact with the application by connecting a set of routes to the controllers they're associated to.
So, at the end of the day, there are only 2 kinds of controllers:
action handlers
a) operations on objects (create, update, delete)
b) operations on App state
data providers
a) operations on objects (find, read, list)
e.g.: GET /api/v1/user/321
b) utilities (data generation based on provided params and/or App state)
examples:
GET /api/v1/sql-schema
POST /api/v1/graphql
Finally, here is an example of a JSON file holding the required information in order to route API calls to their related routes :
{
"/users": {
"GET": {
"description": "Retrieve all users matching given criteria",
"operation": "?get=qinoa_model_collection&entity=core\\User"
}
},
"/user/:id": {
"GET": {
"description": "Retrieve fields values related to a given user",
"operation": "?get=qinoa_model_object&entity=core\\User"
},
"POST": {
"description": "Create a new user",
"operation": "?do=qinoa_model_create&entity=core\\User"
},
"PUT": {
"description": "Update a user",
"operation": "?do=qinoa_model_update&entity=core\\User"
},
"DELETE": {
"description": "Delete a user",
"operation": "?do=qinoa_model_delete&entity=core\\User"
}
},
"/user": {
"POST": {
"description": "Create a new user",
"operation": "?do=qinoa_model_create&entity=core\\User"
}
},
"/me": {
"GET": {
"description": "Return authentified user, if any",
"operation": "?get=qinoa_me"
}
}
}