Axumのハンドラで任意で渡されるクエリパラメータを受け取る

たとえばAxumのハンドラでクエリパラメータ page を受け付けるときに、

  • page=1が付与されていれば値として1を使う
  • page=aのように値が無効ならデフォルト値を使う
  • pageが付与されていないならデフォルト値を使う
  • page=のように値が空ならデフォルト値を使う

… のすべてをうまくハンドリングしたい。

方法

1つの方法として、たとえばAxumのextractorのドキュメントでも取り上げられているPaginationを任意のクエリパラメータに対応させると次のように書ける。crateとしてserde、serde_withも必要です。

#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Pagination {
    #[serde_as(as = "NoneAsEmptyString")]
    pub page: Option<usize>,
    #[serde_as(as = "NoneAsEmptyString")]
    pub per_page: Option<usize>,
}

impl Pagination {
    const DEFAULT_PAGE: usize = 1;
    const DEFAULT_PER_PAGE: usize = 10;

    pub fn params(&self) -> (usize, usize) {
        (
            self.page.unwrap_or(Self::DEFAULT_PAGE),
            self.per_page.unwrap_or(Self::DEFAULT_PER_PAGE),
        )
    }
}

impl Default for Pagination {
    fn default() -> Self {
        Self {
            page: Some(Self::DEFAULT_PAGE),
            per_page: Some(Self::DEFAULT_PER_PAGE),
        }
    }
}

pub async fn get_products(query: Option<Query<Pagination>>) {
    let (page, per_page) = query.unwrap_or_default().params();

    // ...
}

これは

  • GET /products
  • GET /products?page=1
  • GET /products?per_page=10
  • GET /products?page
  • GET /products?page=
  • GET /products?page=a

のいずれも対応できる。有効なパラメータでなければ、デフォルト値を使う。

解説

AxumのextractorとSerdeを併用すると、クエリパラメータを引っこ抜いて構造体に格納できる。

#[derive(Deserialize)]
pub struct Pagination {
    pub page: usize,
    pub per_page: usize,
}

pub async fn get_products(query: Query<Pagination>) {
  // let Query(pagination) = query;
  // let page = query.page;
  // ...
}

しかし、このコードだと GET products?page=GET /products? のようにパラメータが送られてこなかったときエラーになる。

Failed to deserialize query string: missing field `page`

まず、GET /products?のようにそもそもパラメータが見つからないならNoneとして扱いたい。これはフィールドをOptionで包めばよい。

#[derive(Debug, Deserialize)]
pub struct Pagination {
    pub page: Option<usize>,
    pub per_page: Option<usize>,
}

さらに、パラメータは送られてきても値が空のときはNone扱いにしたい。これは#[serde(default)]とserde_withのNoneAsEmptyStringを併用することで実現できる。NoneAsEmptyStringを使うとパラメータの値が空(?page=のような形式)をNoneにデシリアライズできるが、その代わりパラメータのキーの存在が必須になる。ここで#[serde(default)]を付与することで、キーが存在しないときはデフォルト値をフィールドにセットするようにしておく。デフォルト値を与えるためにDefaultを実装しておくのがよい。

#[serde_as] // 先頭に付与する必要がある
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Pagination {
    #[serde_as(as = "NoneAsEmptyString")]
    pub page: Option<usize>,
    #[serde_as(as = "NoneAsEmptyString")]
    pub per_page: Option<usize>,
}

impl Default for Pagination {
    fn default() -> Self {
        Self {
            page: Some(1),
            per_page: Some(10),
        }
    }
}

さらに、パラメータの値が構造体のフィールドの型と合わずデシリアライズでエラーになるとき(?page=abcのような形式)はデフォルトの値を使うとすると、extractor自体をOptionで包んでエラー時にはNoneになるようにしておき、先ほど実装したDefaultを利用することができる。

pub async fn get_products(query: Option<Query<Pagination>>) {
    // paginationはデシリアライズできたものかDefault::default()で得られるもののどちらかになる
    let Query(pagination) = query.unwrap_or_default();
}

これらすべてを取り込むと、最初に書いたようなコードになる。

他の方法

Query extractorは内部(extract::Query::try_from_uri)で文字列をSerdeのデシリアライザに渡しているので、カスタムのデシリアライザを書けばNoneAsEmptyStringは使わなくてよくなりそう。また、たとえばpageper_pageの片方がデシリアライズできなければ、全体をデフォルト値にフォールバックしていたが、それもカスタムのデシリアライザでデフォルト値にするなどの方法も考えられる。