Odunayo Ogungbure
Posted on July 31, 2020
In this tutorial, we will be building a product reviews API. Users will be able to add, view, update and delete products. Users will also be able to rate and review a product. We will also be implementing authentication functionality with JSON Web Tokens (JWT) to secure our API.
Prerequisites
- Basic knowledge of Laravel
- Basic knowledge of REST APIs
Creating a new project
First we need to create a new laravel project
$ laravel new product-review-api
Then create your database and update the database credentials in the .env
file.
Create models and migrations
This API will be having 3 models: user, product and review. By default laravel comes with a user model, migration and factory file, so we will just be creating the remaining two. Let's start with the Product model:
$ php artisan make:model Product -a
The -a
flag will create corresponding migration, controller, factory and seeder file for the model. We will be looking at the factory and seeder file in a bit.
Lets edit the product migration file as below:
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->longText('description');
$table->decimal('price');
$table->unsignedBigInteger('user_id');
$table->timestamps();
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade');
});
We are just defining the columns for the products table and defining its relationship with the users table and what happens if the parent data is deleted.
Now we'll do the same for the Review model
$ php artisan make:model Review -a
And also define the reviews table structure
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('product_id');
$table->text('review');
$table->integer('rating');
$table->timestamps();
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade');
$table->foreign('product_id')
->references('id')
->on('products')
->onDelete('cascade');
});
Run the migrate artisan command and head over to your database manager and you should see the users, products and reviews table.
$ php artisan migrate
Model Relationships
While writing our migrations, we could see there's a relationship between the users, products and reviews table. Laravel ships with Eloquent ORM which provides a beautiful simple implementation for interacting with our tables.
Let's see what relationship exists between this tables.
- A user can add many products but a product can only belong to one user. This is a one to many relationship between user and product.
- A product can have many reviews but a review can only belong to one product. This is a one to many relationship between product and review.
- And finally user can make many reviews (be it same or different products) but a review can only belong to one user. This is a one to many relationship between the user and review.
Now that we can see the relationship, let's define this in our model. The reason we are doing this is that eloquent makes managing and working with relationships easy, and supports a lot of relationship types.
In the User model (app\User.php), let us define the relationship between the user and the other models.
<?php
namespace App;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
// Rest omitted for brevity
/**
* Get the products the user has added.
*/
public function products()
{
return $this->hasMany('App\Product');
}
/**
* Get the reviews the user has made.
*/
public function reviews()
{
return $this->hasMany('App\Review');
}
}
On the product model we will define the relationship between the product and the review model, and also the inverse relationship to the user model.
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
/**
* Get the reviews of the product.
*/
public function reviews()
{
return $this->hasMany('App\Review');
}
/**
* Get the user that added the product.
*/
public function user()
{
return $this->belongsTo('App\User');
}
}
On the review model we will define the inverse relationship to the user and product model.
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Review extends Model
{
/**
* Get the product that owns the review.
*/
public function product()
{
return $this->belongsTo('App\Product');
}
/**
* Get the user that made the review.
*/
public function user()
{
return $this->belongsTo('App\User');
}
}
Database Seeding
Remember the factory and seeder file created when we ran the make:model command with the -a flag? Now it's the time to see this files in action.
Database seeding in summary is just populating tables with data we want to develop. In essence this means having fake data which we can be used for but not limited to testing, demo, building routes or views to consume this data, initial setup etc. without the need to start inputting this data manually.
Laravel ships with a PHP faker library Faker which is an awesome library by the way and this is the library used in creating the fake data.
Opening the UserFactory file which is included in by default, we see that how this works is we define the model we want to generate fake data for and return an associative array in which the key is the attributes on the model and the value is the relevant property or method on the faker library.
Now it's time to do same for the ProductFactory
<?php
/** @var \Illuminate\Database\Eloquent\Factory $factory */
use App\Product;
use App\User;
use Faker\Generator as Faker;
$factory->define(Product::class, function (Faker $faker) {
return [
'name' => $faker->word,
'description' => $faker->paragraph,
'price' => $faker->numberBetween(1000, 20000),
'user_id' => function() {
return User::all()->random();
},
];
});
And ReviewFactory
<?php
/** @var \Illuminate\Database\Eloquent\Factory $factory */
use App\Review;
use App\User;
use Faker\Generator as Faker;
$factory->define(Review::class, function (Faker $faker) {
return [
'review' => $faker->paragraph,
'rating' => $faker->numberBetween(0, 5),
'user_id' => function() {
return User::all()->random();
},
];
});
Next is to write out our seeders. According to laravel's documentation
A seeder class only contains one method by default: run. This method is called when the db:seed Artisan command is executed. Within the run method, you may insert data into your database however you wish or use model factories to conveniently generate large amounts of database records.
While laravel comes with default user model, migration and factory files, it doesnt include a user seeder class. So we need to create one by running the command:
$ php artisan make:seeder UserSeeder
This will create a UserSeeder.php
file in the database\seeds folder
. Open the file and edit
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
factory(App\User::class, 50)->create();
}
We are using the factory helper method to insert 50 user records into the users table.
To run our seeders, open the database\seeds\DatabaseSeeder.php
file and uncomment the line
$this->call(UserSeeder::class);
Then we can use the db:seed artisan command to seed the database. Head over to your users and you should see 50 user records created.
$ php artisan db:seed
Cool! Let's do the same for the ProductSeeder with a little twist
<?php
use Illuminate\Database\Seeder;
class ProductSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
factory(App\Product::class, 100)->create()->each(function ($product) {
$product->reviews()->createMany(factory(App\Review::class, 5)->make()->toArray());
});
}
}
No magic here, what we doing is simply creating 100 products and for each product we are creating 5 reviews for that product.
To call additional seeders in our DatabaseSeeder.php
file, we pass an array instead to the call method
$this->call([
UserSeeder::class,
ProductSeeder::class,
]);
Let's use the migrate:fresh
command, which will drop all tables and re-run all of our migrations. This command is useful for completely re-building our database. The --seed
flag instructs laravel to seed the database once the migration is completed.
$ php artisan migrate:fresh --seed
Check your tables and Voila! I know this section might be overwhelming. If you are feeling overwhelmed by database seeding, I'll recommend going through laravel's documentation or search online for materials (there are a lot out there).
Authentication
To secure our application we will be using the package jwt-auth for authentication with JSON Web Tokens (JWT). Run the command to install the package latest version:
$ composer require tymon/jwt-auth
Note: If you are using Laravel 5.4 and below, you will need to manually register the service provider. Add the service provider to the providers array in the config/app.php
config file as follows:
'providers' => [
...
Tymon\JWTAuth\Providers\LaravelServiceProvider::class,
]
Next is to publish the package config file:
$ php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
You should now have a config/jwt.php
file that allows you to configure the basics of this package.
Next is to run the command below to generate a secret key. This secret key will be used to sign our tokens.
$ php artisan jwt:secret
This will update the .env
file with something like JWT_SECRET=value
Before we can start authenticating our users using JWT we need to update the user model to implement the Tymon\JWTAuth\Contracts\JWTSubject
contract, which requires we implement the 2 methods getJWTIdentifier()
and getJWTCustomClaims()
.
<?php
namespace App;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
// Rest omitted for brevity
/**
* Get the identifier that will be stored in the subject claim of the JWT.
*
* @return mixed
*/
public function getJWTIdentifier()
{
return $this->getKey();
}
/**
* Return a key value array, containing any custom claims to be added to the JWT.
*
* @return array
*/
public function getJWTCustomClaims()
{
return [];
}
}
To use laravel's built in auth system, with jwt-auth doing the heavy lifting we need to make a few changes to the config/auth.php
.
'defaults' => [
'guard' => 'api',
'passwords' => 'users',
],
...
'guards' => [
'api' => [
'driver' => 'jwt',
'provider' => 'users',
'hash' => false,
],
],
Here we are telling the api
guard to use the jwt
driver, and setting the api
guard as the default auth guard.
Now we can begin implementing the authentication logic into our application. Let's start by creating an AuthController
$ php artisan make:controller AuthController
Let's add a few methods to this controller.
<?php
namespace App\Http\Controllers;
use App\User;
use Illuminate\Http\Request;
class AuthController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['register', 'login']);
}
public function register(Request $request)
{
$request->validate([
'name' => 'required|string',
'email' => 'required|string|unique:users',
'password' => 'required|string|min:8',
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => bcrypt($request->password),
]);
$token = auth()->login($user);
return $this->respondWithToken($token);
}
public function login(Request $request)
{
$request->validate([
'email' => 'required|string',
'password' => 'required|string',
]);
$credentials = $request->only(['email', 'password']);
if (!$token = auth()->attempt($credentials)) {
return response()->json(['error' => 'Invalid Credentials'], 401);
}
return $this->respondWithToken($token);
}
protected function respondWithToken($token)
{
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth()->factory()->getTTL() * 60
]);
}
/**
* Get the authenticated User.
*
* @return \Illuminate\Http\JsonResponse
*/
public function me()
{
return response()->json(auth()->user());
}
/**
* Log the user out (Invalidate the token).
*
* @return \Illuminate\Http\JsonResponse
*/
public function logout()
{
auth()->logout();
return response()->json(['message' => 'Successfully logged out']);
}
}
We use the auth
middleware to only allow authenticated users to access the methods while exempting the register and login methods. The register
method creates and stores a new user into the database while the login
method which logs user into the application. This two methods returns a JWT response by calling the respondWithToken()
method. The me
method returns the currently authenticated user while the logout
method logs out the currently authenticated user from the application.
Let's define our api routes. Open up the routes/api.php
and add the following routes.
Route::get('me', 'AuthController@me');
Route::post('login', 'AuthController@login');
Route::post('register', 'AuthController@register');
Route::post('logout', 'AuthController@logout');
Test the endpoints and we should see authentication working.
Product Endpoints
Let's begin by defining the product endpoints.
-
GET
/products
- Fetch all products -
GET
/products/:id
- Fetch a single product and its reviews -
POST
/products
- Create a product -
PUT
/products/:id
- Update a product -
DELETE
/products/:id
- Delete a product
In our routes/api.php
file we can define this routes individually or use laravel resource routing feature or even better still use the api resource route feature. According to the laravel docs
When declaring resource routes that will be consumed by APIs, you will commonly want to exclude routes that present HTML templates such as create and edit. For convenience, you may use the apiResource method to automatically exclude these two routes:
So we add the products routes in the route file:
Route::apiResource('products', 'ProductController');
In the ProductController.php
file
<?php
namespace App\Http\Controllers;
use App\Product;
use App\Review;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show']);
}
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$products = Product::with('user:id,name')
->withCount('reviews')
->latest()
->paginate(20);
return response()->json(['products' => $products]);
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$request->validate([
'name' => 'required|string',
'description' => 'required|string',
'price' => 'required|numeric|min:0',
]);
$product = new Product;
$product->name = $request->name;
$product->description = $request->description;
$product->price = $request->price;
auth()->user()->products()->save($product);
return response()->json(['message' => 'Product Added', 'product' => $product]);
}
/**
* Display the specified resource.
*
* @param \App\Product $product
* @return \Illuminate\Http\Response
*/
public function show(Product $product)
{
$product->load(['reviews' => function ($query) {
$query->latest();
}, 'user']);
return response()->json(['product' => $product]);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Product $product
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Product $product)
{
if (auth()->user()->id !== $product->user_id) {
return response()->json(['message' => 'Action Forbidden']);
}
$request->validate([
'name' => 'required|string',
'description' => 'required|string',
'price' => 'required|numeric',
]);
$product->name = $request->name;
$product->description = $request->description;
$product->price = $request->price;
$product->save();
return response()->json(['message' => 'Product Updated', 'product' => $product]);
}
/**
* Remove the specified resource from storage.
*
* @param \App\Product $product
* @return \Illuminate\Http\Response
*/
public function destroy(Product $product)
{
if (auth()->user()->id !== $product->user_id) {
return response()->json(['message' => 'Action Forbidden']);
}
$product->delete();
return response()->json(null, 204);
}
}
In the constructor we are using the auth middleware but exempting the index and show methods from using the middleware. This means unauthenticated users will be able to view all products and a single product which is okay for our api.
The index method, returns a list of products ordered by the created date, the number of reviews the product has and paginates the records.
The store method validates the request input and then creates a new product and attaches the product to the currently authenticated user and returns the newly created product.
The show method returns a single product, the creator(user) and its reviews.
The update method checks the currently authenticated user trying to update the record is the user that created the record. If the user is not the creator, a forbidden response is returned else we carry out the update operation.
The delete method checks the currently authenticated user trying to delete the record is the user that created the record. If the user is not the creator, a forbidden response is returned else we carry out the delete operation.
Review Endpoints
Let's begin by defining the review endpoints.
-
POST
/products/:id/reviews
- Create a review for a product -
PUT
/products/:id/reviews/:id
- Update a product review -
DELETE
/products/:id/reviews/:id
- Delete a product review
We define the apiResource route for the reviews and also specifying actions the controller should handle instead of the full set of default actions.
Route::apiResource('products/{product}/reviews', 'ReviewController')
->only('store', 'update', 'destroy');
In the ReviewController.php
file
<?php
namespace App\Http\Controllers;
use App\Product;
use App\Review;
use Illuminate\Http\Request;
class ReviewController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Product $product
* @return \Illuminate\Http\Response
*/
public function store(Request $request, Product $product)
{
$request->validate([
'review' => 'required|string',
'rating' => 'required|numeric|min:0|max:5',
]);
$review = new Review;
$review->review = $request->review;
$review->rating = $request->rating;
$review->user_id = auth()->user()->id;
$product->reviews()->save($review);
return response()->json(['message' => 'Review Added', 'review' => $review]);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Product $product
* @param \App\Review $review
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Product $product, Review $review)
{
if (auth()->user()->id !== $review->user_id) {
return response()->json(['message' => 'Action Forbidden']);
}
$request->validate([
'review' => 'required|string',
'rating' => 'required|numeric|min:0|max:5',
]);
$review->review = $request->review;
$review->rating = $request->rating;
$review->save();
return response()->json(['message' => 'Review Updated', 'review' => $review]);
}
/**
* Remove the specified resource from storage.
*
* @param \App\Product $product
* @param \App\Review $review
* @return \Illuminate\Http\Response
*/
public function destroy(Product $product, Review $review)
{
if (auth()->user()->id !== $review->user_id) {
return response()->json(['message' => 'Action Forbidden']);
}
$review->delete();
return response()->json(null, 204);
}
}
Conclusion
That’s it! In this tutorial, we have built a simple api and covered authentication, database seeding and CRUD operation.
The complete code for this tutorial is available on GitHub.
Posted on July 31, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 9, 2024
November 13, 2024