atk4/api

Agile API - Extensible API server in PHP for Agile Data

0.2 2018-11-21 22:44 UTC

README

Build Status StyleCI codecov Code Climate Issue Count

License GitHub release

End-to-end implementation for your RESTful API and RPC. Provides a very simple means for you to define API end-points for the application that already uses Agile Data.

1. Simple To Use

Agile API strives to be very simple and work out of the box. Below is a minimal code to get your basic API going, put that into v1.php file then invoke composer require atk4/api :

include 'vendor/autoload.php';

$api = new \atk4\api\Api();

// Simple handling of GET request through a callback.
$api->get('/ping', function() {
   return 'Pong'; 
});

// Methods can accept arguments, and everything is type-safe.
$api->get('/hello/:name', function ($name) {
    return "Hello, $name";
});

2. Agile Data Integration

Agile Data is a data persistence framework. In simple terms, you can use Agile Data to create your business models (entities) and interact with the database. Agile API is designed to be a perfect integration if you are have already defined classes and persistence in Agile Data. Next code assumes you have Model Country and Persistence $db:

$api->rest('/countries', new Country($db));

This creates a standard standard-compliant RESTful interface for interfacing the client model:

  • GET /countries responds with list of all Country records from $db.
  • POST /countries adds a new Country reading data from Form data or JSON in POST body.
  • GET /countries/123 loads client with specified ID.
  • PATCH /countries/123 with some Form data or JSON will update existing Country.
  • DELETE /countries/123 will delete a record.

Through Agile UI you may add conditions, limits and more. Also second argument can be a call-back:

$api->rest('/countries', function() use($db) {
  $c = new Country($db));
  $c->addCondition('is_eu', true);
  $c->setLimit(20);
  return $c;
});

Field types, data conversions, validation and hooks can all be defined through Agile Data, making the API layer very transparent and simple. If you attempt to load non-existant record, API will respond with 404. Other errors will be properly mapped to the API codes or fallback to 500.

Work in progress

Agile UI is still a work in progress. This readme will be further updated to reflect a current features.

Planned Features

Agile API is in development but the following features are planned:

  • Simple to use.

  • Model routing. Provide end-points by associating them with models.

  • Global authentication. Provide authentication strategy for entire framework.

  • Support for rate limits. Per-account, per-IP counters which can be stored in MemCache or Redis.

  • Deep logging, integrated with data persistence. Not only stores the request, but what data was affected inside persistence.

  • Support for API UNDO. Neutralize effect of API call had on your backend.

Simple to use

To set up your API, simply create new RestAPI class instance and define routes. You can enable versioning by creating "v1" folder and placing index.php in that folder. Some things work and we do not want to re-invent them!

require 'vendor/autoload.php';
$app = new \atk4\api\Api();

$db = \atk4\data\Persistence::connect($DSN);

// Lets set our index page
$app->get('/', function() {
    return 'This worked!';
});

// Getting access to POST data
$app->post('/stats/:id', function($id, $data) {
   return ['Received POST', 'id'=>$id, 'post_data'=>$data] 
});

Calling methods such as get(), post() with a function call-back will register them and if URL matches a pattern, all the matching callbacks will be executed, that is, until some of them will present a return value.

Execution will occur as soon as the match is confirmed (to help with error display).

Technically this allows multiple call-backs to be matched:

$app->get('/:method', function($method) {
    // do something 
});

$app->get('/ping', function() {
    return 'pong';
});

Note, that some popular PHP API frameworks (like Slim) use {name} for matching parameters, however rest of IT industry prefers using ":name" instead. We will use industry pattern matching, but will try to also support {$foo}, although it does look too similar to Agile UI template tags.

I think that the methods can be cleverly made to match the rules too:

function get($route, $action) {
    if ($_SERVER['REQUEST_METHOD'] == 'GET') {
    	return $this->match($route, $action);
    }
}

A useful note about match is that it can be used without action and will return true/false.

if ($app->match('/misc/**')) {
    // .. execute logic for requests starting with /misc/...
} else {
    // .. other logic
}

Model Routing

Method rest() implements a standard Restful API end-point dedicated to a model. There are two ways to use it:

$app->rest('/clients', new Client($db));

This would simple enable all the necessary operations for accessing the model, in particular:

  • GET /clients - listing all clients
  • GET /clients/:id - get specific client data
  • POST /clients - create new client
  • PUT /clients/:id - same as patch
  • PATCH /clients/:id - load, update specified fields only, save
  • DELETE /clients/:id - delete specific client record

You can also specify a different field if you don't want to use primary key:

$app->rest('/country/:iso_name');

Agile Data offers powerful ways of traversing references, and the above approach can also utilize:

$app->rest('/clients/:id/orders/::Orders:id', new Client($db));

This would create new route for URLs such as /clients/123/orders/395. The model for the client with id 123 would be loaded first, then ref('Orders') would be executed. The rest of the logic is similar to before.

This gives us option to perform deep traversal too:

$app->rest('/clients/:id/order_payments/::Orders::Payments:id', new Client($db));

This would load the Client, perform ref('Orders')->ref('Payments'). Finally, the "id" is optional:

$app->rest('/client/:/order_payments/::Orders::Payments', new Client($db));

Sometimes you would want to have even more control, so you can use:

$app->rest('/client/:id/invoices-due', function($id) use($db) {
    $client = new Client($db);
    $client->load($id);
    return $client->ref('Invoices')->addCondition('status', 'due');
});

Method rest() builds on top of methods put(), get(), post() and others. Third argument to method rest() can specify array with options.

Auth

