rust 121 lines · 3 tabs

Parse URL Query Strings into a Typed Filter Struct with Defaults in Rust

Shared by codesnips Jul 2026
3 tabs
use serde::{Deserialize, Deserializer};

#[derive(Debug, Deserialize)]
pub struct ListFilter {
    #[serde(default)]
    pub status: Status,
    #[serde(default)]
    pub sort: SortField,
    #[serde(default = "default_page")]
    pub page: u32,
    #[serde(default = "default_per_page")]
    pub per_page: u32,
    #[serde(default, deserialize_with = "parse_bool")]
    pub include_archived: bool,
    #[serde(default)]
    pub q: Option<String>,
}

#[derive(Debug, Default, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Status {
    #[default]
    All,
    Active,
    Suspended,
}

#[derive(Debug, Default, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SortField {
    #[default]
    Created,
    Updated,
    Name,
}

fn default_page() -> u32 {
    1
}

fn default_per_page() -> u32 {
    25
}

fn parse_bool<'de, D>(de: D) -> Result<bool, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = String::deserialize(de)?;
    match raw.to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" | "on" => Ok(true),
        "0" | "false" | "no" | "off" | "" => Ok(false),
        other => Err(serde::de::Error::custom(format!("invalid bool: {other}"))),
    }
}
3 files · rust Explain with highlit

This snippet shows how a raw URL query string is turned into a strongly typed filter struct, applying defaults for missing keys and validating ranges. The pattern is common in list/search endpoints where clients send ?status=active&sort=created&per_page=50 and the handler needs a well-formed, bounded set of options rather than a loose map of strings.

In filter.rs, ListFilter is the target struct. Instead of leaving fields raw, it uses serde attributes to bridge the gap between the wire format and Rust types. Every field carries a #[serde(default = "...")] pointing at a small free function like default_page or default_per_page, so a query string that omits a key still deserializes cleanly. The status and sort fields are modeled as enums (Status, SortField) with #[serde(rename_all = "lowercase")], which means an unknown value fails loudly instead of silently defaulting — the enum acts as a whitelist. Status also derives Default so its absence resolves to Status::All.

The key detail is deserialize_with = "parse_bool" on include_archived. Query strings are all strings, so "true", "1", and "yes" must be coerced to a real bool; parse_bool handles that and rejects anything else. This is the kind of tolerant-but-strict parsing real APIs need.

In parse.rs, ListFilter::from_query is the entry point. It leans on the serde_urlencoded crate, which deserializes application/x-www-form-urlencoded data — exactly what a query string is — directly into any Deserialize type. After decoding, validate runs to clamp or reject out-of-range values: per_page is capped at MAX_PER_PAGE rather than trusting the client, and page must be at least one. Clamping instead of erroring is a deliberate trade-off that keeps pagination robust against hostile input.

main.rs exercises the whole path with three representative query strings, including an empty one that exercises every default. Note the pitfall this design avoids: because enums reject unknown variants, a typo like sort=creatd returns a clear FilterError instead of quietly falling back, which callers can map to an HTTP 400. Reach for this approach whenever an endpoint accepts structured filters and correctness matters more than accepting anything.


Related snips

Share this code

Here's the card — post it anywhere.

Parse URL Query Strings into a Typed Filter Struct with Defaults in Rust — share card
Link copied