做者 | 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__
。