OpenAPI

OpenAPI 是一個開源的規範,用於描述 RESTful APIs 的接口設計。它以 JSON 或 YAML 格式定義了 API 的請求和響應的結構、參數、返回類型、錯誤碼等細節,使得客戶端和服務端之間的通信更加明確和規範化。

OpenAPI 最初是 Swagger 規範的開源版本,現在已經成爲了一個獨立的項目,並得到了許多大型企業和開發者的支持。使用 OpenAPI 規範可以幫助開發團隊更好地協作,減少溝通成本,提高開發效率。同時,OpenAPI 還爲開發者提供了自動生成 API 文檔、Mock 數據和測試用例等工具,方便開發和測試工作。

Salvo 提供了 OpenAPI 的集成 (修改自 utoipa在新窗口打開).

示例代碼

use salvo::oapi::extract::*;
use salvo::prelude::*;

#[endpoint]
async fn hello(name: QueryParam<String, false>) -> String {
    format!("Hello, {}!", name.as_deref().unwrap_or("World"))
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().init();

    let router = Router::new().push(Router::with_path("hello").get(hello));

    let doc = OpenApi::new("test api", "0.0.1").merge_router(&router);

    let router = router
        .push(doc.into_router("/api-doc/openapi.json"))
        .push(SwaggerUi::new("/api-doc/openapi.json").into_router("swagger-ui"));

    let acceptor = TcpListener::new("127.0.0.1:5800").bind().await;
    Server::new(acceptor).serve(router).await;
}
[package]
name = "example-oapi-hello"
version = "0.1.0"
edition = "2021"
publish = false

[dependencies]
salvo = { workspace = true, features = ["oapi"] }
tokio = { workspace = true, features = ["macros"] }
tracing.workspace = true
tracing-subscriber.workspace = true

在瀏覽器裏面輸入 http://localhost:5800/swagger-ui 就可以看到 Swagger UI 的頁面。

Salvo 中的 OpenAPI 集成是相當優雅的,對於上面的示例,相比於普通的 Salvo 項目,我們只是做了以下幾步:

  • Cargo.toml 中開啓 oapi 功能: salvo = { workspace = true, features = ["oapi"] };

  • [handler] 換成 [endpoint];

  • 使用 name: QueryParam<String, false> 獲取查詢字符串的值, 當你訪問網址 http://localhost/hello?name=chris 時, 這個 name 的查詢字符串就會被解析。 QueryParam<String, false> 這裏的 false 代表這個參數是可以省略的, 如果訪問 http://localhost/hello 依然不會報錯。 相反, 如果是 QueryParam<String, true> 則代表此參數是必須提供的, 否則返回錯誤。

  • 創建 OpenAPI 並且創建對應的 RouterOpenApi::new("test api", "0.0.1").merge_router(&router) 這裏的 merge_router 表示這個 OpenAPI 通過解析某個路由獲取它和它的子孫路由獲取必要的文檔信息。 某些路由的 Handler 可能沒有提供生成文檔的信息, 這些路由將被忽略, 比如使用 #[handler] 宏而非 #[endpoint] 宏定義的 Handler。 也就是說, 實際項目中, 爲了開發進度等原因, 你可以選擇實現不生成 OpenAPI 文檔, 或者部分生成 OpenAPI 文檔。 後續可以逐步增加生成 OpenAPI 接口的數量, 而你需要做的也僅僅只是把 #[handler] 改成 #[endpoint], 以及修改函數簽名。

數據提取器

通過 use salvo::oapi::extract:*; 可以導入預置的常用的數據提取器。 提取器會提供一些必要的信息給 Salvo, 以便 Salvo 生成 OpenAPI 的文檔。

  • QueryParam<T, const REQUIRED: bool>: 一個從查詢字符串提取數據的提取器。 QueryParam<T, false> 代表此參數不是必須的, 可以省略。 QueryParam<T, true> 代表此參數是必須的, 不可以省略, 如果不提供, 則返回錯誤;

  • HeaderParam<T, const REQUIRED: bool>: 一個從請求的頭部信息中提取數據的提取器。 HeaderParam<T, false> 代表此參數不是必須的, 可以省略。 HeaderParam<T, true> 代表此參數是必須的, 不可以省略, 如果不提供, 則返回錯誤;

  • CookieParam<T, const REQUIRED: bool>: 一個從請求的頭部信息中提取數據的提取器。 CookieParam<T, false> 代表此參數不是必須的, 可以省略。 CookieParam<T, true> 代表此參數是必須的, 不可以省略, 如果不提供, 則返回錯誤;

  • PathParam<T>: 一個從請求 URL 中提取路徑參數的提取器。 此參數如果不存在, 路由匹配就是不成功, 因此不存在可以省略的情況;

  • FormBody<T>: 從請求提交的表單中提取信息;

  • JsonBody<T>: 從請求提交的 JSON 格式的負載中提取信息;

