使用 Laravel 创建 REST API
在本节中,我们将从头开始使用 Laravel 构建 REST API。 此 REST API 将允许您管理书店的不同客户端,不仅可以通过浏览器,还可以通过 UI。您将能够执行与以前几乎相同的操作,即列出书籍、购买书籍、免费借阅等等。
完成 REST API 后,您应该从前几章中构建的书店中删除所有业务逻辑。原因是您应该有一个独特的位置,可以在其中实际操作数据库和 REST API,而其余应用程序(例如 Web 应用程序)应该能够与 REST API 进行通信以管理数据。这样做,您将能够为不同平台创建其他应用程序,例如移动应用程序,它们也将使用 REST API,并且网站和移动应用程序将始终同步,因为它们将使用相同的源。
与我们之前的 Laravel 示例一样,为了创建一个新项目,您只需运行以下命令:
$ laravel new bookstore_api
设置 OAuth2 身份验证
我们首先要实现的是身份验证层。我们将使用 OAuth2,以使我们的应用程序比基本认证更安全。Laravel 本身并不支持 OAuth2,但有一个服务提供商可以为我们提供支持。
安装 OAuth2 服务器
要安装 OAuth2,请使用 Composer 将其作为依赖项添加到您的项目中:
$ composer require "lucadegasperi/oauth2-server-laravel:5.1.*"
该服务提供商需要做出一些改变。我们在此不做过多赘述。如果你对这个主题更感兴趣,或者你想为 Laravel 创建自己的服务提供者,我们建议你阅读大量的官方文档。
首先,我们需要将新的 OAuth2Server 服务提供者添加到 config/app.php 文件的提供者阵列中。在服务提供者数组的末尾添加以下几行:
/*
* OAuth2 Server Service Providers...
*/
LucaDegasperi\OAuth2Server\Storage\FluentStorageServiceProvider::class,
LucaDegasperi\OAuth2Server\OAuth2ServerServiceProvider::class,
同样,您需要在同一文件的别名数组中添加一个新的别名:
'Authorizer' => LucaDegasperi\OAuth2Server\Facades\Authorizer::class,
让我们转到 app/Http/Kernel.php 文件,在这里我们也需要做一些改动。在 Kernel 类的 $middleware 数组属性中添加以下条目:
\LucaDegasperi\OAuth2Server\Middleware\OAuthExceptionHandlerMiddleware::class,
将以下键值对添加到同一类的 $routeMiddleware 数组属性中:
'oauth' => \LucaDegasperi\OAuth2Server\Middleware\OAuthMiddleware::class,
'oauth-user' => \LucaDegasperi\OAuth2Server\Middleware\OAuthUserOwnerMiddleware::class,
'oauth-client' => \LucaDegasperi\OAuth2Server\Middleware\OAuthClientOwnerMiddleware::class,
'check-authorization-params' => \LucaDegasperi\OAuth2Server\Middleware\CheckAuthCodeRequestMiddleware::class,
'csrf' => \App\Http\Middleware\VerifyCsrfToken::class,
我们在 $routeMiddleware 中添加了 CSRF 令牌验证器,因此需要删除已在 $middlewareGroups 中定义的验证器,因为它们不兼容。请使用下面一行执行此操作:
\App\Http\Middleware\VerifyCsrfToken::class,
设置数据库
现在让我们来设置数据库。在本节中,我们将假设您的环境中已经有了书店数据库。如果没有,请回到第 5 章 "使用数据库" 创建数据库,以便继续进行设置。
首先要做的是更新 .env 文件中的数据库凭据。更新后的凭证应与下面几行相似,但要加上用户名和密码:
DB_HOST=localhost
DB_DATABASE=bookstore
DB_USERNAME=root
DB_PASSWORD=
为了准备好 OAuth2Server 服务提供商的配置和数据库迁移文件,我们需要发布它。在 Laravel 中,执行以下命令即可:
$ php artisan vendor:publish
现在,database/migrations 目录包含了所有必要的迁移文件,这些文件将在我们的数据库中创建与 OAuth2 相关的必要表。要执行这些文件,我们需要运行以下命令:
$ php artisan migrate
我们需要在 oauth_clients 表中添加至少一个客户端,该表存储了所有想要连接到我们的 REST API 的客户端的密钥和秘密。这个新客户端将在开发过程中使用,以便测试您所做的工作。我们可以设置一个随机 ID—密钥和秘密,如下所示:
mysql> INSERT INTO oauth_clients(id, secret, name)
-> VALUES('iTh4Mzl0EAPn90sK4EhAmVEXS',
-> 'PfoWM9yq4Bh6rGbzzJhr8oDDsNZwGlsMIAeVRaPM',
-> 'Toni');
Query OK, 1 row affected, 1 warning (0.00 sec)
启用客户端凭据身份验证
在上一步中,我们在供应商中发布了插件,现在我们有了 OAuth2Server 的配置文件。该插件允许我们根据需要使用不同的身份验证系统(均使用 OAuth2)。在我们的项目中,我们感兴趣的是 client_credentials 类型。为了让 Laravel 知道,在 config/oauth2.php 文件的数组末尾添加以下几行:
'grant_types' => [
'client_credentials' => [
'class' =>
'\League\OAuth2\Server\Grant\ClientCredentialsGrant',
'access_token_ttl' => 3600
]
]
前面几行授予了对 client_credentials 类型的访问权,该访问权由 ClientCredentialsGrant 类管理。access_token_ttl 值指的是访问令牌的使用期限,也就是某人可以使用访问令牌的时间。在本例中,它被设置为 1 小时,即 3600 秒。
最后,我们需要启用一个路由,这样就可以发布我们的凭据,以换取访问令牌。在 app/Http/routes.php 中的路由文件中添加以下路由:
Route::post('oauth/access_token', function() {
return Response::json(Authorizer::issueAccessToken());
});
请求访问令牌
现在是测试我们目前所做工作的时候了。为此,我们需要向刚才启用的 /oauth/access_token 端点发送 POST 请求。该请求需要以下 POST 参数:
-
client_id 包含数据库中的密钥
-
client_secret 包含数据库中的秘密
-
grant_type 用于指定我们要执行的身份验证类型,在本例中为 client_credentials
使用 Chrome 的高级 REST 客户端插件发出的请求如下所示:

