Introduction

APIs are first class citizens in exposing data for client consumption in mobile applications and single-page applications (SPA). APIs also allow developers to integrate with third-party services enabling them to concentrate on their business logic and to quickly ship their products. That is why building and consuming APIs is a must-have skill for developers.

What you will learn

  • Design a simple CRUD RESTful API using best practices.
  • Set up a Laravel development environment using Docker and Laradock.
  • Use database migration and seeding.
  • Use API resource collection for consistent response format.
  • Use correct HTTP codes in responses.
  • Use Postman to test API responses.

Prerequisites

To follow this tutorial, you should have basic knowledge of working with:

Tools

We will be using these tools:

  • Docker - to host our application and its dependencies
  • Postman - to test our API endpoints

An API about African languages

We will design and code an API to Create, Read, Update and Delete (CRUD) languages spoken in the African continent.

API design

Resource identification

In REST terminology, a resource is a representation of something which has associated data, and there can be a set of methods to operate on it.

For our example, we have the following resources:

  1. languages

API endpoint design

When designing URL endpoints consistency is critical. To adhere to RESTful practices, our endpoints should not contain actions or verbs but only nouns. The HTTP method governs what type of activity we want to perform on the resource. It is also a good practice to version our API.

  1. GET /v1/languages - Retrieves a list of languages
  2. GET /v1/languages/{id} - Retrieves a specific language
  3. POST /v1/languages - Creates a new language
  4. PUT /v1/languages/{id} - Updates a specific language
  5. DELETE /v1/languages/{id} - Deletes a specific language

Representation

We will use the JSON format for requests and responses.

Environment setup

We will use Laradock to set up a development environment with all dependencies for our API.

Step 1 - Clone this repository anywhere on your machine. You must have Git installed to be able to execute this command.

$ git clone https://github.com/laradock/laradock.git

Step 2 - Create the project folder.
Create a folder called decoded-rest-tutorial. Our Laravel project will reside in this folder.

$ mkdir decoded-rest-tutorial

Your folder structure should look like this:
+-- laradock
+-- decoded-rest-tutorial

Step 3 - Edit container settings file.
In the /laradock folder, create an .env file. This file allows us to specify what packages we want to be available in our development environment. Execute this command in the /laradock folder.

$ cp env-example .env

Edit the newly created /laradock/.env file.

Change the APP_CODE_PATH_HOST variable to point to the project folder we created earlier.
APP_CODE_PATH_HOST=../decoded-rest-tutorial/

Change COMPOSE_PATH_SEPARATOR depending on your operating system.
COMPOSE_PATH_SEPARATOR=:

Step 4 - Build the development environment and run it.

We will be using Apache web server and MySQL database for our development environment. To speed things up, we will disable the installation of some packages not used in this tutorial.

In the /laradock/.env file, disable the following packages by setting the value to false:

WORKSPACE_INSTALL_NODE=false
WORKSPACE_INSTALL_YARN=false
WORKSPACE_INSTALL_NPM_GULP=false
WORKSPACE_INSTALL_NPM_VUE_CLI=false
WORKSPACE_INSTALL_PHPREDIS=false
PHP_FPM_INSTALL_IMAGEMAGICK=false
PHP_FPM_INSTALL_OPCACHE=false
PHP_FPM_INSTALL_IMAGE_OPTIMIZERS=false
PHP_FPM_INSTALL_PHPREDIS=false
/laradock/.env

Execute this command to run the development environment. You must have docker installed and running for this command to work. Port 80 and 3306 must be available as our containers will use these ports.

$ docker-compose up -d apache2 mysql

Step 5 - Check if containers are running.
Run this command to show running containers. You should have Apache running on port 80, MySQL running on port 3306.

$ docker ps

Step 6 - Install Laravel using Composer
To execute commands in a container, we have to access the container’s terminal. Enter the Workspace container’s terminal.

$ docker-compose exec workspace bash

Once in the container’s terminal, execute this command to install Laravel.

root@cb61a9596419:/var/www# composer create-project --prefer-dist laravel/laravel .

Step 7 - Update your project settings to connect to MySQL.
Update the database settings for Laravel. It should be the same as those defined for the MySQL container in /laradock/.env.

Edit the Laravel .env file:

root@cb61a9596419:/var/www# nano .env

Modify these settings and save the file. CTRL+O keys to save and CTRL+X keys to exit.

DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=default
DB_USERNAME=default
DB_PASSWORD=secret
/var/www/.env

Step 8 - Test your installation.

Access your project using a browser by visiting http://localhost/public. You should see the Laravel welcome screen.

API coding

Create a model and migration for our resource

We will generate a model class and a database migration file that will create the table for our language resource.

