做者 | Peter Boyer 原文连接: https://medium.com/studioarmix/learn-restful-api-design-ideals-c5ec915a430fjavascript
记得在 Ken Rogers的Medium博客 里曾经见过这么一句话(原文出自海明威):前端
咱们都是手艺学徒,没有人会成为大师。java
在我写这篇文章的时候,我不由笑了起来,由于从这件事情的背后看到了一个伟大的类比,那就是从其余人那里引用了海明威的话。也就是说,我不须要为了获得相似的功能和结果而花费精力本身去建立一个不同凡响的东西,上面提到的海明威的话正是代码重用在文学上的例子。nginx
可是,我在这里不会写代码包的好处,而是更多地提一些个人感觉,这些感觉会在当前以及将来的项目中积极地获得实现。我还总结了一套API规则和原语,包括了功能和实现细节。git
若是你要开发一个提供客户端服务的API,你须要为最后可能的修改而作好准备。最好的办法就是经过为RESTful API提供“版本命名空间”来实现。github
咱们只需将版本号做为前缀添加到全部的URL里便可。sql
GET www.myservice.com/api/v1/posts
然而,在我研究了其余的API实现以后发现,我喜欢上了这种较短的URL样式,它把api做为是子域名的一部分,并从路由中删除了 /api
,这样更短、更简洁。数据库
GET api.myservice.com/v1/posts
须要重点关注的是,若是你打算在 www.myservice.com
上托管你的前端站点,而将API放在另一个不一样的子域上,例如 api.myservice.com
,那么你须要在后端实现CORS,这样才能使得AJAX调用不会抛出 No Access-Control-Allow-Origin header is present
这样的错误。后端
当你从 /posts
请求多个帖子的时候,这样的URL看起来更明了:api
// 复数形式看起来更一致,更有意义
GET /v1/posts/:id/attachments/:id/comments // 不能有歧义 // 这只是一个评论? 仍是一个表格? GET /v1/post/:id/attachment/:id/comment
更多有关混合类型的信息,请看下文:“ 使用根级别的‘me’端点(URL) ”。
查询字符串的做用是对关系数据库返回的记录集作进一步地过滤。
/projects/:id/collections
优于 /collections?projectId=:id
。
/projects/:id/collections/:id/items
优于 /items?projectId=:id&collectionId=:id
。
更多信息请看下文:“ 避免对嵌套路由的操做 ”。
咱们可以使用下面这些HTTP方法:
- GET
用于获取数据。
- POST
用于添加数据。
- PUT
用于更新数据(整个对象)。
- PATCH
用于更新数据(附带对象的部分信息)。
- DELETE
用于删除数据。
补充一点,对于修改对象的部份内容的请求来讲,我认为PATCH是减小请求包大小的一个好的方法,而且它也能很好的跟自动提交/自动保存字段配合起来用。
一个很好的例子是Tumblr的“仪表盘设置”屏幕,其中,“服务的用户体验”的一些非关键性选项能够单独地编辑和保存,而不须要点最下面的提交按钮。
对于 POST
, PUT
或 PATCH
的成功响应消息,应该返回更新后的对象,而不是只返回一个 null
。
有关响应的其余内容,请阅读下文:“ JSON格式的响应和请求 ”。
“我不喜欢数据封包。它只是引入了另外一个键来浏览数据树。元信息应该包含在包头中。”
最初,我坚持认为封包数据是没必要要的,HTTP协议已经提供了足够的“封包”来传递响应消息。
然而,根据 Reddit上的回复 所述,若是不封包为JSON数组,则可能会出现各类漏洞和潜在的黑客攻击。
如今建议使用封包,你应该把数据封包后再应答!
// 已封包,最顶级的对象既安全又简洁
{
data: [
{ ... },
{ ... }, // ... ] } // 未封包,存在安全风险 [ { ... }, { ... }, // ... ]
一样要重点关注的是,不像其余语言那样,JavaScript之类的语言将会将空对象认为是true! 所以,在下面这种状况下,不要返回空的对象来做为响应的一部分:
// 从payload中提取封包和错误
const { data, error } = payload // 错误处理 if (error) { throw ... } // 不然 const normalizedData = normalize(data, schema)
全部东西都应该被序列化成JSON。若是你期待从服务器上获取JSON格式的数据,那么请客气一点,请发送JSON格式的内容给服务器。请两边保持一致!
某些状况下,若是动做执行成功(例如 DELETE
),那我并无什么须要返回的。可是,在某些语言(如Python)中返回一个空对象可能被认为是false,而且在开发人员调试程序的时候,这种状况并不容易发现。所以,我喜欢返回“OK”,尽管这是一个字符串,可是在返回的时候会被包装成一个简单的响应对象。
DELETE /v1/posts/:id // response - HTTP 200 { "message": "OK" }
由于咱们使用了HTTP方法,因此咱们应当使用HTTP状态码。
我喜欢使用这些状态码:
400
:请求信息不完整或没法解析。
422
:请求信息完整,但无效。
404
:资源不存在。
409
:资源冲突。
401
:访问令牌没有提供,或者无效。
403
:访问令牌有效,但没有权限。
200
: 全部的都正确。
500
: 服务器内部抛出错误。
假设要建立一个新账户,咱们提供了 email
和 password
两个值。咱们但愿让客户端应用程序可以阻止任何无效的电子邮件或密码过短的请求,但外部人员能够像咱们的客户端应用程序同样在须要的时候直接访问API。
若是 email
字段丢失,则返回 400
。
若是 password
字段过短,则返回 422
。
若是 email
字段不是有效的电子邮件,则返回 422
。
若是 email
已经被使用,返回一个 409
。
从上面这些状况来看,有两个错误会返回 422
,不过他们的缘由是不一样的。这就是为何咱们须要一个错误码,甚至是一个错误描述。要区分代码和描述,我打算将 error
(代码)做为机器可识别的常量,将 description
做为可更改的用于人类识别的字符串。
对于字段的错误,能够这样返回:
POST /v1/register
// 请求
{
"email": "end@@user.comx" "password": "abc" } // 响应 - 422 { "error": { "status": 422, "error": "FIELDS_VALIDATION_ERROR", "description": "One or more fields raised validation errors." "fields": { "email": "Invalid email address.", "password": "Password too short." } } }
对于返回操做校验错误:
POST /v1/register
// 请求
{
"email": "end@user.com", "password": "password" } // 响应 - 409 { "error": { "status": 409, "error": "EMAIL_ALREADY_EXISTS", "description": "An account already exists with this email." } }
这样,你的程序的错误提取逻辑要小心非200的错误了,你能够直接从响应中检查 error
字段,而后将其与客户端中相应的逻辑进行比较。
status
这个字段彷佛也颇有用,若是你不想检查响应里的元数据,那你能够在须要的时候有条件地添加这个字段。
description
可做为备用的用户可读的错误消息。
在作了不少密码规则的研究以后,我比较赞同 密码规则是废话 和 NIST禁止作的事情 这两篇帖子的观点。
整理了一些处理密码的规则:
在某种程度上,全部这些规则能使密码验证更容易!
现代的无状态、RESTful API通常会使用令牌来实现身份认证。这消除了在无状态服务器上处理会话和Cookie的须要,而且能够很容易地使用 Authorization
头(或 access_token
查询参数)来调试网络请求。
访问令牌用于认证全部将来的API请求,生命期短,不会被取消。
刷新令牌在初始登陆的响应中返回,而后跟过时时间戳和与使用者的关系一块儿进行散列计算后存储到数据库中。这个长生命期的像密码同样的密钥,能够被用来请求新的短生命期的JWT访问令牌。刷新令牌也能够用于续订并延长其使用寿命,这意味着若是用户持续使用该服务,则无需再次登陆。
可是,若是API但愿签定一个不一样的“密钥”,JWT就会被取消,可是这将使全部当前发出的令牌所有无效,但由于这些令牌是短生命期的,因此这并无关系。
在个人程序实现中,正常的登陆过程以下所示:
/login
接收邮件和密码。正常的续订验证流程以下所示:
/renew
。经过检查到期日期和签名哈希能够校验JWT访问令牌的有效性。若是校验失败,则认为是一个无效的令牌。
若是验证经过,则JWT的有效载荷中包含了一个 uid
,它用于在API响应的上下文中传递一个对应的 user
对象来检查权限/角色,并相应地建立/读取/更新/删除数据。
因为刷新令牌存储在数据库中,所以能够将其删除来“终止会话”。这为用户提供了一个控制方法,即他们能够经过主动的刷新令牌“会话”来保护本身的账户,而且经过这种方法来进行屡次重复认证(经过调整超时时间戳来实现)。
在把信息序列化到JWT访问令牌中时,请尽量地让这个信息小巧,身份验证令牌的生命期不须要很长,所以不必。若是能够的话,只序列化用户的 uid
(id)就能够了,其他的能够经过“GET /me”来传递。
还值得注意的是,存储在JWT有效载荷中的任何敏感信息并不安全,由于它只是一个通过base64编码的字符串。
通常人会使用 /profile
这个URL来提供自身的基本属性。可是,我也看到过比较混论的实现,例如对于 /users/:id
这种接受整数的URL,它居然容许传入字符串 me
来指向自身的属性。
经过 /me
访问自身信息的更深层次的URL,例如 /me
的 /settings
或者 /billing
信息,而经过 users/:id/billing
访问其余用户的信息。
// 不推荐
GET /v1/users/me // 推荐,由于更短,没有把整数和字符串混在一块儿 GET /v1/me
有一个采用了以上一些设计理念的重构的项目,最后却设计出了一个难用的URL系统:
// 一个长长的URL
PATCH /v1/projects/:id/collections/:id/items/:id/attachments
若是要POST上传一个附件,这个URL可能看起来还行,可是若是在开发客户端应用程序时想要实现像对附件标星号这么一个简单操做的功能的话,那你就须要重写相关的代码。相关代码以下:
const apiRoot = 'https://api.myservice.com/v1' const starAttachment = (projectId, collectionId, itemId, attachmentId, starred) => { fetch( `${apiRoot}/projects/${projectId}/collections/${collectionId}/items/${itemId}/attachments/${attachmentId}`, { method: 'PATCH', body: JSON.stringify({ starred }), // ... } }
助手函数的代码以下:
import { starAttachment } from './actions/attachments.js'
class MyComponent extends React.Component { doStarAttachment = (id, starred) => { // now all the "boilerplate" for starring the attachment const { projectId, collectionsId, itemId } = this.props.entities.attachments[id] // now actually plugging in all that information starAttachment(projectId, collectionId, itemId, id, starred) } // ... }
若是你把获取附件属性这个功能委派给服务器来实现,而且只使用根级别的URL,这样不是更好吗?
const apiRoot = 'https://api.myservice.com/v1' const starAttachment = (id, starred) => { fetch( `${apiRoot}/attachments/${id}`, { method: 'PATCH', body: JSON.stringify({ starred }), // ... } }
import { starAttachment } from './actions/attachments.js'
class MyComponent extends React.Component { doStarAttachment = (id, starred) => { // simple as, and you could even easily call it from a gallery-like list starAttachment(id, starred) } // ... }
总的来讲,我认为这两种方法各有各的优点,而我倾向于用一个 长的路径来建立/提取 资源,用一个 短的路径来更新/删除 资源。
分页很重要,由于你不会想让一个简单的请求就得到数千行的记录。这个问题彷佛很明显,可是仍是会有许多人忽略这个功能。
有多种方法来实现分页:
能够说这是最容易实现的,API接受一个 from
查询字符串参数,而后从这个偏移量开始返回有限数量的结果(一般返回20个结果)。
另外最好提供一个limit参数来限制最大记录数,例如Twitter,最大限制为1000,而默认限制为200。
若是每页20个结果以外还有其余的结果,谷歌的Places API就会在响应中返回next_page_token。而后,服务器在新的请求中接收到这个令牌后,就会返回更多的结果,并附带新的next_page_token,直到全部的结果所有都返回给客户端。
Twitter使用参数next_cursor实现了相似的功能。
颇有必要提供一种方法来输出一个简单的响应,以此来代表API实例是活着的,不须要从新启动。这个功能也颇有用,经过它能够很方便地检查某个时间点的某台服务器上的API是什么版本,而这无需经过认证。
GET /v1
// response - HTTP 200
{
"status": "running", "version": "fdb1d5e" }
我提供了 status
和 version
这两个值。另外值得一提的是,这个值是从 version.txt
文件读取到的,若是读取错误或者文件不存在,则默认值为 __UNKNOWN__
。