Skip to content

feat(axum-extra): support deserializing Vec<Enum> from repeated query parameters#3751

Open
Zelys-DFKH wants to merge 5 commits intotokio-rs:mainfrom
Zelys-DFKH:feature/query-list-enum
Open

feat(axum-extra): support deserializing Vec<Enum> from repeated query parameters#3751
Zelys-DFKH wants to merge 5 commits intotokio-rs:mainfrom
Zelys-DFKH:feature/query-list-enum

Conversation

@Zelys-DFKH
Copy link
Copy Markdown

Fixes #3696

Summary

This PR adds two new extractors to handle a real pain point: query strings with mixed enum variants. You can now write handlers that accept ?id=123&username=alice&id=456 and get back a properly-typed Vec<SearchFilter> with all the variants in order.

Problem

Axum's query parameter handling works great for flat structs. But if you need to accept different parameter names as different enum variants — like filtering by ID or username in the same request — you're stuck. serde_html_form doesn't support this pattern because it doesn't know that the parameter name should pick the enum variant.

Your options were: hand-parse the query string, build a custom extractor, or restructure the API. None of them are fun.

Solution

Two new extractors:

  • QueryList<T> - Deserializes all query parameters into Vec<T>, where T is an enum. The parameter name becomes the variant discriminator.
  • OptionalQueryList<T> - Same as QueryList, but returns an empty vec if there are no parameters (no error on missing data).

How it works

The trick is simple: for each query parameter pair, create a JSON object { variant_name: value } and let serde's enum deserializer do the work. Serde already knows how to pick the right enum variant from the object key. We just had to bridge the gap between query strings and JSON objects.

The implementation also handles value types correctly: id=123 deserializes as the number 123 (not the string "123"), and active=true becomes a boolean, just like you'd expect from JSON semantics.

Example

#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
enum SearchFilter {
    Id(u32),
    Username(String),
}

async fn search(QueryList(filters): QueryList<SearchFilter>) {
    for filter in filters {
        match filter {
            SearchFilter::Id(id) => println!("Filter by ID: {}", id),
            SearchFilter::Username(name) => println!("Filter by user: {}", name),
        }
    }
}

// GET /search?id=123&username=alice&id=456
// Receives: [Id(123), Username("alice"), Id(456)]

Testing

Nine tests cover the realistic cases:

  • Alternating enum variants in any order
  • The same variant repeated (e.g., ?id=1&id=2&id=3)
  • Empty query strings
  • Unknown variant names (400 Bad Request)
  • Invalid values (type errors)
  • Both sync and async extraction paths
  • OptionalQueryList behavior with and without parameters

All existing tests pass. No breakage.

Changes

  • axum-extra/src/extract/query.rs — Added QueryList<T>, OptionalQueryList<T>, the deserialize_pair helper, and rejection types
  • axum-extra/src/extract/mod.rs — Exported the new types
  • axum-extra/Cargo.toml — Added serde_json dependency for the "query" feature

Notes

  • Reuses the existing FailedToDeserializeQueryString rejection type for consistency
  • Follows the same patterns as Query/OptionalQuery so the API surface is familiar
  • Works with any DeserializeOwned enum — full type safety
  • Values parse as JSON types: id=123 is a number, active=true is a boolean, name=alice is a string

Natural fit for axum-extra's extraction toolkit. The approach is simple and delegates the hard work to serde, which already knows how to deserialize enums correctly.

Zelys-DFKH added 2 commits May 7, 2026 13:45
…Vec<Enum> query deserialization

Implements support for deserializing repeated query parameters into enum variants,
enabling handlers to accept mixed parameter names that map to different enum variants.

For example, ?id=123&username=alice&id=456 deserializes into Vec<SearchFilter>
with alternating Id and Username variants in order.

Implementation uses a JSON-bridge approach: for each query parameter pair, creates
a single-entry JSON object { variant_name: value } and leverages serde's existing
enum deserialization machinery for variant discrimination.

Adds two new extractors:
- QueryList<T>: Deserializes all query parameters into Vec<T>
- OptionalQueryList<T>: Same as QueryList, returns empty vec when no parameters

Includes comprehensive test coverage for:
- Alternating and repeated enum variants
- Empty query strings
- Unknown variant names (400 Bad Request)
- Invalid type values (deserialization errors)
- Both sync (try_from_uri) and async (from_request_parts) extraction paths

Fixes tokio-rs#3696
- Remove duplicate #[allow(dead_code)] attribute on struct
- Replace useless format!("{}", ...) with direct .to_string()
- Inline format string variables: format!("id:{id}") instead of format!("id:{}", id)
@yanns
Copy link
Copy Markdown
Collaborator

yanns commented May 8, 2026

Could you add some tests where the parameter values have special characters (like & or whitespace) that needs to be encoded / decoded.

…ueryList

Tests cover URL-encoded whitespace (%20 and +), ampersands (%26), equals signs
(%3D), plus signs (%2B), and other special characters (slashes, colons, percent).
Verifies form_urlencoded properly decodes values in all scenarios.

Addresses review feedback from yanns on PR tokio-rs#3751.
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();

let mut result = Vec::new();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case we keep pairs, can we use Vec with capacity of the pairs to avoid allocations?


async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let query = parts.uri.query().unwrap_or_default();
let pairs: Vec<(String, String)> = form_urlencoded::parse(query.as_bytes())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to create this temporary Vec or could we use iterator all way long?

async fn correct_rejection_status_code() {
#[derive(Deserialize)]
#[allow(dead_code)]
#[derive(Deserialize)]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: this change is not necessary.

Zelys-DFKH added 2 commits May 8, 2026 12:24
Reformat the OptionalQueryList handler closure and .get() call to match
rustfmt expectations. Splits multi-line closure into proper indentation
and collapses single-line method chain onto one line.
Clippy warns against calling .to_string() on string literals. Use .to_owned()
instead, which is the idiomatic way to convert &str to String.
@Zelys-DFKH
Copy link
Copy Markdown
Author

Thanks for catching that special character requirement! Added tests with URL-encoded whitespace and ampersand. Also fixed a couple of clippy warnings that showed up in the new run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Supporting deserialize Vec<Enum> from query strings

2 participants