LoginSignup
3
3

まだ手書きでJSONバリデーションしてるの?

Posted at

はじめに

みなさま、良きJSONライフは送ってますか?
バリデーションってめちゃめちゃ面倒ですよね。バグがあったら僕たちの責任ですよね。そんなのイヤですよね。じゃあにその責任押し付けてやりましょう。

令和のこの時代にJSONのバリデーションを手動でやるのはやめましょう!

読者対象 ✏️📕

  • Rustを触ってみたい方
  • プログラミングのことが最近好きになった方
  • バリデーション処理を書きたくない方
  • 型をキツキツにして、バリデーションを簡潔にしたい方

本日のJSON 🥺

おやおや、とあるユーザがサービスに登録しようとしてるみたいですね。
こちらのお便り(JSON)をご覧ください。

user.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書きたくない族なので?で書いていきます。

main.rs
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との相性も良いのでDeserializederiveするのも朝飯前です。
それではName型を定義してみましょう!

main.rs
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が空文字の場合に失敗することを確認してみよう。

main.rs (自分の名前が空文字版)
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の誰かの名前が空文字の場合も失敗することを確認してみよう。

main.rs (名前が空文字の友達版)
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型を定義してみましょう。
複数の制約を合成するには、AndOrを利用することで実現できます。

main.rs
+ 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文字のパスワードにしてみましょう!

main.rs (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文字のパスワードにしてみましょう!

main.rs (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型を実装してみましょう

main.rs
  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

まずは成功パターン。毎度お馴染みでしっかりとデコードできました。うれしい。
嬉しさに浸る前に、ちゃんと失敗を確認しておきましょう。

main.rs(年齢が19歳版)
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歳にしてみよう!

main.rs (年齢が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型を実装してみましょう!

main.rs
  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にしてみましょう!

main.rs (友達の人数が2人未満版)
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

遂に完成しました!道のりは長いようで短いかったですね!

最終的に重要な部分は以下のようになってます。

main.rs
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ライフを!

最後まで読んでくださりありがとうございました🥺

参考

依存

Cargo.toml
[dependencies]
refined_type = "0.5.4"
serde = "1.0.203"
serde_json = "1.0.66"
3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3