Execute this command to create the model and migration file.

root@cb61a9596419:/var/www# php artisan make:model Models/Language -m

To keep things simple, our language model will have a name field and a country field.

Edit /database/migrations/2020_06_15_124413_create_languages_table.php created by the last command to add the two fields. Your file name may differ because of the date.

The file can be found in the project folder decoded-rest-tutorial. You may use your favorite text/code editor to edit the file.

We will be modifying the up() function of the migration class.

public function up()
{
	Schema::create('languages', function (Blueprint $table) {
    	$table->bigIncrements('id');
        $table->string('name');
        $table->string('country');
        $table->timestamps();
	});
}
/decoded-rest-tutorial/database/migrations/2020_06_15_124413_create_languages_table.php

Create the table by executing the migration command.

root@cb61a9596419:/var/www# php artisan migrate

Add rows in our table by using seeding.

Edit the model file /app/Models/Language.php. To be able to insert and update the name and country field, we add them to the $fillable property of our model.

class Language extends Model
{
   protected $fillable = [name, country];
}
/decoded-rest-tutorial/app/Models/Language.php

Generate the seed file using this command:

root@cb61a9596419:/var/www# php artisan make:seeder LanguagesTableSeeder

Edit the seed file located in /database/seeds/LanguagesTableSeeder.php to add sample rows in our table.

use Illuminate\Database\Seeder;
use App\Models\Language;
 
class LanguagesTableSeeder extends Seeder
{
   /**
    * Run the database seeds.
    *
    * @return void
    */
   public function run()
   {
       $seeds = array(
           ['name' => 'Sudan', 'country' => 'Arabic'],
           ['name' => 'Somalia', 'country' => 'Somali'],
           ['name' => 'South Africa', 'country' => 'Sesotho']
       );
 
       foreach($seeds as $seed) {
           Language::create([
               'name' => $seed['name'],
               'country' => $seed['country'],
           ]);
       }
   }
}
/decoded-rest-tutorial/database/seeds/LanguagesTableSeeder.php

To add the rows in the languages table, run the seed using this command:

root@cb61a9596419:/var/www# php artisan db:seed --class=LanguagesTableSeeder

Add API routes

We will now add the API endpoints that we designed earlier in /routes/api.php. When adding a route, we specify the URL and the controller action that will be executed when that particular endpoint is called.

Edit the file /routes/api.php and add our API endpoints

use Illuminate\Support\Facades\Route;
 
Route::get(
    'v1/languages', 
    'Api\v1\LanguageController@index');

Route::get(
    'v1/languages/{language_id}', 
    'Api\v1\LanguageController@get');

Route::post(
    'v1/languages', 
    'Api\v1\LanguageController@add');

Route::put(
    'v1/languages/{language_id}', 
    'Api\v1\LanguageController@update');

Route::delete(
    'v1/languages/{language_id}', 
    'Api\v1\LanguageController@delete');
/decoded-rest-tutorial/routes/api.php

Create an API resource for responses

To achieve consistency, we will create a resource that allows us to output the same JSON structure for responses. This helps developers consuming your API to know what they can expect as a response.

We will use the format below for this tutorial. It allows us to output data, errors, links in case we want to implement hypermedia and a message.

{
   "data": null,
   "errors": [],
   "links": [],
   "message": null
}
Example JSON response format

To create a resource, execute this command:

root@cb61a9596419:/var/www# php artisan make:resource ApiResponseCollection

Edit the toArray() function in the newly created resource file /app/Http/Resources/ApiResponseCollection.php

public function toArray($request)
{
	$items = $this->collection->all();
    return [
           'data' => (\array_key_exists('result', $items)) ? $items['result'] : null,
           'errors' => (\array_key_exists('errors', $items)) ? $items['errors'] : null,
           'links' => (\array_key_exists('links', $items)) ? $items['links'] : null,
           'message' => (\array_key_exists('message', $items)) ? $items['message'] : null,
       ];
}
/decoded-rest-tutorial/app/Http/Resources/ApiResponseCollection.php

Create the controller

When defining routes, we mapped them to controller actions. We will now create the controller and the corresponding actions.

To create the controller, execute this command:

root@cb61a9596419:/var/www# php artisan make:controller Api/v1/LanguageController

Add controller actions by editing the file app/Http/Controllers/Api/v1/LanguageController

namespace App\Http\Controllers\Api\v1;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Http\Resources\ApiResponseCollection;
use Symfony\Component\HttpFoundation\Response;
use App\Models\Language;

