Design API URL's
Let's go by example. I have website where people can search for frequency for healing the "clostridium tetani" based on Hulda Clarck research. So API will be very simple for searching the frequency for curing that disease.
Lets design URLS on how it shouldn't be:
searchAllDiseases/?query=clostridium+tetani
retriveDiseasefirstTopTen/clostridium+tetani
showFrequencyOf/clostridium+tetani
This is great example, where you as developer you have to search all actions to get started with working with API. This is where we do next: BACK TO DRAWING BOARD, STARTING AGAIN
Let's see how easier can it be done with RESTfull design:
GET diseases/
This one is not much useful, because it calls all diseases and we need specific disease.
To not list more then 10 results, the default should be present in the code. To avoid unnecessary load on servers and nwtwork.
GET diseases/clostridium+tetani
Or
GET diseases/q=clostridium+tetani
Or
GET diseases/?q=clostridium+tetani
Or
GET diseases/?query=clostridium+tetani
Or
GET diseases/search/clostridium+tetani
Why 3 options? I want developers to just go on their intuition. If some old developers choose to take old approach and use question mark (?) or q or query, then all of that should work well.
Now we have all frequencies on clostridium+tetani.
At our example there maybe a couple of frequencies, but when we have more then 1.000 frequencies we could have problem with servers. So pagination is needed. To reduce developers getting too much data from server we need to set default value of retrieved data from server. Then let the developer be specific if he/she needs more results. Here is how this problem can be solved:
GET /diseases/clostridium+tetani?limit=10&offset=50
This could have another solution too, maybe people are still used to call keyword as some variable that is not like part after slash, but as part that could come after ? sign so we could code like this:
GET /diseases/?query=clostridium+tetani&limit=10&offset=50
We could make it both work in our API to try to achieve that developers don’t need to go to the documentation, but rather intuitively guess what could it be.
CODE
for this purpose I will use Slim framework, but it can be used any framework like Codeigniter, Zend, Kohana, Symphony etc.
First install slim framework. I will be using composer in my example:
Have a look at http://www.slimframework.com/docs/tutorial/first-app.html how to set slim framework.
This is how I did on the c9.io. I made the folder API and installed slim framework with composer:
composer require slim/slim:~2.0
That code installed the slim framework in the folder vendor and so in the code I can include it like this:
require 'vendor/autoload.php';
$app = new \Slim\Slim();
$app->get('/', 'getDiseases');
$app->get('/diseases', 'getDiseases');
$app->get('/diseases/:id', 'getDisease');
$app->get('/diseases/search/:query', 'findDiseaseByName');
$app->post('/diseases', 'addDisease');
$app->put('/diseases/:id', 'updateDisease');
$app->delete('/diseases/:id', 'deleteDisease');
$app->run();
Here I made new object with routes that point to functions.
Let's see how next URL works:
/diseases
Next code is function that has been called:
function getDiseases() {
$offset = (isset($_GET['offset']) && $_GET['offset'] > 0) ? intval($_GET['offset']) : 1;
$limit = isset($_GET['limit']) ? intval($_GET['limit']) : 10;
$sql = "SELECT * FROM disease LIMIT ".intval($limit)." OFFSET ". intval($offset);
$count_query = "SELECT COUNT(*) as count FROM disease";
try {
$db = getConnection();
$stmt = $db->prepare($sql);
$stmt->execute();
$diseases = $stmt->fetchAll(PDO::FETCH_OBJ);
$stmt = $db->prepare($count_query);
$stmt->execute();
$countQuery = $stmt->fetchAll(PDO::FETCH_OBJ);
$db = null;
$res = '{
"meta": {
"total-results": '.$countQuery[0]->count.'
},
"data": [
{
"diseases": '.json_encode($diseases).'
}
],
"links": {
"self": "/diseases?limit='. $limit .'&offset='. $offset .'"
}
}';
echo $res;
} catch(PDOException $e) {
echo '{"error":{"text":'. $e->getMessage() .'}}';
}
}
This function calls database to give the results of database with default offset and limit. If offset and limit is called then it will bring results with offset and limit back. Then I echo JSON and the api can be used.
To test my codes I use Postman chrome plugin and when I was testing I had to switch from HTML to JSON. Why? Because I didn't set header. So above in the code let's do that:
$app->response->headers->set('Content-Type', 'application/json');
Now in postman it returns json format.
That was slim framework 2, but lets update this project to Slim framework 3 and lets move all database calls and all other calls to Model. When app is growing the codes needs to have better structure to work easier with it.
First thing first update from Slim framework 2 to slimframework 3.
we had:
"require": {
"slim/slim": "~2.0",
"slim/extras": "*",
"slim/middleware": "*"
},
"archive": {
"exclude": ["vendor", ".DS_Store", "*.log"]
},
}
Let's update by changing the composer like so:
"require": {
"slim/slim": "~3.0",
"slim/extras": "*",
"slim/middleware": "*"
},
"archive": {
"exclude": ["vendor", ".DS_Store", "*.log"]
}
}
and got to commanline and say:composer update
That will remove slim framework 2 and install slimframework 3.
When app is growing we need to structure the code so lets do that.
I will have next structure:
- Myclasses/models/
- models.php
- public/
- index.php
- .htaccess
- vendor
- bootstrap.php
so start with bootstrap.php
require realpath(__DIR__ . '/vendor/autoload.php');
$config['displayErrorDetails'] = true;
$config['db']['host'] = getenv('IP');
$config['db']['user'] = getenv('C9_USER');
$config['db']['pass'] = "";
$config['db']['dbname'] = "frequency";
$config['db']['port'] = "";
$app = new \Slim\App(["settings" => $config]);
$model = new \Myclasses\Models\Model($config['db']);
above we see require vendor/autoload.php where all classes are with installation of composser.
then the database config file, at my case becasue of cloud 9 IDE c9.io I have getenv('IP') and getenv('C9_USER') as login credidentials.
Then I start slim as object and moving all databse queries to model. I just love to have all database calls and everything else to classes and as controller or index.php at my case to be as skinny as possible.
What I did is in the composer added ps4 load of classes. and now composer looks like this:
"require": {
"slim/slim": "~3.0",
"slim/extras": "*",
"slim/middleware": "*"
},
"archive": {
"exclude": ["vendor", ".DS_Store", "*.log"]
},
"autoload": {
"psr-4":{
"Myclasses\\": "Myclasses"
}
}
}
Now I have all classes loaded under the directory Myclasses and I can call it like:
$model = new \Myclasses\Models\Model($config['db']);
now lets go to index.php and modify it:
use \Psr\Http\Message\ServerRequestInterface as Request;
use \Psr\Http\Message\ResponseInterface as Response;
require_once __DIR__ . '/../bootstrap.php';
$app->get('/diseases', function (Request $request, Response $response) use ($app, $model){
$render = $model->getDiseases($request->getAttribute('offset'), $request->getAttribute('limit'));
$response->withStatus(200)
->withHeader('Content-Type', 'application/json')
->write(json_encode($render));
});
$app->get('/diseases/{id}', function (Request $request, Response $response, $args) use ($app, $model){
$render = $model->getDisease(intval($args['id']));
$response->withStatus(200)
->withHeader('Content-Type', 'application/json')
->write(json_encode($render));
});
Now lets look at our routers, it calls class and method of getDiseases with some parameters, now lets open our model.php and see what is going on there:
<?php
namespace Myclasses\Models;
use PDO;
class Model {
private $db;
function __construct($config)
{
$dsn = 'mysql:host=' . $config['host'] . ';dbname=' . $config['dbname'] . ';port=' . $config['port'];
$options = array(PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ, PDO::ATTR_ERRMODE => PDO::ERRMODE_WARNING);
$this->db = new PDO($dsn, $config['user'], $config['pass'], $options);
}
function getDiseases($offset, $limit) {
$offset = (isset($offset) && $offset > 0) ? intval($offset) : 1;
$limit = isset($limit) ? intval($limit) : 10;
$sql = "SELECT * FROM disease LIMIT ".intval($limit)." OFFSET ". intval($offset);
$count_query = "SELECT COUNT(*) as count FROM disease";
try {
$stmt = $this->db->prepare($sql);
$stmt->execute();
$diseases = $stmt->fetchAll(PDO::FETCH_OBJ);
$stmt = $this->db->prepare($count_query);
$stmt->execute();
$countQuery = $stmt->fetchAll(PDO::FETCH_OBJ);
$res = array("meta" =>
array('total-results' => $countQuery[0]->count),
"data" =>
array('diseases' => $diseases),
"links" =>
array('self' => '/diseases?limit='. $limit .'&offset='. $offset)
);
return $res;
} catch(PDOException $e) {
echo '{"error":{"text":'. $e->getMessage() .'}}';
}
}
public function getDisease($id){
//we could go with left join, but the thing is if this have to be fast preforming site
//so lets choose for simple two queries
//SELECT disease . * , frequency . *
//FROM disease
//INNER JOIN frequency ON disease.id = frequency.disease_id
//WHERE disease.id =55
//ORDER BY frequency.freq
//LIMIT 0 , 30;
//This example can give you great feature from mysql 5.7 to design table as MongoDB does ;)
//and then with one query you have all results you need, but table design is also different
$sql = "SELECT * FROM disease WHERE id=:id";
try {
$stmt = $this->db->prepare($sql);
$stmt->bindParam("id", intval($id));
$stmt->execute();
$disease = $stmt->fetchObject();
$sql = "SELECT * FROM frequency WHERE disease_id = ".intval($disease->id);
try {
$stmt = $this->db->prepare($sql);
$stmt->execute();
$freq = $stmt->fetchAll(PDO::FETCH_OBJ);
$freq_list = array();
foreach($freq as $fr){
$freq_list['frequencies'][] = $fr->freq;
}
$list = array_merge((array)$disease, $freq_list);
$res = array("meta" => "",
"data" =>
array('diseases' => $list),
"links" =>
array('self' => '/disease/'. $id)
);
return $res;
} catch(PDOException $e) {
echo '{"error":{"text":'. $e->getMessage() .'}}';
}
} catch(PDOException $e) {
echo '{"error":{"text":'. $e->getMessage() .'}}';
}
}
Ar the begining I have __construct where the database is initiated every time I call that function, then I have methode getdiseases and that fetch the data from database and gives array back to index.php and index.php print out valid json code with headers.
$response->withStatus(200)
->withHeader('Content-Type', 'application/json')
->write(json_encode($render));
With status of 200 OK and header of content type of JSON and the it writes in to the body json encoded array.
Same thing we have with get diseases with id and from index.php is called like this:
$app->get('/diseases/{id}', function (Request $request, Response $response, $args) use ($app, $model){
$render = $model->getDisease(intval($args['id']));
$response->withStatus(200)
->withHeader('Content-Type', 'application/json')
->write(json_encode($render));
});
The id is passed to model and model returns the array and it prints all out.
At model you can see I called two queries, because it is faster then any join, but from mysql 5.7 you have option to design database as MongoDB and have it all in one query and mucher faster. But that I leave it right now like this, but you could go and experiement with this :)
Lets get back to design of response.
One of the easiest json representation that I found is here: https://labs.omniti.com/labs/jsend, it is simpel and preatty sraight forward.
There is meta object where I passed extra info how much pages are. Here you can place all additional informtions.
Data section
In the secion data we have data that API user is asking. Not much to say, it gives data that user is asking. I could go ferder to standarizied all kind of things, but lets keep it simpel and easy to work with API.
Errors section
just give useful error to the API user, where he can understand where the problem is.
Use HATEOAS
Hypermedia as the Engine of Application State is a principle that hypertext links should be used to create a better navigation through the API.
So far so good :) Now we already have the working API with respond of good headers and json data. But there is one important part of REST is response code. Lets mention them all:
200 –> OK –> It is working
201 –> OK –> New resource has been added
204 –> OK -> The resource successfully deleted
304 –> Not modified –> The client can use cached data400 – Bad Request – The request was invalid or cannot be served.
401 – Unauthorized – The request requires an user authentication
403 – Forbidden – The server understood the request, but is refusing it or the access is not allowed.
404 – Not found – There is no resource behind the URI.
422 – Unprocessable Entity – Should be used if the server cannot process the enitity, e.g. if an image cannot be formatted or mandatory fields are missing in the payload.500 – Internal Server Error – API developers should avoid this error. If an error occurs in the global catch blog, the stracktrace should be logged and not returned as response.
In our first case the response code should be 200. Becasue we only made request and recieved.
So right now lets see our json output:
{
"meta": {
"total-results": "3474"
},
"data": {
"diseases": [
{
"id": "19",
"name": "Acute Inflammatory neuropathy"
},
{
"id": "20",
"name": "Acrocephalosyndactylia"
},
{
"id": "21",
"name": "Acromegaly"
},
{
"id": "22",
"name": "Addictions, Alcohol, General"
},
{
"id": "23",
"name": "Addictions, Drug, General"
},
{
"id": "24",
"name": "Addison Disease"
},
{
"id": "25",
"name": "Addison's Anemia"
},
{
"id": "26",
"name": "Adenitis"
},
{
"id": "27",
"name": "Adenohypophyseal Diseases"
},
{
"id": "28",
"name": "Adenohypophyseal Hyposecretion"
}
]
},
"links": {
"self": "\/diseases?limit=10&offset=1"
}
}
at meta I placed som additional information about how many recods are there, then the data with id and name of disease and links to know where you are with offset and limit.
now lets see individial design of json:
{
"meta": "",
"data": {
"diseases": {
"id": "88",
"name": "ALS",
"frequencies": [
"0.02",
"2.5",
"60",
"95",
"225.33",
"479.5",
"527",
"667",
"742",
"985.67"
]
}
},
"links": {
"self": "\/disease\/88"
}
}
Authenticate
There is one part of header that is very important when you build api to work as authenticated user and that will do later.