使用 REST 規範你從未做過的 5 件事

已發表: 2022-03-11

大多數前端和後端開發人員之前都處理過 REST 規範和 RESTful API。 但並非所有 RESTful API 都是一樣的。 事實上,它們很少是 RESTful 的……

什麼RESTful API?

這是一個神話。

如果您認為您的項目具有 RESTful API,那麼您很可能錯了。 RESTful API 背後的理念是以遵循 REST 規範中描述的所有架構規則和限制的方式進行開發。 然而,實際上,這在實踐中基本上是不可能的。

一方面,REST 包含太多模糊不清的定義。 例如,在實踐中,HTTP 方法和狀態碼字典中的某些術語的使用與預期目的相反,或者根本沒有使用。

另一方面,REST 開發造成了太多的限制。 例如,對於移動應用程序中使用的實際 API,原子資源的使用不是最理想的。 完全拒絕請求之間的數據存儲基本上禁止了隨處可見的“用戶會話”機制。

但是等等,這並沒有那麼糟糕!

您需要什麼 REST API 規範?

儘管有這些缺點,但通過明智的方法,REST 仍然是創建真正出色的 API 的驚人概念。 這些 API 可以是一致的,並且具有清晰的結構、良好的文檔和較高的單元測試覆蓋率。 您可以通過高質量的API 規範來實現所有這些。

通常,REST API 規範與其文檔相關聯。 與規範(對 API 的正式描述)不同,文檔旨在供人類閱讀:例如,由使用 API 的移動或 Web 應用程序的開發人員閱讀。

正確的 API 描述不僅僅是寫好 API 文檔。 在本文中,我想分享一些示例,說明如何:

  • 讓您的單元測試更簡單、更可靠;
  • 設置用戶輸入預處理和驗證;
  • 自動化序列化並確保響應一致性; 乃至
  • 享受靜態類型的好處。

但首先,讓我們先介紹一下 API 規范世界。

開放API

OpenAPI 是目前最廣泛接受的 REST API 規範格式。 該規範以 JSON 或 YAML 格式編寫在單個文件中,由三個部分組成:

  1. 帶有 API 名稱、描述和版本以及任何其他信息的標頭。
  2. 所有資源的描述,包括標識符、HTTP 方法、所有輸入參數、響應代碼和正文數據類型,以及指向定義的鏈接。
  3. 所有可用於輸入或輸出的定義,採用 JSON Schema 格式(是的,也可以用 YAML 表示。)

OpenAPI 的結構有兩個顯著的缺點:它太複雜而且有時是多餘的。 一個小項目可以有數千行的 JSON 規範。 手動維護此文件變得不可能。 這對在開發 API 時保持規範最新的想法構成了重大威脅。

有多個編輯器允許您描述 API 並生成 OpenAPI 輸出。 基於它們的其他服務和雲解決方案包括 Swagger、Apiary、Stoplight、Restlet 等。

然而,這些服務對我來說並不方便,因為快速規範編輯和使其與代碼更改保持一致很複雜。 此外,功能列表取決於特定服務。 例如,基於雲服務工具創建完整的單元測試幾乎是不可能的。 代碼生成和模擬端點雖然看起來很實用,但在實踐中幾乎沒有用處。 這主要是因為端點行為通常取決於各種因素,例如用戶權限和輸入參數,這對 API 架構師來說可能很明顯,但不容易從 OpenAPI 規範中自動生成。

小規格

在本文中,我將使用基於我自己的 REST API 定義格式tinyspec的示例。 定義由具有直觀語法的小文件組成。 它們描述了項目中使用的端點和數據模型。 文件存儲在代碼旁邊,提供快速參考和在代碼編寫期間進行編輯的能力。 Tinyspec 會自動編譯成成熟的 OpenAPI 格式,可以立即在您的項目中使用。

我還將使用 Node.js(Koa、Express)和 Ruby on Rails 示例,但我將演示的實踐適用於大多數技術,包括 Python、PHP 和 Java。