#[endpoint]

在生成 OpenAPI 文檔時, 需要使用 #[endpoint] 宏代替常規的 #[handler] 宏, 它實際上是一個增強版本的 #[handler] 宏。

  • 它可以通過函數的簽名獲取生成 OpenAPI 所必須的信息;

  • 對於不方便通過簽名提供的信息, 可以直接在 #[endpoint] 宏中添加屬性的方式提供, 通過這種方式提供的信息會於通過函數簽名獲取的信息合併, 如果存在衝突, 則會覆蓋函數簽名提供的信息。

你可以使用 Rust 自帶的 #[deprecated] 屬性標註某個 Handler 已經過時被廢棄。 雖然 #[deprecated] 屬性支持添加諸如廢棄原因,版本等信息, 但是 OpenAPI 並不支持, 因此這些信息在生成 OpenAPI 時將會被忽略。

代碼中的文檔註釋部分會自動被提取用於生成 OpenAPI, 第一行被用於生成 summary, 整個註釋部分會被用於生成 description

/// This is a summary of the operation
///
/// All lines of the doc comment will be included to operation description.
#[endpoint]
fn endpoint() {}

ToSchema

可以使用 #[derive(ToSchema)] 定義數據結構:

#[derive(ToSchema)]
struct Pet {
    id: u64,
    name: String,
}

可以使用 #[salvo(schema(...))] 定義可選的設置:

  • example = ... 可以是 json!(...). json!(...) 會被 serde_json::json! 解析爲serde_json::Value

    #[derive(ToSchema)]
    #[salvo(schema(example = json!({"name": "bob the cat", "id": 0})))]
    struct Pet {
        id: u64,
        name: String,
    }
    
  • xml(...) 可以用於定義 Xml 對象屬性:

    #[derive(ToSchema)]
    struct Pet {
        id: u64,
        #[salvo(schema(xml(name = "pet_name", prefix = "u")))]
        name: String,
    }
    

ToParameters

從結構體的字段生成 路徑參數

這是 ToParameters trait 的 #[derive] 實現。

通常情況下,路徑參數需要在 endpoint#[salvo_oapi::endpoint(...parameters(...))] 中定義。但是當使用 struct在新窗口打開 來定義參數時,就可以省略上面的步驟。儘管如此,如果需要給出描述或更改默認配置,那麼 [std::primitive] 和 String 路徑參數或 [tuple] 風格的路徑參數還是需要在 parameters(...) 中定義。

你可以使用 Rust 內置的 #[deprecated] 屬性標記字段為已棄用,它將反映到生成出來的 OpenAPI 規範中。

#[deprecated] 屬性支持添加額外的信息比如棄用原因或者從某個版本開始棄用,但 OpenAPI 並不支持。OpenAPI 只支持一個布爾值來確定是否棄用。雖然完全可以聲明一個帶原因的棄用,如 #[deprecated = "There is better way to do this"],但這個原因不會在 OpenAPI 規範中呈現。

結構體字段上的註釋文檔會用作生成出來的 OpenAPI 規範中的參數描述。

#[derive(salvo_oapi::ToParameters, serde::Deserialize)]
struct Query {
    /// Query todo items by name.
    name: String
}

ToParameters Container Attributes for #[salvo(parameters(...))]

以下屬性可以用在那些派生於 ToParameters 的結構體的容器屬性 #[salvo(parameters(…))]

  • names(...) 為作為路徑參數使用的結構體的未命名字段定義逗號分隔的名稱列表。僅支持在未命名結構體上使用。
  • style = ... 可定義所有參數的序列化方式,由 ParameterStyle 指定。默認值基於 parameter_in 屬性。
  • default_parameter_in = ... 定義此字段的參數使用的默認位置,該位置的值來自於 parameter::ParameterIn。如果沒有提供此屬性,則默認來自 query
  • rename_all = ... 可以作為 serderename_all 的替代方案。實際上提供了相同的功能。

使用 names 給單個未命名的參數定義名稱。

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(names("id")))]
struct Id(u64);

使用 names 給多個未命名的參數定義名稱。

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(names("id", "name")))]
struct IdAndName(u64, String);

