作者: 孙黎


目录

大家好,我是老油条,一个热爱Rust语言的码农。上个月我决定开发一个新的Web框架Poem,当整个框架基本成型之后,我觉得应该给它添加别的框架所不具备的并且很有用的功能,所以我开发了Poem-openapi

简介

OpenAPI规范为RESTful API定义了一个标准的并且与语言无关的接口,它允许人类和计算机在不访问源代码、文档或通过网络流量检查的情况下发现和理解服务的功能。调用者可以很容易的理解远程服务并与之交互,并提供了一些好用的工具,例如 Swagger UI (在网页中浏览测试测试API),Swagger CodeGen (生成多种语言的客户端SDK)。

Poem-openapi是基于PoemOpenAPI 服务端框架。

通常,如果你希望让你的API支持该规范,首先需要创建一个 接口定义文件 ,然后再按照接口定义编写对应的代码。或者创建接口定义文件后,用 Swagger CodeGen 来生成服务端代码框架。但Poem-openapi区别于这两种方法,它让你只需要编写Rust的业务代码,利用过程宏来自动生成符合OpenAPI规范的接口和接口定义文件(这相当于接口的文档),和我之前开源的另外一个库[Async-graphql](https://github.com/async-graphql/async-graphql) 的原理很像,OpenAPIGraphQL是互补的关系,它们适用于不同的场景。

有的朋友可能觉得宏很可怕,它会让代码难以理解,但我觉得如果能用正确的方法来实现过程宏,那么它可以帮我们大大提升开发的效率,所以Poem-openapi过程宏的实现遵循了以下几个原则:

  1. 你永远都不会直接用到过程宏生成的任何东西。(因为IDE无法识别过程宏生成的代码,如果直接使用它们,可能会有烦人的红色下划线,并且自动完成也无法使用,相当于让IDE变成了一个文本编辑器)
  2. 如果你的代码无法通过编译,那么你的接口不符合**OpenAPI**规范。(尽量把所有的问题都暴露在编译阶段)
  3. 不自己发明DSL。(如果我的代码没法被Rustfmt格式化,这会让我相当恼火)
  4. 不带来额外的开销。(你完全可以纯手工打造符合OpenAPI规范的接口,但在执行效率上通常没有任何的提升)

快速开始

下面这个例子,我们定义了一个路径为/hello的API,它接受一个名为name的URL参数,并且返回一个字符串作为响应内容。name参数的类型是Option<String>,意味着这是一个可选参数。

运行以下代码后,用浏览器打开http://localhost:3000就能看到Swagger UI,你可以用它来浏览API的定义并且测试它们。

  1. use poem::{listener::TcpListener, route};
  2. use poem_openapi::{payload::PlainText, OpenApi, OpenApiService};
  3. struct Api;
  4. #[OpenApi]
  5. impl Api {
  6. #[oai(path = "/hello", method = "get")]
  7. async fn index(
  8. &self,
  9. #[oai(name = "name", in = "query")] name: Option<String>, // in="query" 说明这个参数来自Url
  10. ) -> PlainText<String> { // PlainText是响应类型,它表明该API的响应类型是一个字符串,Content-Type是`text/plain`
  11. match name {
  12. Some(name) => PlainText(format!("hello, {}!", name)),
  13. None => PlainText("hello!".to_string()),
  14. }
  15. }
  16. }
  17. #[tokio::main]
  18. async fn main() -> Result<(), std::io::Error> {
  19. // 创建一个TCP监听器
  20. let listener = TcpListener::bind("127.0.0.1:3000");
  21. // 创建API服务
  22. let api_service = OpenApiService::new(Api)
  23. .title("Hello World")
  24. .server("http://localhost:3000/api");
  25. // 开启Swagger UI
  26. let ui = api_service.swagger_ui("http://localhost:3000");
  27. // 启动服务器,并指定api的根路径为 /api,Swagger UI的路径为 /
  28. poem::Server::new(listener)
  29. .await?
  30. .run(route().nest("/api", api_service).nest("/", ui))
  31. .await
  32. }

这是poem-openapi的一个例子,所以你也可以直接执行以下命令来验证:

  1. git clone https://github.com/poem-web/poem
  2. cargo run --bin example-openapi-hello-world

基础类型

基础类型可以作为请求的参数,请求内容或者请求响应内容。Poem定义了一个Type trait,实现了该trait的类型都是基础类型,它们能在运行时提供一些关于该类型的信息用于生成接口定义文件。

Poem为大部分常用类型实现了Typetrait,你可以直接使用它们,同样也可以自定义新的类型,但你需要对 Json Schema 有一定了解(这并不难,事实上在写这个库之前我也只会Json Schema的一些简单用法,并没有进行过深入的了解)。

下表是Json Schema中的数据类型对应的Rust数据类型(只是一小部分):

Json Schema Rust
{type: "integer", format: "int32"} i32
{type: "integer", format: "float32"} f32
{type: "string" } String, &str
{type: "string", format: "binary" } Binary
{type: "string", format: "bytes" } Base64
{type: "array" } Vec

对象类型

用过程宏Object来定义一个对象,对象的成员必须是实现了Type trait的类型(除非你用#[oai(skip)]来标注它,那么序列化和反序列化时降忽略该字段用默认值代替)。

用以下代码定义了一个对象类型,它包含四个字段,其中有一个字段是枚举类型。

对象类型也是基础类型的一种,它同样实现了_Type trait_,所以它也可以作为另一个对象的成员。

  1. use poem_api::{Object, Enum};
  2. #[derive(Enum)]
  3. enum PetStatus {
  4. Available,
  5. Pending,
  6. Sold,
  7. }
  8. #[derive(Object)]
  9. struct Pet {
  10. id: u64,
  11. name: String,
  12. photo_urls: Vec<String>,
  13. status: PetStatus,
  14. }

定义API

下面定义一组API对宠物表进行增删改查的操作。

add_petupdate_pet用于添加和更新Pet对象,这是我们在之前定义的基本类型,基本类型不能直接作为请求内容,需要使用一个**Payload**类型来包装它,这样就可以确定内容的Content-Type。在下面的例子中,我们使用payload::Json来包装它,表示这两个API请求内容的Content-Typeapplication/json

find_pet_by_idfind_pets_by_status用于查找Pet对象,它们的响应也是一个Pet对象,同样需要使用Payload类型来包装。

我们可以用#[oai(name = "...", in = "...")]来修饰一个函数参数用于指定此参数值的来源,in的值可以是query, path, header, cookie四种类型。delete_petid参数从路径中提取,find_pet_by_idfind_pets_by_status的参数从Query中获取。如果参数类型不是Option<T>,那么表示这个参数不是一个可选参数,提取失败时会返回400 Bad Request错误。

你可以定义多个函数参数,但只能有一个Payload类型作为请求内容,或者多个基本类型作为请求的参数。

  1. use poem_api::{
  2. OpenApi,
  3. poem_api::payload::Json,
  4. };
  5. use poem::Result;
  6. struct Api;
  7. #[OpenApi]
  8. impl Api {
  9. /// 添加新Pet
  10. #[oai(path = "/pet", method = "post")]
  11. async fn add_pet(&self, pet: Json<Pet>) -> Result<()> {
  12. todo!()
  13. }
  14. /// 更新已有的Pet
  15. #[oai(path = "/pet", method = "put")]
  16. async fn update_pet(&self, pet: Json<Pet>) -> Result<()> {
  17. todo!()
  18. }
  19. /// 删除一个Pet
  20. #[oai(path = "/pet/:pet_id", method = "delete")]
  21. async fn delete_pet(&self, #[oai(name = "pet_id", in = "path")] id: u64) -> Result<()> {
  22. todo!()
  23. }
  24. /// 根据ID查询Pet
  25. #[oai(path = "/pet/:pet_id", method = "delete")]
  26. async fn find_pet_by_id(&self, #[oai(name = "status", in = "query")] id: u64) -> Result<Json<Pet>> {
  27. todo!()
  28. }
  29. /// 根据状态查询Pet
  30. #[oai(path = "/pet/findByStatus", method = "delete")]
  31. async fn find_pets_by_status(&self, #[oai(name = "status", in = "query")] status: Status) -> Result<Json<Vec<Pet>>> {
  32. todo!()
  33. }
  34. }

自定义请求

OpenAPI规范允许同一个接口支持处理不同Content-Type的请求,例如一个接口可以同时接受application/jsontext/plain类型的Payload,你可以根据不同的Content-Type分别做处理。

Poem-openapi中,要支持此类型请求,需要用ApiRequest宏自定义一个实现了Payload trait的请求对象。

create_post函数接受CreatePostRequest请求,当创建成功后,返回id

  1. use poem_open::{
  2. ApiRequest, Object,
  3. payload::{PlainText, Json},
  4. };
  5. use poem::Result;
  6. #[derive(Object)]
  7. struct Post {
  8. title: String,
  9. content: String,
  10. }
  11. #[derive(ApiRequest)]
  12. enum CreatePostRequest {
  13. /// 从JSON创建
  14. Json(Json<Blog>),
  15. /// 从文本创建
  16. Text(PlainText<String>),
  17. }
  18. struct Api;
  19. #[OpenApi]
  20. impl Api {
  21. #[oai(path = "/hello", method = "post")]
  22. async fn create_post(
  23. &self,
  24. req: CreatePostRequest,
  25. ) -> Result<Json<u64>> {
  26. // 根据Content-Type分别处理
  27. match req {
  28. CreatePostRequest::Json(Json(blog)) => {
  29. todo!();
  30. }
  31. CreatePostRequest::Text(content) => {
  32. todo!();
  33. }
  34. }
  35. }
  36. }

自定义响应

在前面的例子中,我们的所有请求处理函数都返回的Result类型,当发生错误时返回一个poem::Error,它包含错误的原因以及状态码。但OpenAPI规范允许更详细的描述请求的响应,例如该接口可能会返回哪些状态码,以及状态码对应的原因和响应的内容。

下面的我们修改create_post函数的返回值为CreateBlogResponse

OkForbiddenInternalError描述了特定状态码的响应类型。

  1. use poem_openapi::ApiResponse;
  2. use poem::http::StatusCode;
  3. #[derive(ApiResponse)]
  4. enum CreateBlogResponse {
  5. /// 创建完成
  6. #[oai(status = 200)]
  7. Ok(Json<u64>),
  8. /// 没有权限
  9. #[oai(status = 403)]
  10. Forbidden,
  11. /// 内部错误
  12. #[oai(status = 500)]
  13. InternalError,
  14. }
  15. struct Api;
  16. #[OpenApi]
  17. impl Api {
  18. #[oai(path = "/hello", method = "get")]
  19. async fn create_post(
  20. &self,
  21. req: CreatePostRequest,
  22. ) -> CreateBlogResponse {
  23. match req {
  24. CreatePostRequest::Json(Json(blog)) => {
  25. todo!();
  26. }
  27. CreatePostRequest::Text(content) => {
  28. todo!();
  29. }
  30. }
  31. }
  32. }

当请求解析失败时,默认会返回400 Bad Request错误,但有时候我们想返回一个自定义的错误内容,可以使用bad_request_handler属性设置一个错误处理函数,这个函数用于转换ParseRequestError到指定的响应类型。

  1. use poem_openapi::{
  2. ApiResponse, Object, ParseRequestError, payload::Json,
  3. };
  4. #[derive(Object)]
  5. struct ErrorMessage {
  6. code: i32,
  7. reason: String,
  8. }
  9. #[derive(ApiResponse)]
  10. #[oai(bad_request_handler = "bad_request_handler")]
  11. enum CreateBlogResponse {
  12. /// 创建完成
  13. #[oai(status = 200)]
  14. Ok(Json<u64>),
  15. /// 没有权限
  16. #[oai(status = 403)]
  17. Forbidden,
  18. /// 内部错误
  19. #[oai(status = 500)]
  20. InternalError,
  21. /// 请求无效
  22. #[oai(status = 400)]
  23. BadRequest(Json<ErrorMessage>),
  24. }
  25. fn bad_request_handler(err: ParseRequestError) -> CreateBlogResponse {
  26. // 当解析请求失败时,返回一个自定义的错误内容,它是一个JSON
  27. CreateBlogResponse::BadRequest(ErrorMessage {
  28. code: -1,
  29. reason: err.to_string(),
  30. })
  31. }

文件上传

Multipart通常用于文件上传,它可以定义一个表单来包含一个或者多个文件以及一些附加字段。下面的例子提供一个创建Pet对象的接口,它在创建Pet对象的同时上传一些图片文件。

  1. use poem_openapi::{Multipart, OpenApi}
  2. use poem::Result;
  3. #[derive(Debug, Multipart)]
  4. struct CreatePetPayload {
  5. name: String,
  6. status: PetStatus,
  7. protos: Vec<Upload>, // 多个照片文件
  8. }
  9. struct Api;
  10. #[OpenApi]
  11. impl Api {
  12. #[oai(path = "/pet", method = "post")]
  13. async fn create_pet(&self, payload: CreatePetPayload) -> Result<Json<u64>> {
  14. todo!()
  15. }
  16. }

完整的代码请参考例子

参数校验

OpenAPI引用了Json Schema的校验规范,Poem-openapi同样支持它们。你可以在请求的参数,对象的成员和Multipart的字段三个地方应用校验器。校验器是类型安全的,如果待校验的数据类型和校验器所需要的不匹配,那么将无法编译通过。例如maximum只能用于数值类型,max_items只能用于数组类型。更多的校验器请参考文档

  1. use poem_openapi::{Object, OpenApi, Multipart};
  2. #[derive(Object)]
  3. struct Pet {
  4. id: u64,
  5. /// 名字长度不能超过32
  6. #[oai(max_length = "32")]
  7. name: String,
  8. /// 数组长度不能超过3
  9. #[oai(max_items = "3")]
  10. photo_urls: Vec<String>,
  11. status: PetStatus,
  12. }

认证

OpenApi规范定义了apikeybasicbeareroauth2openIdConnect五种认证模式,它们描述了指定的API接口需要的认证参数。

注意:API的认证信息最主要的用途是让**Swagger UI**在测试该API时能够正确的执行认证流程。

下面的例子是用Github登录,并提供一个获取所有公共仓库信息的接口。

  1. use poem_openapi::{
  2. SecurityScheme, SecurityScope, OpenApi,
  3. auth::Bearer,
  4. };
  5. #[derive(OAuthScopes)]
  6. enum GithubScope {
  7. /// access to public repositories.
  8. #[oai(rename = "public_repo")]
  9. PublicRepo,
  10. /// access to read a user's profile data.
  11. #[oai(rename = "read:user")]
  12. ReadUser,
  13. }
  14. /// Github authorization
  15. #[derive(SecurityScheme)]
  16. #[oai(
  17. type = "oauth2",
  18. flows(authorization_code(
  19. authorization_url = "https://github.com/login/oauth/authorize",
  20. token_url = "https://github.com/login/oauth/token",
  21. scopes = "GithubScope",
  22. ))
  23. )]
  24. struct GithubAuthorization(Bearer);
  25. struct Api;
  26. #[OpenApi]
  27. impl Api {
  28. #[oai(path = "/repo", method = "get")]
  29. async fn repo_list(
  30. &self,
  31. #[oai(auth("GithubScope::PublicRepo"))] auth: GithubAuthorization,
  32. ) -> Result<PlainText<String>> {
  33. // 使用GithubAuthorization得到的token向Github获取需要的数据
  34. todo!()
  35. }
  36. }

完整的代码请参考例子

总结

当你读到这里时候,恭喜你已经掌握了Poem-openapi的大部分用法,使用它开发API接口比直接使用Poem这样通用Web框架更加的方便,并且它并不是独立于Poem的另外一套框架,你可以很容易复用现有的提取器,中间件等组件。

有用的链接