はじめに
みなさま、良きJSONライフは送ってますか?
バリデーションってめちゃめちゃ面倒ですよね。バグがあったら僕たちの責任ですよね。そんなのイヤですよね。じゃあ型にその責任押し付けてやりましょう。
令和のこの時代にJSONのバリデーションを手動でやるのはやめましょう!
読者対象 ✏️📕
- Rustを触ってみたい方
- プログラミングのことが最近好きになった方
- バリデーション処理を書きたくない方
- 型をキツキツにして、バリデーションを簡潔にしたい方
本日のJSON 🥺
おやおや、とあるユーザがサービスに登録しようとしてるみたいですね。
こちらのお便り(JSON)をご覧ください。
{
"name": "John",
"password": "p@ssw0rd",
"age": 43,
"friends": ["Tom", "Hanako"]
}
サービスの要件 🤖
今回の想定するユーザ登録サービスには
-
1文字以上の
name
-
5文字以上, 10文字以下の
password
-
20歳以上, 60歳以下の
age
-
1文字以上の名前を持つ2人以上の
friends
の条件を満たさないと登録できません。
素直にバリデーション処理を書いてもいいけど再利用性や拡張性もないので、ちょっと腰が重いですね。
まず愚直に素直に雑にバリデーションしてみる 💩
まずはとりあえずUser
構造体を作って、TryFrom
Trait を使って雑にバリデーションしてみよう。余談ですが、宗教上の理由でreturn
書きたくない族なので?
で書いていきます。
use serde::Deserialize;
#[derive(Debug)]
struct User {
name: String,
password: String,
age: u8,
friends: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct UserJson {
name: String,
password: String,
age: u8,
friends: Vec<String>,
}
impl TryFrom<UserJson> for User {
type Error = ();
fn try_from(value: UserJson) -> Result<Self, Self::Error> {
let name = (if !value.name.is_empty() {
Ok(value.name)
} else {
Err(())
})?;
let password = (if value.password.len() >= 5 && value.password.len() <= 10 {
Ok(value.password)
} else {
Err(())
})?;
let age = (if (20..=60).contains(&value.age) {
Ok(value.age)
} else {
Err(())
})?;
let friends = (if value.friends.len() >= 2 && value.friends.iter().all(|n| n.len() > 0) {
Ok(value.friends)
} else {
Err(())
})?;
Ok(User {
name,
password,
age,
friends,
})
}
}
fn main() {
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 43,
"friends": ["Tom", "Hanako"]
}
"#;
let maybe_decoded = serde_json::from_str::<UserJson>(json)
.ok()
.and_then(|n| User::try_from(n).ok());
match maybe_decoded {
Some(user) => println!("{:?}", user),
None => println!("Failed to parse json"),
}
}
うええ!再利用性のカケラもねぇ!!
だけどひとまず完成。try_from
の中身が煩雑だね。
そんでもって、このままだとUser
インスタンスが生まれたからって中のデータがサービスの要件を満たしてる保証はどこにもないですよね?
なぜなら「型を見る限りは」name
は空文字かもしれないし、friends
だって1人もいない可能性だってある。
こんなんじゃヤダ!🥺
型の制約をキツキツにしてみよう
でもそんな人のためにRustにはこんなcrateがあります!
https://crates.io/crates/refined_type
cargo add refined_type
インストールが終わったらまずは
-
1文字以上の
name
-
1文字以上の名前を持つ
friends
(一旦2人以上という制約は無視)
から実装してみよう。
NonEmptyString
型
refined_type
には標準でNonEmptyString
型が提供されています。
またserde
との相性も良いのでDeserialize
をderive
するのも朝飯前です。
それではName
型を定義してみましょう!
use refined_type::rule::NonEmptyString;
use serde::Deserialize;
+ type Name = NonEmptyString;
#[derive(Debug, Deserialize)]
struct User {
- name: String
+ name: Name,
password: String,
age: u8,
- friends: Vec<String>
+ friends: Vec<Name>,
}
impl Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let _ = write!(
f,
"name: {}\npassword: {}\nage: {}\n",
self.name, self.password, self.age
);
for friend in &self.friends {
let _ = write!(f, "{} ", friend);
}
Ok(())
}
}
fn main() {
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 43,
"friends": ["Tom", "Hanako"]
}
"#;
match serde_json::from_str::<User>(json) {
Ok(user) => println!("{}", user),
Err(e) => println!("Failed to parse json: {}", e),
}
}
> cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
Running `target/debug/untitled1`
name: John
password: p@ssw0rd
age: 43
Tom Hanako
うまく変換できたようだ。
では次にname
が空文字の場合に失敗することを確認してみよう。
let json = r#"
{
"name": "",
"password": "p@ssw0rd",
"age": 43,
"friends": ["Tom", "Hanako"]
}
"#;
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/untitled1`
Failed to parse json: Target satisfies the `Not` rule at line 3 column 22
おおお!しっかり失敗した。
ユーザに返すときは、もう少しエラーメッセージを推敲する必要があるけどひとまず目的の第一段階を突破できた。
ちなみにfriends
の誰かの名前が空文字の場合も失敗することを確認してみよう。
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 43,
"friends": ["", "Hanako"]
}
"#;
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.35s
Running `target/debug/untitled1`
Failed to parse json: Target satisfies the `Not` rule at line 6 column 28
うん。しっかりとバリデーションに引っかかって失敗している。完璧だね。
じゃあ次のステップに進もう。
-
5文字以上, 10文字以下の
password
を実装してみよう!
LengthLessThanN
, LengthEqualN
, LengthGreaterThanN
型
refined_type
は任意の型に対して、長さの制約をつけることが可能です。
今回作るPassword
型は5文字以上, 10文字以下という制約があるので、実際に沿うようにPassword
型を定義してみましょう。
複数の制約を合成するには、And
とOr
を利用することで実現できます。
+ length_greater_than!(5);
+ length_equal!(5, 10);
+ length_less_than!(10);
type Name = NonEmptyString;
+ type Password = Refined<
+ And<
+ Or<LengthEqualRule5<String>, LengthGreaterThanRule5<String>>,
+ Or<LengthEqualRule10<String>, LengthLessThanRule10<String>>,
+ >,
+ >;
#[derive(Debug, Deserialize)]
struct User {
name: Name,
- password: String,
+ password: Password,
age: u8,
friends: Vec<Name>,
}
impl Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let _ = write!(
f,
"name: {}\npassword: {}\nage: {}\n",
self.name, self.password, self.age
);
for friend in &self.friends {
let _ = write!(f, "{} ", friend);
}
Ok(())
}
}
fn main() {
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 43,
"friends": ["Tom", "Hanako"]
}
"#;
match serde_json::from_str::<User>(json) {
Ok(user) => println!("{}", user),
Err(e) => println!("Failed to parse json: {}", e),
}
}
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.57s
Running `target/debug/untitled1`
name: John
password: p@ssw0rd
age: 43
Tom Hanako
しっかり動きましたね!では次は失敗させるために4文字のパスワードにしてみましょう!
let json = r#"
{
"name": "John",
"password": "p@ss",
"age": 43,
"friends": ["Tom", "Hanako"]
}
"#;
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/untitled1`
Failed to parse json: target length is not greater than 5 at line 4 column 30
最高ですね!次は11文字のパスワードにしてみましょう!
let json = r#"
{
"name": "John",
"password": "p@ssw0rd!!!",
"age": 43,
"friends": ["Tom", "Hanako"]
}
"#;
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s
Running `target/debug/untitled1`
Failed to parse json: target length is not less than 10 at line 4 column 37
これもしっかり失敗しました。型を定義するだけでだいぶ楽になりましたね!
だいぶ終盤に差し掛かってきました!続いては
-
20歳以上, 60歳以下の
age
の制約を実装してみましょう!
Less
, Equal
, Greater
型
refined_type
は数値の範囲指定も対応しています。
Length
型にそっくりなので、同様の手法でAge
型を実装してみましょう
greater_rule!((20, u8));
equal_rule!((20, u8), (60, u8));
less_rule!((60, u8));
+ length_greater_than!(5);
+ length_equal!(5, 10);
+ length_less_than!(10);
type Name = NonEmptyString;
type Password = Refined<
And<
Or<LengthEqualRule5<String>, LengthGreaterThanRule5<String>>,
Or<LengthEqualRule10<String>, LengthLessThanRule10<String>>,
>,
>;
+ type Age = Refined<And<Or<EqualRule20u8, GreaterRule20u8>, Or<LessRule60u8, EqualRule60u8>>>;
#[derive(Debug, Deserialize)]
struct User {
name: Name,
password: Password,
- age: u8,
+ age: Age,
friends: Vec<Name>,
}
impl Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let _ = write!(
f,
"name: {}\npassword: {}\nage: {}\n",
self.name, self.password, self.age
);
for friend in &self.friends {
let _ = write!(f, "{} ", friend);
}
Ok(())
}
}
fn main() {
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 43,
"friends": ["Tom", "Hanako"]
}
"#;
match serde_json::from_str::<User>(json) {
Ok(user) => println!("{}", user),
Err(e) => println!("Failed to parse json: {}", e),
}
}
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.40s
Running `target/debug/untitled1`
name: John
password: p@ssw0rd
age: 43
Tom Hanako
まずは成功パターン。毎度お馴染みでしっかりとデコードできました。うれしい。
嬉しさに浸る前に、ちゃんと失敗を確認しておきましょう。
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 19,
"friends": ["Tom", "Hanako"]
}
"#;
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
Running `target/debug/untitled1`
Failed to parse json: 19 is not greater than 20 at line 5 column 21
しっかり落ちてくれたね🥺
ちなみに余談ですが、僕はインターネット上では一人称が「ぴぃちゃん」になります。
気を取り直して、61歳にしてみよう!
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 61,
"friends": ["Tom", "Hanako"]
}
"#;
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
Running `target/debug/untitled1`
Failed to parse json: 61 does not equal 60 at line 5 column 21
やったー!もうほぼ完成だね!
ラストは
-
1文字以上の名前を持つ2人以上の
friends
の「2人以上の」の部分を実装してないのでやってみよう!
LengthLessThanN
, LengthEqualN
, LengthGreaterThanN
型 Season 2
長さの概念を持つものであれば、どんな型にでもこれらの型を適用することが可能です!
我々は要素数が2以上のVec
型を入手できれば良いので、早速Friends
型を実装してみましょう!
greater_rule!((20, u8));
equal_rule!((20, u8), (60, u8));
less_rule!((60, u8));
- length_greater_than!(5);
+ length_greater_than!(2, 5);
- length_equal!(5, 10);
+ length_equal!(2, 5, 10);
length_less_than!(10);
type Name = NonEmptyString;
type Password = Refined<
And<
Or<LengthEqualRule5<String>, LengthGreaterThanRule5<String>>,
Or<LengthEqualRule10<String>, LengthLessThanRule10<String>>,
>,
>;
type Age = Refined<And<Or<EqualRule20u8, GreaterRule20u8>, Or<LessRule60u8, EqualRule60u8>>>;
+ type Friends = Refined<Or<LengthEqualRule2<Vec<Name>>, LengthGreaterThanRule2<Vec<Name>>>>;
#[derive(Debug, Deserialize)]
struct User {
name: Name,
password: Password,
age: Age,
- friends: Vec<Name>,
+ friends: Friends,
}
impl Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let _ = write!(
f,
"name: {}\npassword: {}\nage: {}\n",
self.name, self.password, self.age
);
+ // Iterator ではなくなってしまったので `Refined` から中の値を借用する
- for friend in self.friends {
+ for friend in self.friends.value() {
let _ = write!(f, "{} ", friend);
}
Ok(())
}
}
fn main() {
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 43,
"friends": ["Tom", "Hanako"]
}
"#;
match serde_json::from_str::<User>(json) {
Ok(user) => println!("{}", user),
Err(e) => println!("Failed to parse json: {}", e),
}
}
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s
Running `target/debug/untitled1`
name: John
password: p@ssw0rd
age: 43
Tom Hanako
今回もしっかり動いてますね!では要素数を1にしてみましょう!
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 43,
"friends": ["Tom"]
}
"#;
cargo run
Compiling untitled1 v0.1.0 (/Users/****/RustroverProjects/untitled1)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/untitled1`
Failed to parse json: target length is not greater than 2 at line 7 column 9
遂に完成しました!道のりは長いようで短いかったですね!
最終的に重要な部分は以下のようになってます。
type Name = NonEmptyString;
type Password = Refined<
And<
Or<LengthEqualRule5<String>, LengthGreaterThanRule5<String>>,
Or<LengthEqualRule10<String>, LengthLessThanRule10<String>>,
>,
>;
type Age = Refined<And<Or<EqualRule20u8, GreaterRule20u8>, Or<LessRule60u8, EqualRule60u8>>>;
type Friends = Refined<Or<LengthEqualRule2<Vec<Name>>, LengthGreaterThanRule2<Vec<Name>>>>;
#[derive(Debug, Deserialize)]
struct User {
name: Name,
password: Password,
age: Age,
friends: Friends,
}
fn main() {
let json = r#"
{
"name": "John",
"password": "p@ssw0rd",
"age": 43,
"friends": ["Tom"]
}
"#;
match serde_json::from_str::<User>(json) {
Ok(user) => println!("{}", user),
Err(e) => println!("Failed to parse json: {}", e),
}
}
あのとき作った手続き的なバリデーションには拡張性も再利用性もありませんでしたが、今回我々は型として用意しましたので、バリデーション層でアプリケーションが汚れることもなければ、その型を使い回すこともできます。
まとめ
いかがだったでしょうか?
refined_type
を使って、一人でもJSONバリデーションの沼から抜け出してくれれば幸いです。今回はJSONを取り上げましたが、その他バリデーションに関わる部分においても大いに活躍してくれるはずです。
ちなみにまだrefined_type
は発展途上なので、コントリビューションチャンスが沢山あります。それでは、良きJSONライフを!
最後まで読んでくださりありがとうございました🥺
参考
依存
[dependencies]
refined_type = "0.5.4"
serde = "1.0.203"
serde_json = "1.0.66"