In this section, we will build a REST API with Laravel from scratch. This REST API will allow you to manage different clients at your bookstore, not only via the browser, but via the UI as well. You will be able to perform pretty much the same actions as before, that is, listing books, buying them, borrowing for free, and so on.
Once the REST API is done, you should remove all the business logic from the bookstore that you built during the previous chapters. The reason is that you should have one unique place where you can actually manipulate your databases and the REST API, and the rest of the applications, like the web one, should able to communicate with the REST API for managing data. In doing so, you will be able to create other applications for different platforms, like mobile apps, that will use the REST API too, and both the website and the mobile app will always be synchronized, since they will be using the same sources.
As with our previous Laravel example, in order to create a new project, you just need to run the following command:
$ laravel new bookstore_api
The first thing that we are going to implement is the authentication layer. We will use OAuth2 in order to make our application more secure than basic authentication. Laravel does not provide support for OAuth2 out of the box, but there is a service provider which does that for us.
To install OAuth2, add it as a dependency to your project using Composer:
$ composer require "lucadegasperi/oauth2-server-laravel:5.1.*"
This service provider needs quite a few changes. We will go through them without going into too much detail on how things work exactly. If you are more interested in the topic, or if you want to create your own service providers for Laravel, we recommend you to go though the extensive official documentation.
To start with, we need to add the new OAuth2Server service provider to the array of providers in the config/app.php
file. Add the following lines at the end of the providers
array:
/* * OAuth2 Server Service Providers... */ LucaDegasperiOAuth2ServerStorageFluentStorageServiceProvider::class, LucaDegasperiOAuth2ServerOAuth2ServerServiceProvider::class,
In the same way, you need to add a new alias to the aliases
array in the same file:
'Authorizer' => LucaDegasperiOAuth2ServerFacadesAuthorizer::class,
Let's move to the app/Http/Kernel.php
file, where we need to make some changes too. Add the following entry to the $middleware
array property of the Kernel
class:
LucaDegasperiOAuth2ServerMiddlewareOAuthExceptionHandlerMiddleware::class,
Add the following key-value pairs to the $routeMiddleware
array property of the same class:
'oauth' => LucaDegasperiOAuth2ServerMiddlewareOAuthMiddleware::class, 'oauth-user' => LucaDegasperiOAuth2ServerMiddlewareOAuthUserOwnerMiddleware::class, 'oauth-client' => LucaDegasperiOAuth2ServerMiddlewareOAuthClientOwnerMiddleware::class, 'check-authorization-params' => LucaDegasperiOAuth2ServerMiddlewareCheckAuthCodeRequestMiddleware::class, 'csrf' => AppHttpMiddlewareVerifyCsrfToken::class,
We added a CSRF token verifier to the $routeMiddleware
, so we need to remove the one already defined in $middlewareGroups
, since they are incompatible. Use the following line to do so:
AppHttpMiddlewareVerifyCsrfToken::class,
Let's set up the database now. In this section, we will assume that you already have the bookstore database in your environment. If you do not have it, go back to Chapter 5, Using Databases, to create it in order to proceed with this setup.
The first thing to do is to update the database credentials in the .env
file. They should look something similar to the following lines, but with your username and password:
DB_HOST=localhost DB_DATABASE=bookstore DB_USERNAME=root DB_PASSWORD=
In order to prepare the configuration and database migration files from the OAuth2Server service provider, we need to publish it. In Laravel, you do it by executing the following command:
$ php artisan vendor:publish
Now the database/migrations
directory contains all the necessary migration files that will create the necessary tables related to OAuth2 in our database. To execute them, we run the following command:
$ php artisan migrate
We need to add at least one client to the oauth_clients
table, which is the table that stores the key and secrets for all clients that want to connect to our REST API. This new client will be the one that you will use during the development process in order to test what you have done. We can set a random ID—the key—and the secret as follows:
mysql> INSERT INTO oauth_clients(id, secret, name) -> VALUES('iTh4Mzl0EAPn90sK4EhAmVEXS', -> 'PfoWM9yq4Bh6rGbzzJhr8oDDsNZwGlsMIAeVRaPM', -> 'Toni'); Query OK, 1 row affected, 1 warning (0.00 sec)
Since we published the plugins in vendor
in the previous step, now we have the configuration files for the OAuth2Server. This plugin allows us different authentication systems (all of them with OAuth2), depending on our necessities. The one that we are interested in for our project is the client_credentials
type. To let Laravel know, add the following lines at the end of the array in the config/oauth2.php
file:
'grant_types' => [ 'client_credentials' => [ 'class' => 'LeagueOAuth2ServerGrantClientCredentialsGrant', 'access_token_ttl' => 3600 ] ]
These preceding lines grant access to the client_credentials
type, which are managed by the ClientCredentialsGrant
class. The access_token_ttl
value refers to the time period of the access token, that is, for how long someone can use it. In this case, it is set to 1 hour, that is, 3,600 seconds.
Finally, we need to enable a route so we can post our credentials in exchange for an access token. Add the following route to the routes file in app/Http/routes.php
:
Route::post('oauth/access_token', function() { return Response::json(Authorizer::issueAccessToken()); });
It is time to test what we have done so far. To do so, we need to send a POST request to the /oauth/access_token
endpoint that we enabled just now. This request needs the following POST parameters:
client_id
with the key from the databaseclient_secret
with the secret from the databasegrant_type
to specify the type of authentication that we are trying to perform, in this case client_credentials
The request issued using the Advanced REST Client add-on from Chrome looks as follows:
The response that you should get should have the same format as this one:
{ "access_token": "MPCovQda354d10zzUXpZVOFzqe491E7ZHQAhSAax" "token_type": "Bearer" "expires_in": 3600 }
Note that this is a different way of requesting for an access token than what the Twitter API does, but the idea is still the same: given a key and a secret, the provider gives us an access token that will allow us to use the API for some time.
Even though we've already done the same in the previous chapter, you might think: "Why do we start by preparing the database?". We could argue that you first need to know the kind of endpoints you want to expose in your REST API, and only then you can start thinking about what your database should look like. But you could also think that, since we are working with an API, each endpoint should manage one resource, so first you need to define the resources you are dealing with. This code first versus database/model first is an ongoing war on the Internet. But whichever way you think is better, the fact is that we already know what the users will need to do with our REST API, since we already built the UI previously; so it does not really matter.
We need to create four tables: books
, sales
, sales_books
, and borrowed_books
. Remember that Laravel already provides a users
table, which we can use as our customers. Run the following four commands to create the migrations files:
$ php artisan make:migration create_books_table --create=books $ php artisan make:migration create_sales_table --create=sales $ php artisan make:migration create_borrowed_books_table --create=borrowed_books $ php artisan make:migration create_sales_books_table --create=sales_books
Now we have to go file by file to define what each table should look like. We will try to replicate the data structure from Chapter 5, Using Databases, as much as possible. Remember that the migration files can be found inside the database/migrations
directory. The first file that we can edit is the create_books_table.php
. Replace the existing empty up
method by the following one:
public function up() { Schema::create('books', function (Blueprint $table) { $table->increments('id'); $table->string('isbn')->unique(); $table->string('title'); $table->string('author'); $table->smallInteger('stock')->unsigned(); $table->float('price')->unsigned(); }); }
The next one in the list is create_sales_table.php
. Remember that this one has a foreign key pointing to the users
table. You can use references(field)->on(tablename)
to define this constraint.
public function up() { Schema::create('sales', function (Blueprint $table) { $table->increments('id'); $table->string('user_id')->references('id')->on('users'); $table->timestamps(); }); }
The create_sales_books_table.php
file contains two foreign keys: one pointing to the ID of the sale, and one to the ID of the book. Replace the existing up
method by the following one:
public function up() { Schema::create('sales_books', function (Blueprint $table) { $table->increments('id'); $table->integer('sale_id')->references('id')->on('sales'); $table->integer('book_id')->references('id')->on('books'); $table->smallInteger('amount')->unsigned(); }); }
Finally, edit the create_borrowed_books_table.php
file, which has the book_id
foreign key and the start
and end
timestamps:
public function up() { Schema::create('borrowed_books', function (Blueprint $table) { $table->increments('id'); $table->integer('book_id')->references('id')->on('books'); $table->string('user_id')->references('id')->on('users'); $table->timestamp('start'); $table->timestamp('end'); }); }
The migration files are ready so we just need to migrate them in order to create the database tables. Run the following command:
$ php artisan migrate
Also, add some books to the database manually so that you can test later. For example:
mysql> INSERT INTO books (isbn,title,author,stock,price) VALUES -> ("9780882339726","1984","George Orwell",12,7.50), -> ("9789724621081","1Q84","Haruki Murakami",9,9.75), -> ("9780736692427","Animal Farm","George Orwell",8,3.50), -> ("9780307350169","Dracula","Bram Stoker",30,10.15), -> ("9780753179246","19 minutes","Jodi Picoult",0,10); Query OK, 5 rows affected (0.01 sec) Records: 5 Duplicates: 0 Warnings: 0
The next thing to do on the list is to add the relationships that our data has, that is, to translate the foreign keys from the database to the models. First of all, we need to create those models, and for that we just run the following commands:
$ php artisan make:model Book $ php artisan make:model Sale $ php artisan make:model BorrowedBook $ php artisan make:model SalesBook
Now we have to go model by model, and add the one to one and one to many relationships as we did in the previous chapter. For BookModel
, we will only specify that the model does not have timestamps, since they come by default. To do so, add the following highlighted line to your app/Book.php
file:
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
class Book extends Model
{
public $timestamps = false;
}
For the BorrowedBook
model, we need to specify that it has one book, and it belongs to a user. We also need to specify the fields we will fill once we need to create the object—in this case, book_id
and start
. Add the following two methods in app/BorrowedBook.php
:
<?php namespace App; use IlluminateDatabaseEloquentModel; class BorrowedBook extends Model { protected $fillable = ['user_id', 'book_id', 'start']; public $timestamps = false; public function user() { return $this->belongsTo('AppUser'); } public function book() { return $this->hasOne('AppBook'); } }
Sales can have many "sale books" (we know it might sound a little awkward), and they also belong to just one user. Add the following to your app/Sale.php
:
<?php namespace App; use IlluminateDatabaseEloquentModel; class Sale extends Model { protected $fillable = ['user_id']; public function books() { return $this->hasMany('AppSalesBook'); } public function user() { return $this->belongsTo('AppUser'); } }
Like borrowed books, sale books can have one book and belong to one sale instead of to one user. The following lines should be added to app/SalesBook.php
:
<?php namespace App; use IlluminateDatabaseEloquentModel; class SaleBook extends Model { public $timestamps = false; protected $fillable = ['book_id', 'sale_id', 'amount']; public function sale() { return $this->belongsTo('AppSale'); } public function books() { return $this->hasOne('AppBook'); } }
Finally, the last model that we need to update is the User
model. We need to add the opposite relationship to the belongs
we used earlier in Sale
and BorrowedBook
. Add these two functions, and leave the rest of the class intact:
<?php namespace App; use IlluminateFoundationAuthUser as Authenticatable; class User extends Authenticatable { //... public function sales() { return $this->hasMany('AppSale'); } public function borrowedBooks() { return $this->hasMany('AppBorrowedBook'); } }
In this section, we need to come up with the list of endpoints that we want to expose to the REST API clients. Keep in mind the "rules" explained in the Best practices with REST APIs section. In short, keep the following rules in mind:
<API version>/<resource name>/<optional id>/<optional action>
So what will the user need to do? We already have a good idea about that, since we created the UI. A brief summary would be as follows:
We will start straightaway with our list of endpoints, specifying the path, the HTTP method, and the optional parameters. It will also give you an idea on how to document your REST APIs.
/books
title
: Optional and filters by titleauthor
: Optional and filters by authorpage
: Optional, default is 1, and specifies the page to returnpage-size
: Optional, default is 50, and specifies the page size to return/books/<book id>
/borrowed-books
book-id
: Mandatory and specifies the ID of the book to borrow/borrowed-books
from
: Optional and returns borrowed books from the specified datepage
: Optional, default is 1, and specifies the page to returnpage-size
: Optional, default is 50, and specifies the number of borrowed books per page/borrowed-books/<borrowed book id>/return
/sales
books
: Mandatory and it is an array listing the book IDs to buy and their amounts, that is, {"book-id-1": amount, "book-id-2": amount, ...}/sales
from
: Optional and returns borrowed books from the specified datepage
: Optional, default is 1, and specifies the page to returnpage-size
: Optional, default is 50, and specifies the number of sales per page/sales/<sales id>
We use POST requests when creating sales and borrowed books, since we do not know the ID of the resource that we want to create a priori, and posting the same request will create multiple resources. On the other hand, when returning a book, we do know the ID of the borrowed book, and sending the same request multiple times will leave the database in the same state. Let's translate these endpoints to routes in app/Http/routes.php
:
/* * Books endpoints. */ Route::get('books', ['middleware' => 'oauth', 'uses' => 'BookController@getAll']); Route::get('books/{id}', ['middleware' => 'oauth', 'uses' => 'BookController@get']); /* * Borrowed books endpoints. */ Route::post('borrowed-books', ['middleware' => 'oauth', 'uses' => 'BorrowedBookController@borrow']); Route::get('borrowed-books', ['middleware' => 'oauth', 'uses' => 'BorrowedBookController@get']); Route::put('borrowed-books/{id}/return', ['middleware' => 'oauth', 'uses' => 'BorrowedBookController@returnBook']); /* * Sales endpoints. */ Route::post('sales', ['middleware' => 'oauth', 'uses' => 'SalesController@buy]); Route::get('sales', ['middleware' => 'oauth', 'uses' => 'SalesController@getAll']); Route::get('sales/{id}', ['middleware' => 'oauth', 'uses' => 'SalesController@get']);
In the preceding code, note how we added the middleware oauth
to all the endpoints. This will require the user to provide a valid access token in order to access them.
From the previous section, you can imagine that we need to create three controllers: BookController
, BorrowedBookController
, and SalesController
. Let's start with the easiest one: returning the information of a book given the ID. Create the file app/Http/Controllers/BookController.php
, and add the following code:
<?php namespace AppHttpControllers; use AppBook; use IlluminateHttpJsonResponse; use IlluminateHttpResponse; class BookController extends Controller { public function get(string $id): JsonResponse { $book = Book::find($id); if (empty($book)) { return new JsonResponse ( null, JsonResponse::HTTP_NOT_FOUND ); } return response()->json(['book' => $book]); } }
Even though this preceding example is quite easy, it contains most of what we will need for the rest of the endpoints. We try to fetch a book given the ID from the URL, and when not found, we reply with a 404 (not found) empty response—the constant Response::HTTP_NOT_FOUND
is 404. In case we have the book, we return it as JSON with response->json()
. Note how we add the seemingly unnecessary key book
; it is true that we do not return anything else and, since we ask for the book, the user will know what we are talking about, but as it does not really hurt, it is good to be as explicit as possible.
Let's test it! You already know how to get an access token—check the Requesting an access token section. So get one, and try to access the following URLs:
http://localhost/books/0?access_token=12345
http://localhost/books/1?access_token=12345
Assuming that 12345
is your access token, that you have a book in the database with ID 1
, and you do not have a book with ID 0
, the first URL should return a 404 response, and the second one, a response something similar to the following:
{ "book": { "id": 1 "isbn": "9780882339726" "title": "1984" "author": "George Orwell" "stock": 12 "price": 7.5 } }
Let's now add the method to get all the books with filters and pagination. It looks quite verbose, but the logic that we use is quite simple:
public function getAll(Request $request): JsonResponse { $title = $request->get('title', ''); $author = $request->get('author', ''); $page = $request->get('page', 1); $pageSize = $request->get('page-size', 50); $books = Book::where('title', 'like', "%$title%") ->where('author', 'like', "%$author%") ->take($pageSize) ->skip(($page - 1) * $pageSize) ->get(); return response()->json(['books' => $books]); }
We get all the parameters that can come from the request, and set the default values of each one in case the user does not include them (since they are optional). Then, we use the Eloquent ORM to filter by title and author using where()
, and limiting the results with take()->skip()
. We return the JSON in the same way we did with the previous method. In this one though, we do not need any extra check; if the query does not return any book, it is not really a problem.
You can now play with your REST API, sending different requests with different filters. The following are some examples:
http://localhost/books?access_token=12345
http://localhost/books?access_token=12345&title=19&page-size=1
http://localhost/books?access_token=12345&page=2
The next controller in the list is BorrowedBookController
. We need to add three methods: borrow
, get
, and returnBook
. As you already know how to work with requests, responses, status codes, and the Eloquent ORM, we will write the entire class straightaway:
<?php namespace AppHttpControllers; use AppBook; use AppBorrowedBook; use IlluminateHttpJsonResponse; use IlluminateHttpRequest; use LucaDegasperiOAuth2ServerFacadesAuthorizer; class BorrowedBookController extends Controller { public function get(): JsonResponse { $borrowedBooks = BorrowedBook::where( 'user_id', '=', Authorizer::getResourceOwnerId() )->get(); return response()->json( ['borrowed-books' => $borrowedBooks] ); } public function borrow(Request $request): JsonResponse { $id = $request->get('book-id'); if (empty($id)) { return new JsonResponse( ['error' => 'Expecting book-id parameter.'], JsonResponse::HTTP_BAD_REQUEST ); } $book = Book::find($id); if (empty($book)) { return new JsonResponse( ['error' => 'Book not found.'], JsonResponse::HTTP_BAD_REQUEST ); } else if ($book->stock < 1) { return new JsonResponse( ['error' => 'Not enough stock.'], JsonResponse::HTTP_BAD_REQUEST ); } $book->stock--; $book->save(); $borrowedBook = BorrowedBook::create( [ 'book_id' => $book->id, 'start' => date('Y-m-d H:i:s'), 'user_id' => Authorizer::getResourceOwnerId() ] ); return response()->json(['borrowed-book' => $borrowedBook]); } public function returnBook(string $id): JsonResponse { $borrowedBook = BorrowedBook::find($id); if (empty($borrowedBook)) { return new JsonResponse( ['error' => 'Borrowed book not found.'], JsonResponse::HTTP_BAD_REQUEST ); } $book = Book::find($borrowedBook->book_id); $book->stock++; $book->save(); $borrowedBook->end = date('Y-m-d H:m:s'); $borrowedBook->save(); return response()->json(['borrowed-book' => $borrowedBook]); } }
The only thing to note in the preceding code is how we also update the stock of the book by increasing or decreasing the stock, and invoke the save
method to save the changes in the database. We also return the borrowed book object as the response when borrowing a book so that the user can know the borrowed book ID, and use it when querying or returning the book.
You can test how this set of endpoints works with the following use cases:
Of course, you can always try to trick the API and ask for books without stock, non-existing borrowed books, and the like. All these edge cases should respond with the correct status codes and error messages.
We finish this section, and the REST API, by creating the SalesController
. This controller is the one that contains more logic, since creating a sale implies adding entries to the sales books table, prior to checking for enough stock for each one. Add the following code to app/Html/SalesController.php
:
<?php namespace AppHttpControllers; use AppBook; use AppSale; use AppSalesBook; use IlluminateHttpJsonResponse; use IlluminateHttpRequest; use LucaDegasperiOAuth2ServerFacadesAuthorizer; class SalesController extends Controller { public function get(string $id): JsonResponse { $sale = Sale::find($id); if (empty($sale)) { return new JsonResponse( null, JsonResponse::HTTP_NOT_FOUND ); } $sale->books = $sale->books()->getResults(); return response()->json(['sale' => $sale]); } public function buy(Request $request): JsonResponse { $books = json_decode($request->get('books'), true); if (empty($books) || !is_array($books)) { return new JsonResponse( ['error' => 'Books array is malformed.'], JsonResponse::HTTP_BAD_REQUEST ); } $saleBooks = []; $bookObjects = []; foreach ($books as $bookId => $amount) { $book = Book::find($bookId); if (empty($book) || $book->stock < $amount) { return new JsonResponse( ['error' => "Book $bookId not valid."], JsonResponse::HTTP_BAD_REQUEST ); } $bookObjects[] = $book; $saleBooks[] = [ 'book_id' => $bookId, 'amount' => $amount ]; } $sale = Sale::create( ['user_id' => Authorizer::getResourceOwnerId()] ); foreach ($bookObjects as $key => $book) { $book->stock -= $saleBooks[$key]['amount']; $saleBooks[$key]['sale_id'] = $sale->id; SalesBook::create($saleBooks[$key]); } $sale->books = $sale->books()->getResults(); return response()->json(['sale' => $sale]); } public function getAll(Request $request): JsonResponse { $page = $request->get('page', 1); $pageSize = $request->get('page-size', 50); $sales = Sale::where( 'user_id', '=', Authorizer::getResourceOwnerId() ) ->take($pageSize) ->skip(($page - 1) * $pageSize) ->get(); foreach ($sales as $sale) { $sale->books = $sale->books()->getResults(); } return response()->json(['sales' => $sales]); } }
In the preceding code, note how we first check the availability of all the books before creating the sales entry. This way, we make sure that we do not leave any unfinished sale in the database when returning an error to the user. You could change this, and use transactions instead, and if a book is not valid, just roll back the transaction.
In order to test this, we can follow similar steps as we did with borrowed books. Just remember that the books
parameter, when posting a sale, is a JSON map; for example, {"1": 2, "4": 1}
means that I am trying to buy two books with ID 1
and one book with ID 4
.