社内の Rust コードが Option まみれになってしまった話と、その解決策。
結論
Actix-Web で API Request を JsonSchema で事前にバリデーションするクレートを公開しました
actix-web-jsonschema
事の発端
会社で Rust によって記述された API サーバのプロジェクトに途中で参加しました。
Rust 書くの久しぶりだな〜とワクワクしながらコードを見ると、構造体が全て Option でした
// 遭遇したコード
#[derive(Debug, Validate)]
struct User {
#[validate(required)]
name: Option<String>,
#[validate(required)]
age: Option<i32>
// ...
}
必須(required)だけど、任意(Option)...だって!?
単に Option を抜いてはダメなのか?
// 期待したコード
#[derive(Debug, Validate)]
struct User {
name: String,
age: u32
// ...
}
全ての構造体が Option フィールドを持っていたため、 unwrap が多用されたプロジェクトになり、
本当の Option 要素も誤って unwrap されていました
防御的プログラミングはどこに行ったのか?
私の調査が始まりました 🕵
何が起こっていたか?
まず、なぜ Option を使い始めたのかを聞き取りました。
その結果、外部入力にあたる Request Body で全て Option にしていたことが始まりでした。
// Option が導入され出したコード
#[derive(Debug, Deserialize, Validate)]
struct PostUserRequest {
#[validate(required)]
name: Option<String>,
#[validate(required)]
age: Option<u32>,
// ...
}
fn post_users(Json(user): Json<PostUserRequest>) -> Result<(), api::Error> {
// バリデーションをする。
user.validate()?;
// 以降はロジック
}
なるほど。外部からの入力をバリデーションするのはいいことです。
PostUserRequest になった時点で型を保障してくれますが、range(max, min) などは保障してくれませんからね。
問題は、このコードを見た他の人が、
「入口で Option を受け取っているから、その後も Option を渡してしまえ」
と考えてしまった事のようです。
まぁ、 Option を外した型をもう一度定義し、変換する作業を毎回書くのが煩わしかったのでしょう。
ちょっと待った
わざわざ必須を表すために、下記のような指示を書く必要があるのはなぜでしょう?
#[validate(required)]
name: Option<String>,
Option を Request に使い始めた人に聞くと、
このような書き方をしないとフィールドをリクエストで送り忘れた時、
どのフィールドが足りないかをエラーメッセージで返してくれないそうです。
確認してみましょう
required/Option を使わない場合
use std::error::Error;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, Serialize, Deserialize)]
struct User {
name: String,
age: u32,
}
fn main() -> Result<(), Box<dyn Error>> {
serde_json::from_value::<User>(json!({"nam": "taro"}))?;
Ok(())
}
結果は
$ cargo run
Compiling serde_error_test v0.1.0 (/Users/yasutani/develop/rust/serde_error_test)
Finished dev [unoptimized + debuginfo] target(s) in 0.49s
Running `target/debug/serde_error_test`
Error: Error("missing field `name`", line: 0, column: 0)
なるほど。 name がないという指摘がありますが、 age も足りないことを教えてくれません。
required/Option を使う場合
use std::error::Error;
use serde::{Deserialize, Serialize};
use serde_json::json;
use validator::Validate;
#[derive(Debug, Serialize, Deserialize, Validate)]
struct User {
#[validate(required)]
name: Option<String>,
#[validate(required)]
age: Option<u32>,
}
fn main() -> Result<(), Box<dyn Error>> {
let user = serde_json::from_value::<User>(json!({"nam": "taro"}))?;
user.validate()?;
Ok(())
}
結果は
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/serde_error_test`
Error: ValidationErrors({"age": Field([ValidationError { code: "required", message: None, params: {"value": Null} }]), "name": Field([ValidationError { code: "required", message: None, params: {"value": Null} }])})
required / Option の場合は、全てのフィールドでエラーが出ました。
つまり、 required/Option を導入した人は、優しいエラーバンドリングを志向しており
それが導入者の意図から外れた形で拡大解釈されて広まったことが原因のようです
どう解決したか
対立
私と導入者の間で、下記のような会話が起こりました。
- 私)validate を handler で書くべきではなく、それ以前のレイヤーで型判定は行われているべきだ
- 導入者)全然、同意
- 私)validate を Extractor で書けば良い(サンプルを見せる)
- 導入者)すでに crate が公開 されているよ
- 私)再発明をしてしまった...これを使うのではダメなのか?
- 導入者)ユーザフレンドリーではない
なんということだ。 このままでは require/Option が消えてくれない...
解決
理想のコードは下記のようなものでした。
// 理想のコード
// 直感的なインターフェース
#[derive(Debug, Serialize, Deserialize)]
struct User {
name: String,
age: u32,
}
fn post_users(Json(user): Json<PostUserRequest>) -> Result<(), api::Error> {
// バリデーションはすでに終わっている状態。
// user.validate()?;
// 最初からロジックに集中
}
これらは、現状用いている crate では解決できない問題でした。
そのため、これらの crate に加え、 schemars, jsonschema を組み合わせることで解決しました。
// 解決したコード
// 直感的なインターフェース
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct User {
name: String,
age: u32,
}
fn post_users(Json(user): Json<PostUserRequest>) -> Result<(), api::Error> {
// バリデーションはすでに終わっている状態。
// user.validate()?;
// 最初からロジックに集中
}
struct に JsonSchema を derive させるだけで、対応できるようになりました。
フローは次のようなものです。
- RequestBody として application/json を受け取る
- FromRequest 内で serde_json::Value に一旦変換する
- 変換先 T から schemars を用いて JSONSchema を生成する
- serde_json::Value を JSONSchema でバリデーションする(ここで 必須項目がない場合も判定可能)
- serde_json::Value から T に変換する
- T に validator::validate を行わせる(任意機能。 jsonschema 以上のバリデーションをしたい場合)
- T を FromRequest の戻り値として返す
実際のコードを追ってみましょう(記事用にコメントを追加しています)。
Extractor の部分です。
impl<T> actix_web::FromRequest for $Type<T>
where
T: crate::schema::SchemaDeserialize,
{
type Error = actix_web::Error;
type Future = futures::future::LocalBoxFuture<'static, Result<Self, Self::Error>>;
#[inline]
fn from_request(
req: &actix_web::HttpRequest,
payload: &mut actix_web::dev::Payload,
) -> Self::Future {
// 最初は serde_json::Value に変換する。
actix_web::web::$Type::<serde_json::Value>::from_request(req, payload)
.map(|result| match result {
// バリデーションをした後 T として返す(内部処理は後述)。
Ok(value) => Ok($Type(crate::schema::SchemaContext::from_value::<T>(
value.into_inner(),
)?)),
Err(err) => Err(err),
})
.boxed_local()
}
}
実際のバリデーション部分です。
impl SchemaContext {
pub fn from_value<T>(value: Value) -> Result<T, crate::Error>
where
T: SchemaDeserialize,
{
CONTEXT.with(|ctx| {
// この辺りは一度生成した JsonSchema をキャッシュしているので少し複雑。
let ctx = &mut *ctx.borrow_mut();
let schema = ctx.schemas.entry(TypeId::of::<T>()).or_insert_with(|| {
// このブロックは初回のみ実行。Json Schema を生成している。
jsonschema::JSONSchema::compile(
// schemars で T のスキーマを取得している。
&serde_json::to_value(ctx.generator.root_schema_for::<T>()).unwrap(),
)
.unwrap_or_else(|err| {
tracing::error!(
%err,
type_name = type_name::<T>(),
"invalid JSON schema for type"
);
JSONSchema::compile(&Value::Object(Map::default())).unwrap()
})
});
// JsonSchema を用いて、 serde_json::Value をバリデーションしている。
if let BasicOutput::Invalid(err) = schema.apply(&value).basic() {
Err(crate::Error::JsonSchema(err))?
}
// JsonSchema に則っていれば、 T に変換する。
let data: T = serde_json::from_value(value).map_err(crate::Error::SerdeJson)?;
// validator を使いたい場合、さらにバリデーションする。
#[cfg(feature = "validator")]
data.validate().map_err(crate::Error::Validator)?;
// バリデーション付の T を返す。
Ok(data)
})
}
}
実は...
Rust で FastAPI のようなことができる方法を実装しているときに、偶然 axum-jsonschema を発見しました。
JsonSchema を使おうという同じ発想で実現している人がいたので、その人のコードをかなり参考にしました。
次は Rust で FastAPI のような開発をする話は別記事で紹介するので、お楽しみに!
2022/12/08 追記 公開しました
最後に
なんというキメラを生み出してしまったのだろうか...
serde はバリデーション機能を含むべきですね。
v2 でサポートしてくれないだろうか?