
使用NestJS和Prisma構建REST API:身份驗證
前面說過,環境的設置是第一道難關。由于 Smithy CLI 的出現,這個問題得到大大地緩解。感恩節期間,我探索出一套簡單可行的環境配置方案。
一開始,我是想把所有應用到的資源都打包成一個 docker,然后把 smithy build
所需要的配置和 model 描述放進 docker 中進行構建,生成的代碼所在的路徑映射到宿主機。
后來我發現這有些多此一舉:Smithy 所依賴的代碼生成器都是 jar 包,所以我其實預先打包好這些 jar,使用時(無論是 CI 還是本地)直接下載這些 jar,放在合適的位置,然后使用即可。
比如寫這樣的 Makefile 來設置環境:
ASSETS = assets.tar.gz
update-smithy:
@gh release download -R tyrchen/smithy-docker -p '$(ASSETS)'
@rm -rf $HOME/.m2
@tar -xzf $(ASSETS) -C $(HOME) --strip-components=2
@rm $(ASSETS)
其中 tyrchen/smithy-docker
是我用于編譯各種 Smithy 代碼生成器的 repo,編譯好的資源打包成一個 assets.tar.gz。你只需要將其下載下來,解壓到本地的 maven 路徑下即可。注意 Smithy 相關的代碼都用 jdk 17,所以確保你本地的 java 是正確的版本。
之后,安裝 Smithy CLI,撰寫你的 model,以及 build 配置,就可以用 smithy build
生成相關的代碼了。
有了這個環境,你再也不需要和 gradle 以及復雜的環境設置打交道了。
我構建了一個最簡單的 repo: tyrchen/smithy-test 來展示如何使用 smithy,你可以使用 make build-smithy
來體驗對一個 smithy API 定義構建出 Rust 服務器和客戶端,python/typescript/swift 客戶端的過程。
服務的 model 如下:
$version: "2.0"
namespace com.example
use aws.protocols#restJson1
use smithy.framework#ValidationException
@restJson1
service EchoService {
version: "2006-03-01"
operations: [EchoMessage]
}
@http(uri: "/echo", method: "POST")
operation EchoMessage {
input := {
@required
@httpHeader("x-echo-message")
message: String
}
output := {
@required
message: String
}
errors: [ValidationException]
}
它只有一個服務,服務下沒有任何資源(resource),只有一個操作(operation)。Smithy IDL 有 trait 的概念,其中 @
開頭的這些修飾,如 restJson1
,http
都是 trait。trait 具體描述了代碼生成時,這個服務使用什么協議(http),該如何序列化/反序列化資源(restJson1),以及哪些是必要字段(required),字段出現在服務操作的什么位置(httpHeader,httpLabel,httpPayload 等)。每個操作可以定義 input
,output
和 errors
。
比如一個更復雜的服務:
@service(sdkId: "taotie")
@httpBearerAuth
@restJson1
service TaotieService {
version: "2006-03-01"
resources: [Logger]
operations: [Flush, Signin]
}
/// Logger resource provides the ability to manage different loggers.
resource Logger {
identifiers: { id: Name }
read: GetLogger
create: CreateLogger
operations: [IngestLog]
}
@readonly
@http(uri: "/logger/{id}", method: "GET")
operation GetLogger {
input := {
@required
@httpLabel
id: Name
}
output := {
@required
@httpPayload
payload: LoggerSummary
}
errors: [ValidationException, NotFoundError, ThrottlingError, ServerError]
}
...
structure LoggerSummary {
@required
total: Integer
@required
schema: LoggerSchema
}
...
@error("client")
@retryable
@httpError(429)
structure ThrottlingError {
@required
message: String
}
...
@error("server")
@httpError(500)
structure ServerError {
@required
code: ErrorCode
@required
message: String
}
服務的輸入輸出可以以內聯的方式定義,也可以詳細定義每種數據結構。數據結構可以用 structure,list 和 enum 來定義。有了上述基本的介紹,相信不難理解這段 spec。如果你想要學習更多關于 spec 的細節,可以自行閱讀 Smithy 2.0 官方文檔。
搞定了 model 的定義后,你還需要一個 smithy-build.json
來描述如何讓 smithy build
工作。根據你使用的代碼生成器的多少,這個配置文件可以很長,但基本上根據示例文件,然后連蒙帶猜可以攢出一個可用的版本(見 tyrchen/smithy-test):
如果一切正常,那么,運行 smithy build
就可以生成大量的代碼。
Smithy 官方支持 rust / typescript 的服務器代碼生成,但這里我們只介紹 Rust。感謝 Rust 生態下的 tower 生態和 hyper 生態(它們衍生出 tower-http,axum,tonic 等一系列優秀的 crate),smithy-rs 也將它們作為構建服務端 SDK 的基石。這也就意味著 smithy 生成的代碼中廣泛采用的注入 Request
和 Response
這樣的概念大家都是相通的,比如你可以通過 Extension
為路由添加新的屬性,你可以用 Service
和 Layer
來構建中間層。甚至,你可以把 Smithy 生成的 server SDK 作為一個 Route Service 添加到 axum 的一個子路由中,實現 website 和 REST API 共用同一個服務器的功能。在我的 smithy-test 演示代碼中,我混用了 axum 和 smithy server sdk:
pub async fn get_router(conf: AppConfig) -> Router {
// make name with static lifetime
let name = Box::leak(Box::new(conf.server_name.clone()));
let state = Arc::new(AppState::new(conf));
// smithy config
let config = EchoServiceConfig::builder()
// IdentityPlugin is a plugin that adds a middleware to the service, it just shows how to use plugins
.http_plugin(IdentityPlugin)
.layer(AddExtensionLayer::new(state.clone()))
// 我做的 smithy auth middleware
.layer(BearerTokenProviderLayer::new())
.layer(ServerRequestIdProviderLayer::new_with_response_header(
HeaderName::from_static("x-request-id"),
))
.build();
// smithy 兼容 Tower 的 service
let api = EchoService::builder(config)
.echo_message(api::echo_message)
.signin(api::signin)
.build()
.expect("failed to build an instance of Echo Service");
let doc_url = "/swagger/openapi.json";
let doc = include_str!("../../../smithy/gen/openapi/EchoService.openapi.json");
// axum 路由
Router::new()
.route("/swagger", get(|| async { Html(swagger_ui(doc_url)) }))
.route(doc_url, get(move || async move { doc }))
// 使用 nest_service 將 smithy service 加載為子路由
.nest_service("/api/", api)
// 在 smithy 之外定義的 middleware 也會影響 smithy Response
.layer(ServerTimingLayer::new(name))
.with_state(state)
}
如果你熟悉 axum,那么這段代碼很好地詮釋了 tower 生態的強大之處。說句題外話,我覺得 Rust 下的 web 框架,如果現在還沒有構建在 tower 和 hyper 生態下,那么是不值得學習和使用的。它們會慢慢凋零,無論它曾經有多大的用戶群體。Rocket 如此,actix-web 亦如此。
如上代碼所示,構建一個 smithy server sdk 的 middleware 其實就是構建一個 tower Service
。比如代碼中提供的 Bearer Auth 的支持,就是實現這個接口而已:
impl<Body, S> Service<Request<Body>> for BearerTokenProvider<S>
where
S: Service<Request<Body>, Response = Response<BoxBody>>,
S::Future: std::marker::Send + 'static,
由于我對 smithy 的了解還不夠深入,所以我一直沒有找到正確的姿勢讓 smithy server sdk 生成處理 auth 的代碼。我覺得這應該是框架提供的,我只需要提供一個回調函數來驗證 auth token 即可。然而我并未在 smithy-rs 的源碼中找到這樣的代碼,所以這里我自己為其實現了一個 middleware。
在我嘗試構建服務端代碼時,我的一個最大的感悟是 smithy 讓你在定義 API 時就想好都有什么錯誤,如何組合他們,并且隨著服務的迭代,可以不斷累加錯誤的定義。錯誤處理一直是做任何系統的夢魘,但 smithy 以一種非常簡單的方式把錯誤的定義集中起來,并根據需要組合使用。比如 signin 這個操作,我目前是如下定義的:
errors: [
ValidationException,
UnauthorizedError,
ForbiddenError,
ThrottlingError
]
以后我需要更多錯誤類型時,如 ServerError
,只需要相應添加,重新生成代碼,然后在代碼中應用新的錯誤類型即可。
所有代碼生成器,減輕的最大的負擔是客戶端代碼。服務端一般會固定一種語言撰寫,但客戶端語言可以千變萬化,光是主流的客戶端語言就有 typescript / swift / kotlin / dart (flutter),如果考慮服務和服務間的集成,那還有 rust / go / python / csharp / java 等。為服務接口維護這么多客戶端是一種極大的痛苦。我們看看上面的 Echo service,如何使用 Rust 調用:
let config = Config::builder()
.endpoint_url("http://localhost:3000/api")
.bearer_token(Token::new(token, None))
.behavior_version_latest()
.build();
let client = Client::from_conf(config);
let ret = client.echo_message().message("example").send().await?;
是不是似曾相識?對,如果你用 aws Rust SDK 訪問 AWS service,那么你的代碼有幾乎一樣的結構。在這樣的客戶端代碼中,你無需關心 REST API 的細節(比如 message 放在 header 而非 body),就可以輕松調用。
除了各種語言的客戶端代碼外, Smithy 還可以生成 OpenAPI spec。這帶來一個巨大的好處就是可以在服務定義完成之后,就能借助 swagger UI 的力量,有一個可以簡單交互的 API 工具:
理論上,任何人都可以通過添加 trait 來擴展 smithy 的能力。但 smithy 整套構建體系基于 kotlin 和 gradle,所以這個「任何人」至少不包括對 java 體系一無所知的我。業界比較知名的 smithy 的擴展是 Tubi 同行 Disney streaming 的 smithy4s,看名字就知道 Disney 為 smithy 提供了 scala 支持。在這個支持中,Disney 順帶對 smithy 做了不少擴展的 trait。
本來 smithy 相關的文檔和示例就很少,關于擴展 smithy,自己撰寫 trait 和代碼生成器的就更是鳳毛麟角。如果你對此有需要,相較于在互聯網上求助,不若直接聯系熟悉的 aws 工程師或者 disney 工程師來得更快。
當我耐著極大的性子,一點點看 smithy pokemon rust 的示例代碼,smithy-rs 源碼并和網上其他各種資料相互印證,把各種艱難險阻踩在腳下,成功調用生成好的代碼時,我一下子有了桃花源記中「初極狹,才通人,復行數十步,豁然開朗」的感覺。Smithy 絕對是那種上手很痛苦(因為文檔例子不給力),但用起來很舒服(至少 Rust server SDK 如此)的工具。
這個周末,我用 smithy 寫了個 POC 項目。我把我的心得記錄于此,一來讓知識和技能得到傳播,讓這么優秀的工具不至于無人問津;二來也是把我自己在全速寫代碼,不斷踩坑又埋坑的過程梳理一下。有機會我會在 B 站做個系列視頻,系統地介紹一下用 smithy 的生態構建一個 API 服務。敬請期待!
文章轉自微信公眾號@程序人生