API 規範動搖的地方

現在我們有了一些背景知識,我們可以探索如何充分利用正確指定的 API。

1. 端點單元測試

行為驅動開發 (BDD) 是開發 REST API 的理想選擇。 最好不要為單獨的類、模型或控制器編寫單元測試,而是為特定的端點編寫單元測試。 在每個測試中,您模擬一個真實的 HTTP 請求並驗證服務器的響應。 對於 Node.js,有用於模擬請求的 supertest 和 chai-http 包,對於 Ruby on Rails,有 airborne。

假設我們有一個User模式和一個返回所有用戶的GET /users端點。 下面是一些描述這一點的 tinyspec 語法:

 # user.models.tinyspec User {name, isAdmin: b, age?: i} # users.endpoints.tinyspec GET /users => {users: User[]}

下面是我們如何編寫相應的測試:

節點.js

 describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); expect(users[0].name).to.be('string'); expect(users[0].isAdmin).to.be('boolean'); expect(users[0].age).to.be.oneOf(['boolean', null]); }); });

Ruby on Rails

 describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect_json_types('users.*', { name: :string, isAdmin: :boolean, age: :integer_or_null, }) end end

當我們已經有了描述服務器響應的規範時,我們可以簡化測試並檢查響應是否符合規範。 我們可以使用 tinyspec 模型,每個模型都可以轉換為遵循 JSON Schema 格式的 OpenAPI 規範。

JS 中的任何文字對象(或 Ruby 中的Hash 、Python 中的dict 、PHP 中的關聯數組,甚至 Java 中的Map )都可以驗證 JSON Schema 合規性。 甚至還有用於測試框架的適當插件,例如 jest-ajv (npm)、chai-ajv-json-schema (npm) 和用於 RSpec (rubygem) 的 json_matchers。

在使用模式之前,讓我們將它們導入到項目中。 首先,根據 tinyspec 規範生成openapi.json文件(您可以在每次測試運行之前自動執行此操作):

 tinyspec -j -o openapi.json

節點.js

現在您可以在項目中使用生成的 JSON 並從中獲取definitions鍵。 此鍵包含所有 JSON 模式。 架構可能包含交叉引用 ( $ref ),因此如果您有任何嵌入式架構(例如, Blog {posts: Post[]} ),您需要解開它們以用於驗證。 為此,我們將使用 json-schema-deref-sync (npm)。

 import deref from 'json-schema-deref-sync'; const spec = require('./openapi.json'); const schemas = deref(spec).definitions; describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); // Chai expect(users[0]).to.be.validWithSchema(schemas.User); // Jest expect(users[0]).toMatchSchema(schemas.User); }); });

Ruby on Rails

json_matchers模塊知道如何處理$ref引用,但在指定位置需要單獨的架構文件,因此您需要swagger.json文件拆分為多個較小的文件:

 # ./spec/support/json_schemas.rb require 'json' require 'json_matchers/rspec' JsonMatchers.schema_root = 'spec/schemas' # Fix for json_matchers single-file restriction file = File.read 'spec/schemas/openapi.json' swagger = JSON.parse(file, symbolize_names: true) swagger[:definitions].keys.each do |key| File.open("spec/schemas/#{key}.json", 'w') do |f| f.write(JSON.pretty_generate({ '$ref': "swagger.json#/definitions/#{key}" })) end end

下面是測試的樣子:

 describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect(result[:users][0]).to match_json_schema('User') end end

以這種方式編寫測試非常方便。 如果您的 IDE 支持運行測試和調試(例如,WebStorm、RubyMine 和 Visual Studio),尤其如此。 這樣就可以避免使用其他軟件,並且整個 API 開發週期僅限於三個步驟:

  1. 在 tinyspec 文件中設計規範。
  2. 為添加/編輯的端點編寫一整套測試。
  3. 實現滿足測試的代碼。

2. 驗證輸入數據

