Introduction
In this blog post we are going to be creating an API to handle posting blogs. For this api we will be using a standard install of concrete5 version 8.2.1 or above with the full_elemental default install. The reason for this is so we can use the /blog page and blog_entry page type with right_sidebar template. Feel free to change these around if you want.
Creating the first file
To start off we are going to create our controller so we create a file name posting_api.php in our application/controllers directory.
/application/controllers/posting_api.php
Once we have created the controller page its time to enter some code and first up we need to set our Namespace and include the classes we will be using
<php namespace Application\Controller; use Concrete\Core\Page\Controller\PageController; use Concrete\Core\Page\Page; use Concrete\Core\Page\Template; use Concrete\Core\Page\Type\Composer\FormLayoutSet; use Concrete\Core\Page\Type\Composer\FormLayoutSetControl; use Concrete\Core\Page\Type\Type; use Concrete\Core\User\User; use Symfony\Component\HttpFoundation\JsonResponse; use Concrete\Core\Http\ResponseFactory; use Concrete\Controller\SinglePage\PageNotFound; defined('C5_EXECUTE') or die('Access Denied.');
Here is a quick rundown of each class:
- PageController is the base class we will be extending for this tutorial
- Page will be used to get our /blog page object
- Template is used to get the template object we are using when we post a blog
- FormLayoutSet and FormLayoutSetControl are for getting list of FormLayoutSets and their controls (needed for posting to composer blocks)
- Type is used to get the blog_entry page type object
- User is needed to get information about the user logged in and the user id supplied, as well as the user object
- JsonResponse - This is for sending a json response
- ResponseFactory and PageNotFound is used to send away non-specific users
Now that we have the classes we are using it is time to create our class and extend PageController. Concrete5 uses snake_case for file names and camelcase for class name so we will call the class PostingApi (posting_api.php is the filename).
class PostingApi extends PageController { // Code goes here }
Generating Client IDs and Secret
The first thing we need our api to do is to create a function that will generate a client_secret. Once we generate the secret we also need to save it in the database. To do this we will save the secret in the config repositories in the database. Finally we need a name, let's say getSecret. Now we know the what of our function we can begin on the how.
//Generate the code on php7+ you can use random_bytes $secret = bin2hex(openssl_random_pseudo_bytes(64)); // Save the secret in the database $this->app->make('config')->save('concrete.api.secret', $secret);
Now we need to add the ability to get the secret from the database, so if we move these around a bit we can create a function that looks like this:
protected function getSecret() { // Get secret from the database $secret = $this->app->make('config')->get('concrete.api.secret'); if (empty($secret)) { // If it is empty create a new secret $secret = bin2hex(openssl_random_pseudo_bytes(64)); // Save the secret into the database $this->app->make('config')->save('concrete.api.secret', $secret); } return $secret; }
Now we have our a function to get our secret lets create a function that generates a client id from a userID. First the function must always generate the exact same thing when input with a userID. So we will get information from a user such as their username, email and the date they joined and turn this into a client id. We have the how and part of the what it is just the question of naming the function, since we are generating a client id lets call it generateClientID
protected function generateClientID($userID) { $clientID = null; // Get User Object from userID $userObject = User::getByUserID($userID); if (is_object($userObject)) { // Get the user info object if userObject is a valid object $userInfo = $userObject->getUserInfoObject(); if (is_object($userInfo)) { // Get the user's email, displayname and the date they joined $part1 = $userInfo->getUserEmail(); $part2 = $userInfo->getUserDisplayName(); $part3 = $userInfo->getUserDateAdded()->format('Ymd'); // combine these three into a hexadecimal string $clientID = bin2hex($part1.':'.$part3.':'.$userID.':'.$part2); // trim the string into a shorter string if (strlen($clientID > 64)) { $clientID = substr($clientID, 0, 64); } } return $clientID; } else { return false; } }
Getting the details
So we have a way of generating a client id and a secret for the app. How do we go about getting the details for the users? For this case we will only be allowing the super admin to retrieve the secret and client id. So we need a function that will check the logged in user if they are a super admin or not. Then we need to display the secret and client id for this user. We shall call this function getDetails and it will need to be public so it is accessible for our route.
public function getDetails() { // Get the current user object $user = new User(); if ($user->isSuperUser()) { // If the current user is a super then return our secret and clientID $jsonArray = [ 'secret'=> $this->getSecret(), 'clientID' => $this->generateClientID($user->getUserID()) ]; return new JsonResponse($jsonArray, 200); } else { // If they are not a super user then make the route show a 404 page not found $content = $this->app->make(PageNotFound::class); return $this->app->make(ResponseFactory::class)->notFound($content); } }
After adding this function you will need to add a route that will be accessible. To do this we will go to application/bootstrap/app.php and add the following line
// Add this in application/bootstrap/app.php ONLY! Route::register('/api/blog/get/details', '\Application\Controller\PostingApi::getDetails');
This will make those details available at /api/blog/get/details When you visit your the route on your site c5-example.app/api/blog/get/details you will be presented by a json encoded string that will look like this:
{"secret":"5468697320697320612073757065722073656372657420737472696e6720646f6e2774206465636f6465206d6520706c6561736521","clientID":"54686973206973206120636c69656e742049442c206e6f7420736f207365637265742e2e2e2e6568"}
You can remove the line making these accessible and save the information for later.
Validating Information
Next up on our function is generating a token, we will generate a token based on 3 items: ClientID, UserID and expiry time. For this function we will be using concrete5's built-in token system. Now we just need a name, function generateValidToken fits our naming preferences.
// Very simple function to generateValidTokens protected function generateValidToken($clientID, $userID, $expires = 360) { return $this->app->make('token')->generate($clientID.':'.$userID.':'.$expires); }
Next comes our validation, to understand our validation I will give you an insight into how we will be receiving data. We will be using a object that will contain our clientID/UserID/Secret etc. So we will begin with validation for our client id and token. The name for these two functions will be validateClientID and validateToken.
// Validate our token using concrete5's built in token class (returns true or false) protected function validateToken($jsonObject) { return $this->app->make('token')->validate($jsonObject->clientID.':'.$jsonObject->userID.':'.$jsonObject->expires, $jsonObject->token); } // Validate our clientID by generating a new one and comparing protected function validateClientID($clientID, $userID) { if ($clientID !== $this->generateClientID($userID)) { return false; } else { return true; } }
Time to combine these and expand on our validation, so lets make a new function called validate to contain all of these validation methods.
// Send our Json object and declare if it contains a token or not protected function validate($jsonObject, $containsToken = true) { if (!is_object($jsonObject)) { // Simple if it isn't an object it is invalid return false; } // If it contains token we will need to verify its expiry and token if ($containsToken === true) { // Validate token if (!$this->validateToken($jsonObject)) { return false; } // Check if expired if ($jsonObject->expires < time()) { return false; } } // Validate Client ID if (!$this->validateClientID($jsonObject->clientID, $jsonObject->userID)) { return false; } // Validate Secret ID if ($jsonObject->secret !== $this->getSecret()) { return false; } // If we get to here then it is 100% valid return true; }
Supplying Tokens
We have a function that will validate this but we also need a function to begin a handshake and supply the token and expiry time to the application that requests it. The function will need to verify the client id, secret and generate a token and expiry time, it will also need to be callable publicly. Since we know all of that information let's call this function startHandshake.
public function startHandshake() { // get the object from the ?data= tag .. use false to make an object rather than an array $jsonObject = json_decode($this->get('data'), false); // Send the object to our validate function with false (we don't have an token or expiry time) if ($this->validate($jsonObject, false)) { // Make sure the integer is safe $userID = $this->app->make('helper/validation/numbers')->integer($jsonObject->userID); // Create an expiry time $expires = time() + 500; // Generate a token from our generateValidToken function $newToken = $this->generateValidToken($jsonObject->clientID, $userID, $expires); // Create an array back containing the token and expiry time $jsonResponse = [ 'token' => $newToken, 'expires' => $expires ]; // Send a successful JSON response with response code of 200 return new JsonResponse($jsonResponse, 200); } else { // Failed our validation so send a JSON response with Invalid Credentials return new JsonResponse(t('Invalid Credentials'),401); } }
We have the function and we need a route so we go to our application/bootstrap/app.php again and add our new route:
Route::register('/api/blog/get/token', '\Application\Controller\PostingApi::startHandshake');
This will be our access point for getting a token for our blog API. Since we now can get a token, we just need one more function that will receive our data and post a blog.
Posting A Blog
This is our super function, the receiver, the catcher, the big man on campus. Therefore this function should have a suitable name, postBlog... It fits out naming convention. For this function I'll break it into parts so it will be easier to understand, first up we will get out json object from POST this time. Then we create the blog page from the templates, etc that we mentioned earlier.
public function postBlog() { // Decode our object and validate it $jsonObject = json_decode($this->post('data'), false); if ($this->validate($jsonObject)) { // Get our parent page (/blog), page type and template $blogPage = Page::getByPath('/blog'); $blogType = Type::getByHandle('blog_entry'); $pageTemplate = Template::getByHandle('right_sidebar'); // Add our page to our parent page $entry = $blogPage->add($blogType, [ 'cName' => $jsonObject->blogTitle, 'uID'=> $jsonObject->userID, 'cDescription'=>$jsonObject->blogDesc, 'cIsActive'=> 1, 'cAcquireComposerOutputControls' => 1 ], $pageTemplate);
After this we need to get our form layout set lists then loop through each set to find our content control block. We are looking for a control object that is an instance of \Concrete\Core\Page\Type\Composer\Control\BlockControl and who's block is a 'content' block.
$formLayoutSetList = FormLayoutSet::getList($entry->getPageTypeObject()); foreach ($formLayoutSetList as $formLayoutSet) { $controls = FormLayoutSetControl::getList($formLayoutSet); foreach ($controls as $outputControl) { // If the output control object is a specific type and its block is content block then save the control object and leave the loop. if ($outputControl->getPageTypeComposerControlObject() instanceof \Concrete\Core\Page\Type\Composer\Control\BlockControl && $outputControl->getPageTypeComposerControlObject()->getBlockTypeObject()->getBlockTypeHandle() == 'content') { $blockControl = $outputControl->getPageTypeComposerControlObject(); break; } } }
After we've found the block control then we need to run publish to page to add the content to the composer controlled block. This allows the page to be editable in composer. Then we close off our if statements, send our responses and close the function.
if (is_object($blockControl)) { $data = ['content'=>$jsonObject->blogContent]; $blockControl->publishToPage($entry, $data, $controls); } // Send JSON Response confirming the blog is posted return new JsonResponse(t('Successfully Posted A New Blog')); } else { return new JsonResponse(t('Invalid Credentials', 401)); } }
Now we just need to set up the route and we have an accessible point to post blogs from. So open up
Route::register('/api/blog/post', '\Application\Controller\PostingApi::postBlog');
Accessing the API
To access the api simply send a url encoded json object containing your secret and client id to /api/blog/get/token?data={urlEncodedObject}. You will receive an expires time and token, add the to your json object Then send a POST request /api/blog/post with a json object that is similar to this :
jsonObject= {data: { blogContent: 'content!', blogDesc: 'description', blogTitle: 'Title', userID: 1, clientID: 'client_id', secret: 'secret', token: 'token!', expires: 360, }}
If you would like to see a working example of this see the html file below. I have included the full source of posting_api.php, app.php and a post_blog.html file
Source Files
Route::register('/api/blog/get/token', '\Application\Controller\PostingApi::startHandshake'); Route::register('/api/blog/get/details', '\Application\Controller\PostingApi::getDetails'); Route::register('/api/blog/post', '\Application\Controller\PostingApi::postBlog');
<?php namespace Application\Controller; use Concrete\Core\Page\Controller\PageController; use Concrete\Core\Page\Page; use Concrete\Core\Page\Template; use Concrete\Core\Page\Type\Composer\FormLayoutSet; use Concrete\Core\Page\Type\Composer\FormLayoutSetControl; use Concrete\Core\Page\Type\Type; use Concrete\Core\User\User; use Symfony\Component\HttpFoundation\JsonResponse; use Concrete\Core\Http\ResponseFactory; use Concrete\Controller\SinglePage\PageNotFound; defined('C5_EXECUTE') or die('Access Denied.'); class PostingApi extends PageController { public function startHandshake() { $jsonObject = json_decode($this----->get('data'), false); if ($this->validate($jsonObject, false)) { $userID = $this->app->make('helper/validation/numbers')->integer($jsonObject->userID); $expires = time() + 500; $newToken = $this->generateValidToken($jsonObject->clientID, $userID, $expires); $jsonResponse = [ 'token' => $newToken, 'expires' => $expires ]; return new JsonResponse($jsonResponse, 200); } else { return new JsonResponse(t('Invalid Creditionals'),401); } } protected function validate($jsonObject, $containsToken = true) { if (!is_object($jsonObject)) { return false; } if ($containsToken === true) { // Validate token if (!$this->validateToken($jsonObject)) { return false; } // Check if expired if ($jsonObject->expires < time()) { return false; } } // Validate Client ID if (!$this->validateClientID($jsonObject->clientID, $jsonObject->userID)) { return false; } // Validate Secret ID if ($jsonObject->secret !== $this->getSecret()) { return false; } return true; } protected function generateValidToken($clientID, $userID, $expires = 360) { return $this->app->make('token')->generate($clientID.':'.$userID.':'.$expires); } protected function validateToken($jsonObject) { return $this->app->make('token')->validate($jsonObject->clientID.':'.$jsonObject->userID.':'.$jsonObject->expires, $jsonObject->token); } protected function validateClientID($clientID, $userID) { if ($clientID != $this->generateClientID($userID)) { return false; } else { return true; } } protected function generateClientID($userID) { $userObject = User::getByUserID($userID); $clientID = null; if (is_object($userObject)) { $userInfo = $userObject->getUserInfoObject(); if (is_object($userInfo)) { $part1 = $userInfo->getUserEmail(); $part2 = $userInfo->getUserDisplayName(); $part3 = $userInfo->getUserDateAdded()->format('Ymd'); $clientID = bin2hex($part1.':'.$part3.':'.$userID.':'.$part2); if (strlen($clientID > 64)) { $clientID = substr($clientID, 0, 64); } } return $clientID; } else { return false; } } protected function getSecret() { $secret = $this->app->make('config')->get('concrete.api.secret'); if (empty($secret)) { $secret = bin2hex(openssl_random_pseudo_bytes(64)); $this->app->make('config')->save('concrete.api.secret', $secret); } return $secret; } public function getDetails() { $user = new User(); if ($user->isSuperUser()) { $jsonArray = [ 'secret'=> $this->getSecret(), 'clientID' => $this->generateClientID($user->getUserID()) ]; return new JsonResponse($jsonArray, 200); } else { $content = $this->app->make(PageNotFound::class); return $this->app->make(ResponseFactory::class)->notFound($content); } } public function postBlog() { $jsonObject = json_decode($this->post('data'), false); if ($this->validate($jsonObject)) { $blogPage = Page::getByPath('/blog'); $blogType = Type::getByHandle('blog_entry'); $pageTemplate = Template::getByHandle('right_sidebar'); $entry = $blogPage->add($blogType, [ 'cName' => $jsonObject->blogTitle, 'uID'=> $jsonObject->userID, 'cDescription' => $jsonObject->blogDesc, 'cIsActive'=> 1, 'cAcquireComposerOutputControls' => 1 ], $pageTemplate); $formLayoutSetList = FormLayoutSet::getList($entry->getPageTypeObject()); foreach ($formLayoutSetList as $formLayoutSet) { $controls = FormLayoutSetControl::getList($formLayoutSet); foreach ($controls as $outputControl) { if ($outputControl->getPageTypeComposerControlObject() instanceof \Concrete\Core\Page\Type\Composer\Control\BlockControl && $outputControl->getPageTypeComposerControlObject()->getBlockTypeObject()->getBlockTypeHandle() == 'content') { $blockControl = $outputControl->getPageTypeComposerControlObject(); break; } } } if (is_object($blockControl)) { $data = ['content'=>$jsonObject->blogContent]; $blockControl->publishToPage($entry, $data, $controls); } return new JsonResponse(t('Successfully Posted A New Blog')); } else { return new JsonResponse(t('Invalid Creditionals', 401)); } } }
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Our Little Blog Test</title> </head> <body> <div id="textAlert"> Nothing Happened! </div> <form name="data" action="/api/blog/post" id="blogForm" enctype="application/x-www-form-urlencoded"> <label for="blogTitle">Blog Title</label><br /> <input type="text" id="blogTitle" name="blogTitle" placeholder="title"/><br /> <label for="blogDescription">Blog Description</label><br /> <textarea id="blogDescription" name="blogDescription" placeholder="description"></textarea><br /> <label for="blogContent">Blog Content</label><br /> <textarea id="blogContent" name="blogContent" placeholder="blog content"></textarea><br /> <button type="button" name="submit" onclick="sendBlogPost()">Submit</button> </form> <script type="application/javascript"> var text = document.getElementById('textAlert'); var data = { secret: "5468697320697320612073757065722073656372657420737472696e6720646f6e2774206465636f6465206d6520706c6561736521", clientID:"54686973206973206120636c69656e742049442c206e6f7420736f207365637265742e2e2e2e6568", userID : "1" } var request = new XMLHttpRequest(); request.onreadystatechange = function() { if(request.readyState === 4) { if(request.status === 200) { text.innerHTML = 'Recieved Token'; jsonResponse = JSON.parse(request.responseText); data.expires = jsonResponse.expires; data.token = jsonResponse.token; } else { text.innerHTML = 'An error occurred during your request: ' + request.status + ' ' + request.statusText; } } } request.open('GET', '/api/blog/get/token?data='+ encodeURIComponent(JSON.stringify(data)), true); request.send(); sendBlogPost = function () { request.onreadystatechange = function() { if(request.readyState === 4) { if(request.status === 200) { text.innerHTML = request.responseText; } else { text.innerHTML = 'An error occurred during your request: ' + request.status + ' ' + request.statusText; } } } data.blogContent = document.getElementById('blogContent').value, data.blogTitle = document.getElementById('blogTitle').value; data.blogDesc = document.getElementById('blogDescription').value; request.open('POST', '/api/blog/post', true); request.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); request.send('data='+ encodeURIComponent(JSON.stringify(data))); } </script> </body> </html>
As always I hope you enjoyed this and it will inspire you to make more APIs for concrete5