Our API supports various authentication methods. Some of them are built-in and 3rd party extensions can also be used.

Lets look at the very basic user/password authentication.

// Enable user/password authentication. Field values are optional
$app->userAuth('/**', new User($db));

You can place the authentication method strategically, and it will protect all the further routes but not the ones above it. Also you can use a custom route if you wish to only protect some portion of your API.

The method AUTH will look for HTTP_AUTH headers and will respond with 405 code if user record cannot be loaded with a corresponding user/password combination.

After user authentication is performed, $app->user will exist:

$app->authUser('/**', new User($db));
$app->rest('/notifications', $app->user->ref('Notifications'));

Rate Limit

Rate Limit support will limit number of requests which user (or IP) can make. It's easy to set it up:

$app->authUser('/**', new User($db));

$limit = new \atk4\api\Limit($db);
$limit->addCondition('user_id', $app->user->id);
  
$app->get('/limits', function() use ($limit){ 
    return $limit;
});

$app->rateLimit('/**', $limit, 10);  // 10 requests per minute

$app->rest('/notifications', $app->user->ref('Notifications'));

It's preferable to use rate limits with persistence such as Redis or Memcache:

$cache = \atk4\data\Persistence\MemCache($conn);
$limit = new \atk4\api\Limit($cache);

Deep logging

Agile Data already supports audit log, but with Agile API you can compliment that even further:

$audit_id = $app->auditLog(
  '/**', 
  new \atk4\audit\Controller(
    new \atk4\audit\model\AuditLog($db)
  )
);

This would create a log entry per invocation and use it for all the subsequent changes inside data persistence.

Note that the $audit_id produced by the above function can also be used for UNDO action:

$app->auditLog->load($audit_id)->undo();

which would also reverse all the changes done on the persistence layer.

Error Logging

Similarly to Agile UI, the application for API will catch exceptions raised.

$app->?

System support and global scoping

Agile Data supports global scoping, so you can add additional hook that would affect creation of all the models and add some further conditioning. That's useful based off the Auth response:

$user_id = $app->authUser('/**', new User($db));

$db->addHook('afterAdd', function($o, $e) use ($user_id) {
    if ($e->hasElement('user_id')) {
        $e->addCondition('user_id', $user_id);
    }
})

Mapping to file-system

$app->map('/:resource/**', function(resource) use($app) {
  
    // convert user-credit to UserCredit
    $class = preg_replace('/[^a-zA-Z]/', '', ucwords($resoprce));
  
  	$object = $app->factory($class, null, 'Interface'); // Interface\UserCredit.php
  
  	return [$object, $app->method]; 
    // convert path to file
    // load file
    // create class instance
    // call method of that class
  
    // TODO: think of some logical example here!!
});

Optional Arguments

Agile API supports various get arguments.

  • ?sort=name,-age specify columns to sort by.
  • ?q=search, will attempt to perform full-text search by phrase. (if supported by persistence)
  • ?condition[name]=value, conditioning, but can also use ?name=value
  • ?limit=20, return only 20 results at a time.
  • ?skip=20, skip first 20 results.
  • ?only=name,surname specify onlyFields
  • ?ad={transformation}, apply Agile Data transformation

Handling of those arguments happens inside function args(). It's passed in a Model, so it will look at the GET arguments and perform the necessary changes.

function args(\atk4\data\Model $m) {
    if ($_GET['sort']) {
        $m->sortBy($_GET['sort']);
    }
  
    if ($_GET['condition']) {
    	foreach($_GET['condition'] as $key=>$val) {
            $m->addCondition($key, $val);
        }
    }
  
    if ($_GET['limit'] || $_GET['skip']) {
        $m->setLimit($_GET['limit']?:null, $_GET['skip']?:null);
    }
  
    // etc. etc...
}

Other points

Agile API is JSON only. You might be able to add XML output, but why.

Agile API does not use envelope. Response data will be "[]" for empty result. If there is a problem with response, you'll get it through status code, in which case output will change.

Agile API does not support HATEOAS. Technically you should be able to add support for it, but it would require a more complex mapping or extra code. We prefer to keep things simple.

Agile API will pretty-print JSON by default, so make sure "gzip" is enabled.

Agile API will accept either raw JSON or Form encoded input, but examples will always use JSON

Agile API does not use "pagination" instead "limit" and "skip" values. You can introduce pages if you wish.

Deep-loading resources is something that you can add. For instance if you load "Invoice" it may contain "lines" array containing list of hashes. Documentation will be provided on how to make this possible. There will also get argument to instruct if deep-loading is needed.

Errors and exceptions will contain "error", "message" and "args" keys. Optional key "raised_by" may contain another object with same keys if said error was raised by another error. Another possibility is "description" field.

(see http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api)

https://www.reddit.com/r/PHP/comments/32tbxs/looking_for_php_rest_api_framework/

testing / behat: http://restler3.luracast.com/examples/_001_helloworld/readme.html

URL patterns

Here are some examples

  • /user/:id matches /user/123 , /user/123/ , /user/abc/ but won't match /user/123/x
  • /user/: same as above
  • /user/:/: matches /user/123/321 but won't match /user/123
  • /user/*/: matches /user/blah/123 but will ignore blah
  • /user/**/: incorrect, as ** must be last.
  • /user/:/** matches /user/123/blah and /user/123/foo/blah and /user/123
  • /user/:id/:action? optional parameter. If unspecified will be null

Route Groups

It's possible to divert route group to a different App.

$app = new \atk4\app\Api();

$app->group('/user/**', function($app2) {
   $app2->get('/test', function() {
     return 'yes';
   });
});

You can also divert