ToParameters Field Attributes for #[salvo(parameter(...))]

以下屬性可以在結構體字段上使用 #[salvo(parameter(...))]

  • style = ... 定義參數如何被 ParameterStyle 序列化。默認值基於 parameter_in 屬性。

  • parameter_in = ... 使用來自 parameter::ParameterIn 的值定義這個字段參數在哪裡。如果沒有提供這個值,則默認來自 query

  • explode 定義是否為每個在 objectarray 中的參數創建新的 parameter=value 對。

  • allow_reserved 定義參數值中是否允許出現保留字符 :/?#[]@!$&'()*+,;=

  • example = ... 可以是方法的引用或 json!(...)。給定的示例會覆蓋底層參數類型的任何示例。

  • value_type = ... 可被用於重寫 OpenAPI 規範中字段使用的默認類型。在默認類型與實際類型不對應的情況下很有用,比如使用非 ToSchemaprimitive types在新窗口打開 中定義的第三方類型時。值可以是正常情況下可被序列化為 JSON 的任意 Rust 類型或如 Object.Object 這種會被渲染成通用 OpenAPI 對象的自定義類型。

  • inline 如果啟用,這個字段類型的定義必須來自 ToSchema,且這個定義會被內聯。

  • default = ... 可以是方法引用或 json!(...)

  • format = ... 可以是 KnownFormat 枚舉的變體,或者是字符串形式的開放值。默認情況下,格式是根據屬性的類型根據 OpenAPI 規範推導而來。

  • write_only 定義屬性僅用於操作 POST,PUT,PATCH 而不是 GET

  • read_only 定義屬性僅用於操作 GET 而不是 POST,PUT,PATCH

  • nullable 定義屬性是否可為 null (注意這與非必需不同)。

  • required = ... 用於強制要求參數必傳。[參見規則][derive@ToParameters#field-nullability-and-required-rules]。

  • rename = ... 可以作為 serderename 的替代方案。實際上提供了相同的功能。

  • multiple_of = ... 用於定義值的倍數。只有當用這個關鍵字的值去除參數值,並且結果是一個整數時,參數值才被認為是有效的。倍數值必須嚴格大於 0

  • maximum = ... 用於定義取值的上限,包含當前取值。

  • minimum = ... 用於定義取值的下限,包含當前取值。

  • exclusive_maximum = ... 用於定義取值的上限,不包含當前取值。

  • exclusive_minimum = ... 用於定義取值的下限,不包含當前取值。

  • max_length = ... 用於定義 string 類型取值的最大長度。

  • min_length = ... 用於定義 string 類型取值的最小長度。

  • pattern = ... 用於定義字段值必須匹配的有效正則表達式,正則表達式採用 ECMA-262 版本。

  • max_items = ... 可用於定義 array 類型字段允許的最大項數。值必須是非負整數。

  • min_items = ... 可用於定義 array 類型字段允許的最小項數。值必須是非負整數。

  • with_schema = ... 使用函數引用創建出的 schema 而不是默認的 schema。該函數必須滿足定義 fn() -> Into<RefOr<Schema>>。它不接收任何參數並且必須返回任何可以轉換為 RefOr<Schema> 的值。

  • additional_properties = ... 用於為 map 定義自由形式類型,比如 HashMapBTreeMap。自由形式類型允許在映射值中使用任意類型。支持的格式有 additional_propertiesadditional_properties = true

Field nullability and required rules

一些應用於 ToParameters 字段屬性的是否可為空和是否必需的規則同樣可用於 ToSchema 字段屬性。[參見規則][derive@ToSchema#field-nullability-and-required-rules]。

Partial #[serde(...)] attributes support

ToParameters 派生目前支持部分 [serde 屬性]。這些支持的屬性將反映到生成的 OpenAPI 文檔中。目前支持以下屬性:

  • rename_all = "..." 在容器級別支持。
  • rename = "..." 在字段級別支持。
  • default 根據 serde attributes在新窗口打開 在容器級和字段級支持。
  • skip_serializing_if = "..." 在字段級別支持。
  • with = ... 在字段級別支持。
  • skip_serializing = "..." 在字段級或變體級支持。
  • skip_deserializing = "..." 在字段級或變體級支持。
  • skip = "..." 在字段級別支持。

其他的 serde 屬性將影響序列化,但不會反映在生成的 OpenAPI 文檔上。

Examples

演示使用 #[salvo(parameters(...))] 容器屬性結合 ToParameters 的用法,用在路徑參數上,並內聯一個查詢字段:

use serde::Deserialize;
use salvo_core::prelude::*;
use salvo_oapi::{ToParameters, ToSchema};

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
enum PetKind {
    Dog,
    Cat,
}

#[derive(Deserialize, ToParameters)]
struct PetQuery {
    /// Name of pet
    name: Option<String>,
    /// Age of pet
    age: Option<i32>,
    /// Kind of pet
    #[salvo(parameter(inline))]
    kind: PetKind
}

#[salvo_oapi::endpoint(
    parameters(PetQuery),
    responses(
        (status_code = 200, description = "success response")
    )
)]
async fn get_pet(query: PetQuery) {
    // ...
}

