RESTful is one of the most widely used conventions for designing and organizing backend APIs. In a RESTful API, every operation is treated as a CRUD action on a resource. The URI identifies the resource, the request method indicates the operation, and the response status code tells you the result.
I’ve built quite a few APIs with RESTful conventions, and to me its biggest advantage is that it helps you organize your interfaces more cleanly. If you keep writing endpoints purely based on requirements, reuse tends to be low and the project quickly turns into a mess.
ThinkJS has a very distinctive feature: file-based routing. For example, the /user route is equivalent to /user/index, which maps to the indexAction method in src/controller/user.js. Using /user as an example, creating a RESTful API in ThinkJS usually takes two steps:
- Run
thinkjs controller user -rto create the route filesrc/controller/user.js - Mark the route as RESTful in
src/config/router.jswith a custom route definition
//src/config/router.js
module.exports = [
['/user/:id?', 'rest']
];
After that, the RESTful route is initialized. All operations on that resource will be mapped to the corresponding Action methods in the controller according to the request method. For example:
GET /usergets the user list, corresponding togetActionGET /user/:idgets details for a specific user, also corresponding togetActionPOST /useradds a user, corresponding topostActionPUT /user/:idupdates a user’s information, corresponding toputActionDELETE /user/:iddeletes a user, corresponding todeleteAction
The problem is that writing custom RESTful routes in router.js for every endpoint is tedious. That’s why I wrote a middleware called think-router-rest. With it, you only need to mark the Controller with a _REST static property, and it will be transformed into a RESTful route automatically.
//src/controller/user.js
module.exports = class extends think.Controller {
static get _REST() {
return true;
}
getAction() {}
postAction() {}
putAction() {}
deleteAction() {}
}
Once you understand the basics, there are a few things that have helped me a lot when building RESTful APIs in daily work.
Organizing the schema first
After getting a requirement, don’t rush to start typing code. The first thing you should do is organize the table structure. Strictly speaking, this is really about organizing resources.
Take MySQL as an example. A single type of resource usually becomes one table, such as a user table for users or a post table for articles. Once you list out the tables, the shape of your RESTful API is mostly visible already. For example, if you have a post table, you will very likely end up with these endpoints:
GET /postgets the article listGET /post/1gets the article withid=1POST /postcreates an articlePUT /post/1updates the article withid=1DELETE /post/1deletes the article withid=1
Of course, things are not always this neat. Sometimes an operation looks complicated on the surface, and in that case you need to think about what the action really is at its core. For example, if you need to move an article to another user, the essence of that action is actually updating the user_id field of the post resource. In the end, it still maps to PUT /post/1.
Thinking clearly about what resources exist helps you design tables better. After that, you should also think about how those resources relate to each other, because that affects your schema as well. In general, resource relationships usually fall into three categories:
- One-to-one: if one
usercan only create onepost, that’s a one-to-one relationship. Inpost, you can useuser_idto link to the correspondinguserrecord, and inuseryou can also usepost_idto point back to the article. - One-to-many: if one
usercan create manypostrecords, that’s a one-to-many relationship. Inpost, you can useuser_idto link to the correspondinguser. - Many-to-many: if one
usercan create manypostrecords and onepostcan also belong to multipleuserrecords, then it’s a many-to-many relationship. This cannot be represented with a single field, so you need an intermediate table such asuser_postto map the relationship betweenuserandpost. In that table,user_idrefers to theusertable ID, andpost_idrefers to the relatedpostrecord ID.
mysql> DESCRIBE user;
+-------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------+--------------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| name | varchar(100) | YES | | NULL | |
+-------+--------------+------+-----+---------+----------------+
2 rows in set (0.01 sec)
mysql> DESCRIBE post;
+-------+---------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------+---------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| title | text | YES | | NULL | |
+-------+---------+------+-----+---------+----------------+
2 rows in set (0.00 sec)
mysql> DESCRIBE user_post;
+---------+---------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+---------+---------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| user_id | int(11) | NO | | NULL | |
| post_id | int(11) | NO | | NULL | |
+---------+---------+------+-----+---------+----------------+
3 rows in set (0.00 sec)
ThinkJS is a framework that values convention over configuration, so when it handles a RESTful resource it will, by default, look for the corresponding table based on the current URI. For example, GET /post will map to the post table. After that, query operations can also perform automatic association lookups. If you mark post and user as a one-to-many relationship in the model, and the post table contains a user_id field, ThinkJS can automatically fetch the associated user data. That saves a lot of work when dealing with data operations.
Login and logout as a resource
When I first wrote RESTful APIs, this was one of the first things that felt awkward. In most systems, login and logout are represented by /login and /logout. Turning that into a resource-based design is not immediately obvious.
Later I realized that the resource involved in login is actually the token issued after authentication. In other words, login is really the creation and retrieval of credentials, and logout is the deletion of those credentials.
GET /token: get the credential, used to check whether the user is logged inPOST /token: create the credential, used for loginDELETE /token: delete the credential, used for logout
Permission checks
A large part of API work is actually request handling rather than core business logic. That includes permission checks and parameter validation, both of which are often unrelated to the main business scenario.
To keep those concerns separate from business logic, ThinkJS provides a Logic layer above the Controller layer. Logic and Controller map to each other one by one, and Logic also provides common validation methods. That makes it a good place for permission checks, parameter checks, and parameter preprocessing, while the Controller focuses on the real business logic.
Both Logic and Controller support a __before() magic method. Before any Action in the current Controller runs, __before() is executed first. That makes it a convenient place for common checks, such as verifying whether the user is logged in, so you don’t need to repeat the same logic everywhere.
//src/logic/base.js
module.exports = class extends think.Logic {
async __before() {
//接口 CSRF 校验
if (!this.isCli && !this.isGet) {
const referrer = this.referrer(true);
if (!/^xxx\.com$/.test(referrer)) {
return this.fail('请不要在非其它网站中使用该接口!');
}
}
// 非登录接口需要做登录校验
const userInfo = await this.session('userInfo') || {};
if(think.isEmpty(userInfo) && !/\/(?:token)\.js/.test(this.__filename)) {
return this.ctx.throw(401, 'UnAuthorized');
}
}
}
//src/logic/user.js
const Base = require('./base.js');
module.exports = class extends Base {}
By creating a Base class, every Logic that inherits from it automatically gets CSRF protection and login verification.
A natural question here is: every request instantiates a class, so the constructor also runs before any Action. Why do we still need __before()?
The answer is simple. A constructor does run early, but it cannot reliably perform asynchronous operations in a guaranteed order. A constructor cannot be marked async, while __before() can. That is the main reason it exists.
Make good use of inheritance
In RESTful APIs, many resources are actually subordinate to other resources. For example, users and articles under a project form a dependency chain. In these subordinate relationships, permissions and data operations are also subordinate. An article belongs to a user, so an unrelated user should not be able to see it. A user belongs to a project, so people from other projects should not be able to operate on that project’s users.
Once the dependency is clear, you’ll notice that the deeper the resource, the more permission checks it needs. In the example above, operating on a project only requires checking whether the current user belongs to that project. But if you want to operate on a user’s article under that project, you first need to verify project membership and then verify that the article belongs to the current user.
This gives us an important pattern: when resource relationships are subordinate, permission checks are also subordinate, and deeper resources require more checks. In object-oriented languages, inheritance is a key feature because it helps with logic reuse. With inheritance, you can reuse parent validation logic directly in child resources and avoid duplication.
//src/logic/base.js
module.exports = class extends think.Logic {
async __before() {
const userInfo = this.session('userInfo') || {};
this.userInfo = this.ctx.state.userInfo = userInfo;
if(think.isEmpty(userInfo)) {
return this.ctx.throw(401);
}
}
}
//src/logic/project/base.js
const Base = require('../base.js');
module.exports = class extends Base {
async __before() {
await super.__before();
const {team_id} = this.get();
const {id: user_id} = this.userInfo;
const permission = await this.model('team_user').where({team_id, user_id}).find();
const {controller} = this.ctx;
// 团队接口中只有普通用户只有权限调用获取邀请链接详细信息和接受邀请链接两个接口
if(controller !== 'team/invitation' && (this.isGet && !this.id)) {
if(think.isEmpty(permission)) {
return this.fail('你没有权限操作该团队');
}
}
this.userInfo.role_id = permission.role_id;
}
}
//src/logic/project/user/base.js
const Base = require('../base');
module.eports = class extends Base {
async __before() {
await super.__before();
const {role_id} = this.userInfo;
if(!global.EDITOR.is(role_id)) {
return this.fail('你没有权限操作该文章');
}
}
}
With these three Base classes, the permission checks are split up in a way that is both manageable and complete. Any route at the same level only needs to inherit from the Base class at that level to get the common checks.
- The Logic for
/projectinherits fromsrc/logic/base.js, so it gets login verification. - The Logic for
/project/1/userinherits fromsrc/logic/project/base.js, so it gets login verification and project membership checks. - The Logic for
/project/1/user/1/postinherits fromsrc/logic/project/user/base.js, so it gets login verification, project membership checks, and role validation.
That’s all it takes to build the nesting cleanly.
Database operations
Subordinate resources are also reflected in the table design. Using the project, user, and post example again, the article table will usually contain both project_id and user_id as related fields to represent the association between the article and the project/user resources, assuming both are one-to-many relationships.
In practice, operations on articles under a project need both project_id and user_id in the WHERE conditions.
ThinkJS uses think-model for SQL operations, and it supports chained calls. A query can look like this:
//src/controller/project/user/post.js
module.exports = class extends think.Controller {
async indexAction() {
const ret = await this.model('post').where({project_id: 1}).where({user_id: 2}).select();
return this.success(ret);
}
}
With that in mind, you can optimize the operation by placing the shared WHERE conditions such as project_id and user_id into the constructor of the current Controller. Then other Actions do not need to pass them again, and you also avoid the risk of forgetting a limiting condition.
//src/controller/project/user/post.js
module.exports = class extends think.Controller {
constructor(ctx) {
super(ctx);
const {project_id, user_id} = this.get();
this.modelInstance = this.model('post').where({project_id, user_id});
}
async getAction() {
const ret = await this.modelInstance.select();
return this.success(ret);
}
}
A few final notes
Beyond what’s discussed above, RESTful APIs also define conventions around response status codes and API versioning. Well-designed RESTful sites like GitHub even implement the Hypermedia API style, returning the RESTful route addresses needed for further operations in each response so that callers can chain requests more easily.
Of course, RESTful is only one API design convention. There are others as well, such as GraphQL. If you’re interested in that direction, you can also look into GraphQL practice and implementation patterns.