Loading pageā¦
Rust walkthroughs
Loading pageā¦
axum::extract::Path and axum::extract::Query for extracting request parameters?axum::extract::Path extracts parameters from the URL path segments (like /users/:id), while axum::extract::Query extracts parameters from the URL query string (like ?page=1&sort=desc). The key difference is location: path parameters are part of the route structure and are required by default, whereas query parameters are optional key-value pairs appended after the ? in the URL. Path parameters define the resource being accessed, query parameters modify how the resource is returned or filtered. Both use serde for deserialization, but they differ in requiredness semantics, validation behavior, and typical use cases.
use axum::{
extract::Path,
routing::get,
Router,
};
use serde::Deserialize;
async fn get_user(Path(user_id): Path<u32>) -> String {
format!("User ID: {}", user_id)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:user_id", get(get_user));
// GET /users/42 -> "User ID: 42"
// GET /users/abc -> 400 Bad Request (failed to parse u32)
// GET /users -> 404 Not Found (route doesn't match)
}Path extracts parameters defined in the route with :param_name syntax.
use axum::{
extract::Query,
routing::get,
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
per_page: Option<u32>,
}
async fn list_users(Query(params): Query<Pagination>) -> String {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(10);
format!("Page {} with {} items", page, per_page)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users", get(list_users));
// GET /users -> "Page 1 with 10 items" (defaults)
// GET /users?page=2 -> "Page 2 with 10 items"
// GET /users?page=2&per_page=50 -> "Page 2 with 50 items"
// GET /users?page=abc -> 400 Bad Request (failed to parse)
}Query extracts optional parameters from the query string.
use axum::extract::Path;
use serde::Deserialize;
#[derive(Deserialize)]
struct UserPostPath {
user_id: u32,
post_id: u32,
}
async fn get_user_post(Path(params): Path<UserPostPath>) -> String {
format!("User {} Post {}", params.user_id, params.post_id)
}
// Alternative: tuple extraction
async fn get_user_post_alt(Path((user_id, post_id)): Path<(u32, u32)>) -> String {
format!("User {} Post {}", user_id, post_id)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:user_id/posts/:post_id", get(get_user_post));
// GET /users/1/posts/42 -> "User 1 Post 42"
}Multiple path parameters can be extracted as a struct or tuple.
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct SearchParams {
q: String, // Required
page: Option<u32>, // Optional
sort: Option<String>, // Optional
#[serde(default)]
tags: Vec<String>, // Default (empty vec)
#[serde(rename = "per_page")]
per_page: Option<u32>, // Renamed field
}
async fn search(Query(params): Query<SearchParams>) -> String {
let page = params.page.unwrap_or(1);
let sort = params.sort.unwrap_or_else(|| "relevance".to_string());
format!(
"Search '{}' on page {}, sorted by {}, tags: {:?}",
params.q, page, sort, params.tags
)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/search", get(search));
// GET /search?q=rust -> "Search 'rust' on page 1, sorted by relevance, tags: []"
// GET /search?q=rust&page=2&sort=date -> "Search 'rust' on page 2, sorted by date, tags: []"
// GET /search?q=rust&tags=new&tags=popular -> tags: ["new", "popular"]
// GET /search -> 400 Bad Request (missing required 'q')
}Query parameters support required fields, optional fields, defaults, and renaming.
use axum::extract::Path;
use serde::Deserialize;
#[derive(Deserialize)]
struct RequiredPath {
id: u32, // Always required - must exist in route
}
async fn get_item(Path(params): Path<RequiredPath>) -> String {
format!("Item {}", params.id)
}
// Path parameters cannot be optional:
// #[derive(Deserialize)]
// struct OptionalPath {
// id: Option<u32>, // This doesn't make sense for path params
// }
// The route defines what exists: /items/:id
// If the route matches, the parameter MUST exist
// If it doesn't exist, the route doesn't match (404)Path parameters are inherently required by route definition.
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct RequiredQuery {
api_key: String, // Required - will 400 if missing
}
#[derive(Deserialize)]
struct OptionalQuery {
api_key: Option<String>, // Optional - will be None if missing
}
async fn with_required(Query(params): Query<RequiredQuery>) -> String {
format!("API Key: {}", params.api_key)
}
async fn with_optional(Query(params): Query<OptionalQuery>) -> String {
match params.api_key {
Some(key) => format!("API Key: {}", key),
None => "No API Key provided".to_string(),
}
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/required", get(with_required))
.route("/optional", get(with_optional));
// GET /required -> 400 Bad Request
// GET /required?api_key=secret -> "API Key: secret"
// GET /optional -> "No API Key provided"
// GET /optional?api_key=secret -> "API Key: secret"
}Query parameter requiredness is controlled by field type.
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use serde::Deserialize;
#[derive(Deserialize)]
struct NumericPath {
id: u32,
}
#[derive(Deserialize)]
struct NumericQuery {
id: u32,
}
async fn path_handler(Path(params): Path<NumericPath>) -> String {
format!("Path ID: {}", params.id)
}
async fn query_handler(Query(params): Query<NumericQuery>) -> Result<String, StatusCode> {
Ok(format!("Query ID: {}", params.id))
}
// Both handlers return 400 Bad Request if parsing fails:
// GET /users/abc -> 400 (Path: invalid u32)
// GET /users?id=abc -> 400 (Query: invalid u32)
// The error messages differ slightly in format
// But both use serde's error systemBoth Path and Query return 400 on deserialization failures.
use axum::{
extract::{Path, Query, rejection::QueryRejection},
response::{Response, IntoResponse},
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
struct UserQuery {
name: String,
}
// Custom extractor with better error messages
struct QueryWithMessage<T>(pub T);
#[axum::async_trait]
impl<T: serde::de::DeserializeOwned + Send + Sync + 'static> axum::extract::FromRequest<axum::body::Body> for QueryWithMessage<T> {
type Rejection = Json<serde_json::Value>;
async fn from_request(
req: axum::extract::Request,
) -> Result<Self, Self::Rejection> {
let query = Query::<T>::from_request(req).await
.map_err(|e| {
Json(json!({
"error": "Invalid query parameters",
"details": e.body_text()
}))
})?;
Ok(QueryWithMessage(query.0))
}
}
async fn search(Query(params): Query<UserQuery>) -> impl IntoResponse {
Json(json!({ "message": format!("Hello, {}", params.name) }))
}Custom extractors can provide better error messages.
use axum::{
extract::{Path, Query},
routing::get,
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct UserPath {
user_id: u32,
}
#[derive(Deserialize)]
struct UserQuery {
include_posts: Option<bool>,
fields: Option<String>,
}
async fn get_user(
Path(path): Path<UserPath>,
Query(query): Query<UserQuery>,
) -> String {
let include = query.include_posts.unwrap_or(false);
format!(
"User {} (include_posts: {})",
path.user_id, include
)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:user_id", get(get_user));
// GET /users/42 -> User 42 (include_posts: false)
// GET /users/42?include_posts=true -> User 42 (include_posts: true)
// GET /users/42?fields=name,email -> User 42 (include_posts: false)
}Path and Query can be combined in the same handler.
use axum::extract::{Path, Query};
use serde::Deserialize;
#[derive(Deserialize)]
struct ResourcePath {
category: String,
resource_id: u32,
}
#[derive(Deserialize)]
struct FilterQuery {
sort: Option<String>,
order: Option<String>,
}
async fn list_resources(
Path(path): Path<ResourcePath>,
Query(query): Query<FilterQuery>,
) -> String {
format!(
"Category: {}, Resource: {}, Sort: {}, Order: {}",
path.category,
path.resource_id,
query.sort.unwrap_or_else(|| "default".to_string()),
query.order.unwrap_or_else(|| "asc".to_string())
)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/:category/:resource_id", get(list_resources));
// URL: /books/42?sort=title&order=desc
// Path params: category="books", resource_id=42
// Query params: sort="title", order="desc"
}Path segments define the resource; query parameters filter or modify.
// PATH: Identifies a specific resource
// /users/42 -> User with ID 42
// /posts/1/comments/2 -> Comment 2 on Post 1
// /products/electronics/laptops -> Laptops in electronics category
// QUERY: Modifies how the resource is returned
// /users?page=2&limit=10 -> Page 2, 10 per page
// /posts?status=published -> Filter by status
// /products?sort=price&order=asc -> Sort by price ascending
// Path parameters should be:
// - Resource identifiers
// - Hierarchical data
// - Required for resource location
// Query parameters should be:
// - Filters
// - Pagination
// - Sorting
// - Optional configurationPath and query have different semantic meanings for REST APIs.
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct ComplexQuery {
// Arrays: ?tags=rust&tags=programming
#[serde(default)]
tags: Vec<String>,
// Nested: ?user.name=alice&user.age=30
#[serde(default)]
user: Option<UserFilter>,
// Renamed: ?page-number=5
#[serde(rename = "page-number")]
page_number: Option<u32>,
// With default value
#[serde(default = "default_limit")]
limit: u32,
}
#[derive(Deserialize)]
struct UserFilter {
name: Option<String>,
age: Option<u32>,
}
fn default_limit() -> u32 { 10 }
async fn search(Query(params): Query<ComplexQuery>) -> String {
format!(
"Tags: {:?}, Page: {}, Limit: {}",
params.tags, params.page_number.unwrap_or(1), params.limit
)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/search", get(search));
// GET /search?tags=rust&tags=web&page-number=5&limit=20
// Tags: ["rust", "web"], Page: 5, Limit: 20
}Query parameters support complex deserialization via serde.
use axum::extract::Path;
use serde::Deserialize;
#[derive(Deserialize)]
struct StringPath {
// String - any value works
name: String,
// With pattern matching in route
// Router would need: .route("/users/:name", ...)
}
async fn get_by_name(Path(params): Path<StringPath>) -> String {
format!("User name: {}", params.name)
}
// Or as a raw string
async fn get_by_name_raw(Path(name): Path<String>) -> String {
format!("User name: {}", name)
}
// Tuple form for multiple strings
async fn get_first_last(Path((first, last)): Path<(String, String)>) -> String {
format!("{} {}", first, last)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:name", get(get_by_name))
.route("/first/:first/last/:last", get(get_first_last));
// GET /users/alice -> "User name: alice"
// GET /first/john/last/doe -> "john doe"
}String path parameters accept any value that can be URL-decoded.
use axum::Router;
use serde::Deserialize;
#[derive(Deserialize)]
struct NumericPath {
id: u32,
}
// Axum doesn't enforce regex constraints on path parameters directly
// But you can validate in the handler:
async fn get_user(Path(params): Path<NumericPath>) -> Result<String, String> {
if params.id == 0 {
return Err("ID must be positive".to_string());
}
Ok(format!("User {}", params.id))
}
// For more complex validation, use a custom extractor or validate in handler
// Route matching by structure:
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:id", get(get_user)) // Matches any string
.route("/users/new", get(get_new_form)); // Exact match for "new"
// Order matters! More specific routes should come first
// "/users/new" would match "/users/:id" if defined first
}Path validation happens at deserialization time, not routing time.
use axum::extract::Query;
use std::collections::HashMap;
// Access raw query as HashMap
async fn raw_query(Query(params): Query<HashMap<String, String>>) -> String {
let mut result = String::new();
for (key, value) in params {
result.push_str(&format!("{}={}\n", key, value));
}
result
}
// Duplicate keys: last value wins
// GET /search?tag=a&tag=b -> {"tag": "b"}
// For multiple values per key:
#[derive(Deserialize)]
struct MultiValueQuery {
#[serde(default)]
tag: Vec<String>,
}
async fn multi_values(Query(params): Query<MultiValueQuery>) -> String {
format!("Tags: {:?}", params.tag)
}
// GET /search?tag=a&tag=b -> Tags: ["a", "b"]HashMap<String, String> gives raw access but loses multi-value support.
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct DefaultsQuery {
// serde default attribute
#[serde(default = "default_page")]
page: u32,
// serde default (uses Default trait)
#[serde(default)]
limit: Limit,
// Optional with default in handler
sort: Option<String>,
}
fn default_page() -> u32 { 1 }
#[derive(Debug, Default, Deserialize)]
struct Limit {
value: u32,
}
async fn with_defaults(Query(mut params): Query<DefaultsQuery>) -> String {
// Can also apply defaults in handler
let sort = params.sort.take().unwrap_or_else(|| "created".to_string());
format!("Page: {}, Sort: {}", params.page, sort)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/items", get(with_defaults));
// GET /items -> Page: 1, Sort: created
// GET /items?page=5 -> Page: 5, Sort: created
// GET /items?sort=updated -> Page: 1, Sort: updated
}Defaults can be specified via serde attributes or handled in code.
use axum::{
extract::{Path, Query, rejection::{PathRejection, QueryRejection}},
response::{Response, IntoResponse},
http::StatusCode,
Json,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct IdPath {
id: u32,
}
#[derive(Deserialize)]
struct FilterQuery {
name: Option<String>,
}
async fn with_rejection_handling(
path: Result<Path<IdPath>, PathRejection>,
query: Result<Query<FilterQuery>, QueryRejection>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let Path(path) = path.map_err(|_| StatusCode::BAD_REQUEST)?;
let Query(query) = query.map_err(|_| StatusCode::BAD_REQUEST)?;
Ok(Json(serde_json::json!({
"id": path.id,
"name": query.name
})))
}
// Or use Result types in signature:
async fn with_result(
path: Result<Path<IdPath>, PathRejection>,
) -> Result<String, (StatusCode, String)> {
let Path(path) = path.map_err(|e| {
(StatusCode::BAD_REQUEST, format!("Invalid path: {}", e))
})?;
Ok(format!("ID: {}", path.id))
}Both extractors can return rejections for custom error handling.
use axum::extract::Path;
use serde::Deserialize;
#[derive(Deserialize)]
struct UserPostPath {
user_id: u32,
post_id: u32,
}
// Struct form - named fields
async fn struct_form(Path(params): Path<UserPostPath>) -> String {
format!("User {}, Post {}", params.user_id, params.post_id)
}
// Tuple form - positional
async fn tuple_form(Path((user_id, post_id)): Path<(u32, u32)>) -> String {
format!("User {}, Post {}", user_id, post_id)
}
// Single parameter
async fn single_form(Path(id): Path<u32>) -> String {
format!("ID: {}", id)
}
// String form
async fn string_form(Path(name): Path<String>) -> String {
format!("Name: {}", name)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:user_id/posts/:post_id", get(struct_form))
.route("/tuple/:user_id/:post_id", get(tuple_form))
.route("/single/:id", get(single_form))
.route("/string/:name", get(string_form));
}Multiple extraction forms offer flexibility.
use axum::{
extract::{Path, Query},
routing::get,
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct UserPath {
user_id: u32,
}
#[derive(Deserialize)]
struct UserQuery {
include_email: Option<bool>,
#[serde(default)]
fields: Vec<String>,
}
#[derive(Deserialize)]
struct PostsQuery {
page: Option<u32>,
per_page: Option<u32>,
status: Option<String>,
}
async fn get_user(
Path(path): Path<UserPath>,
Query(query): Query<UserQuery>,
) -> String {
let include_email = query.include_email.unwrap_or(false);
let fields = if query.fields.is_empty() {
"all".to_string()
} else {
query.fields.join(", ")
};
format!(
"User {} (email: {}, fields: {})",
path.user_id, include_email, fields
)
}
async fn list_user_posts(
Path(path): Path<UserPath>,
Query(query): Query<PostsQuery>,
) -> String {
let page = query.page.unwrap_or(1);
let per_page = query.per_page.unwrap_or(10);
let status = query.status.unwrap_or_else(|| "published".to_string());
format!(
"User {} posts: page {}, {} per page, status {}",
path.user_id, page, per_page, status
)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:user_id", get(get_user))
.route("/users/:user_id/posts", get(list_user_posts));
// GET /users/42 -> User 42 (email: false, fields: all)
// GET /users/42?include_email=true&fields=name&fields=bio
// -> User 42 (email: true, fields: name, bio)
// GET /users/42/posts -> User 42 posts: page 1, 10 per page, status published
// GET /users/42/posts?page=2&status=draft
// -> User 42 posts: page 2, 10 per page, status draft
}| Aspect | Path | Query |
|--------|------|-------|
| Location | URL segments | After ? in URL |
| Example | /users/:id | ?page=1&sort=desc |
| Requiredness | Always required | Controlled by field type |
| Semantics | Resource identity | Resource filtering/modification |
| Typical use | IDs, categories | Pagination, search, options |
| Extraction | Path<T> | Query<T> |
| Missing param | Route doesn't match (404) | Field is None or default |
| Parse failure | 400 Bad Request | 400 Bad Request |
| Multiple values | Not applicable | Vec<T> for repeated params |
| Optional params | Not applicable | Option<T> or #[serde(default)] |
Path extractors:
:param_nameQuery extractors:
?&Option<T> or defaults)When to use Path:
When to use Query:
Key insight: Path parameters are about what resource you're accessing (identity), while query parameters are about how you want it returned (presentation). This separation reflects REST principles: paths define the resource hierarchy, queries modify the representation. A user is identified by path (/users/42) but filtered by query (/users?status=active). The distinction isn't just technicalāit's architectural, influencing how clients think about your API and how caching systems (CDNs, browsers) handle requests. Path parameters typically don't have default values because they're required for resource location; query parameters frequently have defaults because they're optional modifiers.