随着移动开发和 JavaScript 框架的日益流行,使用 RESTful API 在数据层和客户端之间构建交互接口逐渐成为最佳选择。
在本系列教程中,将会带领大家基于 Laravel 5.5 来构建并测试带认证功能的 RESTful API。
先要了解什么是 RESTful API。REST 是 Representational State Transfer 的缩写,表示一种应用之间网络通信的架构风格,依赖于无状态的协议(通常是HTTP)进行交互。
在 RESTful API 中,我们使用 HTTP 动词表示操作,而端点是操作的资源,HTTP 动词的语义如下:
关于 RESTful API 有一些争议,比如更新资源使用 POST、PATCH 还是 PUT 哪一个更好?
在本教程中,我们使用 PUT 进行更新操作,因为基于 HTTP RFC 标准,PUT 的含义是在指定位置上创建/更新资源;使用 PUT 的另一个原因是幂等性,这意味着不管你发送一次、两次还是上千次请求,操作结果都一致。
在 RESTful API 中,资源指的是操作的对象,在我们的例子中就是文章(Articles)和用户(Users)。
它们各自的端点是:
在我们的教程中,资源和数据模型一一对应,但这并不是强制性的要求。
使用 REST 的最大好处是更容易消费和开发 API,一些端点非常直截了当,这样相较于类似 GET /get_article?id_article=12 这样的端点 RESTful API 更容易使用和维护。
不过,在某些案例中映射到 Create/Retrieve/Update/Delete 可能会很困难。
需要牢记的是: URL 中不要包含任何动词而且资源并不一定非得是数据表的某一行数据。另一个需要记住的是不必为每个资源实现所有操作。
首先,通过 Composer 来安装 Laravel 5.5。
composer create-project --prefer-dist laravel/laravel apidemo 5.5.*
然后,自行配置 web 服务器,并修改 hosts 文件。
127.0.0.1 apidemo.test
检测是否可以正常访问 http://apidemo.test 。
修改 config/app.php 中的时区 timezone 配置:
'timezone' => 'Asia/Shanghai',
在编写第一个迁移之前,需要将 .env 文件中的环境变量调整为开发环境中的数据库配置。
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=apidemo
DB_USERNAME=root
DB_PASSWORD=root
接下来,就可以开始创建我们的第一个 Article 模型及其对应的迁移文件。
在项目根目录,运行如下 Artisan 命令一步到位:
php artisan make:model Article -m
-m 是 --migration 的缩写,告知 Artisan 在创建模型的同时创建与之对应的迁移文件。
上述命令创建的模型文件是 app/Article.php,迁移文件是 database/migrations/2018_04_03_160023_create_articles_table.php。
当然,还需要编辑该迁移文件的内容:
increments('id');
$table->string('title', 100);
$table->text('body');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('articles');
}
}
然后,运行命令 php artisan migrate ,就会根据迁移文件来创建对应的数据表结构了。
php artisan migrate
执行成功后,就可以看到 apidemo 数据库中自动生成了 4 张表,articles、migrations、password_resets 和 users。
当然,只有 articles 表是由我们手动生成的迁移文件来生成的。
由上述迁移文件生成的 articles 表的结构为:
CREATE TABLE `articles` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`body` text COLLATE utf8mb4_unicode_ci NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
修改模型
修改 Article 模型类,添加如下属性字段到 $fillable ,以便可以在 Article::create 和 Article::update 方法中使用它们。
class Article extends Model
{
protected $fillable = ['title', 'body'];
}
Laravel 通过 Faker 库可以快速为我们生成格式正确的测试数据。
创建填充器类:
php artisan make:seeder ArticlesTableSeeder
填充器类默认会存放在 database/seeds 目录下。
编辑 database/seeds/ArticlesTableSeeder.php 填充器:
$faker->sentence,
'body' => $faker->paragraph,
]);
}
}
}
然后运行填充命令:
php artisan db:seed --class=ArticlesTableSeeder
类似地,再创建用户表填充器类:
php artisan make:seeder UsersTableSeeder
修改 database/seeds/UsersTableSeeder.php 文件:
'Administrator',
'email' => '[email protected]',
'password' => $password,
]);
// And now let's generate a few dozen users for our app:
for ($i = 0; $i < 10; $i++) {
User::create([
'name' => $faker->name,
'email' => $faker->email,
'password' => $password,
]);
}
}
}
然后,修改 database/seeds/DatabaseSeeder.php 文件:
call(UsersTableSeeder::class);
$this->call(ArticlesTableSeeder::class);
}
}
这样的话,只需运行下面的命令:
php artisan db:seed
就可以调用多个填充器来填充数据。
有了数据之后,接下来我们来为应用创建基本接口:创建、获取列表、获取单条记录、更新以及删除。
在 routes/api.php (API 接口路由文件)中,添加下面的路由配置:
all);
});
Route::put('articles/{id}', function(Request $request, $id) {
$article = Article::findOrFail($id);
$article->update($request->all());
return $article;
});
Route::delete('articles/{id}', function($id) {
Article::find($id)->delete();
return 204;
});
对于 routes/api.php 中定义的路由,访问时需要加上 /api/ 前缀,并且 API 限流中间件会自动应用到所有路由。
配置好上述路由之后,就可以访问某些 API 接口了。
比如,获取文章的单条记录 API 接口:
GET /api/articles/{id}
在 Postman 客户端中,测试该 API 接口。
GET http://apidemo.test/api/articles/1
接下来,我们来创建控制器,以便把路由闭包中的业务逻辑调整到控制器中。
// 创建文章控制器
php artisan make:controller ArticleController
编辑 app/Http/Controllers/ArticleController.php :
all());
}
/**
* 更新文章
*/
public function update(Request $request, $id)
{
$article = Article::findOrFail($id);
$article->update($request->all());
return $article;
}
/**
* 删除文章
*/
public function delete(Request $request, $id)
{
$article = Article::findOrFail($id);
$article->delete();
return 204;
}
}
然后,调整 routes/api.php 文件中的 articles 相关路由即可。
Route::get('articles', 'ArticleController@index');
Route::get('articles/{id}', 'ArticleController@show');
Route::post('articles', 'ArticleController@store');
Route::put('articles/{id}', 'ArticleController@update');
Route::delete('articles/{id}', 'ArticleController@delete');
隐式的路由模型绑定
还可以通过隐式路由模型绑定,来改写路由。
Route::get('articles', 'ArticleController@index');
Route::get('articles/{article}', 'ArticleController@show');
Route::post('articles', 'ArticleController@store');
Route::put('articles/{article}', 'ArticleController@update');
Route::delete('articles/{article}', 'ArticleController@delete');
当然,相应的,也要调整控制器代码:
class ArticleController extends Controller
{
/**
* 获取文章列表
*/
public function index()
{
return Article::all();
}
/**
* 获取文章的单条记录
*/
public function show(Article $article)
{
return $article;
}
/**
* 创建新文章
*/
public function store(Request $request)
{
$article = Article::create($request->all());
return response()->json($article, 201);
}
/**
* 更新文章
*/
public function update(Request $request, Article $article)
{
$article->update($request->all());
return response()->json($article, 200);
}
/**
* 删除文章
*/
public function delete(Article $article)
{
$article->delete();
return response()->json(null, 204);
}
}
改用隐式路由模型绑定后,API 接口的返回结果与之前是一致的。
我们使用了 response()->json(),这可以让我们在显示返回 JSON 数据的同时发送可以被客户端解析的 HTTP 状态码。
常用的 HTTP 状态码如下:
如果你试图获取不存在的资源,会返回 404 页面。
比如:http://apidemo.test/api/articles/1000
对于 404 响应, Laravel 默认提供的是一个 html 页面响应。
如果想要将其改为返回 JSON 响应,可以修改异常处理器 app/Exceptions/Handler.php 的 render 方法。
json([
'error' => 'Resource not found.'
],404);
}
return parent::render($request, $exception);
}
}
再来访问不存在的资源,返回的结果如下:
{
"error": "Resource not found."
}
在 Laravel 中实现 API 认证有多种方式(例如 Passport),这里我们使用一个非常简化的方式。
首先,需要添加 api_token 字段到 users 表。
我们通过创建新的迁移来修改 users 的表结构。
php artisan make:migration --table=users adds_api_token_to_users_table
编辑该迁移文件:
string('api_token', 60)->unique()->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['api_token']);
});
}
}
执行迁移命令,以便作用于数据表。
php artisan migrate
这样,users 表中就自动新增了 api_token 字段。
我们使用 RegisterController 来根据注册请求返回正确的响应。尽管 Laravel 开箱提供了认证功能,但是我们还是需要对其进行调整以便返回我们想要的响应数据。
只需在 app/Http/Controllers/Auth/RegisterController.php 中实现 registered 方法即可。
protected function registered(Request $request, $user)
{
$user->generateToken();
return response()->json(['data' => $user->toArray()], 201);
}
在上面的示例代码中,我们调用了 User 模型上的生成令牌方法 generateToken(),但该方法还不存在。故我们需要修改 User 模型类(app/User.php),添加该方法。
public function generateToken()
{
$this->api_token = str_random(60);
$this->save();
return $this->api_token;
}
然后,在 routes/api.php 中,添加用户注册接口的路由:
Route::post('register', 'Auth\RegisterController@register');
至此,注册接口编写完成,用户现在可以通过注册接口进行注册了,感谢 Laravel 开箱提供的认证字段验证功能,如果你需要调整验证规则的话可以到 RegisterController 中查看 validator 方法。
接下来,下面我们来简单测试下用户注册接口:
curl -X POST http://apidemo.test/api/register \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"name": "user01", "email": "[email protected]", "password": "test123", "password_confirmation": "test123"}'
用户注册成功后的响应结果:
{
"data": {
"name": "user01",
"email": "[email protected]",
"updated_at": "2018-04-04 22:05:43",
"created_at": "2018-04-04 22:05:43",
"id": 14,
"api_token": "Lqd7HfpFaptghJxyV7VaQwUv5JYqIUTehzOblvDDZxIx0M4PpIfODNcKNSVK"
}
}
和用户注册接口类似,可以编辑 LoginController 控制器来支持 API 认证。
为此,我们需要在 LoginController 覆盖 AuthenticatesUsers trait 提供的 login 方法。
修改 app/Http/Controllers/Auth/LoginController.php 文件。
先添加
use Illuminate\Http\Request;
然后,添加
public function login(Request $request)
{
$this->validateLogin($request);
if ($this->attemptLogin($request)) {
$user = $this->guard()->user();
$user->generateToken();
return response()->json([
'data' => $user->toArray(),
]);
}
return $this->sendFailedLoginResponse($request);
}
然后在 routes/api.php 中,配置用户登录的路由:
Route::post('login', 'Auth\LoginController@login');
现在,基于我们上面注册的新用户,我们来测试下登录接口:
curl -X POST http://apidemo.test/api/login \
-H "Content-type: application/json" \
-d '{"email": "[email protected]", "password": "test123"}'
登录成功返回结果:
{
"data": {
"id": 14,
"name": "user01",
"email": "[email protected]",
"created_at": "2018-04-04 22:05:43",
"updated_at": "2018-04-04 22:53:13",
"api_token": "1QzaV8ebVUtrgF6qcvsmdL7S0Jh09tqR4tB1SBQ5tUacl0w2YWjzyLubXTgy"
}
}
后面,可以拿着这个 api_token 作为令牌来请求需要认证的资源了。使用我们现有的策略,请求认证资源时,如果没有 token 或 token 错误,用户将会接收到未认证响应(401)。
为了形成完整闭环,下面我们来编写退出登录接口,实现思路是用户发起退出登录请求时,我们将其对应的 api_token 字段值从数据库移除。
在 routes/api.php 中,添加路由:
Route::post('logout', 'Auth\LoginController@logout');
然后在 Auth\LoginController.php 中编写 logout 方法:
public function logout(Request $request)
{
$user = Auth::guard('api')->user();
if ($user) {
$user->api_token = null;
$user->save();
}
return response()->json(['data' => 'User logged out.'], 200);
}
使用该策略,一旦退出,用户的所有令牌都会失效,访问需要认证的 API 接口都会拒绝访问(通过中间件实现)。
这需要和前端配合来避免用户在没有访问任何内容的权限下保持登录状态。
请求头
通过 token 来保持用户的登录状态。因此,用户登录成功之后,请求后续的 API 接口时,需要传递下面的请求头信息。
$headers = ['Authorization' => "Bearer $token"];
Auth::guard('api') 会根据这个请求头,来判断当前的用户信息,并判断用户的登录状态。
注:$token 的值就是 api_token 字段的值,用户每次登录成功后,api_token 都会刷新。
api_token 创建之后,我们就可以在路由文件中应用认证中间件了:
Route::middleware('auth:api')
->get('/user', function (Request $request) {
return $request->user();
});
我们可以使用 $request->user() 或 Auth 门面访问当前用户:
Auth::guard('api')->user(); // 登录用户实例
Auth::guard('api')->check(); // 用户是否登录
Auth::guard('api')->id(); // 登录用户ID
接下来,我们将之前定义的文章相关路由进行分组:
Route::group(['middleware' => 'auth:api'], function() {
Route::get('articles', 'ArticleController@index');
Route::get('articles/{article}', 'ArticleController@show');
Route::post('articles', 'ArticleController@store');
Route::put('articles/{article}', 'ArticleController@update');
Route::delete('articles/{article}', 'ArticleController@delete');
});
这样就不需要为每个路由单独设置中间件,好处是保持路由的DRY(Don’t Repeat Yourself)。
注:并不是所有的 API 接口都需要通过 auth 中间件认证过滤。
请求需要认证的资源时,如果请求头中没有 token 或 token 错误,就是认证失败,会抛出 AuthenticationException 异常。
AuthenticationException 异常是由 /Illuminate/Foundation/Exceptions/Handler.php 异常处理器的 unauthenticated() 方法来处理的。
protected function unauthenticated($request, AuthenticationException $exception)
{
return $request->expectsJson()
? response()->json(['message' => $exception->getMessage()], 401)
: redirect()->guest(route('login'));
}
如果想自定义认证失败后的响应,可以在 app/Exceptions/Handler.php 中重写该方法。
先添加
use Illuminate\Auth\AuthenticationException;
然后,重写 unauthenticated() 方法:
/**
* Convert an authentication exception into a response.
*
* @param \Illuminate\Http\Request $request
* @param \Illuminate\Auth\AuthenticationException $exception
* @return \Illuminate\Http\Response
*/
protected function unauthenticated($request, AuthenticationException $exception)
{
return response()->json(['message' => $exception->getMessage()], 401);
}
这样的话,只要认证失败,就一定会返回自定义的 JSON 响应。
例如,直接访问 http://apidemo.test/api/articles/1 ,而没有传递带 token 的请求头信息的话,就会得到未认证响应(401)。
{
"message": "Unauthenticated."
}
现在,我们已经构建好了 RESTful 风格的 API 接口。
routes/api.php 文件中的 API 路由配置如下:
'auth:api'], function() {
Route::get('articles', 'ArticleController@index');
Route::get('articles/{article}', 'ArticleController@show');
Route::post('articles', 'ArticleController@store');
Route::put('articles/{article}', 'ArticleController@update');
Route::delete('articles/{article}', 'ArticleController@delete');
});
API 接口列表:
API 名称 | 请求方式 | 功能描述 |
---|---|---|
/api/register | POST | 用户注册 |
/api/login | POST | 用户登录 |
/api/logout | POST | 退出登录 |
/api/articles | GET | 获取文章列表 |
/api/articles/{article} | GET | 获取指定的一篇文章 |
/api/articles | POST | 添加新文章 |
/api/articles/{article} | PUT | 更新文章 |
/api/articles/{article} | DELETE | 删除文章 |
Laravel 开箱集成了 PHPUnit 进行测试,并且在项目根目录下为我们配置好了 phpunit.xml。
本教程中,我们使用内置的测试方法来测试上面编写的 API。
开始之前,我们需要做一些小调整以便使用内存级的 SQLite 数据库进行数据存储。
这样做的好处是可以让测试更快运行,但缺点是某些迁移命令可能不能正常运行,我的建议是当你遇到运行迁移命令出错或者更倾向于更加健壮的测试而不是高性能时不要使用 SQLite。
我们还会在每个测试之前运行迁移,这样就可以为每次测试构建数据库然后销毁掉,从而避免不同组测试间的相互干扰。
在 config/database.php 文件中,设置 sqlite 配置项中的 database 字段值为 :memory: 。
...
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
...
]
然后在 phpunit.xml 中通过新增 DB_CONNECTION 环境变量来启用 SQLite:
基本配置已经完成,接下来就是配置 TestCase 在每次测试前运行迁移并填充数据库。为此,我们需要添加 DatabaseMigrations trait 然后在 setUp() 方法中添加 Artisan 调用。
修改 tests/TestCase.php 文件:
namespace Tests;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication, DatabaseMigrations;
public function setUp()
{
parent::setUp();
Artisan::call('db:seed');
}
}
运行 phpunit 测试命令:
# Windows 环境下需要用反斜线
vendor\bin\phpunit
# Linux 环境下用正斜线
vendor/bin/phpunit
如果报错信息为:
'vendor' 不是内部或外部命令,也不是可运行的程序
或批处理文件。
请删除项目目录下的 vendor 目录,然后重新利用 composer 安装依赖。
composer install
如果报错信息为:
Error: Class 'Doctrine\DBAL\Driver\PDOSqlite\Driver' not found
这是因为没有安装 doctrine/dbal 扩展包,使用 Composer 安装即可:
composer require doctrine/dbal
测试命令的正常输出示例:
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 522 ms, Memory: 14.00MB
OK (2 tests, 2 assertions)
模型工厂可以让我们快速生成测试数据,Laravel 开箱自带了 User 模型工厂,下面我们为 Article 类添加工厂:
php artisan make:factory ArticleFactory
模型工厂的存放目录是 database/factories。
编辑 ArticleFactory 类:
define(Article::class, function (Faker $faker) {
return [
'title' => $faker->sentence,
'body' => $faker->paragraph,
];
});
可以使用 Laravel 的断言方法对请求和响应进行测试。
下面我们来创建第一个测试用例 —— 登录测试
php artisan make:test LoginTest
编辑 tests/Feature/LoginTest.php 文件:
json('POST', 'api/login')
->assertStatus(422)
->assertJson([
'message' => "The given data was invalid.",
'errors' => [
'email' => ['The email field is required.'],
'password' => ['The password field is required.']
]
]);
}
public function testUserLoginsSuccessfully()
{
$user = factory(User::class)->create([
'email' => '[email protected]',
'password' => bcrypt('test123'),
]);
$payload = ['email' => '[email protected]', 'password' => 'test123'];
$this->json('POST', 'api/login', $payload)
->assertStatus(200)
->assertJsonStructure([
'data' => [
'id',
'name',
'email',
'created_at',
'updated_at',
'api_token',
],
]);
}
}
注册测试
php artisan make:test RegisterTest
tests/Feature/RegisterTest.php 内容如下:
'John',
'email' => '[email protected]',
'password' => 'toptal123',
'password_confirmation' => 'toptal123',
];
$this->json('post', '/api/register', $payload)
->assertStatus(201)
->assertJsonStructure([
'data' => [
'id',
'name',
'email',
'created_at',
'updated_at',
'api_token',
],
]);;
}
public function testsRequiresPasswordEmailAndName()
{
$this->json('post', '/api/register')
->assertStatus(422);
}
public function testsRequirePasswordConfirmation()
{
$payload = [
'name' => 'John',
'email' => '[email protected]',
'password' => 'toptal123',
];
$this->json('post', '/api/register', $payload)
->assertStatus(422)
->assertJson([
'message' => "The given data was invalid.",
'errors' => [
'password' => ['The password confirmation does not match.'],
]
]);
}
}
退出测试
php artisan make:test LogoutTest
编辑 LogoutTest 代码如下:
create(['email' => '[email protected]']);
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$this->json('get', '/api/articles', [], $headers)->assertStatus(200);
$this->json('post', '/api/logout', [], $headers)->assertStatus(200);
$user = User::find($user->id);
$this->assertEquals(null, $user->api_token);
}
public function testUserWithNullToken()
{
// Simulating login
$user = factory(User::class)->create(['email' => '[email protected]']);
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
// Simulating logout
$user->api_token = null;
$user->save();
$this->json('get', '/api/articles', [], $headers)->assertStatus(401);
}
}
注:在测试期间,Laravel 应用并不会在发起新请求时再次初始化,所以会在请求之间保存当前用户到 TokenGuard 实例,也因此我们不得不将退出测试一分为二,以避免受之前缓存用户的影响。
文章 API 接口测试:
php artisan make:test ArticleTest
编写 ArticleTest 代码如下:
create();
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$payload = [
'title' => 'Lorem',
'body' => 'Ipsum',
];
$this->json('POST', '/api/articles', $payload, $headers)
->assertStatus(201);
}
public function testsArticlesAreUpdatedCorrectly()
{
$user = factory(User::class)->create();
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$article = factory(Article::class)->create([
'title' => 'First Article',
'body' => 'First Body',
]);
$payload = [
'title' => 'Lorem',
'body' => 'Ipsum',
];
$response = $this->json('PUT', '/api/articles/' . $article->id, $payload, $headers)
->assertStatus(200)
->assertJson([
'title' => 'Lorem',
'body' => 'Ipsum'
]);
}
public function testsArtilcesAreDeletedCorrectly()
{
$user = factory(User::class)->create();
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$article = factory(Article::class)->create([
'title' => 'First Article',
'body' => 'First Body',
]);
$this->json('DELETE', '/api/articles/' . $article->id, [], $headers)
->assertStatus(204);
}
public function testArticlesAreListedCorrectly()
{
factory(Article::class)->create([
'title' => 'First Article',
'body' => 'First Body'
]);
factory(Article::class)->create([
'title' => 'Second Article',
'body' => 'Second Body'
]);
$user = factory(User::class)->create();
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$response = $this->json('GET', '/api/articles', [], $headers)
->assertStatus(200)
->assertJsonStructure([
'*' => ['id', 'body', 'title', 'created_at', 'updated_at'],
]);
}
}
最后,运行测试命令:
vendor\bin\phpunit
就可以对我们编写的所有测试用例进行测试,当然也会对 Laravel 默认提供的两个测试用例 ExampleTest 进行测试。
至此,我们已经完成了 API 接口的编写和测试,下一篇我们会基于 JWT 对 API 进行认证,同时整合 Vue SPA 做一个更偏向实战的教程。