使用 value_typeString 型別覆蓋為 i64 型別。

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = i64))]
    id: String,
}

使用 value_typeString 型別覆蓋為 Object 型別。在 OpenAPI 規範中,Object 型別會顯示為 type:object

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = Object))]
    id: String,
}

你也可以使用泛型來覆蓋字段的默認型別。

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = Option<String>))]
    id: String
}

你甚至可以使用一個 [Vec] 覆蓋另一個 [Vec]。

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = Vec<i32>))]
    id: Vec<String>
}

我們可以使用另一個 ToSchema 來覆蓋字段型別。

# use salvo_oapi::{ToParameters, ToSchema};

#[derive(ToSchema)]
struct Id {
    value: i64,
}

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = Id))]
    id: String
}

屬性值的校驗示例

#[derive(salvo_oapi::ToParameters, serde::Deserialize)]
struct Item {
    #[salvo(parameter(maximum = 10, minimum = 5, multiple_of = 2.5))]
    id: i32,
    #[salvo(parameter(max_length = 10, min_length = 5, pattern = "[a-z]*"))]
    value: String,
    #[salvo(parameter(max_items = 5, min_items = 1))]
    items: Vec<String>,
}

使用 schema_with 為字段手動實現 schema。

# use salvo_oapi::schema::Object;
fn custom_type() -> Object {
    Object::new()
        .schema_type(salvo_oapi::SchemaType::String)
        .format(salvo_oapi::SchemaFormat::Custom(
            "email".to_string(),
        ))
        .description("this is the description")
}

#[derive(salvo_oapi::ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Query {
    #[salvo(parameter(schema_with = custom_type))]
    email: String,
}
  • rename_all = ...: 支持於 serde 類似的語法定義重命名字段的規則. 如果同時定義了 #[serde(rename_all = "...")]#[salvo(schema(rename_all = "..."))], 則優先使用 #[serde(rename_all = "...")]

  • symbol = ...: 一個字符串字面量, 用於定義結構在 OpenAPI 中線上的名字路徑. 比如 #[salvo(schema(symbol = "path.to.Pet"))]

  • default: 可以使用結構體的 Default 實現來為所有字段填充默認值。

錯誤處理方式

對於一般的應用, 我們會定義一個全局的錯誤類型 (AppError), 爲 AppError 實現 Writer 或者 Scribe, 以便可以將錯誤作爲網頁信息發送給客戶端.

而對於 OpenAPI, 我們爲了能達到必要的錯誤信息, 我們還需要爲這個錯誤實現 EndpointOutRegister:

use salvo::http::{StatusCode, StatusError};
use salvo::oapi::{self, EndpointOutRegister, ToSchema};

impl EndpointOutRegister for Error {
    fn register(components: &mut oapi::Components, operation: &mut oapi::Operation) {
        operation.responses.insert(
            StatusCode::INTERNAL_SERVER_ERROR.as_str(),
            oapi::Response::new("Internal server error").add_content("application/json", StatusError::to_schema(components)),
        );
        operation.responses.insert(
            StatusCode::NOT_FOUND.as_str(),
            oapi::Response::new("Not found").add_content("application/json", StatusError::to_schema(components)),
        );
        operation.responses.insert(
            StatusCode::BAD_REQUEST.as_str(),
            oapi::Response::new("Bad request").add_content("application/json", StatusError::to_schema(components)),
        );
    }
}

此錯誤集中定義了整個網頁應用可能返回的所有錯誤信息, 然而, 很多時候我們的 Handler 裏面可能只包含其中幾種具體錯誤類型, 此時可以使用 status_codes 過濾出需要的錯誤類型信息:

#[endpoint(status_codes(201, 409))]
pub async fn create_todo(new_todo: JsonBody<Todo>) -> Result<StatusCode, Error> {
    Ok(StatusCode::CREATED)
}