OpenAPI 不僅描述了響應格式,還描述了輸入數據。 這允許您在運行時驗證用戶發送的數據並確保一致且安全的數據庫更新。

假設我們有以下規範,它描述了用戶記錄的修補以及允許更新的所有可用字段:

 # user.models.tinyspec UserUpdate !{name?, age?: i} # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => {success: b}

之前,我們探索了用於測試內驗證的插件,但對於更一般的情況,有 ajv (npm) 和 json-schema (rubygem) 驗證模塊。 讓我們用它們來編寫一個帶有驗證的控制器:

Node.js (Koa)

這是 Express 的繼承者 Koa 的一個示例,但等效的 Express 代碼看起來很相似。

 import Router from 'koa-router'; import Ajv from 'ajv'; import { schemas } from './schemas'; const router = new Router(); // Standard resource update action in Koa. router.patch('/:id', async (ctx) => { const updateData = ctx.body.user; // Validation using JSON schema from API specification. await validate(schemas.UserUpdate, updateData); const user = await User.findById(ctx.params.id); await user.update(updateData); ctx.body = { success: true }; }); async function validate(schema, data) { const ajv = new Ajv(); if (!ajv.validate(schema, data)) { const err = new Error(); err.errors = ajv.errors; throw err; } }

在此示例中,如果輸入與規範不匹配,服務器將返回500 Internal Server Error響應。 為了避免這種情況,我們可以捕獲驗證器錯誤並形成我們自己的答案,其中將包含有關未通過驗證的特定字段的更詳細信息,並遵循規範。

讓我們添加FieldsValidationError的定義:

 # error.models.tinyspec Error {error: b, message} InvalidField {name, message} FieldsValidationError < Error {fields: InvalidField[]}

現在讓我們將其列為可能的端點響應之一:

 # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError

當無效數據來自客戶端時,這種方法允許您編寫單元測試來測試錯誤場景的正確性。

3.模型序列化

幾乎所有現代服務器框架都以一種或另一種方式使用對象關係映射 (ORM)。 這意味著 API 使用的大部分資源由模型及其實例和集合表示。

為要在響應中發送的這些實體形成 JSON 表示的過程稱為序列化

有許多用於序列化的插件:例如,sequelize-to-json (npm)、acts_as_api (rubygem) 和 jsonapi-rails (rubygem)。 基本上,這些插件允許您提供必須包含在 JSON 對像中的特定模型的字段列表以及其他規則。 例如,您可以重命名字段並動態計算它們的值。

當您需要為一個模型提供多種不同的 JSON 表示,或者當對象包含嵌套實體(關聯)時,這會變得更加困難。 然後你開始需要繼承、重用和序列化鏈接等特性。

不同的模塊提供不同的解決方案,但讓我們考慮一下:規範能否再次提供幫助? 基本上所有關於 JSON 表示的要求的信息,所有可能的字段組合,包括嵌入的實體,都已經在其中了。 這意味著我們可以編寫一個自動序列化程序。

讓我介紹一下小型 sequelize-serialize (npm) 模塊,它支持對 Sequelize 模型執行此操作。 它接受模型實例或數組以及所需的模式,然後遍歷它以構建序列化對象。 它還考慮了所有必填字段,並為其關聯實體使用嵌套模式。

因此,假設我們需要從 API 返回所有用戶在博客中的帖子,包括對這些帖子的評論。 讓我們用以下規範來描述它:

 # models.tinyspec Comment {authorId: i, message} Post {topic, message, comments?: Comment[]} User {name, isAdmin: b, age?: i} UserWithPosts < User {posts: Post[]} # blogUsers.endpoints.tinyspec GET /blog/users => {users: UserWithPosts[]}

現在我們可以使用 Sequelize 構建請求並返回與上述規範完全對應的序列化對象:

 import Router from 'koa-router'; import serialize from 'sequelize-serialize'; import { schemas } from './schemas'; const router = new Router(); router.get('/blog/users', async (ctx) => { const users = await User.findAll({ include: [{ association: User.posts, required: true, include: [Post.comments] }] }); ctx.body = serialize(users, schemas.UserWithPosts); });