class LanguageController extends Controller
{
    public function index()
    {
        $data = collect([
            'result' => null,
            'errors' => [],
            'status_code' => null,
            'links' => [],
            'message' => null
        ]);

        /**
         * Send correct HTTP code
         * For example here, we send 204 - No Content if result is blank
         */
        $languages = Language::all();
        if ($languages->isEmpty()) {
            $data['status_code'] = Response::HTTP_NO_CONTENT;
        } else {
            $data['result'] = $languages;
            $data['status_code'] = Response::HTTP_OK;
        }

        /**
         * Use our API response format for consistent output
         */
        $result = new ApiResponseCollection($data);
        return $result
                ->response()
                ->setStatusCode($data['status_code']);
    }

    public function get(Request $request, $language_id)
    {
        $data = collect([
            'result' => null,
            'errors' => [],
            'status_code' => null,
            'links' => [],
            'message' => null
        ]);

        /**
         * For simplicity, we are not doing any validations here
         */
        $language = Language::where('id', $language_id)->first();
        if (is_null($language)) {
            $data['status_code'] = Response::HTTP_NOT_FOUND;
            $data['message'] = 'No records found.';
        } else {
            $data['result'] = $language;
            $data['status_code'] = Response::HTTP_OK;
        }

        $result = new ApiResponseCollection($data);
        return $result
                ->response()
                ->setStatusCode($data['status_code']);
    }

    public function add(Request $request)
    {
        $data = collect([
            'result' => null,
            'errors' => [],
            'status_code' => null,
            'links' => [],
            'message' => null
        ]);

        /**
         * For simplicity, we are not doing any validations here
         */
        $requestArray = $request->json()->all();
        $language = Language::create($requestArray);
        if ($language) {
            $data['result'] = $language;
            $data['status_code'] = Response::HTTP_CREATED;
        } else {
            $data['status_code'] = Response::HTTP_BAD_REQUEST;
            $data['message'] = 'Record not created.';
        }

        $result = new ApiResponseCollection($data);
        return $result
                ->response()
                ->setStatusCode($data['status_code']);
    }

    public function update(Request $request, $language_id)
    {
        $data = collect([
            'result' => null,
            'errors' => [],
            'status_code' => null,
            'links' => [],
            'message' => null
        ]);

        /**
         * For simplicity, we are not doing any validations here
         */
        $requestArray = $request->json()->all();
        $update = Language::where('id', $language_id)
                    ->update($requestArray);
        if ($update) {
            $language = Language::where('id', $language_id)->first();
            $data['result'] = $language;
            $data['status_code'] = Response::HTTP_OK;
        } else {
            $data['status_code'] = Response::HTTP_BAD_REQUEST;
            $data['message'] = 'Record not updated.';
        }

        $result = new ApiResponseCollection($data);
        return $result
                ->response()
                ->setStatusCode($data['status_code']);
    }

    public function delete(Request $request, $language_id)
    {
        $data = collect([
            'result' => null,
            'errors' => [],
            'status_code' => null,
            'links' => [],
            'message' => null
        ]);

        /**
         * For simplicity, we are not doing any validations here
         */
        $delete = Language::where('id', $language_id)->delete();
        if ($delete) {
            $data['status_code'] = Response::HTTP_OK;
            $data['message'] = 'Record deleted.';
        } else {
            $data['status_code'] = Response::HTTP_BAD_REQUEST;
            $data['message'] = 'Record could not be deleted.';
        }

        $result = new ApiResponseCollection($data);
        return $result
                ->response()
                ->setStatusCode($data['status_code']);
    }
}
/decoded-rest-tutorial/app/Http/Controllers/Api/v1/LanguageController

Test API responses using Postman

Postman is a platform for API development. We will use it to test our endpoints. Install and open Postman if you have not already done so.

In Postman, go to File > Import and select Paste Raw Text.

Copy the raw text in the repository file below and paste it in the box. https://github.com/aliirfaan/decoded-rest-tutorial/blob/master/devops/decoded-rest-tutorial.postman_collection.json

Import Postman collection

After import, you should see a collection with our API endpoints.

Successful import of Postman collection

Go ahead and test the API. For example, to get all languages: select the endpoint and click Send. You should get the JSON response.

Response from API in Postman

Find the source code on GitHub.

You can find the source code for this tutorial on GitHub: https://github.com/aliirfaan/decoded-rest-tutorial

Next steps

I hope you enjoyed every single bit of this tutorial. I encourage you to read in-depth concepts related to REST like caching, idempotence and security.

You can try to add validation rules and use the response format to display errors and investigate how to make the API HATEOAS.

Photo by Mike Kononov on Unsplash

You've successfully subscribed to Decoded For Devs
Welcome back! You've successfully signed in.
Great! You've successfully signed up.
Your link has expired
Success! Your account is fully activated, you now have access to all content.