您应该得到的响应应具有与此相同的格式:
{
"access_token": "MPCovQda354d10zzUXpZVOFzqe491E7ZHQAhSAax",
"token_type": "Bearer",
"expires_in": 3600
}
请注意,这是一种与 Twitter API 不同的请求访问令牌的方式,但想法仍然是一样的:给定密钥和秘密后,提供者会给我们一个访问令牌,让我们可以在一段时间内使用 API。
准备数据库
尽管我们在前一章中已经做了同样的工作,但你可能会想:"为什么我们要从准备数据库开始呢?我们可以说,首先需要知道要在 REST API 中公开的端点类型,然后才能开始考虑数据库应该是什么样的。但你也可以认为,既然我们使用的是 API,每个端点就应该管理一种资源,因此首先需要定义你要处理的资源。代码优先与数据库/模型优先在互联网上是一场持续的战争。但无论你认为哪种方法更好,事实上我们已经知道用户需要用我们的 REST API 做什么,因为我们之前已经构建了用户界面;所以这并不重要。
我们需要创建四个表:books、sales、sales_books 和 borrowed_books。请记住,Laravel 已经提供了一个用户表,我们可以用它作为我们的客户。运行以下四条命令创建迁移文件:
$ 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
现在,我们必须逐个文件定义每个表的外观。我们将尽量复制第 5 章 "使用数据库 "中的数据结构。请记住,迁移文件可以在 database/migrations 目录中找到。我们可以编辑的第一个文件是 create_books_table.php。用下面的方法替换现有的清空方法:
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();
});
}
下一个是 create_sales_table.php。请记住,这个表有一个指向 users 表的外键。你可以使用引用 (field)->on(tablename)
来定义这个约束。
public function up()
{
Schema::create('sales', function (Blueprint $table) {
$table->increments('id');
$table->string('user_id')->references('id')->on('users');
$table->timestamps();
});
}
create_sales_books_table.php 文件包含两个外键:一个指向销售 ID,另一个指向图书 ID。用以下方法替换现有的向上方法:
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();
});
}
最后,编辑 create_borrowed_books_table.php 文件,其中包含 book_id 外键以及开始和结束时间戳:
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');
});
}
迁移文件已准备就绪,因此我们只需迁移它们即可创建数据库表。运行以下命令:
$ php artisan migrate
另外,手动将一些书籍添加到数据库中,以便稍后进行测试。例如:
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
设置模型
下一件要做的事是添加数据的关系,也就是从数据库到模型的外键转换。首先,我们需要创建这些模型,为此只需运行以下命令即可:
$ php artisan make:model Book
$ php artisan make:model Sale
$ php artisan make:model BorrowedBook
$ php artisan make:model SalesBook
现在,我们必须逐个模型添加 "一对一" 和 "一对多" 关系,就像上一章所做的那样。对于 BookModel,我们只需指定该模型没有时间戳,因为默认情况下是有的。为此,请在 app/Book.php 文件中添加以下突出显示行:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Book extends Model
{
public $timestamps = false;
}
对于 BorrowedBook 模型,我们需要指定它有一本书,并且属于一个用户。我们还需要指定创建对象时要填写的字段—本例中是 book_id 和 start。在 app/ BorrowedBook.php 中添加以下两个方法:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class BorrowedBook extends Model
{
protected $fillable = ['user_id', 'book_id', 'start'];
public $timestamps = false;
public function user() {
return $this->belongsTo('App\User');
}
public function book() {
return $this->hasOne('App\Book');
}
}
销售可以有多个 "销售账簿"(我们知道这听起来可能有点别扭),它们也可以只属于一个用户。在 app/Sale.php 中添加以下内容:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Sale extends Model
{
protected $fillable = ['user_id'];
public function books() {
return $this->hasMany('App\SalesBook');
}
public function user() {
return $this->belongsTo('App\User');
}
}
与借阅图书一样,销售图书也可以只有一本书,属于一个销售而不是一个用户。应在 app/SalesBook.php 中添加以下几行:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class SaleBook extends Model
{
public $timestamps = false;
protected $fillable = ['book_id', 'sale_id', 'amount'];
public function sale() {
return $this->belongsTo('App\Sale');
}
public function books() {
return $this->hasOne('App\Book');
}
}
最后,我们需要更新的模型是用户模型。我们需要添加与之前在 Sale 和 BorrowedBook 中使用的归属关系相反的关系。添加这两个函数,类的其他部分保持不变:
<?php
namespace App;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
//...
public function sales() {
return $this->hasMany('App\Sale');
}
public function borrowedBooks() {
return $this->hasMany('App\BorrowedBook');
}
}
设计端点
在本节中,我们需要列出要向 REST API 客户端公开的端点列表。请牢记 "REST API 最佳实践" 一节中解释的 "规则"。简而言之,请牢记以下规则:
-
一个端点与一个资源交互
-
可能的模式为 <API version>/<resource name>/<optional id>/<optional action>
-
使用 GET 参数进行筛选和分页
那么用户需要做什么呢? 自从我们创建了 UI 以来,我们已经对此有了一个很好的想法。 简要总结如下:
-
列出所有可用图书,并进行筛选(按书名和作者),必要时按页码排列。还可根据 ID 检索特定图书的信息。
-
如果有特定图书,允许用户借阅。同样,用户应能归还图书,并列出借阅图书的历史记录(按日期和页码筛选)。
-
允许用户购买图书列表。这一点还可以改进,但现在我们可以强制用户只需一个请求就能购买图书,并在正文中列出完整的图书清单。同时,按照与借阅图书相同的规则列出用户的销售情况。
我们将直接从端点列表开始,指定路径、HTTP 方法和可选参数。这也会让你了解如何记录 REST API。
-
GET /books
-
title:可选,根据标题筛选
-
author:可选,根据作者筛选
-
page:可选,默认为 1,指定要返回的页面
-
page-size:可选,默认为 50,指定要返回的页面大小 返回
-
-
GET /books/<book id>
-
POST /borrowed-books
-
book-id:必须填写,指定要借图书的 ID
-
-
GET /borrowed-books
-
from:可选,返回指定日期起的借阅图书
-
page:可选,默认为 1,指定要返回的页面
-
page-size:可选,默认为 50,指定每页的 每页借阅图书的数量
-
-
PUT /borrowed-books/<borrowed book id>/return
-
POST /sales
-
books:必须填写,它是一个数组,列出要购买的图书 ID 及其金额,即{"books"}。即
{"book-id-1":amount,"book-id-2":amount,...}
。
-
-
GET /sales
-
from:可选,返回指定日期起的借阅图书
-
page:可选,默认为 1,指定要返回的页面
-
page-size:可选,默认为 50,指定每页的销售数量 每页
-
-
GET /sales/<sales id>
在创建销售图书和借阅图书时,我们使用 POST 请求,因为我们事先不知道要创建的资源的 ID,发送同一个请求会创建多个资源。另一方面,在归还图书时,我们知道借阅图书的 ID,多次发送相同的请求会使数据库处于相同的状态。让我们将这些端点转化为 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']);
在前面的代码中,请注意我们是如何为所有端点添加中间件 oauth 的。这将要求用户提供有效的访问令牌才能访问它们。
添加控制器
从上一节可以看出,我们需要创建三个控制器: BookController、BorrowedBookController 和 SalesController。让我们从最简单的开始:返回给定 ID 的图书信息。创建文件 app/Http/Controllers/BookController.php,并添加以下代码:
<?php
namespace App\Http\Controllers;
use App\Book;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
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]);
}
}
尽管前面的示例非常简单,但它包含了其余端点所需的大部分内容。我们会尝试从 URL 中获取一本书的 ID,如果没有找到,我们会回复一个 404(未找到)的空响应—常量 Response::HTTP_NOT_FOUND 为 404。如果我们找到了书,我们就用 response->json()
以 JSON 格式返回。请注意我们是如何添加看似不必要的关键字 book 的;的确,我们没有返回任何其他内容,而且由于我们要求用户提供书籍,因此用户会知道我们在说什么,但这并没有什么坏处,所以尽可能明确一点是好的。
让我们来测试一下!你已经知道如何获取访问令牌—请查看 "申请访问令牌" 部分。那就获取一个,并尝试访问以下 URL:
假设 12345 是您的访问令牌,数据库中有一本 ID 为 1 的图书,而您没有一本 ID 为 0 的图书,第一个 URL 应返回 404 响应,第二个 URL 应返回类似下面的响应:
{
"book": {
"id": 1,
"isbn": "9780882339726",
"title": "1984",
"author": "George Orwell",
"stock": 12,
"price": 7.5
}
}
现在让我们添加一个方法,通过筛选和分页来获取所有图书。这个方法看起来很冗长,但我们使用的逻辑非常简单:
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]);
}
我们从请求中获取所有参数,并设置每个参数的默认值,以防用户不包含这些参数(因为它们是可选参数)。然后,我们使用 Eloquent ORM,使用 where() 按标题和作者进行过滤,并使用 take()->skip()
限制结果。我们以与前一个方法相同的方式返回 JSON。不过,在这个方法中,我们不需要任何额外的检查;如果查询没有返回任何图书,那就没什么问题。
现在,您可以使用 REST API,发送带有不同过滤器的不同请求。下面是一些示例:
列表中的下一个控制器是 BorrowedBookController。我们需要添加三个方法:borrow、get 和 returnBook。由于您已经知道如何使用请求、响应、状态代码和 Eloquent ORM,我们将直接编写整个类:
<?php
namespace App\Http\Controllers;
use App\Book;
use App\BorrowedBook;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use LucaDegasperi\OAuth2Server\Facades\Authorizer;
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]);
}
}
在前面的代码中,唯一需要注意的是我们如何通过增加或减少库存来更新图书的库存,并调用保存方法将更改保存到数据库中。在借书时,我们还会返回借书对象作为响应,这样用户就能知道借书 ID,并在查询或归还图书时使用它。
您可以通过以下用例来测试这组端点是如何工作的:
-
借一本书。检查是否收到有效回复。
-
获取借书列表。您刚创建的那本书应该在列表中,起始日期有效,结束日期为空。
-
获取所借书目的信息。库存应该少一本。
-
归还图书。获取借阅图书列表以检查结束日期,获取归还图书以检查库存。
当然,您也可以尝试欺骗应用程序接口,询问没有库存的图书、不存在的借阅图书等。所有这些边缘情况都应使用正确的状态代码和错误信息进行响应。
最后,我们创建销售控制器(SalesController)来结束本节和 REST API。该控制器包含更多逻辑,因为创建销售意味着在检查每本图书是否有足够库存之前,要向销售图书表中添加条目。在 app/Html/SalesController.php 中添加以下代码:
<?php
namespace App\Http\Controllers;
use App\Book;
use App\Sale;
use App\SalesBook;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use LucaDegasperi\OAuth2Server\Facades\Authorizer;
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]);
}
}
在前面的代码中,请注意我们如何在创建销售条目之前首先检查所有书籍的可用性。 这样,我们可以确保在向用户返回错误时不会在数据库中留下任何未完成的销售。 您可以更改此设置,并使用事务来代替,如果书籍无效,只需回滚事务即可。
为了测试这一点,我们可以遵循与借书相同的步骤。 请记住,发布销售时的 books 参数是一个 JSON 映射; 例如,{"1": 2, "4": 1}
表示我正在尝试购买两本 ID 为 1 的书和一本 ID 为 4 的书。