這幾乎是神奇的,不是嗎?

4. 靜態打字

如果你足夠酷,可以使用 TypeScript 或 Flow,你可能已經問過,“我寶貴的靜態類型呢?!” 使用 sw2dts 或 swagger-to-flowtype 模塊,您可以基於 JSON 模式生成所有必要的靜態類型,並在測試、控制器和序列化程序中使用它們。

 tinyspec -j sw2dts ./swagger.json -o Api.d.ts --namespace Api

現在我們可以在控制器中使用類型:

 router.patch('/users/:id', async (ctx) => { // Specify type for request data object const userData: Api.UserUpdate = ctx.request.body.user; // Run spec validation await validate(schemas.UserUpdate, userData); // Query the database const user = await User.findById(ctx.params.id); await user.update(userData); // Return serialized result const serialized: Api.User = serialize(user, schemas.User); ctx.body = { user: serialized }; });

和測試:

 it('Update user', async () => { // Static check for test input data. const updateData: Api.UserUpdate = { name: MODIFIED }; const res = await request.patch('/users/1', { user: updateData }); // Type helper for request response: const user: Api.User = res.body.user; expect(user).to.be.validWithSchema(schemas.User); expect(user).to.containSubset(updateData); });

請注意,生成的類型定義不僅可以在 API 項目中使用,還可以在客戶端應用程序項目中用於描述與 API 一起使用的函數中的類型。 (Angular 開發人員會對此感到特別高興。)

5. 轉換查詢字符串類型

如果您的 API 出於某種原因使用application/x-www-form-urlencoded MIME 類型而不是application/json的請求,則請求正文將如下所示:

 param1=value&param2=777&param3=false

查詢參數也是如此(例如,在GET請求中)。 在這種情況下,Web服務器將無法自動識別類型:所有數據都是字符串格式,因此解析後會得到這個對象:

 { param1: 'value', param2: '777', param3: 'false' }

在這種情況下,請求將無法通過模式驗證,因此您需要手動驗證正確的參數格式並將它們轉換為正確的類型。

正如您所猜到的,您可以使用規範中的舊模式來做到這一點。 假設我們有這個端點和以下模式:

 # posts.endpoints.tinyspec GET /posts?PostsQuery # post.models.tinyspec PostsQuery { search, limit: i, offset: i, filter: { isRead: b } }

以下是對該端點的請求的外觀:

 GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true

讓我們編寫castQuery函數將所有參數轉換為所需的類型:

 function castQuery(query, schema) { _.mapValues(query, (value, key) => { const { type } = schema.properties[key] || {}; if (!value || !type) { return value; } switch (type) { case 'integer': return parseInt(value, 10); case 'number': return parseFloat(value); case 'boolean': return value !== 'false'; default: return value; } }); }

cast-with-schema (npm) 模塊中提供了支持嵌套模式、數組和null類型的更完整的實現。 現在讓我們在代碼中使用它:

 router.get('/posts', async (ctx) => { // Cast parameters to expected types const query = castQuery(ctx.query, schemas.PostsQuery); // Run spec validation await validate(schemas.PostsQuery, query); // Query the database const posts = await Post.search(query); // Return serialized result ctx.body = { posts: serialize(posts, schemas.Post) }; });

請注意,四行代碼中的三行使用規範模式。

最佳實踐

我們可以在這裡遵循許多最佳實踐。

使用單獨的創建和編輯模式

通常,描述服務器響應的模式與描述輸入的模式不同,它們用於創建和編輯模型。 例如, POSTPATCH請求中可用的字段列表必須嚴格限制, PATCH通常將所有字段標記為可選。 描述響應的模式可以更加自由。

當您自動生成 CRUDL 端點時,tinyspec 使用NewUpdate後綴。 User*模式可以通過以下方式定義:

 User {id, email, name, isAdmin: b} UserNew !{email, name} UserUpdate !{email?, name?}

盡量不要對不同的操作類型使用相同的模式,以避免由於舊模式的重用或繼承而導致意外的安全問題。

遵循架構命名約定

對於不同的端點,相同模型的內容可能會有所不同。 在模式名稱中使用With*For*後綴來顯示差異和目的。 在 tinyspec 中,模型也可以相互繼承。 例如:

 User {name, surname} UserWithPhotos < User {photos: Photo[]} UserForAdmin < User {id, email, lastLoginAt: d}

後綴可以變化和組合。 他們的名字必須仍然反映本質並使文檔更易於閱讀。

根據客戶端類型分離端點

通常,同一個端點會根據客戶端類型或發送請求的用戶的角色返回不同的數據。 例如,對於移動應用程序用戶和後台管理人員, GET /usersGET /messages端點可能有很大不同。 端點名稱的更改可能是開銷。

要多次描述同一個端點,您可以在路徑後的括號中添加其類型。 這也使標籤的使用變得容易:您將端點文檔分成組,每個組都用於特定的 API 客戶端組。 例如:

 Mobile app: GET /users (mobile) => UserForMobile[] CRM admin panel: GET /users (admin) => UserForAdmin[]

REST API 文檔工具

在獲得 tinyspec 或 OpenAPI 格式的規範後,您可以生成漂亮的 HTML 格式文檔並發布。 這將使使用您的 API 的開發人員感到高興,而且它肯定比手動填寫 REST API 文檔模板要好。

除了前面提到的雲服務,還有 CLI 工具可以將 OpenAPI 2.0 轉換為 HTML 和 PDF,可以部署到任何靜態主機。 這裡有些例子:

  • bootprint-openapi(npm,在 tinyspec 中默認使用)
  • swagger2markup-cli(jar,有使用示例,會在tinyspec Cloud中使用)
  • redoc-cli (npm)
  • widdershins (npm)

你有更多的例子嗎? 在評論中分享它們。

遺憾的是,儘管在一年前發布,OpenAPI 3.0 仍然缺乏支持,我在雲解決方案和 CLI 工具中都找不到基於它的適當文檔示例。 出於同樣的原因,tinyspec 還不支持 OpenAPI 3.0。

在 GitHub 上發布

發布文檔的最簡單方法之一是 GitHub Pages。 只需在存儲庫設置中為您的/docs文件夾啟用對靜態頁面的支持,並將 HTML 文檔存儲在此文件夾中。

通過 GitHub Pages 從 /docs 文件夾託管 REST 規範的 HTML 文檔。

您可以在scripts/package.json文件中添加命令以通過 tinyspec 或不同的 CLI 工俱生成文檔,以在每次提交後自動更新文檔:

 "scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }

持續集成

您可以將文檔生成添加到 CI 週期並將其發佈到,例如,根據環境或 API 版本(例如/docs/2.0/docs/stable/docs/staging )在不同地址下的 Amazon S3。

Tinyspec 雲

如果您喜歡 tinyspec 語法,您可以成為 tinyspec.cloud 的早期採用者。 我們計劃基於它構建一個雲服務和一個用於自動部署文檔的 CLI,具有多種模板選擇和開發個性化模板的能力。

REST 規範:一個奇妙的神話

REST API 開發可能是現代 Web 和移動服務開發中最令人愉快的過程之一。 沒有瀏覽器、操作系統和屏幕大小的動物園,一切盡在您的掌控之中,觸手可及。

對自動化和最新規範的支持使這個過程變得更加容易。 使用我所描述的方法的 API 變得結構良好、透明且可靠。

底線是,如果我們在創造一個神話,為什麼不讓它成為一個奇妙的神話呢?

相關: ActiveResource.js ORM:為您的 JSON API 構建強大的 JavaScript SDK,快速